@amritk/generate-parsers 0.12.1 โ 0.12.3
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 +4 -14
- package/dist/generators/build-schema.js +22 -7
- package/dist/generators/generate-discriminated-union-validation.d.ts +9 -0
- package/dist/generators/generate-discriminated-union-validation.js +33 -22
- package/dist/generators/generate-enum-check.d.ts +8 -0
- package/dist/generators/generate-enum-check.js +14 -0
- package/dist/generators/generate-files.js +1 -0
- package/dist/generators/generate-parser-function.d.ts +6 -0
- package/dist/generators/generate-parser-function.js +119 -13
- package/dist/generators/generate-schema-checks.js +7 -2
- package/dist/generators/generate-strict-assertion.js +55 -23
- package/dist/generators/generate-validation-expression.js +61 -2
- package/dist/helpers/collect-helpers.js +3 -1
- package/dist/helpers/collect-imports.js +5 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -97,52 +97,42 @@ Returns: `Promise<GeneratedFile[]>`.
|
|
|
97
97
|
<thead>
|
|
98
98
|
<tr>
|
|
99
99
|
<th>Property</th>
|
|
100
|
-
<th>CLI Flag</th>
|
|
101
100
|
<th>Type</th>
|
|
102
|
-
<th align="center">Required</th>
|
|
103
101
|
<th align="center">Default</th>
|
|
104
102
|
</tr>
|
|
105
103
|
</thead>
|
|
106
104
|
<tbody>
|
|
107
105
|
<tr>
|
|
108
106
|
<td>๐ท๏ธ <code>typesOnly</code></td>
|
|
109
|
-
<td>โ</td>
|
|
110
107
|
<td><code>boolean</code></td>
|
|
111
|
-
<td align="center">โ</td>
|
|
112
108
|
<td align="center"><code>false</code></td>
|
|
113
109
|
</tr>
|
|
114
110
|
<tr>
|
|
115
|
-
<td colspan="
|
|
111
|
+
<td colspan="3">Generate only TypeScript type definitions without parser functions. Runtime helper files (validators, isObject) are also omitted since they are only needed for parsers.</td>
|
|
116
112
|
</tr>
|
|
117
113
|
<tr>
|
|
118
114
|
<td>โ ๏ธ <code>logWarnings</code></td>
|
|
119
|
-
<td>โ</td>
|
|
120
115
|
<td><code>boolean</code></td>
|
|
121
|
-
<td align="center">โ</td>
|
|
122
116
|
<td align="center"><code>false</code></td>
|
|
123
117
|
</tr>
|
|
124
118
|
<tr>
|
|
125
|
-
<td colspan="
|
|
119
|
+
<td colspan="3">Emit a console.warn in the generated parsers for every input key that is not declared in the schema's properties. Useful for detecting schema drift or unexpected data shapes at runtime.</td>
|
|
126
120
|
</tr>
|
|
127
121
|
<tr>
|
|
128
122
|
<td>๐ซ <code>strict</code></td>
|
|
129
|
-
<td>โ</td>
|
|
130
123
|
<td><code>boolean</code></td>
|
|
131
|
-
<td align="center">โ</td>
|
|
132
124
|
<td align="center"><code>false</code></td>
|
|
133
125
|
</tr>
|
|
134
126
|
<tr>
|
|
135
|
-
<td colspan="
|
|
127
|
+
<td colspan="3">Generate parsers that throw on type/shape mismatches (wrong type, missing required property, enum/pattern/min/max violations) instead of coercing invalid input to default values. When a schema sets additionalProperties: false, undeclared keys throw too; otherwise they are still allowed.</td>
|
|
136
128
|
</tr>
|
|
137
129
|
<tr>
|
|
138
130
|
<td>๐งน <code>stripUnknown</code></td>
|
|
139
|
-
<td>โ</td>
|
|
140
131
|
<td><code>boolean</code></td>
|
|
141
|
-
<td align="center">โ</td>
|
|
142
132
|
<td align="center"><code>false</code></td>
|
|
143
133
|
</tr>
|
|
144
134
|
<tr>
|
|
145
|
-
<td colspan="
|
|
135
|
+
<td colspan="3">Build each parser's result from the schema's declared properties only, silently dropping undeclared input keys at every nesting level (zod's .strip()). Extras are never a validation error, so this composes with strict (which still throws on wrong types and missing required properties) and yields to additionalProperties: false, which rejects rather than strips in strict mode.</td>
|
|
146
136
|
</tr>
|
|
147
137
|
</tbody>
|
|
148
138
|
</table>
|
|
@@ -5,13 +5,28 @@ import { generateIndexBarrel } from '@amritk/helpers/generate-index-barrel';
|
|
|
5
5
|
import { walkRefGraph } from '@amritk/helpers/walk-ref-graph';
|
|
6
6
|
import { applySchemaExtensions } from '../helpers/apply-schema-extensions.js';
|
|
7
7
|
import { generateFile } from './generate-files.js';
|
|
8
|
-
/**
|
|
9
|
-
* helper source
|
|
8
|
+
/**
|
|
9
|
+
* Loads a runtime helper's source to copy into `_helpers/` in embedded mode.
|
|
10
|
+
*
|
|
11
|
+
* Prefers the TypeScript source (`src/<helper>.ts`), rewriting any extensionless
|
|
12
|
+
* sibling import (e.g. `validate-record` โ `./is-object`) to `./is-object.js` so
|
|
13
|
+
* the embedded copy resolves under Node ESM, not only Bun. When the resolved
|
|
14
|
+
* `@amritk/helpers` is a published/cached tarball that ships only `dist/` (its
|
|
15
|
+
* `files` historically excluded `src/`, which is exactly why `bunx mjst` crashed
|
|
16
|
+
* on this lookup), it falls back to the always-published compiled `dist/<helper>.js`
|
|
17
|
+
* โ which tsc already emits with `.js`-extension imports โ and writes it verbatim.
|
|
18
|
+
*/
|
|
10
19
|
const readHelperSource = async (helper) => {
|
|
11
20
|
const require = createRequire(import.meta.url);
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
21
|
+
const helpersRoot = dirname(require.resolve('@amritk/helpers/package.json'));
|
|
22
|
+
try {
|
|
23
|
+
const source = await readFile(resolvePath(helpersRoot, 'src', `${helper}.ts`), 'utf-8');
|
|
24
|
+
return { ext: 'ts', content: source.replace(/from '(/./[^'".]+)'/g, "from '$1.js'") };
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
const compiled = await readFile(resolvePath(helpersRoot, 'dist', `${helper}.js`), 'utf-8');
|
|
28
|
+
return { ext: 'js', content: compiled };
|
|
29
|
+
}
|
|
15
30
|
};
|
|
16
31
|
/**
|
|
17
32
|
* Builds all TypeScript files from a JSON Schema by traversing its entire
|
|
@@ -114,8 +129,8 @@ export const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnl
|
|
|
114
129
|
// typesOnly skips parser generation entirely, so no runtime helpers are needed.
|
|
115
130
|
if (helpersMode === 'embedded' && !typesOnly) {
|
|
116
131
|
for (const helper of usedHelpers) {
|
|
117
|
-
const
|
|
118
|
-
files.push({ filename: `_helpers/${helper}
|
|
132
|
+
const { ext, content } = await readHelperSource(helper);
|
|
133
|
+
files.push({ filename: `_helpers/${helper}.${ext}`, content });
|
|
119
134
|
}
|
|
120
135
|
}
|
|
121
136
|
files.push({ filename: 'index.ts', content: generateIndexBarrel(files, { typesOnly: typesOnly ?? false }) });
|
|
@@ -2,5 +2,14 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
|
2
2
|
/**
|
|
3
3
|
* Generates a discriminated union validation expression.
|
|
4
4
|
* Uses a discriminator property to efficiently choose the correct schema.
|
|
5
|
+
*
|
|
6
|
+
* The emitted expression is a single *nested* ternary in schema order โ for
|
|
7
|
+
* branches `dog`, `cat` it reads `disc === "dog" ? v : disc === "cat" ? v :
|
|
8
|
+
* <fallback>`. Each branch tests its discriminant (plus any per-branch shape
|
|
9
|
+
* checks) and, on a match, passes the value through; the innermost `else` is the
|
|
10
|
+
* shared fallback taken when no discriminant matches. The previous
|
|
11
|
+
* `cases[i] + ' : ' + result` folding spliced complete ternaries together
|
|
12
|
+
* (`c1 ? v : d1 : c0 ? v : d0`), which is a parse error for >=2 branches and
|
|
13
|
+
* also evaluated branches in reverse with the wrong per-case default.
|
|
5
14
|
*/
|
|
6
15
|
export declare const generateDiscriminatedUnionValidation: (accessor: string, schemas: readonly JSONSchema[], discriminatorKey: string, defaultValue: string, isRequired: boolean) => string;
|
|
@@ -1,38 +1,49 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { safeAccessor } from '@amritk/helpers/safe-accessor';
|
|
2
2
|
import { getDiscriminatorValue } from '../helpers/get-discriminator-value.js';
|
|
3
3
|
import { generateSchemaChecks } from './generate-schema-checks.js';
|
|
4
4
|
/**
|
|
5
5
|
* Generates a discriminated union validation expression.
|
|
6
6
|
* Uses a discriminator property to efficiently choose the correct schema.
|
|
7
|
+
*
|
|
8
|
+
* The emitted expression is a single *nested* ternary in schema order โ for
|
|
9
|
+
* branches `dog`, `cat` it reads `disc === "dog" ? v : disc === "cat" ? v :
|
|
10
|
+
* <fallback>`. Each branch tests its discriminant (plus any per-branch shape
|
|
11
|
+
* checks) and, on a match, passes the value through; the innermost `else` is the
|
|
12
|
+
* shared fallback taken when no discriminant matches. The previous
|
|
13
|
+
* `cases[i] + ' : ' + result` folding spliced complete ternaries together
|
|
14
|
+
* (`c1 ? v : d1 : c0 ? v : d0`), which is a parse error for >=2 branches and
|
|
15
|
+
* also evaluated branches in reverse with the wrong per-case default.
|
|
7
16
|
*/
|
|
8
17
|
export const generateDiscriminatedUnionValidation = (accessor, schemas, discriminatorKey, defaultValue, isRequired) => {
|
|
9
|
-
|
|
18
|
+
// A non-identifier discriminator key (e.g. `x-type`) must use bracket access;
|
|
19
|
+
// `${accessor}?.${key}` would emit broken TS. `safeAccessor` picks dot vs
|
|
20
|
+
// bracket notation and JSON.stringifies the literal so quotes-in-keys are safe.
|
|
21
|
+
const discAccessor = safeAccessor(`${accessor}?`, discriminatorKey);
|
|
22
|
+
// Each branch contributes its guard condition and the value to yield on a
|
|
23
|
+
// match; the value is `accessor` because `generateSchemaChecks` has already
|
|
24
|
+
// proven the branch's shape (no coercion happens here).
|
|
25
|
+
const branches = [];
|
|
10
26
|
for (const schema of schemas) {
|
|
11
27
|
const discriminatorValue = getDiscriminatorValue(schema, discriminatorKey);
|
|
12
|
-
if (discriminatorValue
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
for (let i = 1; i < checks.length; i++) {
|
|
19
|
-
combinedChecks += ' && ' + checks[i];
|
|
20
|
-
}
|
|
21
|
-
cases.push(`${accessor}?.${discriminatorKey} === ${JSON.stringify(discriminatorValue)} && ${combinedChecks} ? ${accessor} : ${schemaDefault}`);
|
|
22
|
-
}
|
|
23
|
-
else {
|
|
24
|
-
cases.push(`${accessor}?.${discriminatorKey} === ${JSON.stringify(discriminatorValue)} ? ${accessor} : ${schemaDefault}`);
|
|
25
|
-
}
|
|
28
|
+
if (discriminatorValue === null)
|
|
29
|
+
continue;
|
|
30
|
+
let condition = `${discAccessor} === ${JSON.stringify(discriminatorValue)}`;
|
|
31
|
+
const checks = generateSchemaChecks(accessor, schema);
|
|
32
|
+
for (const check of checks) {
|
|
33
|
+
condition += ' && ' + check;
|
|
26
34
|
}
|
|
35
|
+
branches.push(condition);
|
|
27
36
|
}
|
|
28
|
-
if (
|
|
37
|
+
if (branches.length === 0) {
|
|
29
38
|
return isRequired ? `${accessor} ?? ${defaultValue}` : accessor;
|
|
30
39
|
}
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
let result =
|
|
34
|
-
|
|
35
|
-
|
|
40
|
+
// When no discriminant matches, a required union coerces to the shared default;
|
|
41
|
+
// an optional one keeps the raw value untouched.
|
|
42
|
+
let result = isRequired ? defaultValue : accessor;
|
|
43
|
+
// Fold from the last branch inward so the final text lists branches in schema
|
|
44
|
+
// order: `cond0 ? accessor : cond1 ? accessor : <fallback>`.
|
|
45
|
+
for (let i = branches.length - 1; i >= 0; i--) {
|
|
46
|
+
result = `${branches[i]} ? ${accessor} : ${result}`;
|
|
36
47
|
}
|
|
37
48
|
return result;
|
|
38
49
|
};
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Generates an enum validation check expression.
|
|
3
3
|
* Returns a string that checks if the value is in the allowed enum values.
|
|
4
|
+
*
|
|
5
|
+
* For the common all-primitive case this emits a parenthesized `x === a || x ===
|
|
6
|
+
* b` chain rather than `[a, b].includes(x)`. The `.includes` form allocates a
|
|
7
|
+
* fresh array on every call (this expression runs on the parser hot path); the
|
|
8
|
+
* `===` chain is allocation-free and matches the validators package's
|
|
9
|
+
* `enumMembershipExpr`. It falls back to `.includes` when a member is an
|
|
10
|
+
* object/array (reference equality) or `NaN` (where `includes`'s SameValueZero
|
|
11
|
+
* differs from `===`), so the verdict is unchanged.
|
|
4
12
|
*/
|
|
5
13
|
export declare const generateEnumCheck: (accessor: string, enumValues: readonly unknown[]) => string;
|
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Generates an enum validation check expression.
|
|
3
3
|
* Returns a string that checks if the value is in the allowed enum values.
|
|
4
|
+
*
|
|
5
|
+
* For the common all-primitive case this emits a parenthesized `x === a || x ===
|
|
6
|
+
* b` chain rather than `[a, b].includes(x)`. The `.includes` form allocates a
|
|
7
|
+
* fresh array on every call (this expression runs on the parser hot path); the
|
|
8
|
+
* `===` chain is allocation-free and matches the validators package's
|
|
9
|
+
* `enumMembershipExpr`. It falls back to `.includes` when a member is an
|
|
10
|
+
* object/array (reference equality) or `NaN` (where `includes`'s SameValueZero
|
|
11
|
+
* differs from `===`), so the verdict is unchanged.
|
|
4
12
|
*/
|
|
5
13
|
export const generateEnumCheck = (accessor, enumValues) => {
|
|
14
|
+
const allPrimitive = enumValues.length > 0 &&
|
|
15
|
+
enumValues.every((v) => (v === null || typeof v !== 'object') && typeof v !== 'function') &&
|
|
16
|
+
!enumValues.some((v) => typeof v === 'number' && Number.isNaN(v));
|
|
17
|
+
if (allPrimitive) {
|
|
18
|
+
return `(${enumValues.map((v) => `${accessor} === ${JSON.stringify(v)}`).join(' || ')})`;
|
|
19
|
+
}
|
|
6
20
|
const serializedEnum = JSON.stringify(enumValues);
|
|
7
21
|
return `${serializedEnum}.includes(${accessor} as never)`;
|
|
8
22
|
};
|
|
@@ -55,6 +55,7 @@ export const generateFile = (schema, typeName, options) => {
|
|
|
55
55
|
const parserFunction = generateParserFunction(schema, typeName, {
|
|
56
56
|
useRefImports: true,
|
|
57
57
|
typeSuffix,
|
|
58
|
+
...(rootSchema !== undefined ? { rootSchema } : {}),
|
|
58
59
|
...(options?.logWarnings !== undefined ? { logWarnings: options.logWarnings } : {}),
|
|
59
60
|
...(options?.strict !== undefined ? { strict: options.strict } : {}),
|
|
60
61
|
...(options?.stripUnknown !== undefined ? { stripUnknown: options.stripUnknown } : {}),
|
|
@@ -36,6 +36,12 @@ type GenerateParserOptions = {
|
|
|
36
36
|
* the suffix used when generating the referenced files. Defaults to `''`.
|
|
37
37
|
*/
|
|
38
38
|
readonly typeSuffix?: string;
|
|
39
|
+
/**
|
|
40
|
+
* The root schema document. When present, a top-level `oneOf`/`anyOf` whose
|
|
41
|
+
* branches are `$ref`s can be resolved to find a shared discriminator and emit a
|
|
42
|
+
* dispatch to the branch parsers, instead of falling back to a passthrough cast.
|
|
43
|
+
*/
|
|
44
|
+
readonly rootSchema?: Record<string, unknown>;
|
|
39
45
|
};
|
|
40
46
|
/**
|
|
41
47
|
* Generates a safe parser as an arrow function that never throws.
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { escapeRegexPattern } from '@amritk/helpers/escape-regex-pattern';
|
|
2
|
+
import { multipleOfPassExpr } from '@amritk/helpers/multiple-of-check';
|
|
2
3
|
import { refToName } from '@amritk/helpers/ref-to-name';
|
|
4
|
+
import { resolveRef } from '@amritk/helpers/resolve-ref';
|
|
3
5
|
import { safeAccessor, safeKey } from '@amritk/helpers/safe-accessor';
|
|
4
6
|
import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasConst, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasItems, hasMaxItems, hasMaximum, hasMaxLength, hasMinItems, hasMinimum, hasMinLength, hasMultipleOf, hasOneOf, hasPattern, hasProperties, hasRef, hasType, hasUniqueItems, isObjectSchema, isSchemaObject, } from '@amritk/helpers/schema-guards';
|
|
5
7
|
import { unknownKeyCheck } from '@amritk/helpers/unknown-key-check';
|
|
8
|
+
import { findDiscriminator } from '../helpers/find-discriminator.js';
|
|
6
9
|
import { getDefaultValue } from '../helpers/get-default-value.js';
|
|
10
|
+
import { getDiscriminatorValue } from '../helpers/get-discriminator-value.js';
|
|
7
11
|
import { generateObjectStrictAssertion, generateScalarStrictAssertion } from './generate-strict-assertion.js';
|
|
8
12
|
import { generateValidationExpression, scalarItemTypeCheck } from './generate-validation-expression.js';
|
|
9
13
|
/**
|
|
@@ -41,7 +45,10 @@ const generateRequiredRefCall = (key, ref, suffix) => {
|
|
|
41
45
|
const generateOptionalRefCall = (key, ref, suffix) => {
|
|
42
46
|
const acc = safeAccessor('input', key);
|
|
43
47
|
const parserName = generateParserName(refToName(ref, suffix));
|
|
44
|
-
|
|
48
|
+
// Gate on presence, not truthiness: a `&&` guard skips parsing when the value
|
|
49
|
+
// is `false`/`0`/`""`/`null`, spreading it raw. `!== undefined` still omits an
|
|
50
|
+
// absent optional property but coerces every present one, matching the main path.
|
|
51
|
+
return `...(${acc} !== undefined && { ${safeKey(key)}: ${parserName}(${acc}) })`;
|
|
45
52
|
};
|
|
46
53
|
/**
|
|
47
54
|
* Generates a validateArray call for required array properties with $ref items.
|
|
@@ -58,7 +65,8 @@ const generateRequiredArrayRefCall = (key, ref, suffix) => {
|
|
|
58
65
|
const generateOptionalArrayRefCall = (key, ref, suffix) => {
|
|
59
66
|
const parserName = generateParserName(refToName(ref, suffix));
|
|
60
67
|
const acc = safeAccessor('input', key);
|
|
61
|
-
|
|
68
|
+
// Presence gating (not truthiness) so a falsy-but-present value is still parsed.
|
|
69
|
+
return `...(${acc} !== undefined && { ${safeKey(key)}: validateArray(${acc}, ${parserName}) })`;
|
|
62
70
|
};
|
|
63
71
|
/**
|
|
64
72
|
* Generates a validateRecord call for required object properties with additionalProperties $ref.
|
|
@@ -76,7 +84,8 @@ const generateRequiredRecordRefCall = (key, ref, suffix) => {
|
|
|
76
84
|
const generateOptionalRecordRefCall = (key, ref, suffix) => {
|
|
77
85
|
const acc = safeAccessor('input', key);
|
|
78
86
|
const parserName = generateParserName(refToName(ref, suffix));
|
|
79
|
-
|
|
87
|
+
// Presence gating (not truthiness) so a falsy-but-present value is still parsed.
|
|
88
|
+
return `...(${acc} !== undefined && { ${safeKey(key)}: validateRecord(${acc}, ${parserName}) })`;
|
|
80
89
|
};
|
|
81
90
|
/**
|
|
82
91
|
* Generates a spread entry for optional inline properties.
|
|
@@ -296,8 +305,75 @@ const scalarDefaultLiteral = (schema) => {
|
|
|
296
305
|
}
|
|
297
306
|
return '{}';
|
|
298
307
|
};
|
|
299
|
-
|
|
308
|
+
/**
|
|
309
|
+
* Emits a dispatcher for a top-level `oneOf`/`anyOf` whose branches are `$ref`s,
|
|
310
|
+
* when the branches share a discriminator (a `const`/`enum` tag such as `kind`).
|
|
311
|
+
* Each resolved branch's discriminant selects its imported parser, so a recursive
|
|
312
|
+
* discriminated union (e.g. `Expr = Lit | BinOp`, where `BinOp` has `Expr`
|
|
313
|
+
* children) is actually validated and dispatched instead of blindly cast. Returns
|
|
314
|
+
* `null` when it can't be done (no root schema, no discriminator, a non-ref
|
|
315
|
+
* branch, or ref imports disabled), so the caller keeps its passthrough fallback.
|
|
316
|
+
*/
|
|
317
|
+
const generateRefUnionDispatch = (functionName, typeName, branches, strict, ctx) => {
|
|
318
|
+
const rootSchema = ctx.rootSchema;
|
|
319
|
+
if (!ctx.useRefImports || rootSchema === undefined)
|
|
320
|
+
return null;
|
|
321
|
+
if (branches.length === 0 || !branches.every((b) => isSchemaObject(b) && hasRef(b)))
|
|
322
|
+
return null;
|
|
323
|
+
// Resolve each branch ref so we can read its discriminant tag and derive its
|
|
324
|
+
// imported parser name.
|
|
325
|
+
const resolved = branches.map((b) => {
|
|
326
|
+
const ref = b.$ref;
|
|
327
|
+
return { parser: generateParserName(refToName(ref, ctx.suffix)), schema: resolveRef(ref, rootSchema) };
|
|
328
|
+
});
|
|
329
|
+
if (resolved.some((r) => !isSchemaObject(r.schema)))
|
|
330
|
+
return null;
|
|
331
|
+
const discriminator = findDiscriminator(resolved.map((r) => r.schema));
|
|
332
|
+
if (discriminator === null)
|
|
333
|
+
return null;
|
|
334
|
+
const cases = [];
|
|
335
|
+
for (const r of resolved) {
|
|
336
|
+
const value = getDiscriminatorValue(r.schema, discriminator);
|
|
337
|
+
if (value === null)
|
|
338
|
+
return null;
|
|
339
|
+
cases.push({ value, parser: r.parser });
|
|
340
|
+
}
|
|
341
|
+
// Read the discriminant safely: `null`/`undefined` is guarded (a property read
|
|
342
|
+
// would throw), and a non-object primitive yields `undefined` (no branch matches).
|
|
343
|
+
const access = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(discriminator)
|
|
344
|
+
? `.${discriminator}`
|
|
345
|
+
: `[${JSON.stringify(discriminator)}]`;
|
|
346
|
+
const discExpr = `input == null ? undefined : (input as Record<string, unknown>)${access}`;
|
|
347
|
+
// Fold branches into a nested ternary in declaration order:
|
|
348
|
+
// `_disc === "lit" ? parseLit(input) : _disc === "binop" ? parseBinop(input) : <fallback>`.
|
|
349
|
+
// Unmatched: strict throws (not a member of the union); non-strict coerces via the
|
|
350
|
+
// first branch's parser, preserving the coercing parser's lenient contract while
|
|
351
|
+
// still running real validation/coercion instead of a blind cast.
|
|
352
|
+
const fallback = strict
|
|
353
|
+
? `(() => { throw new Error(${JSON.stringify(`[${typeName}] value does not match any union branch`)}); })()`
|
|
354
|
+
: `${cases[0]?.parser}(input)`;
|
|
355
|
+
let expr = fallback;
|
|
356
|
+
for (let i = cases.length - 1; i >= 0; i--) {
|
|
357
|
+
const c = cases[i];
|
|
358
|
+
if (c)
|
|
359
|
+
expr = `_disc === ${JSON.stringify(c.value)} ? ${c.parser}(input) : ${expr}`;
|
|
360
|
+
}
|
|
361
|
+
return `export const ${functionName} = (input: unknown): ${typeName} => {\n const _disc = ${discExpr};\n return ${expr};\n};`;
|
|
362
|
+
};
|
|
363
|
+
const generateNonObjectParser = (typeName, schema, strict, unionCtx) => {
|
|
300
364
|
const functionName = generateParserName(typeName);
|
|
365
|
+
// A ref-branch discriminated union dispatches to its branch parsers in *both*
|
|
366
|
+
// modes (strict throws on an unmatched discriminant; non-strict coerces via the
|
|
367
|
+
// first branch). This runs before the `!strict` coercion block below so a strict
|
|
368
|
+
// recursive union is validated instead of blindly cast.
|
|
369
|
+
if (unionCtx && isSchemaObject(schema) && (hasOneOf(schema) || hasAnyOf(schema))) {
|
|
370
|
+
const branches = hasOneOf(schema) ? schema.oneOf : hasAnyOf(schema) ? schema.anyOf : [];
|
|
371
|
+
if (branches.length > 0 && branches.every((b) => isSchemaObject(b) && hasRef(b))) {
|
|
372
|
+
const dispatch = generateRefUnionDispatch(functionName, typeName, branches, strict, unionCtx);
|
|
373
|
+
if (dispatch !== null)
|
|
374
|
+
return dispatch;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
301
377
|
// `const`/`enum` define the value space directly (the generated type is a
|
|
302
378
|
// literal / literal-union), so a non-member must coerce to a valid member โ
|
|
303
379
|
// otherwise the returned value would not be of the declared type. This runs
|
|
@@ -322,10 +398,7 @@ const generateNonObjectParser = (typeName, schema, strict) => {
|
|
|
322
398
|
}
|
|
323
399
|
// A top-level union must validate membership: an unmatched value is not of
|
|
324
400
|
// the declared union type, so coerce it to a member-shaped default. Reuse the
|
|
325
|
-
// same union validation the property path uses.
|
|
326
|
-
// validated inline here (the membership test would silently drop it and
|
|
327
|
-
// wrongly coerce a valid ref-shaped value), so fall through to a passthrough
|
|
328
|
-
// cast rather than discarding valid input.
|
|
401
|
+
// same union validation the property path uses.
|
|
329
402
|
if (hasOneOf(schema) || hasAnyOf(schema)) {
|
|
330
403
|
const branches = hasOneOf(schema) ? schema.oneOf : hasAnyOf(schema) ? schema.anyOf : [];
|
|
331
404
|
const hasRefBranch = branches.some((b) => isSchemaObject(b) && hasRef(b));
|
|
@@ -334,6 +407,9 @@ const generateNonObjectParser = (typeName, schema, strict) => {
|
|
|
334
407
|
const expr = generateValidationExpression('', schema, fallback, true, undefined, undefined, 'input', true);
|
|
335
408
|
return `export const ${functionName} = (input: unknown): ${typeName} => (${expr}) as ${typeName};`;
|
|
336
409
|
}
|
|
410
|
+
// Ref-branch union: a *discriminated* one was already dispatched above (both
|
|
411
|
+
// modes). Anything reaching here is non-discriminated, which can't be validated
|
|
412
|
+
// inline without risking dropping valid input โ fall back to a passthrough cast.
|
|
337
413
|
return `export const ${functionName} = (input: unknown): ${typeName} => input as ${typeName};`;
|
|
338
414
|
}
|
|
339
415
|
}
|
|
@@ -356,8 +432,10 @@ const generateNonObjectParser = (typeName, schema, strict) => {
|
|
|
356
432
|
case 'string':
|
|
357
433
|
return `export const ${functionName} = (input: unknown): ${typeName} => typeof input === "string" ? input as ${typeName} : "" as ${typeName};`;
|
|
358
434
|
case 'number':
|
|
359
|
-
case 'integer':
|
|
360
435
|
return `export const ${functionName} = (input: unknown): ${typeName} => typeof input === "number" ? input as ${typeName} : 0 as ${typeName};`;
|
|
436
|
+
case 'integer':
|
|
437
|
+
// `integer` rejects non-integral numbers; a bare typeof would accept `1.5`.
|
|
438
|
+
return `export const ${functionName} = (input: unknown): ${typeName} => typeof input === "number" && Number.isInteger(input) ? input as ${typeName} : 0 as ${typeName};`;
|
|
361
439
|
case 'boolean':
|
|
362
440
|
return `export const ${functionName} = (input: unknown): ${typeName} => typeof input === "boolean" ? input as ${typeName} : false as ${typeName};`;
|
|
363
441
|
case 'array': {
|
|
@@ -492,6 +570,14 @@ const generatePropertyTypeCheck = (varName, schema, useRefImports, suffix) => {
|
|
|
492
570
|
'not' in schema) {
|
|
493
571
|
return null;
|
|
494
572
|
}
|
|
573
|
+
// A `const` property (the usual discriminated-union tag, e.g. `kind: "lit"`) has
|
|
574
|
+
// no `type`, but is fast-path predicable via strict equality. Handling it here is
|
|
575
|
+
// what lets a discriminated branch's shape validator be a real predicate instead
|
|
576
|
+
// of the `=> false` stub. Structural consts need deep equality, so skip those.
|
|
577
|
+
if (hasConst(schema)) {
|
|
578
|
+
const c = schema.const;
|
|
579
|
+
return c === null || typeof c !== 'object' ? `${varName} === ${JSON.stringify(c)}` : null;
|
|
580
|
+
}
|
|
495
581
|
if (!hasType(schema))
|
|
496
582
|
return null;
|
|
497
583
|
// Array of $refs: each item must satisfy the imported shape predicate.
|
|
@@ -518,6 +604,10 @@ const generatePropertyTypeCheck = (varName, schema, useRefImports, suffix) => {
|
|
|
518
604
|
case 'number':
|
|
519
605
|
case 'integer': {
|
|
520
606
|
checks.push(`typeof ${varName} === "number"`);
|
|
607
|
+
// `integer` must reject non-integral numbers on the fast path too, otherwise
|
|
608
|
+
// `1.5` would pass through uncoerced.
|
|
609
|
+
if (schema.type === 'integer')
|
|
610
|
+
checks.push(`Number.isInteger(${varName})`);
|
|
521
611
|
if (hasMinimum(schema))
|
|
522
612
|
checks.push(`${varName} >= ${schema.minimum}`);
|
|
523
613
|
if (hasMaximum(schema))
|
|
@@ -527,7 +617,7 @@ const generatePropertyTypeCheck = (varName, schema, useRefImports, suffix) => {
|
|
|
527
617
|
if (hasExclusiveMaximum(schema))
|
|
528
618
|
checks.push(`${varName} < ${schema.exclusiveMaximum}`);
|
|
529
619
|
if (hasMultipleOf(schema))
|
|
530
|
-
checks.push(
|
|
620
|
+
checks.push(multipleOfPassExpr(varName, schema.multipleOf));
|
|
531
621
|
break;
|
|
532
622
|
}
|
|
533
623
|
case 'boolean':
|
|
@@ -641,9 +731,17 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
|
|
|
641
731
|
schema.allOf.some((entry) => isSchemaObject(entry) && hasRef(entry));
|
|
642
732
|
let canFastPath = !hasAllOfRefParsers;
|
|
643
733
|
const fastPathChecks = [];
|
|
734
|
+
// `toVarName` collapses distinct keys that differ only in non-identifier chars
|
|
735
|
+
// (e.g. `a-b` and `a.b` both โ `_a_b`), which would emit two `const _a_b`
|
|
736
|
+
// declarations (TS2451). Dedupe by suffixing `_` until unique โ the same
|
|
737
|
+
// approach `collectInlineObjectProperties` uses โ so each key gets its own var.
|
|
738
|
+
const usedVarNames = new Set();
|
|
644
739
|
for (const [key, propSchema] of properties) {
|
|
645
740
|
const isRequired = isPropertyRequired(key, schema);
|
|
646
|
-
|
|
741
|
+
let varName = toVarName(key);
|
|
742
|
+
while (usedVarNames.has(varName))
|
|
743
|
+
varName = `${varName}_`;
|
|
744
|
+
usedVarNames.add(varName);
|
|
647
745
|
propInfo.push({ key, varName, isRequired, propSchema });
|
|
648
746
|
if (!canFastPath)
|
|
649
747
|
continue;
|
|
@@ -1335,7 +1433,11 @@ const selectParserStrategy = (schema, typeName, options) => {
|
|
|
1335
1433
|
(isSchemaObject(schema) && ('patternProperties' in schema || 'additionalProperties' in schema));
|
|
1336
1434
|
// Handle non-object schemas with type-appropriate validation
|
|
1337
1435
|
if (!isObjectLikeSchema && !isSchemaObject(schema)) {
|
|
1338
|
-
return generateNonObjectParser(typeName, schema, strict
|
|
1436
|
+
return generateNonObjectParser(typeName, schema, strict, {
|
|
1437
|
+
useRefImports,
|
|
1438
|
+
suffix,
|
|
1439
|
+
...(options?.rootSchema !== undefined ? { rootSchema: options.rootSchema } : {}),
|
|
1440
|
+
});
|
|
1339
1441
|
}
|
|
1340
1442
|
// Handle schemas with both properties AND patternProperties.
|
|
1341
1443
|
// This generates a parser that handles known properties and also iterates
|
|
@@ -1362,7 +1464,11 @@ const selectParserStrategy = (schema, typeName, options) => {
|
|
|
1362
1464
|
}
|
|
1363
1465
|
// Handle non-object schemas with type-appropriate validation (no properties, no conditionals)
|
|
1364
1466
|
if (!isObjectLikeSchema) {
|
|
1365
|
-
return generateNonObjectParser(typeName, schema, strict
|
|
1467
|
+
return generateNonObjectParser(typeName, schema, strict, {
|
|
1468
|
+
useRefImports,
|
|
1469
|
+
suffix,
|
|
1470
|
+
...(options?.rootSchema !== undefined ? { rootSchema: options.rootSchema } : {}),
|
|
1471
|
+
});
|
|
1366
1472
|
}
|
|
1367
1473
|
// Handle schemas with patternProperties (but no properties)
|
|
1368
1474
|
if ('patternProperties' in schema) {
|
|
@@ -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 { multipleOfPassExpr } from '@amritk/helpers/multiple-of-check';
|
|
3
4
|
import { hasAdditionalProperties, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasMaxItems, hasMaximum, hasMaxLength, hasMaxProperties, hasMinItems, hasMinimum, hasMinLength, hasMinProperties, hasMultipleOf, hasPattern, hasProperties, hasRequired, hasType, hasUniqueItems, } from '@amritk/helpers/schema-guards';
|
|
4
5
|
import { generateEnumCheck } from './generate-enum-check.js';
|
|
5
6
|
/**
|
|
@@ -37,6 +38,10 @@ export const generateSchemaChecks = (accessor, schema) => {
|
|
|
37
38
|
case 'number':
|
|
38
39
|
case 'integer':
|
|
39
40
|
checks.push(`typeof ${accessor} === "number"`);
|
|
41
|
+
// `integer` must reject non-integral numbers (a bare typeof accepts `1.5`).
|
|
42
|
+
if (schema.type === 'integer') {
|
|
43
|
+
checks.push(`Number.isInteger(${accessor})`);
|
|
44
|
+
}
|
|
40
45
|
if (hasMinimum(schema)) {
|
|
41
46
|
checks.push(`${accessor} >= ${schema.minimum}`);
|
|
42
47
|
}
|
|
@@ -50,7 +55,7 @@ export const generateSchemaChecks = (accessor, schema) => {
|
|
|
50
55
|
checks.push(`${accessor} < ${schema.exclusiveMaximum}`);
|
|
51
56
|
}
|
|
52
57
|
if (hasMultipleOf(schema)) {
|
|
53
|
-
checks.push(
|
|
58
|
+
checks.push(multipleOfPassExpr(accessor, schema.multipleOf));
|
|
54
59
|
}
|
|
55
60
|
break;
|
|
56
61
|
case 'boolean':
|
|
@@ -73,7 +78,7 @@ export const generateSchemaChecks = (accessor, schema) => {
|
|
|
73
78
|
// Check required properties exist
|
|
74
79
|
if (hasRequired(schema) && schema.required.length > 0) {
|
|
75
80
|
for (const requiredKey of schema.required) {
|
|
76
|
-
checks.push(
|
|
81
|
+
checks.push(`${JSON.stringify(requiredKey)} in ${accessor}`);
|
|
77
82
|
}
|
|
78
83
|
}
|
|
79
84
|
// Check minProperties
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { escapeRegexPattern } from '@amritk/helpers/escape-regex-pattern';
|
|
2
2
|
import { getMjstInstanceOf, getMjstPrimitive } from '@amritk/helpers/mjst-extension';
|
|
3
|
+
import { multipleOfFailExpr } from '@amritk/helpers/multiple-of-check';
|
|
3
4
|
import { safeAccessor } from '@amritk/helpers/safe-accessor';
|
|
4
|
-
import { hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasMaximum, hasMaxLength, hasMinimum, hasMinLength, hasMultipleOf, hasPattern, hasProperties, hasRef, hasRequired, hasType, isSchemaObject, } from '@amritk/helpers/schema-guards';
|
|
5
|
+
import { hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasMaxItems, hasMaximum, hasMaxLength, hasMinItems, hasMinimum, hasMinLength, hasMultipleOf, hasPattern, hasProperties, hasRef, hasRequired, hasType, hasUniqueItems, isSchemaObject, } from '@amritk/helpers/schema-guards';
|
|
5
6
|
/**
|
|
6
7
|
* Returns the inline condition that is true when `accessor` is the wrong type
|
|
7
8
|
* for the given JSON Schema primitive type.
|
|
@@ -11,8 +12,10 @@ const wrongTypeCondition = (accessor, type) => {
|
|
|
11
12
|
case 'string':
|
|
12
13
|
return `typeof ${accessor} !== "string"`;
|
|
13
14
|
case 'number':
|
|
14
|
-
case 'integer':
|
|
15
15
|
return `typeof ${accessor} !== "number"`;
|
|
16
|
+
case 'integer':
|
|
17
|
+
// `integer` also rejects non-integral numbers; a bare typeof accepts `1.5`.
|
|
18
|
+
return `(typeof ${accessor} !== "number" || !Number.isInteger(${accessor}))`;
|
|
16
19
|
case 'boolean':
|
|
17
20
|
return `typeof ${accessor} !== "boolean"`;
|
|
18
21
|
case 'array':
|
|
@@ -28,6 +31,18 @@ const wrongTypeCondition = (accessor, type) => {
|
|
|
28
31
|
* `integer` collapses to `number` since both are validated via `typeof === "number"`.
|
|
29
32
|
*/
|
|
30
33
|
const typeLabel = (type) => (type === 'integer' ? 'number' : type);
|
|
34
|
+
/**
|
|
35
|
+
* Emits `throw new Error(<message>[ + <suffixExpr>])`. The static message may
|
|
36
|
+
* contain schema-controlled text (property names, patterns, enum values), so it
|
|
37
|
+
* is always serialized with `JSON.stringify` โ a key like `it's` or a pattern
|
|
38
|
+
* containing a quote or newline can no longer break out of the string literal or
|
|
39
|
+
* inject code. `suffixExpr`, when given, is a runtime expression appended to the
|
|
40
|
+
* message (e.g. `typeof input`).
|
|
41
|
+
*/
|
|
42
|
+
const throwError = (message, suffixExpr) => {
|
|
43
|
+
const literal = JSON.stringify(message);
|
|
44
|
+
return suffixExpr ? `throw new Error(${literal} + (${suffixExpr}))` : `throw new Error(${literal})`;
|
|
45
|
+
};
|
|
31
46
|
/**
|
|
32
47
|
* Generates strict-mode constraint checks for a typed property
|
|
33
48
|
* (pattern, length, min/max, multipleOf).
|
|
@@ -37,33 +52,47 @@ const generateConstraintChecks = (acc, propSchema, typeName, key) => {
|
|
|
37
52
|
return [];
|
|
38
53
|
const t = propSchema.type;
|
|
39
54
|
const lines = [];
|
|
55
|
+
const field = `[${typeName}] field '${key}'`;
|
|
40
56
|
if (t === 'string') {
|
|
41
57
|
if (hasPattern(propSchema)) {
|
|
42
58
|
const pattern = escapeRegexPattern(propSchema.pattern);
|
|
43
|
-
lines.push(` if (typeof ${acc} === "string" && !/${pattern}/.test(${acc}))
|
|
59
|
+
lines.push(` if (typeof ${acc} === "string" && !/${pattern}/.test(${acc})) ${throwError(`${field} must match pattern ${propSchema.pattern}`)};`);
|
|
44
60
|
}
|
|
45
61
|
if (hasMinLength(propSchema)) {
|
|
46
|
-
lines.push(` if (typeof ${acc} === "string" && ${acc}.length < ${propSchema.minLength})
|
|
62
|
+
lines.push(` if (typeof ${acc} === "string" && ${acc}.length < ${propSchema.minLength}) ${throwError(`${field} must have at least ${propSchema.minLength} characters`)};`);
|
|
47
63
|
}
|
|
48
64
|
if (hasMaxLength(propSchema)) {
|
|
49
|
-
lines.push(` if (typeof ${acc} === "string" && ${acc}.length > ${propSchema.maxLength})
|
|
65
|
+
lines.push(` if (typeof ${acc} === "string" && ${acc}.length > ${propSchema.maxLength}) ${throwError(`${field} must have at most ${propSchema.maxLength} characters`)};`);
|
|
50
66
|
}
|
|
51
67
|
}
|
|
52
68
|
if (t === 'number' || t === 'integer') {
|
|
53
69
|
if (hasMinimum(propSchema)) {
|
|
54
|
-
lines.push(` if (typeof ${acc} === "number" && ${acc} < ${propSchema.minimum})
|
|
70
|
+
lines.push(` if (typeof ${acc} === "number" && ${acc} < ${propSchema.minimum}) ${throwError(`${field} must be >= ${propSchema.minimum}`)};`);
|
|
55
71
|
}
|
|
56
72
|
if (hasMaximum(propSchema)) {
|
|
57
|
-
lines.push(` if (typeof ${acc} === "number" && ${acc} > ${propSchema.maximum})
|
|
73
|
+
lines.push(` if (typeof ${acc} === "number" && ${acc} > ${propSchema.maximum}) ${throwError(`${field} must be <= ${propSchema.maximum}`)};`);
|
|
58
74
|
}
|
|
59
75
|
if (hasExclusiveMinimum(propSchema)) {
|
|
60
|
-
lines.push(` if (typeof ${acc} === "number" && ${acc} <= ${propSchema.exclusiveMinimum})
|
|
76
|
+
lines.push(` if (typeof ${acc} === "number" && ${acc} <= ${propSchema.exclusiveMinimum}) ${throwError(`${field} must be > ${propSchema.exclusiveMinimum}`)};`);
|
|
61
77
|
}
|
|
62
78
|
if (hasExclusiveMaximum(propSchema)) {
|
|
63
|
-
lines.push(` if (typeof ${acc} === "number" && ${acc} >= ${propSchema.exclusiveMaximum})
|
|
79
|
+
lines.push(` if (typeof ${acc} === "number" && ${acc} >= ${propSchema.exclusiveMaximum}) ${throwError(`${field} must be < ${propSchema.exclusiveMaximum}`)};`);
|
|
64
80
|
}
|
|
65
81
|
if (hasMultipleOf(propSchema)) {
|
|
66
|
-
lines.push(` if (typeof ${acc} === "number" && ${acc
|
|
82
|
+
lines.push(` if (typeof ${acc} === "number" && ${multipleOfFailExpr(acc, propSchema.multipleOf)}) ${throwError(`${field} must be a multiple of ${propSchema.multipleOf}`)};`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (t === 'array') {
|
|
86
|
+
if (hasMinItems(propSchema)) {
|
|
87
|
+
lines.push(` if (Array.isArray(${acc}) && ${acc}.length < ${propSchema.minItems}) ${throwError(`${field} must have at least ${propSchema.minItems} items`)};`);
|
|
88
|
+
}
|
|
89
|
+
if (hasMaxItems(propSchema)) {
|
|
90
|
+
lines.push(` if (Array.isArray(${acc}) && ${acc}.length > ${propSchema.maxItems}) ${throwError(`${field} must have at most ${propSchema.maxItems} items`)};`);
|
|
91
|
+
}
|
|
92
|
+
if (hasUniqueItems(propSchema) && propSchema.uniqueItems === true) {
|
|
93
|
+
// Deep-equality dedupe via JSON keys, matching the interpreter's semantics โ
|
|
94
|
+
// a plain `new Set` would only catch primitive duplicates, not equal objects.
|
|
95
|
+
lines.push(` if (Array.isArray(${acc}) && new Set(${acc}.map((_u) => JSON.stringify(_u))).size !== ${acc}.length) ${throwError(`${field} must NOT have duplicate items`)};`);
|
|
67
96
|
}
|
|
68
97
|
}
|
|
69
98
|
return lines;
|
|
@@ -75,9 +104,10 @@ const generateConstraintChecks = (acc, propSchema, typeName, key) => {
|
|
|
75
104
|
*/
|
|
76
105
|
const generatePropertyAssertion = (key, propSchema, isRequired, typeName) => {
|
|
77
106
|
const acc = safeAccessor('input', key);
|
|
107
|
+
const field = `[${typeName}] field '${key}'`;
|
|
78
108
|
const lines = [];
|
|
79
109
|
if (isRequired) {
|
|
80
|
-
lines.push(` if (!(${JSON.stringify(key)} in input))
|
|
110
|
+
lines.push(` if (!(${JSON.stringify(key)} in input)) ${throwError(`[${typeName}] missing required property '${key}'`)};`);
|
|
81
111
|
}
|
|
82
112
|
if (!isSchemaObject(propSchema))
|
|
83
113
|
return lines;
|
|
@@ -86,20 +116,20 @@ const generatePropertyAssertion = (key, propSchema, isRequired, typeName) => {
|
|
|
86
116
|
const instanceOf = getMjstInstanceOf(propSchema);
|
|
87
117
|
if (instanceOf) {
|
|
88
118
|
if (isRequired) {
|
|
89
|
-
lines.push(` if (!(${acc} instanceof ${instanceOf}))
|
|
119
|
+
lines.push(` if (!(${acc} instanceof ${instanceOf})) ${throwError(`${field} must be ${instanceOf}`)};`);
|
|
90
120
|
}
|
|
91
121
|
else {
|
|
92
|
-
lines.push(` if (${acc} !== undefined && !(${acc} instanceof ${instanceOf}))
|
|
122
|
+
lines.push(` if (${acc} !== undefined && !(${acc} instanceof ${instanceOf})) ${throwError(`${field} must be ${instanceOf}`)};`);
|
|
93
123
|
}
|
|
94
124
|
return lines;
|
|
95
125
|
}
|
|
96
126
|
const primitive = getMjstPrimitive(propSchema);
|
|
97
127
|
if (primitive) {
|
|
98
128
|
if (isRequired) {
|
|
99
|
-
lines.push(` if (typeof ${acc} !== "${primitive}")
|
|
129
|
+
lines.push(` if (typeof ${acc} !== "${primitive}") ${throwError(`${field} must be ${primitive}`)};`);
|
|
100
130
|
}
|
|
101
131
|
else {
|
|
102
|
-
lines.push(` if (${acc} !== undefined && typeof ${acc} !== "${primitive}")
|
|
132
|
+
lines.push(` if (${acc} !== undefined && typeof ${acc} !== "${primitive}") ${throwError(`${field} must be ${primitive}`)};`);
|
|
103
133
|
}
|
|
104
134
|
return lines;
|
|
105
135
|
}
|
|
@@ -107,10 +137,10 @@ const generatePropertyAssertion = (key, propSchema, isRequired, typeName) => {
|
|
|
107
137
|
const allowed = JSON.stringify(propSchema.enum);
|
|
108
138
|
const label = propSchema.enum.map((v) => JSON.stringify(v)).join(', ');
|
|
109
139
|
if (isRequired) {
|
|
110
|
-
lines.push(` if (!(${allowed} as readonly unknown[]).includes(${acc}))
|
|
140
|
+
lines.push(` if (!(${allowed} as readonly unknown[]).includes(${acc})) ${throwError(`${field} must be one of: ${label}`)};`);
|
|
111
141
|
}
|
|
112
142
|
else {
|
|
113
|
-
lines.push(` if (${acc} !== undefined && !(${allowed} as readonly unknown[]).includes(${acc}))
|
|
143
|
+
lines.push(` if (${acc} !== undefined && !(${allowed} as readonly unknown[]).includes(${acc})) ${throwError(`${field} must be one of: ${label}`)};`);
|
|
114
144
|
}
|
|
115
145
|
return lines;
|
|
116
146
|
}
|
|
@@ -118,11 +148,12 @@ const generatePropertyAssertion = (key, propSchema, isRequired, typeName) => {
|
|
|
118
148
|
const t = propSchema.type;
|
|
119
149
|
const wrongType = wrongTypeCondition(acc, t);
|
|
120
150
|
if (wrongType) {
|
|
151
|
+
const expected = throwError(`${field} expected ${typeLabel(t)}, got `, `typeof ${acc}`);
|
|
121
152
|
if (isRequired) {
|
|
122
|
-
lines.push(` if (${wrongType})
|
|
153
|
+
lines.push(` if (${wrongType}) ${expected};`);
|
|
123
154
|
}
|
|
124
155
|
else {
|
|
125
|
-
lines.push(` if (${acc} !== undefined && (${wrongType}))
|
|
156
|
+
lines.push(` if (${acc} !== undefined && (${wrongType})) ${expected};`);
|
|
126
157
|
}
|
|
127
158
|
}
|
|
128
159
|
lines.push(...generateConstraintChecks(acc, propSchema, typeName, key));
|
|
@@ -142,7 +173,7 @@ const generatePropertyAssertion = (key, propSchema, isRequired, typeName) => {
|
|
|
142
173
|
*/
|
|
143
174
|
export const generateObjectStrictAssertion = (schema, typeName) => {
|
|
144
175
|
const lines = [];
|
|
145
|
-
lines.push(` if (!isObject(input))
|
|
176
|
+
lines.push(` if (!isObject(input)) ${throwError(`[${typeName}] expected object, got `, 'input === null ? "null" : typeof input')};`);
|
|
146
177
|
if (!hasProperties(schema) || !isSchemaObject(schema))
|
|
147
178
|
return lines;
|
|
148
179
|
const required = new Set(hasRequired(schema) ? schema.required : []);
|
|
@@ -156,13 +187,14 @@ export const generateObjectStrictAssertion = (schema, typeName) => {
|
|
|
156
187
|
* Returns null when the schema has no type information to assert on.
|
|
157
188
|
*/
|
|
158
189
|
export const generateScalarStrictAssertion = (schema, typeName) => {
|
|
190
|
+
const got = 'input === null ? "null" : typeof input';
|
|
159
191
|
const instanceOf = getMjstInstanceOf(schema);
|
|
160
192
|
if (instanceOf) {
|
|
161
|
-
return ` if (!(input instanceof ${instanceOf}))
|
|
193
|
+
return ` if (!(input instanceof ${instanceOf})) ${throwError(`[${typeName}] expected ${instanceOf}, got `, got)};`;
|
|
162
194
|
}
|
|
163
195
|
const primitive = getMjstPrimitive(schema);
|
|
164
196
|
if (primitive) {
|
|
165
|
-
return ` if (typeof input !== "${primitive}")
|
|
197
|
+
return ` if (typeof input !== "${primitive}") ${throwError(`[${typeName}] expected ${primitive}, got `, got)};`;
|
|
166
198
|
}
|
|
167
199
|
if (!isSchemaObject(schema) || !hasType(schema))
|
|
168
200
|
return null;
|
|
@@ -170,5 +202,5 @@ export const generateScalarStrictAssertion = (schema, typeName) => {
|
|
|
170
202
|
const wrongType = wrongTypeCondition('input', t);
|
|
171
203
|
if (!wrongType)
|
|
172
204
|
return null;
|
|
173
|
-
return ` if (${wrongType})
|
|
205
|
+
return ` if (${wrongType}) ${throwError(`[${typeName}] expected ${typeLabel(t)}, got `, got)};`;
|
|
174
206
|
};
|
|
@@ -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 { multipleOfPassExpr } from '@amritk/helpers/multiple-of-check';
|
|
3
4
|
import { resolveRef } from '@amritk/helpers/resolve-ref';
|
|
4
5
|
import { safeAccessor } from '@amritk/helpers/safe-accessor';
|
|
5
6
|
import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasConst, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasItems, hasMaxItems, hasMaximum, hasMaxLength, hasMaxProperties, hasMinItems, hasMinimum, hasMinLength, hasMinProperties, hasMultipleOf, hasOneOf, hasPattern, hasProperties, hasRef, hasRequired, hasType, hasUniqueItems, isSchemaObject, } from '@amritk/helpers/schema-guards';
|
|
@@ -33,16 +34,55 @@ export const scalarItemTypeCheck = (itemSchema, accessor) => {
|
|
|
33
34
|
case 'string':
|
|
34
35
|
return `typeof ${accessor} === "string"`;
|
|
35
36
|
case 'number':
|
|
37
|
+
return `typeof ${accessor} === "number"`;
|
|
36
38
|
case 'integer':
|
|
39
|
+
// `integer` items reject non-integral numbers; a bare typeof accepts `1.5`.
|
|
40
|
+
return `typeof ${accessor} === "number" && Number.isInteger(${accessor})`;
|
|
41
|
+
case 'boolean':
|
|
42
|
+
return `typeof ${accessor} === "boolean"`;
|
|
43
|
+
case 'null':
|
|
44
|
+
return `${accessor} === null`;
|
|
45
|
+
default:
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* A boolean type check for a single JSON Schema primitive type name. Unlike
|
|
51
|
+
* {@link scalarItemTypeCheck} this also covers `array`/`object` (as inline shape
|
|
52
|
+
* checks, so no `isObject` import is assumed) and enforces `integer` with
|
|
53
|
+
* `Number.isInteger` โ a bare `typeof === "number"` accepts `1.5`. Used to build
|
|
54
|
+
* the disjunction for an array-form `type` (e.g. `["string","null"]`).
|
|
55
|
+
*/
|
|
56
|
+
const singleTypeCheck = (accessor, type) => {
|
|
57
|
+
switch (type) {
|
|
58
|
+
case 'string':
|
|
59
|
+
return `typeof ${accessor} === "string"`;
|
|
60
|
+
case 'number':
|
|
37
61
|
return `typeof ${accessor} === "number"`;
|
|
62
|
+
case 'integer':
|
|
63
|
+
return `typeof ${accessor} === "number" && Number.isInteger(${accessor})`;
|
|
38
64
|
case 'boolean':
|
|
39
65
|
return `typeof ${accessor} === "boolean"`;
|
|
40
66
|
case 'null':
|
|
41
67
|
return `${accessor} === null`;
|
|
68
|
+
case 'array':
|
|
69
|
+
return `Array.isArray(${accessor})`;
|
|
70
|
+
case 'object':
|
|
71
|
+
return `typeof ${accessor} === "object" && ${accessor} !== null && !Array.isArray(${accessor})`;
|
|
42
72
|
default:
|
|
43
73
|
return null;
|
|
44
74
|
}
|
|
45
75
|
};
|
|
76
|
+
/**
|
|
77
|
+
* Returns the list of type names when `schema.type` is an array (the JSON Schema
|
|
78
|
+
* multi-type / nullable idiom, e.g. `["string","null"]`), or `null` for a single
|
|
79
|
+
* or absent type. Multi-type is validated as a *disjunction* of per-type checks.
|
|
80
|
+
*/
|
|
81
|
+
const getTypeArray = (schema) => {
|
|
82
|
+
if (!isSchemaObject(schema) || !('type' in schema) || !Array.isArray(schema.type))
|
|
83
|
+
return null;
|
|
84
|
+
return schema.type;
|
|
85
|
+
};
|
|
46
86
|
/**
|
|
47
87
|
* Generates a type coercion expression for converting a value to the expected type.
|
|
48
88
|
*/
|
|
@@ -171,6 +211,20 @@ export const generateValidationExpression = (key, schema, defaultValue, isRequir
|
|
|
171
211
|
? `${accessor} === ${constLiteral} ? ${accessor} : ${constLiteral}`
|
|
172
212
|
: `${accessor} === ${constLiteral} ? ${accessor} : (${accessor} !== undefined ? ${constLiteral} : undefined)`;
|
|
173
213
|
}
|
|
214
|
+
// Array-form `type` (multi-type / nullable, e.g. `["string","null"]`). `hasType`
|
|
215
|
+
// is false for an array `type`, so without this the value would fall through
|
|
216
|
+
// every branch below and emit NO validation โ not even a required-presence
|
|
217
|
+
// check. Emit a disjunction: the value is accepted when it matches ANY listed
|
|
218
|
+
// type, otherwise it is coerced to the default (required) or dropped (optional).
|
|
219
|
+
// A missing value fails every disjunct, so `required` presence is still enforced.
|
|
220
|
+
const typeArray = getTypeArray(schema);
|
|
221
|
+
if (typeArray && !hasEnum(schema)) {
|
|
222
|
+
const checks = typeArray.map((t) => singleTypeCheck(accessor, t)).filter((c) => c !== null);
|
|
223
|
+
if (checks.length > 0) {
|
|
224
|
+
const combined = checks.map((c) => `(${c})`).join(' || ');
|
|
225
|
+
return isRequired ? `${combined} ? ${accessor} : ${defaultValue}` : `${combined} ? ${accessor} : undefined`;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
174
228
|
if (!hasType(schema) && !hasEnum(schema)) {
|
|
175
229
|
return `${accessor} ?? ${defaultValue}`;
|
|
176
230
|
}
|
|
@@ -207,6 +261,11 @@ export const generateValidationExpression = (key, schema, defaultValue, isRequir
|
|
|
207
261
|
case 'number':
|
|
208
262
|
case 'integer': {
|
|
209
263
|
checks.push(`typeof ${accessor} === "number"`);
|
|
264
|
+
// `integer` must reject non-integral numbers; a bare `typeof === "number"`
|
|
265
|
+
// accepts `1.5`. Matches the validators package (which uses Number.isInteger).
|
|
266
|
+
if (schema.type === 'integer') {
|
|
267
|
+
checks.push(`Number.isInteger(${accessor})`);
|
|
268
|
+
}
|
|
210
269
|
if (hasMinimum(schema)) {
|
|
211
270
|
checks.push(`${accessor} >= ${schema.minimum}`);
|
|
212
271
|
}
|
|
@@ -220,7 +279,7 @@ export const generateValidationExpression = (key, schema, defaultValue, isRequir
|
|
|
220
279
|
checks.push(`${accessor} < ${schema.exclusiveMaximum}`);
|
|
221
280
|
}
|
|
222
281
|
if (hasMultipleOf(schema)) {
|
|
223
|
-
checks.push(
|
|
282
|
+
checks.push(multipleOfPassExpr(accessor, schema.multipleOf));
|
|
224
283
|
}
|
|
225
284
|
break;
|
|
226
285
|
}
|
|
@@ -248,7 +307,7 @@ export const generateValidationExpression = (key, schema, defaultValue, isRequir
|
|
|
248
307
|
// Check required properties exist
|
|
249
308
|
if (hasRequired(schema) && schema.required.length > 0) {
|
|
250
309
|
for (const requiredKey of schema.required) {
|
|
251
|
-
checks.push(
|
|
310
|
+
checks.push(`${JSON.stringify(requiredKey)} in ${accessor}`);
|
|
252
311
|
}
|
|
253
312
|
}
|
|
254
313
|
// Check minProperties
|
|
@@ -11,7 +11,9 @@ const EMBEDDED_NAMED_EXPORTS = {
|
|
|
11
11
|
'validate-record': 'validateRecord',
|
|
12
12
|
'has-ref': 'hasRef',
|
|
13
13
|
};
|
|
14
|
-
const embeddedImport = (helper, prefix) =>
|
|
14
|
+
const embeddedImport = (helper, prefix) =>
|
|
15
|
+
// `.js` extension so the embedded-helper import resolves under Node ESM, not only Bun.
|
|
16
|
+
`import { ${EMBEDDED_NAMED_EXPORTS[helper]} } from '${prefix}_helpers/${helper}.js';`;
|
|
15
17
|
/**
|
|
16
18
|
* Detects which runtime helpers a generated parser body references.
|
|
17
19
|
*
|
|
@@ -2,7 +2,11 @@ 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
4
|
import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasItems, hasOneOf, hasRef } from '@amritk/helpers/schema-guards';
|
|
5
|
-
|
|
5
|
+
// Emit an explicit `.js` extension on the relative specifier. Node's ESM resolver
|
|
6
|
+
// requires it (extensionless relative imports only work under a bundler or Bun),
|
|
7
|
+
// and `./x.js` pointing at a sibling `x.ts` is the standard TS NodeNext form โ
|
|
8
|
+
// accepted by Bun, esbuild, webpack, and tsc alike.
|
|
9
|
+
const getImportPathForFilename = (filename) => `./${filename}.js`;
|
|
6
10
|
/**
|
|
7
11
|
* Collects all import statements needed for a schema by finding $ref references
|
|
8
12
|
* that are directly used in the type definition (from properties, items, etc.).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amritk/generate-parsers",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.3",
|
|
4
4
|
"description": "Generate TypeScript parsers and type definitions from JSON Schemas.",
|
|
5
5
|
"module": "./dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"json-schema-typed": "^8.0.1",
|
|
51
|
-
"@amritk/helpers": "0.10.
|
|
52
|
-
"@amritk/generate-markdown": "0.
|
|
51
|
+
"@amritk/helpers": "0.10.2",
|
|
52
|
+
"@amritk/generate-markdown": "0.4.1"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
55
|
"@sinclair/typebox": "^0.34.49",
|