@amritk/generate-parsers 0.14.0 → 0.16.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 +9 -1
- package/dist/generators/build-schema.d.ts +12 -3
- package/dist/generators/build-schema.js +14 -3
- package/dist/generators/generate-enum-check.d.ts +17 -0
- package/dist/generators/generate-enum-check.js +32 -0
- package/dist/generators/generate-files.d.ts +14 -0
- package/dist/generators/generate-files.js +2 -0
- package/dist/generators/generate-parser-function.d.ts +17 -0
- package/dist/generators/generate-parser-function.js +36 -24
- package/dist/generators/generate-validation-expression.d.ts +1 -1
- package/dist/generators/generate-validation-expression.js +14 -6
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|

|
|
10
10
|

|
|
11
11
|

|
|
12
|
-

|
|
13
13
|
|
|
14
14
|
</div>
|
|
15
15
|
|
|
@@ -134,6 +134,14 @@ Returns: `Promise<GeneratedFile[]>`.
|
|
|
134
134
|
<tr>
|
|
135
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>
|
|
136
136
|
</tr>
|
|
137
|
+
<tr>
|
|
138
|
+
<td>🔡 <code>caseInsensitive</code></td>
|
|
139
|
+
<td><code>boolean</code></td>
|
|
140
|
+
<td align="center"><code>false</code></td>
|
|
141
|
+
</tr>
|
|
142
|
+
<tr>
|
|
143
|
+
<td colspan="3">Normalize a mis-cased string to the exact casing of a declared enum/const member it matches case-insensitively (e.g. hElLo → hello) instead of coercing to the default. Coerce mode only — strict parsers still reject a casing mismatch. Correctly-cased input keeps the exact-match fast path, so the hot path is unaffected.</td>
|
|
144
|
+
</tr>
|
|
137
145
|
</tbody>
|
|
138
146
|
</table>
|
|
139
147
|
<!-- config-table-end -->
|
|
@@ -48,8 +48,17 @@ export type GeneratedFile = {
|
|
|
48
48
|
* is used verbatim and is not affected by this suffix.
|
|
49
49
|
* @param importExt - Extension used on every relative import specifier in the generated
|
|
50
50
|
* output (cross-file `$ref` imports, the index barrel, and embedded-helper imports).
|
|
51
|
-
* `'js'` (default) is the standard TS NodeNext form; `'ts'` emits the
|
|
52
|
-
* paths so the generated sources run directly under Node's type
|
|
51
|
+
* `'js'` (this function's default) is the standard TS NodeNext form; `'ts'` emits the
|
|
52
|
+
* literal on-disk paths so the generated sources run directly under Node's type
|
|
53
|
+
* stripping. Note the `mjst` CLI defaults this to `'ts'` (falling back to `'js'` only
|
|
54
|
+
* under `--build`, where tsc must compile the sources) so generated output runs under
|
|
55
|
+
* Node without a build step; direct callers of `buildSchema` opt in explicitly.
|
|
56
|
+
* @param caseInsensitive - When true, the generated coercing parsers normalize a mis-cased
|
|
57
|
+
* string to the exact casing of a declared `enum`/`const` member it matches
|
|
58
|
+
* case-insensitively (e.g. `hElLo` → `hello`) instead of coercing to the default. Coerce
|
|
59
|
+
* mode only — strict parsers still reject a casing mismatch. The normalization lives on
|
|
60
|
+
* the coercion failure branch, so correctly-cased input keeps the exact-match fast path
|
|
61
|
+
* and the hot path is unaffected.
|
|
53
62
|
* @returns An array of generated TypeScript files
|
|
54
63
|
*
|
|
55
64
|
* @example
|
|
@@ -82,4 +91,4 @@ export type GeneratedFile = {
|
|
|
82
91
|
* });
|
|
83
92
|
* ```
|
|
84
93
|
*/
|
|
85
|
-
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, importExt?: ImportExtension) => Promise<GeneratedFile[]>;
|
|
94
|
+
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, importExt?: ImportExtension, caseInsensitive?: boolean) => Promise<GeneratedFile[]>;
|
|
@@ -84,8 +84,17 @@ const readHelperSource = async (helper, importExt) => {
|
|
|
84
84
|
* is used verbatim and is not affected by this suffix.
|
|
85
85
|
* @param importExt - Extension used on every relative import specifier in the generated
|
|
86
86
|
* output (cross-file `$ref` imports, the index barrel, and embedded-helper imports).
|
|
87
|
-
* `'js'` (default) is the standard TS NodeNext form; `'ts'` emits the
|
|
88
|
-
* paths so the generated sources run directly under Node's type
|
|
87
|
+
* `'js'` (this function's default) is the standard TS NodeNext form; `'ts'` emits the
|
|
88
|
+
* literal on-disk paths so the generated sources run directly under Node's type
|
|
89
|
+
* stripping. Note the `mjst` CLI defaults this to `'ts'` (falling back to `'js'` only
|
|
90
|
+
* under `--build`, where tsc must compile the sources) so generated output runs under
|
|
91
|
+
* Node without a build step; direct callers of `buildSchema` opt in explicitly.
|
|
92
|
+
* @param caseInsensitive - When true, the generated coercing parsers normalize a mis-cased
|
|
93
|
+
* string to the exact casing of a declared `enum`/`const` member it matches
|
|
94
|
+
* case-insensitively (e.g. `hElLo` → `hello`) instead of coercing to the default. Coerce
|
|
95
|
+
* mode only — strict parsers still reject a casing mismatch. The normalization lives on
|
|
96
|
+
* the coercion failure branch, so correctly-cased input keeps the exact-match fast path
|
|
97
|
+
* and the hot path is unaffected.
|
|
89
98
|
* @returns An array of generated TypeScript files
|
|
90
99
|
*
|
|
91
100
|
* @example
|
|
@@ -118,7 +127,7 @@ const readHelperSource = async (helper, importExt) => {
|
|
|
118
127
|
* });
|
|
119
128
|
* ```
|
|
120
129
|
*/
|
|
121
|
-
export const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logWarnings, strict, helpersMode = 'package', helpersImportPrefix = './', readonly = false, stripUnknown = false, typeSuffix = '', importExt = 'js') => {
|
|
130
|
+
export const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logWarnings, strict, helpersMode = 'package', helpersImportPrefix = './', readonly = false, stripUnknown = false, typeSuffix = '', importExt = 'js', caseInsensitive = false) => {
|
|
122
131
|
const files = [];
|
|
123
132
|
const usedHelpers = new Set();
|
|
124
133
|
walkRefGraph(rootSchema, rootTypeName, { typeSuffix }, (node) => {
|
|
@@ -137,10 +146,12 @@ export const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnl
|
|
|
137
146
|
readonly,
|
|
138
147
|
typeSuffix,
|
|
139
148
|
importExt,
|
|
149
|
+
isRoot: node.isRoot,
|
|
140
150
|
...(node.ref !== undefined ? { selfRef: node.ref } : {}),
|
|
141
151
|
...(logWarnings !== undefined ? { logWarnings } : {}),
|
|
142
152
|
...(strict !== undefined ? { strict } : {}),
|
|
143
153
|
...(stripUnknown ? { stripUnknown } : {}),
|
|
154
|
+
...(caseInsensitive ? { caseInsensitive } : {}),
|
|
144
155
|
});
|
|
145
156
|
files.push({ filename: `${node.filename}.ts`, content: result.content });
|
|
146
157
|
for (const helper of result.usedHelpers)
|
|
@@ -11,3 +11,20 @@
|
|
|
11
11
|
* differs from `===`), so the verdict is unchanged.
|
|
12
12
|
*/
|
|
13
13
|
export declare const generateEnumCheck: (accessor: string, enumValues: readonly unknown[]) => string;
|
|
14
|
+
/**
|
|
15
|
+
* Builds a case-insensitive coercion expression for a string `enum`/`const`
|
|
16
|
+
* value whose exact-match check has already failed. Only the *string* members
|
|
17
|
+
* participate (a number/boolean/null member has no case to fold): a value that
|
|
18
|
+
* matches one case-insensitively normalizes to that member's exact casing, and
|
|
19
|
+
* everything else yields `fallback`.
|
|
20
|
+
*
|
|
21
|
+
* This is emitted ONLY on the already-invalid branch of the coercion ternary, so
|
|
22
|
+
* a correctly-cased value never reaches it — the exact `===` fast path (and the
|
|
23
|
+
* shape validators / deep guards built on it) is unchanged, so the hot path pays
|
|
24
|
+
* nothing. Only a mis-cased value, which today coerces straight to the default,
|
|
25
|
+
* now takes a single `toLowerCase()` and an O(1) map lookup instead.
|
|
26
|
+
*
|
|
27
|
+
* Returns `null` when no member is a string (nothing to fold), so the caller
|
|
28
|
+
* keeps its plain fallback and emits no extra code.
|
|
29
|
+
*/
|
|
30
|
+
export declare const generateEnumCaseInsensitiveCoercion: (accessor: string, enumValues: readonly unknown[], fallback: string) => string | null;
|
|
@@ -20,3 +20,35 @@ export const generateEnumCheck = (accessor, enumValues) => {
|
|
|
20
20
|
const serializedEnum = JSON.stringify(enumValues);
|
|
21
21
|
return `${serializedEnum}.includes(${accessor} as never)`;
|
|
22
22
|
};
|
|
23
|
+
/**
|
|
24
|
+
* Builds a case-insensitive coercion expression for a string `enum`/`const`
|
|
25
|
+
* value whose exact-match check has already failed. Only the *string* members
|
|
26
|
+
* participate (a number/boolean/null member has no case to fold): a value that
|
|
27
|
+
* matches one case-insensitively normalizes to that member's exact casing, and
|
|
28
|
+
* everything else yields `fallback`.
|
|
29
|
+
*
|
|
30
|
+
* This is emitted ONLY on the already-invalid branch of the coercion ternary, so
|
|
31
|
+
* a correctly-cased value never reaches it — the exact `===` fast path (and the
|
|
32
|
+
* shape validators / deep guards built on it) is unchanged, so the hot path pays
|
|
33
|
+
* nothing. Only a mis-cased value, which today coerces straight to the default,
|
|
34
|
+
* now takes a single `toLowerCase()` and an O(1) map lookup instead.
|
|
35
|
+
*
|
|
36
|
+
* Returns `null` when no member is a string (nothing to fold), so the caller
|
|
37
|
+
* keeps its plain fallback and emits no extra code.
|
|
38
|
+
*/
|
|
39
|
+
export const generateEnumCaseInsensitiveCoercion = (accessor, enumValues, fallback) => {
|
|
40
|
+
const byLower = {};
|
|
41
|
+
for (const value of enumValues) {
|
|
42
|
+
if (typeof value !== 'string')
|
|
43
|
+
continue;
|
|
44
|
+
const lower = value.toLowerCase();
|
|
45
|
+
// First writer wins, so declaration order breaks ties between members that
|
|
46
|
+
// fold to the same key (e.g. `"on"` and `"ON"`).
|
|
47
|
+
if (!(lower in byLower))
|
|
48
|
+
byLower[lower] = value;
|
|
49
|
+
}
|
|
50
|
+
if (Object.keys(byLower).length === 0)
|
|
51
|
+
return null;
|
|
52
|
+
const map = JSON.stringify(byLower);
|
|
53
|
+
return `(typeof ${accessor} === "string" ? ((${map} as Record<string, string>)[(${accessor} as string).toLowerCase()] ?? ${fallback}) : ${fallback})`;
|
|
54
|
+
};
|
|
@@ -71,6 +71,20 @@ type GenerateFileOptions = {
|
|
|
71
71
|
* stripping.
|
|
72
72
|
*/
|
|
73
73
|
readonly importExt?: ImportExtension;
|
|
74
|
+
/**
|
|
75
|
+
* True when this file is the root document rather than a `$ref`-reached
|
|
76
|
+
* definition. Forwarded to the parser generator so the JSON Schema
|
|
77
|
+
* meta-schema special case (type name `Schema`) never applies to a
|
|
78
|
+
* user-named root.
|
|
79
|
+
*/
|
|
80
|
+
readonly isRoot?: boolean;
|
|
81
|
+
/**
|
|
82
|
+
* When true, a mis-cased string that matches a declared `enum`/`const` member
|
|
83
|
+
* case-insensitively is normalized to that member's exact casing (coerce mode
|
|
84
|
+
* only). The normalization is emitted on the coercion failure branch, so
|
|
85
|
+
* already-valid input keeps the exact-match fast path.
|
|
86
|
+
*/
|
|
87
|
+
readonly caseInsensitive?: boolean;
|
|
74
88
|
};
|
|
75
89
|
/** Result of generating a single parser file. */
|
|
76
90
|
export type GeneratedFileContent = {
|
|
@@ -62,10 +62,12 @@ export const generateFile = (schema, typeName, options) => {
|
|
|
62
62
|
useRefImports: true,
|
|
63
63
|
typeSuffix,
|
|
64
64
|
reservedNames,
|
|
65
|
+
...(options?.isRoot !== undefined ? { isRoot: options.isRoot } : {}),
|
|
65
66
|
...(rootSchema !== undefined ? { rootSchema } : {}),
|
|
66
67
|
...(options?.logWarnings !== undefined ? { logWarnings: options.logWarnings } : {}),
|
|
67
68
|
...(options?.strict !== undefined ? { strict: options.strict } : {}),
|
|
68
69
|
...(options?.stripUnknown !== undefined ? { stripUnknown: options.stripUnknown } : {}),
|
|
70
|
+
...(options?.caseInsensitive !== undefined ? { caseInsensitive: options.caseInsensitive } : {}),
|
|
69
71
|
});
|
|
70
72
|
const shapeValidator = generateShapeValidator(schema, typeName, true, typeSuffix, true, stripUnknown, reservedNames);
|
|
71
73
|
const combinedFunctions = `${shapeValidator}\n\n${parserFunction}`;
|
|
@@ -50,6 +50,23 @@ type GenerateParserOptions = {
|
|
|
50
50
|
* validate against the wrong schema.
|
|
51
51
|
*/
|
|
52
52
|
readonly reservedNames?: ReadonlySet<string>;
|
|
53
|
+
/**
|
|
54
|
+
* True when this schema is the root document (not a `$ref`-reached definition).
|
|
55
|
+
* The root type name is user-derived (schema `title`, filename, or `--root-type`),
|
|
56
|
+
* so the JSON Schema meta-schema special case — keyed on the literal name
|
|
57
|
+
* `Schema` — must not apply to it, or a common `schema.json` root would silently
|
|
58
|
+
* generate a validation-free pass-through parser.
|
|
59
|
+
*/
|
|
60
|
+
readonly isRoot?: boolean;
|
|
61
|
+
/**
|
|
62
|
+
* When true, a mis-cased string that matches a declared `enum`/`const` member
|
|
63
|
+
* case-insensitively is normalized to that member's exact casing (e.g. `hElLo`
|
|
64
|
+
* → `hello`) instead of coercing to the default. Coerce mode only — strict
|
|
65
|
+
* parsers still reject a casing mismatch. The normalization lives on the
|
|
66
|
+
* failure branch of the coercion ternary, so a correctly-cased value keeps the
|
|
67
|
+
* exact `===` fast path and the hot path is unaffected.
|
|
68
|
+
*/
|
|
69
|
+
readonly caseInsensitive?: boolean;
|
|
53
70
|
};
|
|
54
71
|
/**
|
|
55
72
|
* Generates a safe parser as an arrow function that never throws.
|
|
@@ -8,6 +8,7 @@ import { unknownKeyCheck } from '@amritk/helpers/unknown-key-check';
|
|
|
8
8
|
import { findDiscriminator } from '../helpers/find-discriminator.js';
|
|
9
9
|
import { getDefaultValue } from '../helpers/get-default-value.js';
|
|
10
10
|
import { getDiscriminatorValue } from '../helpers/get-discriminator-value.js';
|
|
11
|
+
import { generateEnumCaseInsensitiveCoercion } from './generate-enum-check.js';
|
|
11
12
|
import { generateObjectStrictAssertion, generateScalarStrictAssertion } from './generate-strict-assertion.js';
|
|
12
13
|
import { canEnforceUnion, generatePropertyTypeCheck, generateUnionCheck, getUnionBranches, isInlineObjectArrayProperty, isInlineObjectProperty, shapeValidatorName, } from './generate-type-checks.js';
|
|
13
14
|
import { generateValidationExpression, isCoercibleItemSchema } from './generate-validation-expression.js';
|
|
@@ -147,7 +148,7 @@ const shouldUseRecordRefImport = (propSchema, useRefImports) => {
|
|
|
147
148
|
/**
|
|
148
149
|
* Generates the value expression for a property based on its schema and options.
|
|
149
150
|
*/
|
|
150
|
-
const generatePropertyValue = (key, propSchema, isRequired, useRefImports, suffix) => {
|
|
151
|
+
const generatePropertyValue = (key, propSchema, isRequired, useRefImports, suffix, caseInsensitive = false) => {
|
|
151
152
|
// Handle direct $ref properties
|
|
152
153
|
if (shouldUseRefImport(propSchema, useRefImports)) {
|
|
153
154
|
const ref = propSchema.$ref;
|
|
@@ -173,20 +174,20 @@ const generatePropertyValue = (key, propSchema, isRequired, useRefImports, suffi
|
|
|
173
174
|
}
|
|
174
175
|
// Generate standard validation expression
|
|
175
176
|
const defaultValue = getDefaultValue(propSchema);
|
|
176
|
-
const valueExpression = generateValidationExpression(key, propSchema, defaultValue, isRequired);
|
|
177
|
+
const valueExpression = generateValidationExpression(key, propSchema, defaultValue, isRequired, undefined, undefined, undefined, undefined, caseInsensitive);
|
|
177
178
|
return isRequired ? valueExpression : generateOptionalInlineProperty(key, valueExpression);
|
|
178
179
|
};
|
|
179
180
|
/**
|
|
180
181
|
* Generates property entries for all properties in the schema.
|
|
181
182
|
*/
|
|
182
|
-
const generatePropertyEntries = (schema, useRefImports, suffix) => {
|
|
183
|
+
const generatePropertyEntries = (schema, useRefImports, suffix, caseInsensitive = false) => {
|
|
183
184
|
if (!hasProperties(schema)) {
|
|
184
185
|
return [];
|
|
185
186
|
}
|
|
186
187
|
const entries = [];
|
|
187
188
|
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
|
188
189
|
const isRequired = isPropertyRequired(key, schema);
|
|
189
|
-
const value = generatePropertyValue(key, propSchema, isRequired, useRefImports, suffix);
|
|
190
|
+
const value = generatePropertyValue(key, propSchema, isRequired, useRefImports, suffix, caseInsensitive);
|
|
190
191
|
// Determine if the property uses spread syntax (making it optional in the object literal)
|
|
191
192
|
const isOptional = value.startsWith('...');
|
|
192
193
|
entries.push({ key, value, isOptional });
|
|
@@ -419,7 +420,11 @@ const generateNonObjectParser = (typeName, schema, strict, unionCtx) => {
|
|
|
419
420
|
if (hasEnum(schema) && schema.enum.length > 0) {
|
|
420
421
|
const values = JSON.stringify(schema.enum);
|
|
421
422
|
const fallback = JSON.stringify(schema.enum[0]);
|
|
422
|
-
|
|
423
|
+
// Case-insensitive normalization sits on the non-member branch only, so an
|
|
424
|
+
// exact member still returns via the `includes` fast path untouched.
|
|
425
|
+
const ci = unionCtx?.caseInsensitive ? generateEnumCaseInsensitiveCoercion('input', schema.enum, fallback) : null;
|
|
426
|
+
const coerced = ci ? `(${ci})` : fallback;
|
|
427
|
+
return `export const ${functionName} = (input: unknown): ${typeName} => ${values}.includes(input as never) ? input as ${typeName} : ${coerced} as ${typeName};`;
|
|
423
428
|
}
|
|
424
429
|
// A top-level union must validate membership: an unmatched value is not of
|
|
425
430
|
// the declared union type, so coerce it to a member-shaped default. Reuse the
|
|
@@ -477,7 +482,7 @@ const generateNonObjectParser = (typeName, schema, strict, unionCtx) => {
|
|
|
477
482
|
const preamble = [
|
|
478
483
|
`type ${itemName} = ${typeName}[number];`,
|
|
479
484
|
generateShapeValidator(items, itemName, useRefImports, suffix, false, stripUnknown, reserved),
|
|
480
|
-
generateObjectParser(items, itemName, useRefImports, suffix, unionCtx?.logWarnings ?? false, strict, false, stripUnknown, unionCtx?.rootSchema, reserved),
|
|
485
|
+
generateObjectParser(items, itemName, useRefImports, suffix, unionCtx?.logWarnings ?? false, strict, false, stripUnknown, unionCtx?.rootSchema, reserved, unionCtx?.caseInsensitive ?? false),
|
|
481
486
|
].join('\n\n');
|
|
482
487
|
return `${preamble}\n\n${delegated(generateParserName(itemName))}`;
|
|
483
488
|
}
|
|
@@ -511,7 +516,7 @@ const generateNonObjectParser = (typeName, schema, strict, unionCtx) => {
|
|
|
511
516
|
// Coerce each element when the item schema is a single scalar type or an enum.
|
|
512
517
|
if (hasItems(schema) && !Array.isArray(schema.items) && isCoercibleItemSchema(schema.items)) {
|
|
513
518
|
const item = schema.items;
|
|
514
|
-
const itemExpr = generateValidationExpression('', item, getDefaultValue(item), true, undefined, undefined, '_it', true);
|
|
519
|
+
const itemExpr = generateValidationExpression('', item, getDefaultValue(item), true, undefined, undefined, '_it', true, unionCtx?.caseInsensitive);
|
|
515
520
|
return `export const ${functionName} = (input: unknown): ${typeName} => Array.isArray(input) ? (input as unknown[]).map((_it) => ${itemExpr}) as ${typeName} : [] as ${typeName};`;
|
|
516
521
|
}
|
|
517
522
|
return `export const ${functionName} = (input: unknown): ${typeName} => Array.isArray(input) ? [...input] as ${typeName} : [] as ${typeName};`;
|
|
@@ -664,7 +669,7 @@ const shouldCacheVariable = (propSchema, _canFastPath, _useRefImports) => {
|
|
|
664
669
|
* - Fast path that returns input directly when all properties are already valid
|
|
665
670
|
* - Slow path with optimized expressions
|
|
666
671
|
*/
|
|
667
|
-
const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnings, strict, exported = true, stripUnknown = false, rootSchema, reservedNames = NO_RESERVED_NAMES) => {
|
|
672
|
+
const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnings, strict, exported = true, stripUnknown = false, rootSchema, reservedNames = NO_RESERVED_NAMES, caseInsensitive = false) => {
|
|
668
673
|
const functionName = generateParserName(typeName);
|
|
669
674
|
const exportPrefix = exported ? 'export ' : '';
|
|
670
675
|
if (!hasProperties(schema)) {
|
|
@@ -690,7 +695,7 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
|
|
|
690
695
|
const propSchema = schemaProps[key];
|
|
691
696
|
preamble.push(`type ${subName} = ${typeName}[${JSON.stringify(key)}];`);
|
|
692
697
|
preamble.push(generateShapeValidator(propSchema, subName, useRefImports, suffix, false, stripUnknown, reservedNames));
|
|
693
|
-
preamble.push(generateObjectParser(propSchema, subName, useRefImports, suffix, logWarnings, strict, false, stripUnknown, rootSchema, reservedNames));
|
|
698
|
+
preamble.push(generateObjectParser(propSchema, subName, useRefImports, suffix, logWarnings, strict, false, stripUnknown, rootSchema, reservedNames, caseInsensitive));
|
|
694
699
|
}
|
|
695
700
|
for (const [key, subName] of subItemNames) {
|
|
696
701
|
const propSchema = schemaProps[key];
|
|
@@ -701,7 +706,7 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
|
|
|
701
706
|
// Hand-rolled loop instead of Array.prototype.every on the guard path — the
|
|
702
707
|
// callback protocol costs a few percent on element-heavy hot paths.
|
|
703
708
|
preamble.push(`const _every${subName} = (arr: readonly unknown[]): boolean => {\n for (let i = 0; i < arr.length; i++) if (!${shapeValidatorName(subName)}(arr[i])) return false;\n return true;\n};`);
|
|
704
|
-
preamble.push(generateObjectParser(itemSchema, subName, useRefImports, suffix, logWarnings, strict, false, stripUnknown, rootSchema, reservedNames));
|
|
709
|
+
preamble.push(generateObjectParser(itemSchema, subName, useRefImports, suffix, logWarnings, strict, false, stripUnknown, rootSchema, reservedNames, caseInsensitive));
|
|
705
710
|
}
|
|
706
711
|
// additionalProperties: false — the per-key "is this undeclared" test inlines
|
|
707
712
|
// `!==` comparisons for a short key list and hoists a Set only for a long one
|
|
@@ -979,7 +984,7 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
|
|
|
979
984
|
// For optional properties we know the value is not undefined because
|
|
980
985
|
// we're inside the `...(accessor !== undefined && { ... })` check.
|
|
981
986
|
const knownNotUndefined = !isRequired;
|
|
982
|
-
valueExpr = generateValidationExpression(key, propSchema, defaultValue, true, undefined, undefined, shouldCache ? varName : undefined, knownNotUndefined);
|
|
987
|
+
valueExpr = generateValidationExpression(key, propSchema, defaultValue, true, undefined, undefined, shouldCache ? varName : undefined, knownNotUndefined, caseInsensitive);
|
|
983
988
|
}
|
|
984
989
|
if (isRequired) {
|
|
985
990
|
objectLines.push(` ${safeKey(key)}: ${valueExpr},`);
|
|
@@ -1193,9 +1198,9 @@ ${loopLines.join('\n')}
|
|
|
1193
1198
|
* Generates a parser for schemas that have both properties AND patternProperties.
|
|
1194
1199
|
* Parses the known properties first, then iterates remaining keys to match patterns.
|
|
1195
1200
|
*/
|
|
1196
|
-
const generateCombinedObjectParser = (schema, typeName, useRefImports, suffix, logWarnings, strict, stripUnknown = false) => {
|
|
1201
|
+
const generateCombinedObjectParser = (schema, typeName, useRefImports, suffix, logWarnings, strict, stripUnknown = false, caseInsensitive = false) => {
|
|
1197
1202
|
const functionName = generateParserName(typeName);
|
|
1198
|
-
const entries = generatePropertyEntries(schema, useRefImports, suffix);
|
|
1203
|
+
const entries = generatePropertyEntries(schema, useRefImports, suffix, caseInsensitive);
|
|
1199
1204
|
// Build the known property lines for the initial object
|
|
1200
1205
|
const propertyLines = entries.map((entry) => {
|
|
1201
1206
|
if (entry.isOptional) {
|
|
@@ -1205,7 +1210,7 @@ const generateCombinedObjectParser = (schema, typeName, useRefImports, suffix, l
|
|
|
1205
1210
|
});
|
|
1206
1211
|
// Find the first pattern with a $ref for parser delegation
|
|
1207
1212
|
if (!isSchemaObject(schema) || !('patternProperties' in schema)) {
|
|
1208
|
-
return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown);
|
|
1213
|
+
return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown, undefined, NO_RESERVED_NAMES, caseInsensitive);
|
|
1209
1214
|
}
|
|
1210
1215
|
const patternProps = schema.patternProperties;
|
|
1211
1216
|
const patterns = Object.entries(patternProps);
|
|
@@ -1219,7 +1224,7 @@ const generateCombinedObjectParser = (schema, typeName, useRefImports, suffix, l
|
|
|
1219
1224
|
return generateStrictCombinedParser(schema, typeName, functionName, propertyLines, patterns, useRefImports, suffix, strict);
|
|
1220
1225
|
}
|
|
1221
1226
|
if (!refPattern || !useRefImports) {
|
|
1222
|
-
return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown);
|
|
1227
|
+
return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown, undefined, NO_RESERVED_NAMES, caseInsensitive);
|
|
1223
1228
|
}
|
|
1224
1229
|
const [pattern, patternSchema] = refPattern;
|
|
1225
1230
|
const ref = patternSchema.$ref;
|
|
@@ -1398,7 +1403,7 @@ ${notObjectBranch}
|
|
|
1398
1403
|
* This always generates parser calls regardless of useRefImports setting,
|
|
1399
1404
|
* because conditional logic requires delegating to the appropriate parser.
|
|
1400
1405
|
*/
|
|
1401
|
-
const generateConditionalParser = (schema, typeName, suffix, stripUnknown = false) => {
|
|
1406
|
+
const generateConditionalParser = (schema, typeName, suffix, stripUnknown = false, caseInsensitive = false) => {
|
|
1402
1407
|
const functionName = generateParserName(typeName);
|
|
1403
1408
|
// Extract the condition and branches
|
|
1404
1409
|
const ifSchema = schema.if;
|
|
@@ -1413,7 +1418,7 @@ const generateConditionalParser = (schema, typeName, suffix, stripUnknown = fals
|
|
|
1413
1418
|
// regular object parser from the merged property set.
|
|
1414
1419
|
const mergedSchema = getConditionalObjectSchema(schema);
|
|
1415
1420
|
if (mergedSchema) {
|
|
1416
|
-
return generateObjectParser(mergedSchema, typeName, false, suffix, false, false, true, stripUnknown);
|
|
1421
|
+
return generateObjectParser(mergedSchema, typeName, false, suffix, false, false, true, stripUnknown, undefined, NO_RESERVED_NAMES, caseInsensitive);
|
|
1417
1422
|
}
|
|
1418
1423
|
return generateEmptyObjectParser(typeName);
|
|
1419
1424
|
}
|
|
@@ -1427,7 +1432,7 @@ const generateConditionalParser = (schema, typeName, suffix, stripUnknown = fals
|
|
|
1427
1432
|
// Condition is not a $ref check — flatten into an object parser
|
|
1428
1433
|
const mergedSchema = getConditionalObjectSchema(schema);
|
|
1429
1434
|
if (mergedSchema) {
|
|
1430
|
-
return generateObjectParser(mergedSchema, typeName, false, suffix, false, false, true, stripUnknown);
|
|
1435
|
+
return generateObjectParser(mergedSchema, typeName, false, suffix, false, false, true, stripUnknown, undefined, NO_RESERVED_NAMES, caseInsensitive);
|
|
1431
1436
|
}
|
|
1432
1437
|
return generateEmptyObjectParser(typeName);
|
|
1433
1438
|
}
|
|
@@ -1523,10 +1528,15 @@ const selectParserStrategy = (schema, typeName, options) => {
|
|
|
1523
1528
|
const logWarnings = options?.logWarnings ?? false;
|
|
1524
1529
|
const strict = options?.strict ?? false;
|
|
1525
1530
|
const stripUnknown = options?.stripUnknown ?? false;
|
|
1531
|
+
const caseInsensitive = options?.caseInsensitive ?? false;
|
|
1526
1532
|
const suffix = options?.typeSuffix ?? '';
|
|
1527
1533
|
// Special case for the self-referential JSON Schema meta-schema type (e.g.
|
|
1528
|
-
// `Schema` / `SchemaObject`) - it can be any JSON Schema.
|
|
1529
|
-
|
|
1534
|
+
// `Schema` / `SchemaObject`) - it can be any JSON Schema. This is an OpenAPI
|
|
1535
|
+
// heuristic for a `$defs`/`components.schemas` entry named `schema`; it must
|
|
1536
|
+
// never fire for the root document, whose name is user-derived (a `schema.json`
|
|
1537
|
+
// file naturally yields the root type `Schema`) and would otherwise collapse
|
|
1538
|
+
// to a validation-free pass-through parser.
|
|
1539
|
+
if (!options?.isRoot && typeName === `Schema${suffix}`) {
|
|
1530
1540
|
return generateSchemaObjectParser(typeName);
|
|
1531
1541
|
}
|
|
1532
1542
|
const isObjectLikeSchema = isObjectSchema(schema) ||
|
|
@@ -1537,6 +1547,7 @@ const selectParserStrategy = (schema, typeName, options) => {
|
|
|
1537
1547
|
useRefImports,
|
|
1538
1548
|
suffix,
|
|
1539
1549
|
stripUnknown,
|
|
1550
|
+
caseInsensitive,
|
|
1540
1551
|
logWarnings,
|
|
1541
1552
|
...(options?.rootSchema !== undefined ? { rootSchema: options.rootSchema } : {}),
|
|
1542
1553
|
...(options?.reservedNames !== undefined ? { reservedNames: options.reservedNames } : {}),
|
|
@@ -1546,24 +1557,24 @@ const selectParserStrategy = (schema, typeName, options) => {
|
|
|
1546
1557
|
// This generates a parser that handles known properties and also iterates
|
|
1547
1558
|
// pattern-matched keys (e.g. responses with "default" + "200", "4XX").
|
|
1548
1559
|
if (hasProperties(schema) && isSchemaObject(schema) && 'patternProperties' in schema) {
|
|
1549
|
-
return generateCombinedObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, stripUnknown);
|
|
1560
|
+
return generateCombinedObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, stripUnknown, caseInsensitive);
|
|
1550
1561
|
}
|
|
1551
1562
|
// Handle schemas that have explicit properties — generate a full object parser.
|
|
1552
1563
|
// This intentionally runs before if/then checks so that schemas with both
|
|
1553
1564
|
// properties AND conditional keywords use all declared properties rather than
|
|
1554
1565
|
// only the if/then fragment.
|
|
1555
1566
|
if (hasProperties(schema)) {
|
|
1556
|
-
return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown, options?.rootSchema, options?.reservedNames);
|
|
1567
|
+
return generateObjectParser(schema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown, options?.rootSchema, options?.reservedNames, caseInsensitive);
|
|
1557
1568
|
}
|
|
1558
1569
|
// Handle conditional schemas (if/then/else) for schemas without explicit properties.
|
|
1559
1570
|
if (isSchemaObject(schema) && 'if' in schema && 'then' in schema && 'else' in schema) {
|
|
1560
|
-
return generateConditionalParser(schema, typeName, suffix, stripUnknown);
|
|
1571
|
+
return generateConditionalParser(schema, typeName, suffix, stripUnknown, caseInsensitive);
|
|
1561
1572
|
}
|
|
1562
1573
|
// Handle conditional schemas that only define if/then object fragments.
|
|
1563
1574
|
// We flatten the fragments into a regular object parser.
|
|
1564
1575
|
const conditionalObjectSchema = getConditionalObjectSchema(schema);
|
|
1565
1576
|
if (conditionalObjectSchema) {
|
|
1566
|
-
return generateObjectParser(conditionalObjectSchema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown);
|
|
1577
|
+
return generateObjectParser(conditionalObjectSchema, typeName, useRefImports, suffix, logWarnings, strict, true, stripUnknown, undefined, NO_RESERVED_NAMES, caseInsensitive);
|
|
1567
1578
|
}
|
|
1568
1579
|
// Handle non-object schemas with type-appropriate validation (no properties, no conditionals)
|
|
1569
1580
|
if (!isObjectLikeSchema) {
|
|
@@ -1571,6 +1582,7 @@ const selectParserStrategy = (schema, typeName, options) => {
|
|
|
1571
1582
|
useRefImports,
|
|
1572
1583
|
suffix,
|
|
1573
1584
|
stripUnknown,
|
|
1585
|
+
caseInsensitive,
|
|
1574
1586
|
logWarnings,
|
|
1575
1587
|
...(options?.rootSchema !== undefined ? { rootSchema: options.rootSchema } : {}),
|
|
1576
1588
|
...(options?.reservedNames !== undefined ? { reservedNames: options.reservedNames } : {}),
|
|
@@ -18,4 +18,4 @@ export declare const isCoercibleItemSchema: (itemSchema: JSONSchema) => boolean;
|
|
|
18
18
|
* Returns the value if valid, coerces to the correct type if invalid, or uses default if required and missing.
|
|
19
19
|
* Supports type checking, pattern validation, enum validation, union types, and $ref resolution.
|
|
20
20
|
*/
|
|
21
|
-
export declare const generateValidationExpression: (key: string, schema: JSONSchema, defaultValue: string, isRequired: boolean, rootSchema?: Record<string, unknown>, visitedRefs?: Set<string>, accessorOverride?: string, knownNotUndefined?: boolean) => string;
|
|
21
|
+
export declare const generateValidationExpression: (key: string, schema: JSONSchema, defaultValue: string, isRequired: boolean, rootSchema?: Record<string, unknown>, visitedRefs?: Set<string>, accessorOverride?: string, knownNotUndefined?: boolean, caseInsensitive?: boolean) => string;
|
|
@@ -7,7 +7,7 @@ import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasConst, hasEnum, hasExcl
|
|
|
7
7
|
import { findDiscriminator } from '../helpers/find-discriminator.js';
|
|
8
8
|
import { getDefaultValue } from '../helpers/get-default-value.js';
|
|
9
9
|
import { generateDiscriminatedUnionValidation } from './generate-discriminated-union-validation.js';
|
|
10
|
-
import { generateEnumCheck } from './generate-enum-check.js';
|
|
10
|
+
import { generateEnumCaseInsensitiveCoercion, generateEnumCheck } from './generate-enum-check.js';
|
|
11
11
|
import { generateNonDiscriminatedUnionValidation } from './generate-non-discriminated-union-validation.js';
|
|
12
12
|
import { generateSchemaChecks } from './generate-schema-checks.js';
|
|
13
13
|
/**
|
|
@@ -128,7 +128,7 @@ const getTypeCoercion = (accessor, schema, defaultValue) => {
|
|
|
128
128
|
* Returns the value if valid, coerces to the correct type if invalid, or uses default if required and missing.
|
|
129
129
|
* Supports type checking, pattern validation, enum validation, union types, and $ref resolution.
|
|
130
130
|
*/
|
|
131
|
-
export const generateValidationExpression = (key, schema, defaultValue, isRequired, rootSchema, visitedRefs, accessorOverride, knownNotUndefined) => {
|
|
131
|
+
export const generateValidationExpression = (key, schema, defaultValue, isRequired, rootSchema, visitedRefs, accessorOverride, knownNotUndefined, caseInsensitive) => {
|
|
132
132
|
const accessor = accessorOverride ?? safeAccessor('input?', key);
|
|
133
133
|
const checks = [];
|
|
134
134
|
if (!isSchemaObject(schema)) {
|
|
@@ -170,7 +170,7 @@ export const generateValidationExpression = (key, schema, defaultValue, isRequir
|
|
|
170
170
|
if (resolvedSchema) {
|
|
171
171
|
const visited = new Set(visitedRefs);
|
|
172
172
|
visited.add(ref);
|
|
173
|
-
return generateValidationExpression(key, resolvedSchema, defaultValue, isRequired, rootSchema, visited);
|
|
173
|
+
return generateValidationExpression(key, resolvedSchema, defaultValue, isRequired, rootSchema, visited, undefined, undefined, caseInsensitive);
|
|
174
174
|
}
|
|
175
175
|
}
|
|
176
176
|
// Handle union types (oneOf, anyOf)
|
|
@@ -250,7 +250,7 @@ export const generateValidationExpression = (key, schema, defaultValue, isRequir
|
|
|
250
250
|
!Array.isArray(schema.items) &&
|
|
251
251
|
isCoercibleItemSchema(schema.items)) {
|
|
252
252
|
const itemSchema = schema.items;
|
|
253
|
-
const itemExpr = generateValidationExpression('', itemSchema, getDefaultValue(itemSchema), true, rootSchema, visitedRefs, '_it', true);
|
|
253
|
+
const itemExpr = generateValidationExpression('', itemSchema, getDefaultValue(itemSchema), true, rootSchema, visitedRefs, '_it', true, caseInsensitive);
|
|
254
254
|
const mapped = `(Array.isArray(${accessor}) ? (${accessor} as unknown[]).map((_it) => ${itemExpr}) : ${defaultValue})`;
|
|
255
255
|
return isRequired ? mapped : `(${accessor} !== undefined ? ${mapped} : undefined)`;
|
|
256
256
|
}
|
|
@@ -361,6 +361,14 @@ export const generateValidationExpression = (key, schema, defaultValue, isRequir
|
|
|
361
361
|
// enum. Fall back to the default (the first enum value) so the result is always
|
|
362
362
|
// a valid member of the declared literal-union type.
|
|
363
363
|
const typeCoercion = hasEnum(schema) ? null : getTypeCoercion(accessor, schema, defaultValue);
|
|
364
|
+
// Case-insensitive enum normalization is spliced into the *failure* branch of
|
|
365
|
+
// the ternary only, so an already-valid (exactly-cased) member takes the
|
|
366
|
+
// `${accessor}` fast branch untouched. A mis-cased string folds to the exact
|
|
367
|
+
// member; anything else falls through to the branch's normal fallback. `null`
|
|
368
|
+
// when the flag is off or no member is a string, so the plain fallback stands.
|
|
369
|
+
const enumFallback = (fallback) => (caseInsensitive && hasEnum(schema) && schema.enum.length > 0
|
|
370
|
+
? generateEnumCaseInsensitiveCoercion(accessor, schema.enum, fallback)
|
|
371
|
+
: null) ?? fallback;
|
|
364
372
|
if (isRequired) {
|
|
365
373
|
// For required fields: valid ? use_value : (exists ? coerce : default)
|
|
366
374
|
if (typeCoercion) {
|
|
@@ -371,7 +379,7 @@ export const generateValidationExpression = (key, schema, defaultValue, isRequir
|
|
|
371
379
|
}
|
|
372
380
|
return `${combinedCheck} ? ${accessor} : (${accessor} !== undefined ? ${typeCoercion} : ${defaultValue})`;
|
|
373
381
|
}
|
|
374
|
-
return `${combinedCheck} ? ${accessor} : ${defaultValue}`;
|
|
382
|
+
return `${combinedCheck} ? ${accessor} : ${enumFallback(defaultValue)}`;
|
|
375
383
|
}
|
|
376
384
|
else {
|
|
377
385
|
// For optional fields: valid ? use_value : (exists ? coerce : undefined)
|
|
@@ -382,6 +390,6 @@ export const generateValidationExpression = (key, schema, defaultValue, isRequir
|
|
|
382
390
|
}
|
|
383
391
|
return `${combinedCheck} ? ${accessor} : (${accessor} !== undefined ? ${typeCoercion} : undefined)`;
|
|
384
392
|
}
|
|
385
|
-
return `${combinedCheck} ? ${accessor} : undefined`;
|
|
393
|
+
return `${combinedCheck} ? ${accessor} : ${enumFallback('undefined')}`;
|
|
386
394
|
}
|
|
387
395
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amritk/generate-parsers",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Generate TypeScript parsers and type definitions from JSON Schemas.",
|
|
5
5
|
"module": "./dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"json-schema-typed": "^8.0.1",
|
|
51
|
-
"@amritk/helpers": "0.
|
|
51
|
+
"@amritk/helpers": "0.12.0",
|
|
52
52
|
"@amritk/generate-markdown": "0.4.1"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|