@amritk/generate-parsers 0.17.1 → 0.18.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 +28 -12
- package/dist/generators/build-schema.js +4 -1
- package/dist/generators/generate-discriminated-union-validation.js +1 -2
- package/dist/generators/generate-files.d.ts +7 -0
- package/dist/generators/generate-files.js +23 -4
- package/dist/generators/generate-parser-function.js +12 -9
- package/dist/generators/generate-strict-assertion.d.ts +7 -0
- package/dist/generators/generate-strict-assertion.js +51 -8
- package/dist/generators/generate-type-checks.d.ts +15 -0
- package/dist/generators/generate-type-checks.js +48 -1
- package/dist/generators/generate-validation-expression.d.ts +13 -4
- package/dist/generators/generate-validation-expression.js +5 -2
- package/dist/helpers/collect-imports.d.ts +8 -0
- package/dist/helpers/collect-imports.js +1 -1
- package/dist/helpers/get-default-value.js +23 -1
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
**Programmatic API for generating TypeScript parsers and type definitions from JSON Schemas.**
|
|
6
6
|
|
|
7
7
|

|
|
8
|
-

|
|
9
9
|

|
|
10
10
|

|
|
11
11
|

|
|
@@ -75,19 +75,33 @@ Write them to disk however you like.
|
|
|
75
75
|
|
|
76
76
|
## API
|
|
77
77
|
|
|
78
|
-
### `buildSchema(rootSchema, rootTypeName,
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
|
83
|
-
|
|
84
|
-
| `
|
|
85
|
-
|
|
|
86
|
-
| `
|
|
87
|
-
| `
|
|
78
|
+
### `buildSchema(rootSchema, rootTypeName, …options)`
|
|
79
|
+
|
|
80
|
+
Every option after the first two is **positional** — pass them in this order:
|
|
81
|
+
|
|
82
|
+
| # | Parameter | Type | Default | Description |
|
|
83
|
+
|--:|:---|:---|:---|:---|
|
|
84
|
+
| 1 | `rootSchema` | `JSONSchema` | — | The root schema to traverse. `$ref` and `$dynamicRef` are resolved recursively. |
|
|
85
|
+
| 2 | `rootTypeName` | `string` | — | Name used for the root type (e.g. `"Document"`). |
|
|
86
|
+
| 3 | `extensions` | `SchemaExtensions` | — | Map of definition name → extra optional properties to merge in before generation. |
|
|
87
|
+
| 4 | `typesOnly` | `boolean` | — | Only emit `.ts` type definitions — skip parser functions and runtime helpers. |
|
|
88
|
+
| 5 | `logWarnings` | `boolean` | — | Generated parsers emit a `console.warn` for every input key not declared in the schema's properties. |
|
|
89
|
+
| 6 | `strict` | `boolean` | — | Generated parsers throw on type/shape mismatches (wrong type, missing required property, enum/pattern/min/max violations) instead of coercing invalid input to default values. |
|
|
90
|
+
| 7 | `helpersMode` | `'package' \| 'embedded'` | `'package'` | `'package'` imports runtime helpers from `@amritk/helpers`; `'embedded'` emits them alongside the output so it is self-contained. |
|
|
91
|
+
| 8 | `helpersImportPrefix` | `string` | `'./'` | Prefix the generated files use when importing embedded helpers. |
|
|
92
|
+
| 9 | `readonly` | `boolean` | `false` | Emit every property, array, and record in the type definitions as `readonly`. |
|
|
93
|
+
| 10 | `stripUnknown` | `boolean` | `false` | Build each result from the declared properties only, dropping undeclared input keys at every level (zod's `.strip()`). |
|
|
94
|
+
| 11 | `typeSuffix` | `string` | `''` | Suffix appended to every `$ref`-derived type name (`'Object'` turns `Contact` into `ContactObject`). The root type name is unaffected. |
|
|
95
|
+
| 12 | `importExt` | `'js' \| 'ts'` | `'js'` | Extension emitted on relative import specifiers. `'js'` is the NodeNext form for output you compile; `'ts'` emits the literal on-disk paths so the sources run unbuilt. |
|
|
96
|
+
| 13 | `caseInsensitive` | `boolean` | `false` | Normalize a mis-cased string to the exact casing of an enum/const member it matches case-insensitively. Coerce mode only. |
|
|
88
97
|
|
|
89
98
|
Returns: `Promise<GeneratedFile[]>`.
|
|
90
99
|
|
|
100
|
+
> [!NOTE]
|
|
101
|
+
> `importExt` defaults to `'js'` here, whereas the [`mjst` CLI](../cli) defaults it to
|
|
102
|
+
> `'ts'`. The example above therefore emits `./info.js` specifiers; pass `'ts'` for
|
|
103
|
+
> output meant to run without a build step.
|
|
104
|
+
|
|
91
105
|
---
|
|
92
106
|
|
|
93
107
|
## Options
|
|
@@ -151,7 +165,9 @@ The generator handles:
|
|
|
151
165
|
- `$ref` and `$dynamicRef` resolution, including JSON Schema 2020-12 `$dynamicAnchor`
|
|
152
166
|
- Discriminated and non-discriminated unions (`oneOf` / `anyOf`)
|
|
153
167
|
- Enums and `const` values
|
|
154
|
-
- Nested objects, arrays, records, and tuples
|
|
168
|
+
- Nested objects, arrays, records, and tuples (`prefixItems`, and the draft-07 array form of `items`)
|
|
169
|
+
- Multi-type schemas (`type: ["string", "null"]`) and the OpenAPI 3.0 `nullable: true` spelling
|
|
170
|
+
- Composition alongside a declared shape — `allOf` members and sibling unions are intersected into the type, not dropped
|
|
155
171
|
- Pattern-based default values
|
|
156
172
|
|
|
157
173
|
---
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { dirname, resolve as resolvePath } from "node:path";
|
|
4
|
+
import { foldNullable } from "@amritk/helpers/fold-nullable";
|
|
4
5
|
import { generateIndexBarrel } from "@amritk/helpers/generate-index-barrel";
|
|
5
6
|
import { walkRefGraph } from "@amritk/helpers/walk-ref-graph";
|
|
6
7
|
import { applySchemaExtensions } from "../helpers/apply-schema-extensions.js";
|
|
@@ -22,11 +23,13 @@ const readHelperSource = async (helper, importExt) => {
|
|
|
22
23
|
const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logWarnings, strict, helpersMode = "package", helpersImportPrefix = "./", readonly = false, stripUnknown = false, typeSuffix = "", importExt = "js", caseInsensitive = false) => {
|
|
23
24
|
const files = [];
|
|
24
25
|
const usedHelpers = /* @__PURE__ */ new Set();
|
|
25
|
-
|
|
26
|
+
const foldedSchema = foldNullable(rootSchema);
|
|
27
|
+
walkRefGraph(foldedSchema, rootTypeName, { typeSuffix }, (node) => {
|
|
26
28
|
if (node.filename === "index")
|
|
27
29
|
return;
|
|
28
30
|
const extended = extensions ? applySchemaExtensions(node.schema, node.filename, extensions) : node.schema;
|
|
29
31
|
const result = generateFile(extended, node.typeName, {
|
|
32
|
+
selfFilename: node.filename,
|
|
30
33
|
typesOnly: typesOnly ?? false,
|
|
31
34
|
rootSchema: node.rootSchema,
|
|
32
35
|
helpersMode,
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { safeAccessor } from "@amritk/helpers/safe-accessor";
|
|
2
1
|
import { getDiscriminatorValue } from "../helpers/get-discriminator-value.js";
|
|
3
2
|
import { generateSchemaChecks } from "./generate-schema-checks.js";
|
|
4
3
|
const generateDiscriminatedUnionValidation = (accessor, schemas, discriminatorKey, defaultValue, isRequired) => {
|
|
5
|
-
const discAccessor =
|
|
4
|
+
const discAccessor = `(${accessor} as Record<string, unknown> | null | undefined)?.[${JSON.stringify(discriminatorKey)}]`;
|
|
6
5
|
const branches = [];
|
|
7
6
|
for (const schema of schemas) {
|
|
8
7
|
const discriminatorValue = getDiscriminatorValue(schema, discriminatorKey);
|
|
@@ -16,6 +16,13 @@ type GenerateFileOptions = {
|
|
|
16
16
|
* the import list, preventing a file from importing itself.
|
|
17
17
|
*/
|
|
18
18
|
readonly selfRef?: string;
|
|
19
|
+
/**
|
|
20
|
+
* The filename this file is written as, without extension. Used for
|
|
21
|
+
* self-import detection when the schema has no `$ref` of its own — the root
|
|
22
|
+
* document — so a `$defs` entry that collides with the root type name cannot
|
|
23
|
+
* make the root file import itself.
|
|
24
|
+
*/
|
|
25
|
+
readonly selfFilename?: string;
|
|
19
26
|
/**
|
|
20
27
|
* The root schema document. When provided, URI refs that cannot be resolved
|
|
21
28
|
* within the root schema's $defs are excluded from the import list.
|
|
@@ -9,9 +9,20 @@ const generateFile = (schema, typeName, options) => {
|
|
|
9
9
|
const helpersMode = options?.helpersMode ?? "package";
|
|
10
10
|
const typeSuffix = options?.typeSuffix ?? "";
|
|
11
11
|
const importExt = options?.importExt ?? "js";
|
|
12
|
-
const typeDefinition = generateTypeDefinition(schema, typeName, {
|
|
12
|
+
const typeDefinition = generateTypeDefinition(schema, typeName, {
|
|
13
|
+
readonly: options?.readonly ?? false,
|
|
14
|
+
typeSuffix,
|
|
15
|
+
...rootSchema !== void 0 ? { rootSchema } : {}
|
|
16
|
+
});
|
|
13
17
|
if (typesOnly) {
|
|
14
|
-
const imports2 = collectImports(schema, {
|
|
18
|
+
const imports2 = collectImports(schema, {
|
|
19
|
+
typesOnly: true,
|
|
20
|
+
selfRef,
|
|
21
|
+
selfFilename: options?.selfFilename,
|
|
22
|
+
rootSchema,
|
|
23
|
+
typeSuffix,
|
|
24
|
+
importExt
|
|
25
|
+
});
|
|
15
26
|
let result2 = "";
|
|
16
27
|
if (imports2.length > 0) {
|
|
17
28
|
result2 += imports2[0];
|
|
@@ -23,7 +34,12 @@ const generateFile = (schema, typeName, options) => {
|
|
|
23
34
|
return { content: result2 + typeDefinition, usedHelpers: /* @__PURE__ */ new Set() };
|
|
24
35
|
}
|
|
25
36
|
const stripUnknown = options?.stripUnknown ?? false;
|
|
26
|
-
const reservedNames = collectImportTypeNames(schema, {
|
|
37
|
+
const reservedNames = collectImportTypeNames(schema, {
|
|
38
|
+
selfRef,
|
|
39
|
+
selfFilename: options?.selfFilename,
|
|
40
|
+
rootSchema,
|
|
41
|
+
typeSuffix
|
|
42
|
+
});
|
|
27
43
|
const shapeValidator = generateShapeValidator(schema, typeName, true, typeSuffix, true, stripUnknown, reservedNames);
|
|
28
44
|
const parserFunction = generateParserFunction(schema, typeName, {
|
|
29
45
|
useRefImports: true,
|
|
@@ -41,7 +57,10 @@ const generateFile = (schema, typeName, options) => {
|
|
|
41
57
|
|
|
42
58
|
${parserFunction}`;
|
|
43
59
|
const helpers = collectHelpers(combinedFunctions, helpersMode, options?.helpersImportPrefix, importExt);
|
|
44
|
-
const imports = [
|
|
60
|
+
const imports = [
|
|
61
|
+
...collectImports(schema, { selfRef, selfFilename: options?.selfFilename, rootSchema, typeSuffix, importExt }),
|
|
62
|
+
...helpers.imports
|
|
63
|
+
];
|
|
45
64
|
let result = "";
|
|
46
65
|
if (imports.length > 0) {
|
|
47
66
|
result += imports[0];
|
|
@@ -10,7 +10,7 @@ import { getDefaultValue } from "../helpers/get-default-value.js";
|
|
|
10
10
|
import { getDiscriminatorValue } from "../helpers/get-discriminator-value.js";
|
|
11
11
|
import { assertNoUnsupportedKeywords } from "./assert-supported-keywords.js";
|
|
12
12
|
import { generateEnumCaseInsensitiveCoercion } from "./generate-enum-check.js";
|
|
13
|
-
import { generateContainsCheck, generateObjectKeywordChecks, generateObjectStrictAssertion, generateScalarStrictAssertion } from "./generate-strict-assertion.js";
|
|
13
|
+
import { generateContainsCheck, generateObjectKeywordChecks, generateObjectStrictAssertion, generateScalarStrictAssertion, inlineAllOfMembers } from "./generate-strict-assertion.js";
|
|
14
14
|
import { canEnforceUnion, generatePropertyTypeCheck, generateUnionCheck, getUnionBranches, hasFastPathBlockingKeyword, isInlineObjectArrayProperty, isInlineObjectProperty, shapeValidatorName } from "./generate-type-checks.js";
|
|
15
15
|
import { generatePrefixItemsMap, generateValidationExpression, getPrefixItems, isCoercibleItemSchema, prefixItemsCapsLength } from "./generate-validation-expression.js";
|
|
16
16
|
const generateParserName = (typeName) => {
|
|
@@ -165,7 +165,8 @@ const generateFallbackObject = (schema, useRefImports, typeName, suffix, subType
|
|
|
165
165
|
if (fallbackValue === "undefined") {
|
|
166
166
|
return `{} as ${typeName}`;
|
|
167
167
|
}
|
|
168
|
-
|
|
168
|
+
const partialFallback = fallbackValue === "{}" || isSchemaObject(propSchema) && (hasAllOf(propSchema) || hasOneOf(propSchema) || hasAnyOf(propSchema));
|
|
169
|
+
requiredProps.push(` ${safeLiteralKey(key)}: ${partialFallback ? `${fallbackValue} as NonNullable<${typeName}>[${JSON.stringify(key)}]` : fallbackValue},`);
|
|
169
170
|
}
|
|
170
171
|
}
|
|
171
172
|
if (requiredProps.length === 0) {
|
|
@@ -330,7 +331,7 @@ ${delegatedBody(itemParserName)}
|
|
|
330
331
|
const stripUnknown = unionCtx?.stripUnknown ?? false;
|
|
331
332
|
const itemShapeValidator = generateShapeValidator(items, itemName, useRefImports, suffix, false, stripUnknown, reserved);
|
|
332
333
|
const preamble = [
|
|
333
|
-
`type ${itemName} =
|
|
334
|
+
`type ${itemName} = NonNullable<${typeName}>[number];`,
|
|
334
335
|
itemShapeValidator,
|
|
335
336
|
generateObjectParser(items, itemName, useRefImports, suffix, unionCtx?.logWarnings ?? false, strict, false, stripUnknown, unionCtx?.rootSchema, reserved, unionCtx?.caseInsensitive ?? false, itemShapeValidator)
|
|
336
337
|
].join("\n\n");
|
|
@@ -339,7 +340,8 @@ ${delegatedBody(itemParserName)}
|
|
|
339
340
|
${delegated(generateParserName(itemName))}`;
|
|
340
341
|
}
|
|
341
342
|
}
|
|
342
|
-
|
|
343
|
+
const isMultiType = isSchemaObject(schema) && Array.isArray(schema.type);
|
|
344
|
+
if (!isSchemaObject(schema) || !hasType(schema) && !isMultiType) {
|
|
343
345
|
return `export const ${functionName} = (input: unknown): ${typeName} => input as ${typeName};`;
|
|
344
346
|
}
|
|
345
347
|
if (!strict && schema.type === "null") {
|
|
@@ -350,7 +352,8 @@ ${delegated(generateParserName(itemName))}`;
|
|
|
350
352
|
if (assertion === null) {
|
|
351
353
|
return `export const ${functionName} = (input: unknown): ${typeName} => input as ${typeName};`;
|
|
352
354
|
}
|
|
353
|
-
const
|
|
355
|
+
const arrayCast = getPrefixItems(schema) ? `[...(input as readonly unknown[])] as unknown as ${typeName}` : `[...(input as readonly unknown[])] as ${typeName}`;
|
|
356
|
+
const returnExpr = !isMultiType && schema.type === "array" ? arrayCast : `input as ${typeName}`;
|
|
354
357
|
return `export const ${functionName} = (input: unknown): ${typeName} => {
|
|
355
358
|
${assertion}
|
|
356
359
|
return ${returnExpr};
|
|
@@ -369,7 +372,7 @@ ${assertion}
|
|
|
369
372
|
const prefixItems = getPrefixItems(schema);
|
|
370
373
|
if (prefixItems) {
|
|
371
374
|
const mapped = generatePrefixItemsMap("input", prefixItems, prefixItemsCapsLength(schema), unionCtx?.rootSchema, void 0, unionCtx?.caseInsensitive);
|
|
372
|
-
return `export const ${functionName} = (input: unknown): ${typeName} => Array.isArray(input) ? ${mapped} as ${typeName} : [] as ${typeName};`;
|
|
375
|
+
return `export const ${functionName} = (input: unknown): ${typeName} => Array.isArray(input) ? ${mapped} as unknown as ${typeName} : [] as unknown as ${typeName};`;
|
|
373
376
|
}
|
|
374
377
|
if (hasItems(schema) && !Array.isArray(schema.items) && isCoercibleItemSchema(schema.items)) {
|
|
375
378
|
const item = schema.items;
|
|
@@ -485,7 +488,7 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
|
|
|
485
488
|
const preamble = [];
|
|
486
489
|
for (const [key, subName] of subTypeNames) {
|
|
487
490
|
const propSchema = schemaProps[key];
|
|
488
|
-
preamble.push(`type ${subName} =
|
|
491
|
+
preamble.push(`type ${subName} = NonNullable<${typeName}>[${JSON.stringify(key)}];`);
|
|
489
492
|
const subShapeValidator = generateShapeValidator(propSchema, subName, useRefImports, suffix, false, stripUnknown, reservedNames);
|
|
490
493
|
preamble.push(subShapeValidator);
|
|
491
494
|
preamble.push(generateObjectParser(propSchema, subName, useRefImports, suffix, logWarnings, strict, false, stripUnknown, rootSchema, reservedNames, caseInsensitive, subShapeValidator));
|
|
@@ -493,7 +496,7 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
|
|
|
493
496
|
for (const [key, subName] of subItemNames) {
|
|
494
497
|
const propSchema = schemaProps[key];
|
|
495
498
|
const itemSchema = propSchema.items;
|
|
496
|
-
preamble.push(`type ${subName} = NonNullable<${typeName}[${JSON.stringify(key)}]>[number];`);
|
|
499
|
+
preamble.push(`type ${subName} = NonNullable<NonNullable<${typeName}>[${JSON.stringify(key)}]>[number];`);
|
|
497
500
|
const itemShapeValidator = generateShapeValidator(itemSchema, subName, useRefImports, suffix, false, stripUnknown, reservedNames);
|
|
498
501
|
preamble.push(itemShapeValidator);
|
|
499
502
|
preamble.push(`const _every${subName} = (arr: readonly unknown[]): boolean => {
|
|
@@ -522,7 +525,7 @@ const generateObjectParser = (schema, typeName, useRefImports, suffix, logWarnin
|
|
|
522
525
|
const fallbackObject = generateFallbackObject(schema, useRefImports, typeName, suffix, subTypeNames);
|
|
523
526
|
const propInfo = [];
|
|
524
527
|
const hasAllOfRefParsers = useRefImports && isSchemaObject(schema) && hasAllOf(schema) && schema.allOf.some((entry) => isSchemaObject(entry) && hasRef(entry));
|
|
525
|
-
let canFastPath = !hasAllOfRefParsers && !hasFastPathBlockingKeyword(schema);
|
|
528
|
+
let canFastPath = !hasAllOfRefParsers && !hasFastPathBlockingKeyword(schema) && !(strict && inlineAllOfMembers(schema).length > 0);
|
|
526
529
|
const fastPathChecks = [];
|
|
527
530
|
const fastPathAccessorChecks = [];
|
|
528
531
|
const usedVarNames = /* @__PURE__ */ new Set();
|
|
@@ -46,6 +46,13 @@ export declare const generateObjectKeywordChecks: (obj: string, schema: JSONSche
|
|
|
46
46
|
* Properties with a `$ref` are validated by the nested parser's own strict
|
|
47
47
|
* check when that parser is invoked downstream.
|
|
48
48
|
*/
|
|
49
|
+
/**
|
|
50
|
+
* The `allOf` members this assertion must enforce itself: plain object schemas
|
|
51
|
+
* written inline. A `$ref` member is enforced by the parser generated for its
|
|
52
|
+
* target, and a member that composes further (nested `allOf`/`oneOf`/`if`) is
|
|
53
|
+
* left alone rather than half-checked.
|
|
54
|
+
*/
|
|
55
|
+
export declare const inlineAllOfMembers: (schema: JSONSchema) => JSONSchema[];
|
|
49
56
|
export declare const generateObjectStrictAssertion: (schema: JSONSchema, typeName: string, context?: StrictAssertionContext) => string[];
|
|
50
57
|
/**
|
|
51
58
|
* Generates a single strict-mode assertion line for a non-object scalar parser.
|
|
@@ -6,7 +6,7 @@ import { resolveRef } from "@amritk/helpers/resolve-ref";
|
|
|
6
6
|
import { safeAccessor } from "@amritk/helpers/safe-accessor";
|
|
7
7
|
import { hasConst, hasDependentRequired, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasItems, hasMaxItems, hasMaximum, hasMaxLength, hasMinItems, hasMinimum, hasMinLength, hasMultipleOf, hasPattern, hasProperties, hasPropertyNames, hasRef, hasRequired, hasType, hasUniqueItems, isSchemaObject } from "@amritk/helpers/schema-guards";
|
|
8
8
|
import { generateEnumCheck } from "./generate-enum-check.js";
|
|
9
|
-
import { canEnforceUnion, generateUnionCheck, getUnionBranches, isInlineObjectProperty } from "./generate-type-checks.js";
|
|
9
|
+
import { canEnforceUnion, generateUnionCheck, getUnionBranches, isInlineObjectProperty, multiTypeCheck } from "./generate-type-checks.js";
|
|
10
10
|
import { getPrefixItems, prefixItemsCapsLength, scalarItemTypeCheck } from "./generate-validation-expression.js";
|
|
11
11
|
import { subschemaMatchExpr } from "./subschema-match.js";
|
|
12
12
|
const wrongTypeCondition = (accessor, type) => {
|
|
@@ -262,6 +262,19 @@ const generatePropertyAssertion = (key, propSchema, isRequired, typeName, contex
|
|
|
262
262
|
}
|
|
263
263
|
return lines;
|
|
264
264
|
}
|
|
265
|
+
const multiType = multiTypeCheck(acc, propSchema, { ignoreConstraints: true });
|
|
266
|
+
if (multiType !== void 0) {
|
|
267
|
+
const types = propSchema.type;
|
|
268
|
+
if (multiType !== null) {
|
|
269
|
+
const expected = throwError(`${field} expected ${types.map(typeLabel).join(" | ")}, got `, `typeof ${acc}`);
|
|
270
|
+
lines.push(isRequired ? ` if (!${multiType}) ${expected};` : ` if (${acc} !== undefined && !${multiType}) ${expected};`);
|
|
271
|
+
}
|
|
272
|
+
const nonNull = types.filter((type) => type !== "null");
|
|
273
|
+
if (nonNull.length === 1) {
|
|
274
|
+
lines.push(...generateConstraintChecks(acc, { ...propSchema, type: nonNull[0] }, field, context));
|
|
275
|
+
}
|
|
276
|
+
return lines;
|
|
277
|
+
}
|
|
265
278
|
if (hasType(propSchema)) {
|
|
266
279
|
const t = propSchema.type;
|
|
267
280
|
const wrongType = wrongTypeCondition(acc, t);
|
|
@@ -285,17 +298,39 @@ const generatePropertyAssertion = (key, propSchema, isRequired, typeName, contex
|
|
|
285
298
|
}
|
|
286
299
|
return lines;
|
|
287
300
|
};
|
|
301
|
+
const inlineAllOfMembers = (schema) => {
|
|
302
|
+
if (!isSchemaObject(schema) || !Array.isArray(schema.allOf))
|
|
303
|
+
return [];
|
|
304
|
+
return schema.allOf.filter((member) => {
|
|
305
|
+
if (!isSchemaObject(member) || hasRef(member))
|
|
306
|
+
return false;
|
|
307
|
+
const record = member;
|
|
308
|
+
if ("allOf" in record || "oneOf" in record || "anyOf" in record || "not" in record)
|
|
309
|
+
return false;
|
|
310
|
+
if ("if" in record || "then" in record || "else" in record)
|
|
311
|
+
return false;
|
|
312
|
+
return hasProperties(member) || hasRequired(member);
|
|
313
|
+
});
|
|
314
|
+
};
|
|
288
315
|
const generateObjectStrictAssertion = (schema, typeName, context = {}) => {
|
|
289
316
|
const lines = [];
|
|
290
317
|
lines.push(` if (!isObject(input)) ${throwError(`[${typeName}] expected object, got `, 'input === null ? "null" : typeof input')};`);
|
|
291
318
|
if (!isSchemaObject(schema))
|
|
292
319
|
return lines;
|
|
320
|
+
const required = new Set(hasRequired(schema) ? schema.required : []);
|
|
293
321
|
if (hasProperties(schema)) {
|
|
294
|
-
const required = new Set(hasRequired(schema) ? schema.required : []);
|
|
295
322
|
const props = schema.properties;
|
|
296
323
|
for (const key in props) {
|
|
297
324
|
lines.push(...generatePropertyAssertion(key, props[key], required.has(key), typeName, context));
|
|
298
325
|
}
|
|
326
|
+
for (const key in props)
|
|
327
|
+
required.delete(key);
|
|
328
|
+
}
|
|
329
|
+
for (const key of required) {
|
|
330
|
+
lines.push(` if (!(${JSON.stringify(key)} in input)) ${throwError(`[${typeName}] missing required property '${key}'`)};`);
|
|
331
|
+
}
|
|
332
|
+
for (const member of inlineAllOfMembers(schema)) {
|
|
333
|
+
lines.push(...generateObjectStrictAssertion(member, typeName, context).slice(1));
|
|
299
334
|
}
|
|
300
335
|
const sr = schema;
|
|
301
336
|
if ("dependentRequired" in sr || "dependentSchemas" in sr || "propertyNames" in sr) {
|
|
@@ -325,6 +360,18 @@ const generateScalarStrictAssertion = (schema, typeName, rootSchema) => {
|
|
|
325
360
|
const enumLabel = schema.enum.map((v) => JSON.stringify(v)).join(", ");
|
|
326
361
|
lines.push(` if (!(${allowed} as readonly unknown[]).includes(input)) ${throwError(`${label} must be one of: ${enumLabel}`)};`);
|
|
327
362
|
}
|
|
363
|
+
const rootMultiType = multiTypeCheck("input", schema, { ignoreConstraints: true });
|
|
364
|
+
if (rootMultiType !== void 0) {
|
|
365
|
+
const types = schema.type;
|
|
366
|
+
if (rootMultiType !== null) {
|
|
367
|
+
lines.push(` if (!${rootMultiType}) ${throwError(`${label} expected ${types.map(typeLabel).join(" | ")}, got `, got)};`);
|
|
368
|
+
}
|
|
369
|
+
const nonNull = types.filter((type) => type !== "null");
|
|
370
|
+
if (nonNull.length === 1) {
|
|
371
|
+
lines.push(...generateConstraintChecks("input", { ...schema, type: nonNull[0] }, label, rootSchema ? { rootSchema } : {}));
|
|
372
|
+
}
|
|
373
|
+
return lines.length > 0 ? lines.join("\n") : null;
|
|
374
|
+
}
|
|
328
375
|
if (hasType(schema)) {
|
|
329
376
|
const t = schema.type;
|
|
330
377
|
const wrongType = wrongTypeCondition("input", t);
|
|
@@ -332,12 +379,7 @@ const generateScalarStrictAssertion = (schema, typeName, rootSchema) => {
|
|
|
332
379
|
lines.push(` if (${wrongType}) ${throwError(`${label} expected ${typeLabel(t)}, got `, got)};`);
|
|
333
380
|
}
|
|
334
381
|
if (t === "array") {
|
|
335
|
-
const itemCheck = generateItemCheck(schema);
|
|
336
|
-
if (itemCheck) {
|
|
337
|
-
lines.push(` if (!(input as readonly unknown[]).every((_it) => ${itemCheck.check})) ${throwError(`${label} ${itemCheck.message}`)};`);
|
|
338
|
-
}
|
|
339
382
|
lines.push(...generateContainsCheck("input", schema, label));
|
|
340
|
-
lines.push(...generatePrefixItemsAssertion("input", label, schema, rootSchema));
|
|
341
383
|
}
|
|
342
384
|
lines.push(...generateConstraintChecks("input", schema, label, rootSchema ? { rootSchema } : {}));
|
|
343
385
|
}
|
|
@@ -347,5 +389,6 @@ export {
|
|
|
347
389
|
generateContainsCheck,
|
|
348
390
|
generateObjectKeywordChecks,
|
|
349
391
|
generateObjectStrictAssertion,
|
|
350
|
-
generateScalarStrictAssertion
|
|
392
|
+
generateScalarStrictAssertion,
|
|
393
|
+
inlineAllOfMembers
|
|
351
394
|
};
|
|
@@ -50,6 +50,21 @@ export declare const getUnionBranches: (schema: JSONSchema) => readonly JSONSche
|
|
|
50
50
|
* both must bail when this is true — the slow path then runs the real checks.
|
|
51
51
|
*/
|
|
52
52
|
export declare const hasFastPathBlockingKeyword: (schema: JSONSchema) => boolean;
|
|
53
|
+
/**
|
|
54
|
+
* The membership check for an array-form `type` (the multi-type / nullable
|
|
55
|
+
* idiom, e.g. `["string","null"]`): the disjunction of the per-type checks.
|
|
56
|
+
* Returns `undefined` when the schema has no array `type`, and `null` when it
|
|
57
|
+
* has one that cannot be checked soundly — a type name we do not model, or a
|
|
58
|
+
* constraint keyword a bare `typeof` would skip.
|
|
59
|
+
*
|
|
60
|
+
* Without this every nullable property fell through to `hasType`, which is false
|
|
61
|
+
* for an array `type`, and produced no check at all: no fast path (so the shape
|
|
62
|
+
* validator degraded to the `=> false` stub) and, in strict mode, nothing to
|
|
63
|
+
* throw on.
|
|
64
|
+
*/
|
|
65
|
+
export declare const multiTypeCheck: (varName: string, schema: JSONSchema, options?: {
|
|
66
|
+
readonly ignoreConstraints?: boolean;
|
|
67
|
+
}) => string | null | undefined;
|
|
53
68
|
/**
|
|
54
69
|
* Generates a fast-path type check expression for a property.
|
|
55
70
|
* Returns null if the schema is too complex for a simple type check.
|
|
@@ -5,13 +5,15 @@ import { resolveRef } from "@amritk/helpers/resolve-ref";
|
|
|
5
5
|
import { safeAccessor } from "@amritk/helpers/safe-accessor";
|
|
6
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";
|
|
7
7
|
import { generateEnumCheck } from "./generate-enum-check.js";
|
|
8
|
-
import { getPrefixItems, prefixItemsCapsLength, scalarItemTypeCheck } from "./generate-validation-expression.js";
|
|
8
|
+
import { getPrefixItems, prefixItemsCapsLength, scalarItemTypeCheck, singleTypeCheck } from "./generate-validation-expression.js";
|
|
9
9
|
const shapeValidatorName = (typeName) => `validate${typeName}Shape`;
|
|
10
10
|
const isInlineObjectProperty = (propSchema) => {
|
|
11
11
|
if (!isSchemaObject(propSchema))
|
|
12
12
|
return false;
|
|
13
13
|
if (hasRef(propSchema) || hasEnum(propSchema) || hasConst(propSchema))
|
|
14
14
|
return false;
|
|
15
|
+
if (Array.isArray(propSchema.type))
|
|
16
|
+
return false;
|
|
15
17
|
if (hasOneOf(propSchema) || hasAnyOf(propSchema) || hasAllOf(propSchema))
|
|
16
18
|
return false;
|
|
17
19
|
if ("patternProperties" in propSchema || "not" in propSchema)
|
|
@@ -48,6 +50,44 @@ const hasFastPathBlockingKeyword = (schema) => {
|
|
|
48
50
|
const record = schema;
|
|
49
51
|
return "contains" in record || "dependentSchemas" in record || "propertyNames" in record || "dependentRequired" in record;
|
|
50
52
|
};
|
|
53
|
+
const CONSTRAINT_KEYWORDS = [
|
|
54
|
+
"pattern",
|
|
55
|
+
"minLength",
|
|
56
|
+
"maxLength",
|
|
57
|
+
"minimum",
|
|
58
|
+
"maximum",
|
|
59
|
+
"exclusiveMinimum",
|
|
60
|
+
"exclusiveMaximum",
|
|
61
|
+
"multipleOf",
|
|
62
|
+
"minItems",
|
|
63
|
+
"maxItems",
|
|
64
|
+
"uniqueItems",
|
|
65
|
+
"items",
|
|
66
|
+
"prefixItems",
|
|
67
|
+
"properties",
|
|
68
|
+
"required",
|
|
69
|
+
"additionalProperties",
|
|
70
|
+
"patternProperties",
|
|
71
|
+
"minProperties",
|
|
72
|
+
"maxProperties"
|
|
73
|
+
];
|
|
74
|
+
const multiTypeCheck = (varName, schema, options = {}) => {
|
|
75
|
+
if (!isSchemaObject(schema) || !Array.isArray(schema.type))
|
|
76
|
+
return void 0;
|
|
77
|
+
const record = schema;
|
|
78
|
+
if (!options.ignoreConstraints && CONSTRAINT_KEYWORDS.some((keyword) => keyword in record))
|
|
79
|
+
return null;
|
|
80
|
+
const checks = [];
|
|
81
|
+
for (const type of schema.type) {
|
|
82
|
+
const check = singleTypeCheck(varName, type);
|
|
83
|
+
if (check === null)
|
|
84
|
+
return null;
|
|
85
|
+
checks.push(`(${check})`);
|
|
86
|
+
}
|
|
87
|
+
if (checks.length === 0)
|
|
88
|
+
return null;
|
|
89
|
+
return checks.length === 1 ? checks[0] : `(${checks.join(" || ")})`;
|
|
90
|
+
};
|
|
51
91
|
const generatePropertyTypeCheck = (varName, schema, useRefImports, suffix) => {
|
|
52
92
|
if (!isSchemaObject(schema))
|
|
53
93
|
return null;
|
|
@@ -75,6 +115,9 @@ const generatePropertyTypeCheck = (varName, schema, useRefImports, suffix) => {
|
|
|
75
115
|
const c = schema.const;
|
|
76
116
|
return c === null || typeof c !== "object" ? `${varName} === ${JSON.stringify(c)}` : null;
|
|
77
117
|
}
|
|
118
|
+
const multiType = multiTypeCheck(varName, schema);
|
|
119
|
+
if (multiType !== void 0)
|
|
120
|
+
return multiType;
|
|
78
121
|
if (!hasType(schema))
|
|
79
122
|
return null;
|
|
80
123
|
if (useRefImports && schema.type === "array" && hasItems(schema) && isSchemaObject(schema.items) && hasRef(schema.items)) {
|
|
@@ -225,6 +268,9 @@ const canTrustPropertyCheck = (schema, rootSchema, visiting) => {
|
|
|
225
268
|
return false;
|
|
226
269
|
if (hasConst(schema))
|
|
227
270
|
return schema.const === null || typeof schema.const !== "object";
|
|
271
|
+
const multiType = multiTypeCheck("_x", schema);
|
|
272
|
+
if (multiType !== void 0)
|
|
273
|
+
return multiType !== null;
|
|
228
274
|
if (!hasType(schema))
|
|
229
275
|
return false;
|
|
230
276
|
if (schema.type === "array" && hasItems(schema) && isSchemaObject(schema.items) && hasRef(schema.items)) {
|
|
@@ -309,5 +355,6 @@ export {
|
|
|
309
355
|
hasFastPathBlockingKeyword,
|
|
310
356
|
isInlineObjectArrayProperty,
|
|
311
357
|
isInlineObjectProperty,
|
|
358
|
+
multiTypeCheck,
|
|
312
359
|
shapeValidatorName
|
|
313
360
|
};
|
|
@@ -14,10 +14,11 @@ export declare const scalarItemTypeCheck: (itemSchema: JSONSchema, accessor: str
|
|
|
14
14
|
*/
|
|
15
15
|
export declare const isCoercibleItemSchema: (itemSchema: JSONSchema) => boolean;
|
|
16
16
|
/**
|
|
17
|
-
* The tuple
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* The tuple positions a schema declares, else `null`. `prefixItems` (JSON
|
|
18
|
+
* Schema 2020-12) positions each element against its own subschema; draft-07
|
|
19
|
+
* spelled the same construct as an array-valued `items`, which every other item
|
|
20
|
+
* path skips (they all read `items` as a single schema), so it is normalized
|
|
21
|
+
* here rather than left unchecked.
|
|
21
22
|
*/
|
|
22
23
|
export declare const getPrefixItems: (schema: JSONSchema) => readonly JSONSchema[] | null;
|
|
23
24
|
/**
|
|
@@ -26,6 +27,14 @@ export declare const getPrefixItems: (schema: JSONSchema) => readonly JSONSchema
|
|
|
26
27
|
* disallowed, so the coercer drops it and strict mode rejects it.
|
|
27
28
|
*/
|
|
28
29
|
export declare const prefixItemsCapsLength: (schema: JSONSchema) => boolean;
|
|
30
|
+
/**
|
|
31
|
+
* A boolean type check for a single JSON Schema primitive type name. Unlike
|
|
32
|
+
* {@link scalarItemTypeCheck} this also covers `array`/`object` (as inline shape
|
|
33
|
+
* checks, so no `isObject` import is assumed) and enforces `integer` with
|
|
34
|
+
* `Number.isInteger` — a bare `typeof === "number"` accepts `1.5`. Used to build
|
|
35
|
+
* the disjunction for an array-form `type` (e.g. `["string","null"]`).
|
|
36
|
+
*/
|
|
37
|
+
export declare const singleTypeCheck: (accessor: string, type: string) => string | null;
|
|
29
38
|
/**
|
|
30
39
|
* Generates a validation expression that checks if a value matches the expected type.
|
|
31
40
|
* Returns the value if valid, coerces to the correct type if invalid, or uses default if required and missing.
|
|
@@ -42,7 +42,9 @@ const getPrefixItems = (schema) => {
|
|
|
42
42
|
if (!isSchemaObject(schema))
|
|
43
43
|
return null;
|
|
44
44
|
const prefix = schema["prefixItems"];
|
|
45
|
-
|
|
45
|
+
if (Array.isArray(prefix))
|
|
46
|
+
return prefix;
|
|
47
|
+
return Array.isArray(schema.items) ? schema.items : null;
|
|
46
48
|
};
|
|
47
49
|
const prefixItemsCapsLength = (schema) => {
|
|
48
50
|
if (!isSchemaObject(schema))
|
|
@@ -328,5 +330,6 @@ export {
|
|
|
328
330
|
getPrefixItems,
|
|
329
331
|
isCoercibleItemSchema,
|
|
330
332
|
prefixItemsCapsLength,
|
|
331
|
-
scalarItemTypeCheck
|
|
333
|
+
scalarItemTypeCheck,
|
|
334
|
+
singleTypeCheck
|
|
332
335
|
};
|
|
@@ -16,6 +16,14 @@ type CollectImportsOptions = {
|
|
|
16
16
|
* the import list, preventing a file from importing itself.
|
|
17
17
|
*/
|
|
18
18
|
readonly selfRef?: string | undefined;
|
|
19
|
+
/**
|
|
20
|
+
* The filename this file is being written as, without extension. Takes
|
|
21
|
+
* precedence over `selfRef` for self-import detection, and covers the case
|
|
22
|
+
* `selfRef` cannot: the root document has no ref of its own, so a definition
|
|
23
|
+
* whose name collides with the root type name (root `Contact` + `$defs.contact`)
|
|
24
|
+
* used to make the root file import its own type from itself.
|
|
25
|
+
*/
|
|
26
|
+
readonly selfFilename?: string | undefined;
|
|
19
27
|
/**
|
|
20
28
|
* The root schema document. When provided, URI refs that cannot be resolved
|
|
21
29
|
* within the root schema's $defs are excluded from the import list, preventing
|
|
@@ -21,7 +21,7 @@ const collectImportTypeNames = (schema, options) => {
|
|
|
21
21
|
return names;
|
|
22
22
|
};
|
|
23
23
|
const collectImportTargets = (schema, options) => {
|
|
24
|
-
const selfFilename = options?.selfRef ? refToFilename(options.selfRef) : void 0;
|
|
24
|
+
const selfFilename = options?.selfFilename ?? (options?.selfRef ? refToFilename(options.selfRef) : void 0);
|
|
25
25
|
const rootSchema = options?.rootSchema;
|
|
26
26
|
const typeSuffix = options?.typeSuffix;
|
|
27
27
|
const refs = /* @__PURE__ */ new Set();
|
|
@@ -1,11 +1,33 @@
|
|
|
1
1
|
import { getMjstPrimitive } from "@amritk/helpers/mjst-extension";
|
|
2
2
|
import { hasAllOf, hasAnyOf, hasConst, hasDefault, hasEnum, hasExamples, hasOneOf, hasPattern, hasProperties, hasRequired, hasType, isSchemaObject } from "@amritk/helpers/schema-guards";
|
|
3
3
|
import { generateDefaultFromPattern } from "../generators/generate-default-from-pattern.js";
|
|
4
|
+
const defaultMatchesType = (value, schema) => {
|
|
5
|
+
if (!hasType(schema))
|
|
6
|
+
return true;
|
|
7
|
+
switch (schema.type) {
|
|
8
|
+
case "string":
|
|
9
|
+
return typeof value === "string";
|
|
10
|
+
case "number":
|
|
11
|
+
return typeof value === "number";
|
|
12
|
+
case "integer":
|
|
13
|
+
return typeof value === "number" && Number.isInteger(value);
|
|
14
|
+
case "boolean":
|
|
15
|
+
return typeof value === "boolean";
|
|
16
|
+
case "null":
|
|
17
|
+
return value === null;
|
|
18
|
+
case "array":
|
|
19
|
+
return Array.isArray(value);
|
|
20
|
+
case "object":
|
|
21
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
22
|
+
default:
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
4
26
|
const getDefaultValue = (schema) => {
|
|
5
27
|
if (!isSchemaObject(schema)) {
|
|
6
28
|
return "undefined";
|
|
7
29
|
}
|
|
8
|
-
if (hasDefault(schema)) {
|
|
30
|
+
if (hasDefault(schema) && defaultMatchesType(schema.default, schema)) {
|
|
9
31
|
return JSON.stringify(schema.default);
|
|
10
32
|
}
|
|
11
33
|
if (hasConst(schema)) {
|
package/package.json
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amritk/generate-parsers",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Generate TypeScript parsers and type definitions from JSON Schemas.",
|
|
5
5
|
"module": "./dist/index.js",
|
|
6
6
|
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=20"
|
|
10
|
+
},
|
|
7
11
|
"license": "MIT",
|
|
8
12
|
"author": "amritk",
|
|
9
13
|
"keywords": [
|
|
@@ -49,8 +53,8 @@
|
|
|
49
53
|
},
|
|
50
54
|
"dependencies": {
|
|
51
55
|
"json-schema-typed": "^8.0.1",
|
|
52
|
-
"@amritk/helpers": "0.
|
|
53
|
-
"@amritk/generate-markdown": "0.4.
|
|
56
|
+
"@amritk/helpers": "0.14.0",
|
|
57
|
+
"@amritk/generate-markdown": "0.4.5"
|
|
54
58
|
},
|
|
55
59
|
"devDependencies": {
|
|
56
60
|
"@sinclair/typebox": "^0.34.49",
|