@amritk/generate-parsers 0.9.0 โ 0.11.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 +10 -0
- package/dist/generators/build-schema.d.ts +5 -1
- package/dist/generators/build-schema.js +6 -1
- package/dist/generators/generate-files.d.ts +9 -0
- package/dist/generators/generate-files.js +3 -1
- package/dist/generators/generate-parser-function.d.ts +11 -1
- package/dist/generators/generate-parser-function.js +252 -155
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -134,6 +134,16 @@ Returns: `Promise<GeneratedFile[]>`.
|
|
|
134
134
|
<tr>
|
|
135
135
|
<td colspan="5">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
136
|
</tr>
|
|
137
|
+
<tr>
|
|
138
|
+
<td>๐งน <code>stripUnknown</code></td>
|
|
139
|
+
<td>โ</td>
|
|
140
|
+
<td><code>boolean</code></td>
|
|
141
|
+
<td align="center">โ</td>
|
|
142
|
+
<td align="center"><code>false</code></td>
|
|
143
|
+
</tr>
|
|
144
|
+
<tr>
|
|
145
|
+
<td colspan="5">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
|
+
</tr>
|
|
137
147
|
</tbody>
|
|
138
148
|
</table>
|
|
139
149
|
<!-- config-table-end -->
|
|
@@ -38,6 +38,10 @@ export type GeneratedFile = {
|
|
|
38
38
|
* directory while the helper sources are emitted once at the output root.
|
|
39
39
|
* @param readonly - When true, every property, array, and record in the generated type
|
|
40
40
|
* definitions is emitted as `readonly`.
|
|
41
|
+
* @param stripUnknown - When true, the generated parsers build their result from declared
|
|
42
|
+
* properties only, silently dropping undeclared input keys at every nesting level (zod's
|
|
43
|
+
* `.strip()`) without treating extras as a validation error. Composes with `strict` and
|
|
44
|
+
* yields to `additionalProperties: false` (which rejects rather than strips in strict mode).
|
|
41
45
|
* @param typeSuffix - Suffix appended to every type/parser name derived from a `$ref`
|
|
42
46
|
* (e.g. `'Object'` โ `ContactObject`). Defaults to `''` (no suffix). The root type name
|
|
43
47
|
* is used verbatim and is not affected by this suffix.
|
|
@@ -73,4 +77,4 @@ export type GeneratedFile = {
|
|
|
73
77
|
* });
|
|
74
78
|
* ```
|
|
75
79
|
*/
|
|
76
|
-
export declare const buildSchema: (rootSchema: JSONSchema, rootTypeName: string, extensions?: SchemaExtensions, typesOnly?: boolean, logWarnings?: boolean, strict?: boolean, helpersMode?: HelpersMode, helpersImportPrefix?: string, readonly?: boolean, typeSuffix?: string) => Promise<GeneratedFile[]>;
|
|
80
|
+
export declare const buildSchema: (rootSchema: JSONSchema, rootTypeName: string, extensions?: SchemaExtensions, typesOnly?: boolean, logWarnings?: boolean, strict?: boolean, helpersMode?: HelpersMode, helpersImportPrefix?: string, readonly?: boolean, stripUnknown?: boolean, typeSuffix?: string) => Promise<GeneratedFile[]>;
|
|
@@ -43,6 +43,10 @@ const readHelperSource = async (helper) => {
|
|
|
43
43
|
* directory while the helper sources are emitted once at the output root.
|
|
44
44
|
* @param readonly - When true, every property, array, and record in the generated type
|
|
45
45
|
* definitions is emitted as `readonly`.
|
|
46
|
+
* @param stripUnknown - When true, the generated parsers build their result from declared
|
|
47
|
+
* properties only, silently dropping undeclared input keys at every nesting level (zod's
|
|
48
|
+
* `.strip()`) without treating extras as a validation error. Composes with `strict` and
|
|
49
|
+
* yields to `additionalProperties: false` (which rejects rather than strips in strict mode).
|
|
46
50
|
* @param typeSuffix - Suffix appended to every type/parser name derived from a `$ref`
|
|
47
51
|
* (e.g. `'Object'` โ `ContactObject`). Defaults to `''` (no suffix). The root type name
|
|
48
52
|
* is used verbatim and is not affected by this suffix.
|
|
@@ -78,7 +82,7 @@ const readHelperSource = async (helper) => {
|
|
|
78
82
|
* });
|
|
79
83
|
* ```
|
|
80
84
|
*/
|
|
81
|
-
export const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logWarnings, strict, helpersMode = 'package', helpersImportPrefix = './', readonly = false, typeSuffix = '') => {
|
|
85
|
+
export const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logWarnings, strict, helpersMode = 'package', helpersImportPrefix = './', readonly = false, stripUnknown = false, typeSuffix = '') => {
|
|
82
86
|
const files = [];
|
|
83
87
|
const usedHelpers = new Set();
|
|
84
88
|
walkRefGraph(rootSchema, rootTypeName, { typeSuffix }, (node) => {
|
|
@@ -99,6 +103,7 @@ export const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnl
|
|
|
99
103
|
...(node.ref !== undefined ? { selfRef: node.ref } : {}),
|
|
100
104
|
...(logWarnings !== undefined ? { logWarnings } : {}),
|
|
101
105
|
...(strict !== undefined ? { strict } : {}),
|
|
106
|
+
...(stripUnknown ? { stripUnknown } : {}),
|
|
102
107
|
});
|
|
103
108
|
files.push({ filename: `${node.filename}.ts`, content: result.content });
|
|
104
109
|
for (const helper of result.usedHelpers)
|
|
@@ -30,6 +30,15 @@ type GenerateFileOptions = {
|
|
|
30
30
|
* of coercing invalid input to default values.
|
|
31
31
|
*/
|
|
32
32
|
readonly strict?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* When true, the generated parser builds its result from declared properties
|
|
35
|
+
* only, silently dropping undeclared input keys at every nesting level (zod's
|
|
36
|
+
* `.strip()`), without treating extras as a validation error. Composes with
|
|
37
|
+
* `strict` (still throws on wrong types / missing required properties) and
|
|
38
|
+
* yields to `additionalProperties: false` (which rejects rather than strips in
|
|
39
|
+
* strict mode).
|
|
40
|
+
*/
|
|
41
|
+
readonly stripUnknown?: boolean;
|
|
33
42
|
/**
|
|
34
43
|
* Controls how runtime helpers are referenced.
|
|
35
44
|
* - `'package'` (default): emit imports from `@amritk/helpers/...`.
|
|
@@ -51,13 +51,15 @@ export const generateFile = (schema, typeName, options) => {
|
|
|
51
51
|
}
|
|
52
52
|
return { content: result + typeDefinition, usedHelpers: new Set() };
|
|
53
53
|
}
|
|
54
|
+
const stripUnknown = options?.stripUnknown ?? false;
|
|
54
55
|
const parserFunction = generateParserFunction(schema, typeName, {
|
|
55
56
|
useRefImports: true,
|
|
56
57
|
typeSuffix,
|
|
57
58
|
...(options?.logWarnings !== undefined ? { logWarnings: options.logWarnings } : {}),
|
|
58
59
|
...(options?.strict !== undefined ? { strict: options.strict } : {}),
|
|
60
|
+
...(options?.stripUnknown !== undefined ? { stripUnknown: options.stripUnknown } : {}),
|
|
59
61
|
});
|
|
60
|
-
const shapeValidator = generateShapeValidator(schema, typeName, true, typeSuffix);
|
|
62
|
+
const shapeValidator = generateShapeValidator(schema, typeName, true, typeSuffix, true, stripUnknown);
|
|
61
63
|
const combinedFunctions = `${shapeValidator}\n\n${parserFunction}`;
|
|
62
64
|
const helpers = collectHelpers(combinedFunctions, helpersMode, options?.helpersImportPrefix);
|
|
63
65
|
const imports = [...collectImports(schema, { selfRef, rootSchema, typeSuffix }), ...helpers.imports];
|
|
@@ -21,6 +21,16 @@ type GenerateParserOptions = {
|
|
|
21
21
|
* undeclared keys throw too; otherwise they are still allowed.
|
|
22
22
|
*/
|
|
23
23
|
readonly strict?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* When true, the generated parser builds its result from the schema's declared
|
|
26
|
+
* properties only, silently dropping any undeclared input key at every nesting
|
|
27
|
+
* level (zod's `.strip()`). Unlike `additionalProperties: false`, extras are
|
|
28
|
+
* never a validation error โ this composes with `strict`: `strict + stripUnknown`
|
|
29
|
+
* still throws on wrong types and missing required properties but strips extras
|
|
30
|
+
* instead of throwing on them. When the schema *also* sets
|
|
31
|
+
* `additionalProperties: false`, rejecting wins over stripping in strict mode.
|
|
32
|
+
*/
|
|
33
|
+
readonly stripUnknown?: boolean;
|
|
24
34
|
/**
|
|
25
35
|
* Suffix appended to every type/parser name derived from a `$ref`. Must match
|
|
26
36
|
* the suffix used when generating the referenced files. Defaults to `''`.
|
|
@@ -55,5 +65,5 @@ export declare const shapeValidatorFunctionName: (typeName: string) => string;
|
|
|
55
65
|
* in additionalProperties). The stub is safe: parents calling it will fall
|
|
56
66
|
* through to the slow path, matching the pre-deep-fast-path behavior.
|
|
57
67
|
*/
|
|
58
|
-
export declare const generateShapeValidator: (schema: JSONSchema, typeName: string, useRefImports: boolean, suffix?: string, exported?: boolean) => string;
|
|
68
|
+
export declare const generateShapeValidator: (schema: JSONSchema, typeName: string, useRefImports: boolean, suffix?: string, exported?: boolean, stripUnknown?: boolean) => string;
|
|
59
69
|
export {};
|
|
@@ -530,7 +530,7 @@ const shouldCacheVariable = (propSchema, _canFastPath, _useRefImports) => {
|
|
|
530
530
|
* - Fast path that returns input directly when all properties are already valid
|
|
531
531
|
* - Slow path with optimized expressions
|
|
532
532
|
*/
|
|
533
|
-
const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnings, strict, exported = true) => {
|
|
533
|
+
const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnings, strict, exported = true, stripUnknown = false) => {
|
|
534
534
|
const functionName = generateParserName(typeName);
|
|
535
535
|
const exportPrefix = exported ? 'export ' : '';
|
|
536
536
|
if (!hasProperties(schema)) {
|
|
@@ -549,38 +549,32 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
|
|
|
549
549
|
for (const [key, subName] of subTypeNames) {
|
|
550
550
|
const propSchema = schema.properties[key];
|
|
551
551
|
preamble.push(`type ${subName} = ${typeName}[${JSON.stringify(key)}];`);
|
|
552
|
-
preamble.push(generateShapeValidator(propSchema, subName, useRefImports, suffix, false));
|
|
553
|
-
preamble.push(generateObjectParser(propSchema, subName, useRefImports, suffix, logWarnings, strict, false));
|
|
552
|
+
preamble.push(generateShapeValidator(propSchema, subName, useRefImports, suffix, false, stripUnknown));
|
|
553
|
+
preamble.push(generateObjectParser(propSchema, subName, useRefImports, suffix, logWarnings, strict, false, stripUnknown));
|
|
554
554
|
}
|
|
555
555
|
// additionalProperties: false โ the per-key "is this undeclared" test inlines
|
|
556
556
|
// `!==` comparisons for a short key list and hoists a Set only for a long one
|
|
557
557
|
// (see unknownKeyCheck). The predicate form is shared by the fast path and the
|
|
558
558
|
// shape validator; strict mode throws with the offending key.
|
|
559
|
+
// `additionalProperties: false` strips *and rejects* extras; `stripUnknown`
|
|
560
|
+
// only strips them. Both build the result from declared properties alone, so
|
|
561
|
+
// they share the same machinery (`stripKeys`): drop the `...input` spread and
|
|
562
|
+
// gate the `{ ...input }` fast path on the known-keys predicate. Only the real
|
|
563
|
+
// `strictKeys` makes an extra a hard error (the throw block further down).
|
|
559
564
|
const strictKeys = hasStrictKeys(schema);
|
|
565
|
+
const stripKeys = strictKeys || stripUnknown;
|
|
560
566
|
const strictKeyCheck = unknownKeyCheck(properties.map(([key]) => key), `_knownKeys${typeName}`);
|
|
561
|
-
if (
|
|
567
|
+
if (stripKeys) {
|
|
562
568
|
for (const declaration of strictKeyCheck.declarations) {
|
|
563
569
|
preamble.push(`${declaration};`);
|
|
564
570
|
}
|
|
565
571
|
preamble.push(`const _hasOnlyKnownKeys${typeName} = (input: Record<string, unknown>): boolean => {\n for (const _k in input) if (${strictKeyCheck.isUnknown('_k')}) return false;\n return true;\n};`);
|
|
566
572
|
}
|
|
567
573
|
const fallbackObject = generateFallbackObject(schema, useRefImports, typeName, suffix, subTypeNames);
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
lines.push(assertionLine);
|
|
573
|
-
}
|
|
574
|
-
if (strictKeys) {
|
|
575
|
-
lines.push(` for (const _k in input) {`);
|
|
576
|
-
lines.push(` if (${strictKeyCheck.isUnknown('_k')}) throw new Error(\`[${typeName}] unknown property "\${_k}"\`);`);
|
|
577
|
-
lines.push(` }`);
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
else {
|
|
581
|
-
lines.push(` if (!isObject(input)) return ${fallbackObject};`);
|
|
582
|
-
}
|
|
583
|
-
// First pass: determine if we can generate a fast path
|
|
574
|
+
// First pass: gather per-property info and decide whether a fast path is
|
|
575
|
+
// possible. A fast path returns the input (or a direct strip-build) for
|
|
576
|
+
// already-valid input without re-running per-property validation, so it may
|
|
577
|
+
// only fire when every property can be cheaply proven in-shape.
|
|
584
578
|
const propInfo = [];
|
|
585
579
|
// Disable fast path when allOf $ref parsers need to be spread โ the fast path
|
|
586
580
|
// returns input as-is and would skip those coercions.
|
|
@@ -594,160 +588,259 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
|
|
|
594
588
|
const isRequired = isPropertyRequired(key, schema);
|
|
595
589
|
const varName = toVarName(key);
|
|
596
590
|
propInfo.push({ key, varName, isRequired, propSchema });
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
591
|
+
if (!canFastPath)
|
|
592
|
+
continue;
|
|
593
|
+
// Records of refs require iterating every value to check shape โ too
|
|
594
|
+
// expensive to inline into the parser's fast path. Disable.
|
|
595
|
+
if (shouldUseRecordRefImport(propSchema, useRefImports)) {
|
|
596
|
+
canFastPath = false;
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
const subName = subTypeNames.get(key);
|
|
600
|
+
// Inline nested objects fast-path through their private shape predicate, the
|
|
601
|
+
// same way $ref properties use the imported one.
|
|
602
|
+
const check = subName
|
|
603
|
+
? `${shapeValidatorName(subName)}(${varName})`
|
|
604
|
+
: generatePropertyTypeCheck(varName, propSchema, useRefImports, suffix);
|
|
605
|
+
if (check === null) {
|
|
606
|
+
canFastPath = false;
|
|
607
|
+
}
|
|
608
|
+
else if (isRequired) {
|
|
609
|
+
fastPathChecks.push(check);
|
|
610
|
+
}
|
|
611
|
+
else {
|
|
612
|
+
fastPathChecks.push(`(${varName} === undefined || ${check})`);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
// The `{ ...input }` fast path preserves extras, so when stripping it may only
|
|
616
|
+
// fire on inputs that carry no undeclared key. For strict + additionalProperties:
|
|
617
|
+
// false the cold path rejects extras instead; folding the known-keys term into
|
|
618
|
+
// the guard lets the guard run *before* that rejection, so a valid clean input
|
|
619
|
+
// never pays for the per-property assertions.
|
|
620
|
+
if (stripKeys) {
|
|
621
|
+
fastPathChecks.push(`_hasOnlyKnownKeys${typeName}(input)`);
|
|
622
|
+
}
|
|
623
|
+
// The deep guard proves the whole shape (so `{ ...input }` can be returned),
|
|
624
|
+
// using the nested shape predicates and the known-keys term above.
|
|
625
|
+
const deepGuard = canFastPath && fastPathChecks.length > 0 ? fastPathChecks.join(' && ') : null;
|
|
626
|
+
// The shallow guard powers the strict strip-build fast path (stripUnknown
|
|
627
|
+
// without additionalProperties: false): it proves every scalar is well-typed
|
|
628
|
+
// and every nested object is an object, but neither walks nested shapes deeply
|
|
629
|
+
// nor requires the absence of extras. That lets it fire on the common
|
|
630
|
+
// stripUnknown input โ which carries extras the build removes, and nested
|
|
631
|
+
// extras each sub-parser removes โ while the cold path's per-property
|
|
632
|
+
// assertions still produce the precise error on a genuine mismatch.
|
|
633
|
+
let canShallowGuard = strict && stripUnknown && !strictKeys;
|
|
634
|
+
const shallowChecks = [];
|
|
635
|
+
if (canShallowGuard) {
|
|
636
|
+
for (const { key, varName, isRequired, propSchema } of propInfo) {
|
|
637
|
+
const subName = subTypeNames.get(key);
|
|
638
|
+
let check;
|
|
639
|
+
if (subName) {
|
|
640
|
+
// Nested inline object: a shallow `isObject` is enough โ the sub-parser
|
|
641
|
+
// validates and strips it.
|
|
642
|
+
check = `isObject(${varName})`;
|
|
643
|
+
}
|
|
644
|
+
else if (isSchemaObject(propSchema) &&
|
|
645
|
+
!hasEnum(propSchema) &&
|
|
646
|
+
!hasRef(propSchema) &&
|
|
647
|
+
!hasOneOf(propSchema) &&
|
|
648
|
+
!hasAnyOf(propSchema) &&
|
|
649
|
+
!hasAllOf(propSchema) &&
|
|
650
|
+
!('not' in propSchema) &&
|
|
651
|
+
!shouldUseRefImport(propSchema, useRefImports) &&
|
|
652
|
+
!shouldUseArrayRefImport(propSchema, useRefImports) &&
|
|
653
|
+
!shouldUseRecordRefImport(propSchema, useRefImports)) {
|
|
654
|
+
// Plain scalar / array / object: its own type check is already shallow
|
|
655
|
+
// (arrays are not walked, objects only `isObject`-checked).
|
|
656
|
+
check = generatePropertyTypeCheck(varName, propSchema, useRefImports, suffix);
|
|
603
657
|
}
|
|
604
658
|
else {
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
: generatePropertyTypeCheck(varName, propSchema, useRefImports, suffix);
|
|
611
|
-
if (check === null) {
|
|
612
|
-
canFastPath = false;
|
|
613
|
-
}
|
|
614
|
-
else {
|
|
615
|
-
if (isRequired) {
|
|
616
|
-
fastPathChecks.push(check);
|
|
617
|
-
}
|
|
618
|
-
else {
|
|
619
|
-
fastPathChecks.push(`(${varName} === undefined || ${check})`);
|
|
620
|
-
}
|
|
621
|
-
}
|
|
659
|
+
check = null;
|
|
660
|
+
}
|
|
661
|
+
if (check === null) {
|
|
662
|
+
canShallowGuard = false;
|
|
663
|
+
break;
|
|
622
664
|
}
|
|
665
|
+
shallowChecks.push(isRequired ? check : `(${varName} === undefined || ${check})`);
|
|
623
666
|
}
|
|
624
667
|
}
|
|
625
|
-
|
|
626
|
-
//
|
|
627
|
-
//
|
|
628
|
-
|
|
629
|
-
fastPathChecks.push(`_hasOnlyKnownKeys${typeName}(input)`);
|
|
630
|
-
}
|
|
631
|
-
// Second pass: generate variable declarations only for properties that need them
|
|
668
|
+
const shallowGuard = canShallowGuard && shallowChecks.length > 0 ? shallowChecks.join(' && ') : null;
|
|
669
|
+
// Variable declarations for properties that need them (the build and guards
|
|
670
|
+
// read each cached value once instead of re-accessing `input`).
|
|
671
|
+
const varDeclLines = [];
|
|
632
672
|
for (const { key, varName, propSchema } of propInfo) {
|
|
633
673
|
if (shouldCacheVariable(propSchema, canFastPath, useRefImports)) {
|
|
634
|
-
|
|
674
|
+
varDeclLines.push(` const ${varName} = ${safeAccessor('input', key)};`);
|
|
635
675
|
}
|
|
636
676
|
}
|
|
637
|
-
// Emit a warning for any input key not declared in the schema's properties
|
|
677
|
+
// Emit a warning for any input key not declared in the schema's properties.
|
|
678
|
+
const warnLines = [];
|
|
638
679
|
if (logWarnings && propInfo.length > 0) {
|
|
639
680
|
const warnKeyCheck = unknownKeyCheck(propInfo.map(({ key }) => key), '_knownKeys');
|
|
640
681
|
for (const declaration of warnKeyCheck.declarations) {
|
|
641
|
-
|
|
682
|
+
warnLines.push(` ${declaration};`);
|
|
642
683
|
}
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
}
|
|
649
|
-
//
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
684
|
+
warnLines.push(` for (const _k in input) {`);
|
|
685
|
+
warnLines.push(` if (${warnKeyCheck.isUnknown('_k')}) {`);
|
|
686
|
+
warnLines.push(` console.warn(\`[${typeName}] Unknown property "\${_k}"\`);`);
|
|
687
|
+
warnLines.push(` }`);
|
|
688
|
+
warnLines.push(` }`);
|
|
689
|
+
}
|
|
690
|
+
// Builds the result object literal. `directAssign` (strict mode) assigns each
|
|
691
|
+
// field straight from its cached read โ the guard or the per-property
|
|
692
|
+
// assertions have already proven the type, so no coercion ternary is needed.
|
|
693
|
+
// Coerce mode keeps the type-checking-and-coercing expression. Refs, arrays of
|
|
694
|
+
// refs, records of refs and inline nested objects always delegate to their
|
|
695
|
+
// parser in both modes; the input spread is dropped whenever stripping.
|
|
696
|
+
const buildObjectLines = (directAssign) => {
|
|
697
|
+
const objectLines = [];
|
|
698
|
+
if (!stripKeys) {
|
|
699
|
+
objectLines.push(' ...input,');
|
|
654
700
|
}
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
}
|
|
664
|
-
// Spread allOf $ref parsers so their field coercions are applied before
|
|
665
|
-
// the explicit property validations below.
|
|
666
|
-
if (useRefImports && isSchemaObject(schema) && hasAllOf(schema)) {
|
|
667
|
-
for (const entry of schema.allOf) {
|
|
668
|
-
if (isSchemaObject(entry) && hasRef(entry)) {
|
|
669
|
-
const parserName = generateParserName(refToName(entry.$ref, suffix));
|
|
670
|
-
objectLines.push(` ...(${parserName}(input) as Record<string, unknown>),`);
|
|
701
|
+
// Spread allOf $ref parsers so their field coercions are applied before
|
|
702
|
+
// the explicit property validations below.
|
|
703
|
+
if (useRefImports && isSchemaObject(schema) && hasAllOf(schema)) {
|
|
704
|
+
for (const entry of schema.allOf) {
|
|
705
|
+
if (isSchemaObject(entry) && hasRef(entry)) {
|
|
706
|
+
const parserName = generateParserName(refToName(entry.$ref, suffix));
|
|
707
|
+
objectLines.push(` ...(${parserName}(input) as Record<string, unknown>),`);
|
|
708
|
+
}
|
|
671
709
|
}
|
|
672
710
|
}
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
711
|
+
for (const { key, varName, isRequired, propSchema } of propInfo) {
|
|
712
|
+
const shouldCache = shouldCacheVariable(propSchema, canFastPath, useRefImports);
|
|
713
|
+
const accessor = shouldCache ? varName : safeAccessor('input', key);
|
|
714
|
+
// Handle inline nested objects via their private sub-parser, mirroring
|
|
715
|
+
// how $ref properties delegate to the imported parser.
|
|
716
|
+
const subName = subTypeNames.get(key);
|
|
717
|
+
if (subName) {
|
|
718
|
+
const subParserName = generateParserName(subName);
|
|
719
|
+
if (isRequired) {
|
|
720
|
+
objectLines.push(` ${safeKey(key)}: ${subParserName}(${accessor}),`);
|
|
721
|
+
}
|
|
722
|
+
else {
|
|
723
|
+
objectLines.push(` ...(${accessor} !== undefined && { ${safeKey(key)}: ${subParserName}(${accessor}) }),`);
|
|
724
|
+
}
|
|
725
|
+
continue;
|
|
684
726
|
}
|
|
685
|
-
|
|
686
|
-
|
|
727
|
+
// Handle direct $ref properties via imported parsers
|
|
728
|
+
if (shouldUseRefImport(propSchema, useRefImports)) {
|
|
729
|
+
const ref = propSchema.$ref;
|
|
730
|
+
const parserName = generateParserName(refToName(ref, suffix));
|
|
731
|
+
if (isRequired) {
|
|
732
|
+
objectLines.push(` ${safeKey(key)}: ${parserName}(${accessor}),`);
|
|
733
|
+
}
|
|
734
|
+
else {
|
|
735
|
+
objectLines.push(` ...(${accessor} !== undefined && { ${safeKey(key)}: ${parserName}(${accessor}) }),`);
|
|
736
|
+
}
|
|
737
|
+
continue;
|
|
687
738
|
}
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
739
|
+
// Handle array properties with $ref items
|
|
740
|
+
if (shouldUseArrayRefImport(propSchema, useRefImports)) {
|
|
741
|
+
const items = propSchema.items;
|
|
742
|
+
const ref = items.$ref;
|
|
743
|
+
const parserName = generateParserName(refToName(ref, suffix));
|
|
744
|
+
if (isRequired) {
|
|
745
|
+
objectLines.push(` ${safeKey(key)}: validateArray(${accessor}, ${parserName}),`);
|
|
746
|
+
}
|
|
747
|
+
else {
|
|
748
|
+
objectLines.push(` ...(${accessor} !== undefined && { ${safeKey(key)}: validateArray(${accessor}, ${parserName}) }),`);
|
|
749
|
+
}
|
|
750
|
+
continue;
|
|
696
751
|
}
|
|
697
|
-
|
|
698
|
-
|
|
752
|
+
// Handle object properties with additionalProperties $ref
|
|
753
|
+
if (shouldUseRecordRefImport(propSchema, useRefImports)) {
|
|
754
|
+
const additionalProps = propSchema.additionalProperties;
|
|
755
|
+
const ref = additionalProps.$ref;
|
|
756
|
+
const parserName = generateParserName(refToName(ref, suffix));
|
|
757
|
+
if (isRequired) {
|
|
758
|
+
objectLines.push(` ${safeKey(key)}: validateRecord(${accessor}, ${parserName}),`);
|
|
759
|
+
}
|
|
760
|
+
else {
|
|
761
|
+
objectLines.push(` ...(${accessor} !== undefined && { ${safeKey(key)}: validateRecord(${accessor}, ${parserName}) }),`);
|
|
762
|
+
}
|
|
763
|
+
continue;
|
|
699
764
|
}
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
765
|
+
// Handle non-schema-object properties
|
|
766
|
+
if (!isSchemaObject(propSchema)) {
|
|
767
|
+
if (isRequired) {
|
|
768
|
+
objectLines.push(` ${safeKey(key)}: undefined,`);
|
|
769
|
+
}
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
// Strict mode has already proven the value's type (by guard or assertion),
|
|
773
|
+
// so the field is assigned straight from the cached read with no coercion.
|
|
774
|
+
let valueExpr;
|
|
775
|
+
if (directAssign) {
|
|
776
|
+
valueExpr = accessor;
|
|
709
777
|
}
|
|
710
778
|
else {
|
|
711
|
-
|
|
779
|
+
const defaultValue = getDefaultValue(propSchema);
|
|
780
|
+
// For optional properties we know the value is not undefined because
|
|
781
|
+
// we're inside the `...(accessor !== undefined && { ... })` check.
|
|
782
|
+
const knownNotUndefined = !isRequired;
|
|
783
|
+
valueExpr = generateValidationExpression(key, propSchema, defaultValue, true, undefined, undefined, shouldCache ? varName : undefined, knownNotUndefined);
|
|
712
784
|
}
|
|
713
|
-
continue;
|
|
714
|
-
}
|
|
715
|
-
// Handle object properties with additionalProperties $ref
|
|
716
|
-
if (shouldUseRecordRefImport(propSchema, useRefImports)) {
|
|
717
|
-
const additionalProps = propSchema.additionalProperties;
|
|
718
|
-
const ref = additionalProps.$ref;
|
|
719
|
-
const parserName = generateParserName(refToName(ref, suffix));
|
|
720
785
|
if (isRequired) {
|
|
721
|
-
objectLines.push(` ${safeKey(key)}:
|
|
786
|
+
objectLines.push(` ${safeKey(key)}: ${valueExpr},`);
|
|
722
787
|
}
|
|
723
788
|
else {
|
|
724
|
-
objectLines.push(` ...(${accessor} !== undefined && { ${safeKey(key)}:
|
|
789
|
+
objectLines.push(` ...(${accessor} !== undefined && { ${safeKey(key)}: ${valueExpr} }),`);
|
|
725
790
|
}
|
|
726
|
-
continue;
|
|
727
791
|
}
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
792
|
+
return objectLines;
|
|
793
|
+
};
|
|
794
|
+
const emitReturn = (lines, objectLines) => {
|
|
795
|
+
lines.push(` return {`);
|
|
796
|
+
lines.push(objectLines.join('\n'));
|
|
797
|
+
lines.push(` } as unknown as ${typeName};`);
|
|
798
|
+
};
|
|
799
|
+
const lines = [];
|
|
800
|
+
lines.push(`${exportPrefix}const ${functionName} = (input: unknown): ${typeName} => {`);
|
|
801
|
+
if (strict) {
|
|
802
|
+
// Guard first: a non-object throws straight away; a clean, well-typed input
|
|
803
|
+
// then short-circuits past the per-property assertions, which only run to
|
|
804
|
+
// pinpoint the failure when the guard rejects the input.
|
|
805
|
+
lines.push(` if (!isObject(input)) throw new Error(\`[${typeName}] expected object, got \${input === null ? "null" : typeof input}\`);`);
|
|
806
|
+
lines.push(...varDeclLines);
|
|
807
|
+
lines.push(...warnLines);
|
|
808
|
+
// The first assertion line is the `isObject` check, already done above.
|
|
809
|
+
const assertionLines = generateObjectStrictAssertion(schema, typeName).slice(1);
|
|
810
|
+
if (shallowGuard) {
|
|
811
|
+
// stripUnknown: a well-typed input skips the assertions and goes straight
|
|
812
|
+
// to the strip build (which removes extras and recurses into sub-parsers).
|
|
813
|
+
lines.push(` if (!(${shallowGuard})) {`);
|
|
814
|
+
for (const assertionLine of assertionLines) {
|
|
815
|
+
lines.push(` ${assertionLine}`);
|
|
732
816
|
}
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
// Generate validation expression
|
|
736
|
-
const defaultValue = getDefaultValue(propSchema);
|
|
737
|
-
// For optional properties in the slow path, we know the value is not undefined
|
|
738
|
-
// because we're inside the `...(accessor !== undefined && { ... })` check
|
|
739
|
-
const knownNotUndefined = !isRequired;
|
|
740
|
-
const valueExpr = generateValidationExpression(key, propSchema, defaultValue, true, undefined, undefined, shouldCache ? varName : undefined, knownNotUndefined);
|
|
741
|
-
if (isRequired) {
|
|
742
|
-
objectLines.push(` ${safeKey(key)}: ${valueExpr},`);
|
|
817
|
+
lines.push(` }`);
|
|
818
|
+
emitReturn(lines, buildObjectLines(true));
|
|
743
819
|
}
|
|
744
820
|
else {
|
|
745
|
-
|
|
821
|
+
if (deepGuard) {
|
|
822
|
+
lines.push(` if (${deepGuard}) return { ...input } as ${typeName};`);
|
|
823
|
+
}
|
|
824
|
+
for (const assertionLine of assertionLines) {
|
|
825
|
+
lines.push(assertionLine);
|
|
826
|
+
}
|
|
827
|
+
if (strictKeys) {
|
|
828
|
+
lines.push(` for (const _k in input) {`);
|
|
829
|
+
lines.push(` if (${strictKeyCheck.isUnknown('_k')}) throw new Error(\`[${typeName}] unknown property "\${_k}"\`);`);
|
|
830
|
+
lines.push(` }`);
|
|
831
|
+
}
|
|
832
|
+
emitReturn(lines, buildObjectLines(true));
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
else {
|
|
836
|
+
lines.push(` if (!isObject(input)) return ${fallbackObject};`);
|
|
837
|
+
lines.push(...varDeclLines);
|
|
838
|
+
lines.push(...warnLines);
|
|
839
|
+
if (deepGuard) {
|
|
840
|
+
lines.push(` if (${deepGuard}) return { ...input } as ${typeName};`);
|
|
746
841
|
}
|
|
842
|
+
emitReturn(lines, buildObjectLines(false));
|
|
747
843
|
}
|
|
748
|
-
lines.push(` return {`);
|
|
749
|
-
lines.push(objectLines.join('\n'));
|
|
750
|
-
lines.push(` } as unknown as ${typeName};`);
|
|
751
844
|
lines.push(`}`);
|
|
752
845
|
const fn = lines.join('\n');
|
|
753
846
|
if (preamble.length === 0)
|
|
@@ -811,7 +904,7 @@ ${loopLines.join('\n')}
|
|
|
811
904
|
* Generates a parser for schemas that have both properties AND patternProperties.
|
|
812
905
|
* Parses the known properties first, then iterates remaining keys to match patterns.
|
|
813
906
|
*/
|
|
814
|
-
const generateCombinedObjectParser = (schema, typeName, useRefImports, suffix, logWarnings, strict) => {
|
|
907
|
+
const generateCombinedObjectParser = (schema, typeName, useRefImports, suffix, logWarnings, strict, stripUnknown = false) => {
|
|
815
908
|
const functionName = generateParserName(typeName);
|
|
816
909
|
const entries = generatePropertyEntries(schema, useRefImports, suffix);
|
|
817
910
|
// Build the known property lines for the initial object
|
|
@@ -823,7 +916,7 @@ const generateCombinedObjectParser = (schema, typeName, useRefImports, suffix, l
|
|
|
823
916
|
});
|
|
824
917
|
// Find the first pattern with a $ref for parser delegation
|
|
825
918
|
if (!isSchemaObject(schema) || !('patternProperties' in schema)) {
|
|
826
|
-
return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict);
|
|
919
|
+
return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown);
|
|
827
920
|
}
|
|
828
921
|
const patternProps = schema.patternProperties;
|
|
829
922
|
const patterns = Object.entries(patternProps);
|
|
@@ -837,7 +930,7 @@ const generateCombinedObjectParser = (schema, typeName, useRefImports, suffix, l
|
|
|
837
930
|
return generateStrictCombinedParser(schema, typeName, functionName, propertyLines, patterns, useRefImports, suffix, strict);
|
|
838
931
|
}
|
|
839
932
|
if (!refPattern || !useRefImports) {
|
|
840
|
-
return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict);
|
|
933
|
+
return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown);
|
|
841
934
|
}
|
|
842
935
|
const [pattern, patternSchema] = refPattern;
|
|
843
936
|
const ref = patternSchema.$ref;
|
|
@@ -1016,7 +1109,7 @@ ${notObjectBranch}
|
|
|
1016
1109
|
* This always generates parser calls regardless of useRefImports setting,
|
|
1017
1110
|
* because conditional logic requires delegating to the appropriate parser.
|
|
1018
1111
|
*/
|
|
1019
|
-
const generateConditionalParser = (schema, typeName, suffix) => {
|
|
1112
|
+
const generateConditionalParser = (schema, typeName, suffix, stripUnknown = false) => {
|
|
1020
1113
|
const functionName = generateParserName(typeName);
|
|
1021
1114
|
// Extract the condition and branches
|
|
1022
1115
|
const ifSchema = schema.if;
|
|
@@ -1031,7 +1124,7 @@ const generateConditionalParser = (schema, typeName, suffix) => {
|
|
|
1031
1124
|
// regular object parser from the merged property set.
|
|
1032
1125
|
const mergedSchema = getConditionalObjectSchema(schema);
|
|
1033
1126
|
if (mergedSchema) {
|
|
1034
|
-
return generateObjectParser(mergedSchema, typeName, false, suffix);
|
|
1127
|
+
return generateObjectParser(mergedSchema, typeName, false, suffix, false, false, true, stripUnknown);
|
|
1035
1128
|
}
|
|
1036
1129
|
return generateEmptyObjectParser(typeName);
|
|
1037
1130
|
}
|
|
@@ -1045,7 +1138,7 @@ const generateConditionalParser = (schema, typeName, suffix) => {
|
|
|
1045
1138
|
// Condition is not a $ref check โ flatten into an object parser
|
|
1046
1139
|
const mergedSchema = getConditionalObjectSchema(schema);
|
|
1047
1140
|
if (mergedSchema) {
|
|
1048
|
-
return generateObjectParser(mergedSchema, typeName, false, suffix);
|
|
1141
|
+
return generateObjectParser(mergedSchema, typeName, false, suffix, false, false, true, stripUnknown);
|
|
1049
1142
|
}
|
|
1050
1143
|
return generateEmptyObjectParser(typeName);
|
|
1051
1144
|
}
|
|
@@ -1140,6 +1233,7 @@ const selectParserStrategy = (schema, typeName, options) => {
|
|
|
1140
1233
|
const useRefImports = options?.useRefImports ?? false;
|
|
1141
1234
|
const logWarnings = options?.logWarnings ?? false;
|
|
1142
1235
|
const strict = options?.strict ?? false;
|
|
1236
|
+
const stripUnknown = options?.stripUnknown ?? false;
|
|
1143
1237
|
const suffix = options?.typeSuffix ?? '';
|
|
1144
1238
|
// Special case for the self-referential JSON Schema meta-schema type (e.g.
|
|
1145
1239
|
// `Schema` / `SchemaObject`) - it can be any JSON Schema.
|
|
@@ -1156,24 +1250,24 @@ const selectParserStrategy = (schema, typeName, options) => {
|
|
|
1156
1250
|
// This generates a parser that handles known properties and also iterates
|
|
1157
1251
|
// pattern-matched keys (e.g. responses with "default" + "200", "4XX").
|
|
1158
1252
|
if (hasProperties(schema) && isSchemaObject(schema) && 'patternProperties' in schema) {
|
|
1159
|
-
return generateCombinedObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict);
|
|
1253
|
+
return generateCombinedObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, stripUnknown);
|
|
1160
1254
|
}
|
|
1161
1255
|
// Handle schemas that have explicit properties โ generate a full object parser.
|
|
1162
1256
|
// This intentionally runs before if/then checks so that schemas with both
|
|
1163
1257
|
// properties AND conditional keywords use all declared properties rather than
|
|
1164
1258
|
// only the if/then fragment.
|
|
1165
1259
|
if (hasProperties(schema)) {
|
|
1166
|
-
return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict);
|
|
1260
|
+
return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown);
|
|
1167
1261
|
}
|
|
1168
1262
|
// Handle conditional schemas (if/then/else) for schemas without explicit properties.
|
|
1169
1263
|
if (isSchemaObject(schema) && 'if' in schema && 'then' in schema && 'else' in schema) {
|
|
1170
|
-
return generateConditionalParser(schema, typeName, suffix);
|
|
1264
|
+
return generateConditionalParser(schema, typeName, suffix, stripUnknown);
|
|
1171
1265
|
}
|
|
1172
1266
|
// Handle conditional schemas that only define if/then object fragments.
|
|
1173
1267
|
// We flatten the fragments into a regular object parser.
|
|
1174
1268
|
const conditionalObjectSchema = getConditionalObjectSchema(schema);
|
|
1175
1269
|
if (conditionalObjectSchema) {
|
|
1176
|
-
return generateObjectParser(conditionalObjectSchema, typeName, useRefImports, suffix, logWarnings, strict);
|
|
1270
|
+
return generateObjectParser(conditionalObjectSchema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown);
|
|
1177
1271
|
}
|
|
1178
1272
|
// Handle non-object schemas with type-appropriate validation (no properties, no conditionals)
|
|
1179
1273
|
if (!isObjectLikeSchema) {
|
|
@@ -1230,7 +1324,7 @@ export const shapeValidatorFunctionName = (typeName) => shapeValidatorName(typeN
|
|
|
1230
1324
|
* in additionalProperties). The stub is safe: parents calling it will fall
|
|
1231
1325
|
* through to the slow path, matching the pre-deep-fast-path behavior.
|
|
1232
1326
|
*/
|
|
1233
|
-
export const generateShapeValidator = (schema, typeName, useRefImports, suffix = '', exported = true) => {
|
|
1327
|
+
export const generateShapeValidator = (schema, typeName, useRefImports, suffix = '', exported = true, stripUnknown = false) => {
|
|
1234
1328
|
const fnName = shapeValidatorName(typeName);
|
|
1235
1329
|
const exportPrefix = exported ? 'export ' : '';
|
|
1236
1330
|
const stub = `${exportPrefix}const ${fnName} = (_input: unknown): boolean => false;`;
|
|
@@ -1253,9 +1347,12 @@ export const generateShapeValidator = (schema, typeName, useRefImports, suffix =
|
|
|
1253
1347
|
// Same deterministic naming as the parser's sub-parser generation, so the
|
|
1254
1348
|
// referenced private shape predicates exist in the same file.
|
|
1255
1349
|
const subTypeNames = collectInlineObjectProperties(schema, typeName);
|
|
1256
|
-
//
|
|
1257
|
-
//
|
|
1258
|
-
|
|
1350
|
+
// When the parser strips extras (additionalProperties: false or stripUnknown),
|
|
1351
|
+
// an input carrying an undeclared key is *not* fast-path eligible โ the parser
|
|
1352
|
+
// would strip it rather than return `{ ...input }` โ so the shape only matches
|
|
1353
|
+
// when every key is declared. (The `_hasOnlyKnownKeys` predicate this calls is
|
|
1354
|
+
// emitted by the parser under the same `stripKeys` condition.)
|
|
1355
|
+
const strictKeysGuard = hasStrictKeys(schema) || stripUnknown ? `\n if (!_hasOnlyKnownKeys${typeName}(input)) return false;` : '';
|
|
1259
1356
|
const checks = [];
|
|
1260
1357
|
for (const [key, propSchema] of properties) {
|
|
1261
1358
|
// Records of refs require iterating every value โ too expensive for the fast path.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amritk/generate-parsers",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Generate TypeScript parsers and type definitions from JSON Schemas.",
|
|
5
5
|
"module": "./dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
"build": "tsgo -p tsconfig.build.json && tsc-alias -p tsconfig.build.json -f",
|
|
33
33
|
"types:check": "tsgo -p . --noEmit",
|
|
34
34
|
"test": "NODE_ENV=production vitest run --root ../.. generate-parsers",
|
|
35
|
-
"generate-readme": "bun run ./scripts/generate-readme.ts"
|
|
35
|
+
"generate-readme": "bun run ./scripts/generate-readme.ts",
|
|
36
|
+
"bench": "bun --conditions development ./bench/run.ts"
|
|
36
37
|
},
|
|
37
38
|
"imports": {
|
|
38
39
|
"#generators/*": "./src/generators/*.ts",
|
|
@@ -49,5 +50,9 @@
|
|
|
49
50
|
"json-schema-typed": "^8.0.1",
|
|
50
51
|
"@amritk/helpers": "0.10.0",
|
|
51
52
|
"@amritk/generate-markdown": "0.2.4"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@sinclair/typebox": "^0.34.49",
|
|
56
|
+
"zod": "^4.4.3"
|
|
52
57
|
}
|
|
53
58
|
}
|