@amritk/validation 0.2.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/AI.md +92 -0
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/dist/generate.d.ts +135 -0
- package/dist/generate.js +74 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +5 -0
- package/dist/parsers/generators/assert-supported-keywords.d.ts +30 -0
- package/dist/parsers/generators/assert-supported-keywords.js +87 -0
- package/dist/parsers/generators/build-schema.d.ts +111 -0
- package/dist/parsers/generators/build-schema.js +74 -0
- package/dist/parsers/generators/generate-deep-equal-check.d.ts +24 -0
- package/dist/parsers/generators/generate-deep-equal-check.js +35 -0
- package/dist/parsers/generators/generate-discriminated-union-validation.d.ts +15 -0
- package/dist/parsers/generators/generate-discriminated-union-validation.js +28 -0
- package/dist/parsers/generators/generate-enum-check.d.ts +35 -0
- package/dist/parsers/generators/generate-enum-check.js +31 -0
- package/dist/parsers/generators/generate-files.d.ts +138 -0
- package/dist/parsers/generators/generate-files.js +89 -0
- package/dist/parsers/generators/generate-non-discriminated-union-validation.d.ts +6 -0
- package/dist/parsers/generators/generate-non-discriminated-union-validation.js +26 -0
- package/dist/parsers/generators/generate-parser-function.d.ts +120 -0
- package/dist/parsers/generators/generate-parser-function.js +1973 -0
- package/dist/parsers/generators/generate-schema-checks.d.ts +5 -0
- package/dist/parsers/generators/generate-schema-checks.js +260 -0
- package/dist/parsers/generators/generate-strict-assertion.d.ts +155 -0
- package/dist/parsers/generators/generate-strict-assertion.js +759 -0
- package/dist/parsers/generators/generate-type-checks.d.ts +148 -0
- package/dist/parsers/generators/generate-type-checks.js +454 -0
- package/dist/parsers/generators/generate-unique-items-check.d.ts +16 -0
- package/dist/parsers/generators/generate-unique-items-check.js +32 -0
- package/dist/parsers/generators/generate-validation-expression.d.ts +79 -0
- package/dist/parsers/generators/generate-validation-expression.js +416 -0
- package/dist/parsers/generators/subschema-match.d.ts +48 -0
- package/dist/parsers/generators/subschema-match.js +485 -0
- package/dist/parsers/generators/unevaluated-match.d.ts +19 -0
- package/dist/parsers/generators/unevaluated-match.js +206 -0
- package/dist/parsers/helpers/apply-schema-extensions.d.ts +35 -0
- package/dist/parsers/helpers/apply-schema-extensions.js +20 -0
- package/dist/parsers/helpers/collect-helpers.d.ts +15 -0
- package/dist/parsers/helpers/collect-helpers.js +64 -0
- package/dist/parsers/helpers/collect-imports.d.ts +104 -0
- package/dist/parsers/helpers/collect-imports.js +227 -0
- package/dist/parsers/helpers/find-discriminator.d.ts +6 -0
- package/dist/parsers/helpers/find-discriminator.js +27 -0
- package/dist/parsers/helpers/get-discriminator-value.d.ts +6 -0
- package/dist/parsers/helpers/get-discriminator-value.js +18 -0
- package/dist/parsers/index.d.ts +2 -0
- package/dist/parsers/index.js +4 -0
- package/dist/parsers/types/schema-extensions.d.ts +34 -0
- package/dist/parsers/types/schema-extensions.js +0 -0
- package/dist/rehome-parser-file.d.ts +25 -0
- package/dist/rehome-parser-file.js +74 -0
- package/dist/validators/generators/assert-generatable-refs.d.ts +19 -0
- package/dist/validators/generators/assert-generatable-refs.js +12 -0
- package/dist/validators/generators/assert-unevaluated-generatable.d.ts +30 -0
- package/dist/validators/generators/assert-unevaluated-generatable.js +71 -0
- package/dist/validators/generators/build-schema.d.ts +61 -0
- package/dist/validators/generators/build-schema.js +666 -0
- package/dist/validators/generators/collect-emitted-refs.d.ts +26 -0
- package/dist/validators/generators/collect-emitted-refs.js +112 -0
- package/dist/validators/generators/collect-validator-imports.d.ts +86 -0
- package/dist/validators/generators/collect-validator-imports.js +62 -0
- package/dist/validators/generators/emit-format-checks.d.ts +50 -0
- package/dist/validators/generators/emit-format-checks.js +248 -0
- package/dist/validators/generators/enforced-keywords.d.ts +33 -0
- package/dist/validators/generators/enforced-keywords.js +70 -0
- package/dist/validators/generators/folds-to-constant.d.ts +15 -0
- package/dist/validators/generators/folds-to-constant.js +43 -0
- package/dist/validators/generators/generate-coerce-function.d.ts +22 -0
- package/dist/validators/generators/generate-coerce-function.js +208 -0
- package/dist/validators/generators/generate-files.d.ts +92 -0
- package/dist/validators/generators/generate-files.js +86 -0
- package/dist/validators/generators/generate-repair-function.d.ts +27 -0
- package/dist/validators/generators/generate-repair-function.js +102 -0
- package/dist/validators/generators/generate-validator-function.d.ts +58 -0
- package/dist/validators/generators/generate-validator-function.js +1691 -0
- package/dist/validators/generators/tuple-shape.d.ts +31 -0
- package/dist/validators/generators/tuple-shape.js +20 -0
- package/dist/validators/generators/unevaluated-match.d.ts +29 -0
- package/dist/validators/generators/unevaluated-match.js +223 -0
- package/dist/validators/index.d.ts +2 -0
- package/dist/validators/index.js +4 -0
- package/package.json +92 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { type UnknownKeysStrategy } from '@amritk/helpers/unknown-keys-strategy';
|
|
2
|
+
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
3
|
+
import type { HelpersMode } from '../helpers/collect-helpers.js';
|
|
4
|
+
import type { ImportExtension } from '../helpers/collect-imports.js';
|
|
5
|
+
import type { SchemaExtensions } from '../types/schema-extensions.js';
|
|
6
|
+
/**
|
|
7
|
+
* Represents a generated file with its filename and content.
|
|
8
|
+
*/
|
|
9
|
+
export type GeneratedFile = {
|
|
10
|
+
filename: string;
|
|
11
|
+
content: string;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Builds all TypeScript files from a JSON Schema by traversing its entire
|
|
15
|
+
* `$ref` / `$dynamicRef` graph (via the shared `@amritk/helpers/walk-ref-graph`
|
|
16
|
+
* walker, which also seeds `$dynamicAnchor` definitions and rewrites
|
|
17
|
+
* `$dynamicRef` to `$ref`).
|
|
18
|
+
*
|
|
19
|
+
* For the root schema and each reachable definition this function generates a
|
|
20
|
+
* TypeScript file, then emits an `index.ts` barrel re-exporting them all.
|
|
21
|
+
*
|
|
22
|
+
* @param rootSchema - The root JSON Schema to build from
|
|
23
|
+
* @param rootTypeName - The name for the root type (e.g., "Document"). Must be a TypeScript
|
|
24
|
+
* identifier: it is emitted verbatim as `export type <name>` and, lowercased, becomes the
|
|
25
|
+
* output filename, so a name derived from an untrusted document is refused rather than
|
|
26
|
+
* written outside the output directory.
|
|
27
|
+
* @param extensions - Optional map of custom extension properties to add to specific definitions.
|
|
28
|
+
* Keys are definition names (matching $defs keys), values are records of extension property
|
|
29
|
+
* names to their JSON Schema definitions. Extensions are merged as optional properties before
|
|
30
|
+
* type and parser generation.
|
|
31
|
+
* @param typesOnly - When true, only generate TypeScript type definitions without parser functions.
|
|
32
|
+
* @param logWarnings - When true, the generated parsers emit a console.warn for every input key
|
|
33
|
+
* that is not declared in the schema's properties.
|
|
34
|
+
* @param strict - When true, the generated parsers throw on type/shape mismatches
|
|
35
|
+
* (wrong type, missing required property, enum/pattern/min/max violations) instead
|
|
36
|
+
* of coercing invalid input to default values.
|
|
37
|
+
* @param helpersMode - `'package'` (default) emits `import ... from '@amritk/helpers/...'`.
|
|
38
|
+
* `'embedded'` emits `import ... from './_helpers/...'` and appends the helper sources
|
|
39
|
+
* as additional `GeneratedFile` entries so the output directory is self-contained.
|
|
40
|
+
* @param helpersImportPrefix - Relative path prefix to the `_helpers/` directory in
|
|
41
|
+
* embedded mode. Defaults to `'./'`. The recursive multi-schema build passes `'../'`,
|
|
42
|
+
* `'../../'`, etc. so nested parsers can import from a single shared `_helpers/`
|
|
43
|
+
* directory while the helper sources are emitted once at the output root.
|
|
44
|
+
* @param readonly - When true, every property, array, and record in the generated type
|
|
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).
|
|
50
|
+
* @param typeSuffix - Suffix appended to every type/parser name derived from a `$ref`
|
|
51
|
+
* (e.g. `'Object'` → `ContactObject`). Defaults to `''` (no suffix). The root type name
|
|
52
|
+
* is used verbatim and is not affected by this suffix.
|
|
53
|
+
* @param importExt - Extension used on every relative import specifier in the generated
|
|
54
|
+
* output (cross-file `$ref` imports, the index barrel, and embedded-helper imports).
|
|
55
|
+
* `'js'` (this function's default) is the standard TS NodeNext form; `'ts'` emits the
|
|
56
|
+
* literal on-disk paths so the generated sources run directly under Node's type
|
|
57
|
+
* stripping. Note the `mjst` CLI defaults this to `'ts'` (falling back to `'js'` only
|
|
58
|
+
* under `--build`, where tsc must compile the sources) so generated output runs under
|
|
59
|
+
* Node without a build step; direct callers of `buildSchema` opt in explicitly.
|
|
60
|
+
* @param caseInsensitive - When true, the generated coercing parsers normalize a mis-cased
|
|
61
|
+
* string to the exact casing of a declared `enum`/`const` member it matches
|
|
62
|
+
* case-insensitively (e.g. `hElLo` → `hello`) instead of coercing to the default. Coerce
|
|
63
|
+
* mode only — strict parsers still reject a casing mismatch. The normalization lives on
|
|
64
|
+
* the coercion failure branch, so correctly-cased input keeps the exact-match fast path
|
|
65
|
+
* and the hot path is unaffected.
|
|
66
|
+
* @param schemas - Other schema documents you have **already loaded**, keyed by the absolute
|
|
67
|
+
* URI a `$ref` names them by. Supplying them makes those URIs resolvable, so the schema can
|
|
68
|
+
* reference a document that is not itself — each one becomes a resource of the generated
|
|
69
|
+
* document, with its own `$id`, anchors and nested resources all resolvable, and a `$ref`
|
|
70
|
+
* from one registered document into another resolving too. Nothing is fetched here: loading
|
|
71
|
+
* is yours to do (or `@amritk/resolve-refs`'), and a `$ref` to a URI nobody registered still
|
|
72
|
+
* stops generation. Only the documents actually referenced get files, so registering more
|
|
73
|
+
* than the schema uses costs nothing in the output.
|
|
74
|
+
* @param unknownKeys - How the generated fast paths prove a closed object
|
|
75
|
+
* (`additionalProperties: false`, or a `stripUnknown` build) carries no undeclared key:
|
|
76
|
+
* `'count-keys'` (the default) compares `Object.keys(input).length` behind a plain-prototype
|
|
77
|
+
* guard, `'count-enumerable'` counts with `for…in`. The first is the faster form on
|
|
78
|
+
* JavaScriptCore (Bun), the second on V8 (Node) — see the README for the measurements.
|
|
79
|
+
* @returns An array of generated TypeScript files
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* ```typescript
|
|
83
|
+
* const schema = {
|
|
84
|
+
* type: "object",
|
|
85
|
+
* properties: {
|
|
86
|
+
* info: { $ref: "#/$defs/info" }
|
|
87
|
+
* },
|
|
88
|
+
* $defs: {
|
|
89
|
+
* info: {
|
|
90
|
+
* type: "object",
|
|
91
|
+
* properties: {
|
|
92
|
+
* title: { type: "string" }
|
|
93
|
+
* }
|
|
94
|
+
* }
|
|
95
|
+
* }
|
|
96
|
+
* };
|
|
97
|
+
*
|
|
98
|
+
* const files = buildSchema(schema, "Document");
|
|
99
|
+
*
|
|
100
|
+
* // Types-only mode — no parser functions or runtime helpers included:
|
|
101
|
+
* const typesFiles = buildSchema(schema, "Document", undefined, true);
|
|
102
|
+
*
|
|
103
|
+
* // With extensions:
|
|
104
|
+
* const filesWithExtensions = buildSchema(schema, "Document", {
|
|
105
|
+
* info: {
|
|
106
|
+
* 'x-internal': { type: 'boolean' },
|
|
107
|
+
* },
|
|
108
|
+
* });
|
|
109
|
+
* ```
|
|
110
|
+
*/
|
|
111
|
+
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, schemas?: Readonly<Record<string, unknown>>, unknownKeys?: UnknownKeysStrategy) => Promise<GeneratedFile[]>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { dirname, resolve as resolvePath } from "node:path";
|
|
4
|
+
import { foldNullable } from "@amritk/helpers/fold-nullable";
|
|
5
|
+
import { generateIndexBarrel } from "@amritk/helpers/generate-index-barrel";
|
|
6
|
+
import { DEFAULT_UNKNOWN_KEYS } from "@amritk/helpers/unknown-keys-strategy";
|
|
7
|
+
import { walkRefGraph } from "@amritk/helpers/walk-ref-graph";
|
|
8
|
+
import { applySchemaExtensions } from "../helpers/apply-schema-extensions.js";
|
|
9
|
+
import { generateFile } from "./generate-files.js";
|
|
10
|
+
const readHelperSource = async (helper, importExt) => {
|
|
11
|
+
const require2 = createRequire(import.meta.url);
|
|
12
|
+
const helpersRoot = dirname(require2.resolve("@amritk/helpers/package.json"));
|
|
13
|
+
try {
|
|
14
|
+
const source = await readFile(resolvePath(helpersRoot, "src", `${helper}.ts`), "utf-8");
|
|
15
|
+
return { ext: "ts", content: source.replace(/\bfrom '(\.\/[^'".]+)'/g, `from '$1.${importExt}'`) };
|
|
16
|
+
} catch {
|
|
17
|
+
const compiled = await readFile(resolvePath(helpersRoot, "dist", `${helper}.js`), "utf-8");
|
|
18
|
+
if (importExt === "ts") {
|
|
19
|
+
return { ext: "ts", content: compiled.replace(/\bfrom '(\.\/[^'"]+)\.js'/g, "from '$1.ts'") };
|
|
20
|
+
}
|
|
21
|
+
return { ext: "js", content: compiled };
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
const ROOT_TYPE_NAME = /^[\p{ID_Start}_$][\p{ID_Continue}$]*$/u;
|
|
25
|
+
const assertRootTypeName = (rootTypeName) => {
|
|
26
|
+
if (ROOT_TYPE_NAME.test(rootTypeName))
|
|
27
|
+
return;
|
|
28
|
+
throw new Error(`Invalid root type name ${JSON.stringify(rootTypeName)}: expected a TypeScript identifier. The name is emitted as \`export type <name>\` and, lowercased, used as the output filename, so anything else either fails to compile or escapes the output directory. Derive it with @amritk/helpers' \`deriveRootTypeName\`, which always produces one.`);
|
|
29
|
+
};
|
|
30
|
+
const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logWarnings, strict, helpersMode = "package", helpersImportPrefix = "./", readonly = false, stripUnknown = false, typeSuffix = "", importExt = "js", caseInsensitive = false, schemas, unknownKeys = DEFAULT_UNKNOWN_KEYS) => {
|
|
31
|
+
assertRootTypeName(rootTypeName);
|
|
32
|
+
const files = [];
|
|
33
|
+
const usedHelpers = /* @__PURE__ */ new Set();
|
|
34
|
+
const foldedSchema = foldNullable(rootSchema);
|
|
35
|
+
walkRefGraph(foldedSchema, rootTypeName, { typeSuffix, ...schemas !== void 0 ? { schemas } : {} }, (node) => {
|
|
36
|
+
if (node.filename === "index")
|
|
37
|
+
return;
|
|
38
|
+
const extended = extensions ? applySchemaExtensions(node.schema, node.filename, extensions) : node.schema;
|
|
39
|
+
const result = generateFile(extended, node.typeName, {
|
|
40
|
+
selfFilename: node.filename,
|
|
41
|
+
typesOnly: typesOnly ?? false,
|
|
42
|
+
rootSchema: node.rootSchema,
|
|
43
|
+
helpersMode,
|
|
44
|
+
helpersImportPrefix,
|
|
45
|
+
readonly,
|
|
46
|
+
typeSuffix,
|
|
47
|
+
importExt,
|
|
48
|
+
isRoot: node.isRoot,
|
|
49
|
+
...node.ref !== void 0 ? { selfRef: node.ref } : {},
|
|
50
|
+
...logWarnings !== void 0 ? { logWarnings } : {},
|
|
51
|
+
...strict !== void 0 ? { strict } : {},
|
|
52
|
+
...stripUnknown ? { stripUnknown } : {},
|
|
53
|
+
...caseInsensitive ? { caseInsensitive } : {},
|
|
54
|
+
unknownKeys
|
|
55
|
+
});
|
|
56
|
+
files.push({ filename: `${node.filename}.ts`, content: result.content });
|
|
57
|
+
for (const helper of result.usedHelpers)
|
|
58
|
+
usedHelpers.add(helper);
|
|
59
|
+
});
|
|
60
|
+
if (helpersMode === "embedded" && !typesOnly) {
|
|
61
|
+
for (const helper of usedHelpers) {
|
|
62
|
+
const { ext, content } = await readHelperSource(helper, importExt);
|
|
63
|
+
files.push({ filename: `_helpers/${helper}.${ext}`, content });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
files.push({
|
|
67
|
+
filename: "index.ts",
|
|
68
|
+
content: generateIndexBarrel(files, { typesOnly: typesOnly ?? false, importExt })
|
|
69
|
+
});
|
|
70
|
+
return files;
|
|
71
|
+
};
|
|
72
|
+
export {
|
|
73
|
+
buildSchema
|
|
74
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds a boolean expression that is TRUE when `accessor` is structurally equal
|
|
3
|
+
* to `value` — a literal known at generation time (an `enum` member, a `const`).
|
|
4
|
+
*
|
|
5
|
+
* Why not `===` / `.includes` / `JSON.stringify`, the three forms this replaced:
|
|
6
|
+
*
|
|
7
|
+
* - `.includes(x)` is SameValueZero, i.e. *reference* equality for objects and
|
|
8
|
+
* arrays. `{ enum: [{ a: 1 }] }` could therefore never match a freshly parsed
|
|
9
|
+
* `{ a: 1 }`, so the strict parser threw on a document Ajv accepts and the fast
|
|
10
|
+
* path never fired.
|
|
11
|
+
* - `JSON.stringify(x) !== '{"a":1,"b":2}'` is key-order sensitive, so a
|
|
12
|
+
* reordered-but-equal `{ b: 2, a: 1 }` was rejected — and it serialized the
|
|
13
|
+
* whole value on every call just to answer a yes/no question.
|
|
14
|
+
*
|
|
15
|
+
* Because the literal is known up front the comparison is unrolled into a flat
|
|
16
|
+
* chain of `typeof` / `===` tests against its shape: no serialization, no
|
|
17
|
+
* allocation, and key order cannot matter because keys are compared by name. The
|
|
18
|
+
* `Object.keys(...).length === n` term is what makes it an equality rather than a
|
|
19
|
+
* "has at least these fields" check.
|
|
20
|
+
*
|
|
21
|
+
* @param accessor - Expression yielding the runtime value (may be `unknown`).
|
|
22
|
+
* @param value - The literal to compare against.
|
|
23
|
+
*/
|
|
24
|
+
export declare const generateDeepEqualCheck: (accessor: string, value: unknown) => string;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { safeAccessor } from "@amritk/helpers/safe-accessor";
|
|
2
|
+
const generateDeepEqualCheck = (accessor, value) => {
|
|
3
|
+
if (value === null || typeof value !== "object") {
|
|
4
|
+
if (typeof value === "number" && Number.isNaN(value))
|
|
5
|
+
return `Number.isNaN(${accessor})`;
|
|
6
|
+
return `${accessor} === ${JSON.stringify(value)}`;
|
|
7
|
+
}
|
|
8
|
+
if (Array.isArray(value)) {
|
|
9
|
+
const elements = `(${accessor} as unknown[])`;
|
|
10
|
+
const parts2 = [`Array.isArray(${accessor})`, `${elements}.length === ${value.length}`];
|
|
11
|
+
for (let i = 0; i < value.length; i++) {
|
|
12
|
+
parts2.push(generateDeepEqualCheck(`${elements}[${i}]`, value[i]));
|
|
13
|
+
}
|
|
14
|
+
return parts2.join(" && ");
|
|
15
|
+
}
|
|
16
|
+
const entries = Object.entries(value);
|
|
17
|
+
const record = `(${accessor} as Record<string, unknown>)`;
|
|
18
|
+
const parts = [
|
|
19
|
+
`typeof ${accessor} === "object"`,
|
|
20
|
+
`${accessor} !== null`,
|
|
21
|
+
`!Array.isArray(${accessor})`,
|
|
22
|
+
// Pins the key count, so an object carrying extra keys is not "equal" to one
|
|
23
|
+
// that only has the declared ones. Combined with the per-key value checks
|
|
24
|
+
// below (an absent key reads as `undefined`, which no JSON literal equals)
|
|
25
|
+
// this is exact structural equality.
|
|
26
|
+
`Object.keys(${accessor} as object).length === ${entries.length}`
|
|
27
|
+
];
|
|
28
|
+
for (const [key, sub] of entries) {
|
|
29
|
+
parts.push(generateDeepEqualCheck(safeAccessor(record, key), sub));
|
|
30
|
+
}
|
|
31
|
+
return parts.join(" && ");
|
|
32
|
+
};
|
|
33
|
+
export {
|
|
34
|
+
generateDeepEqualCheck
|
|
35
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
2
|
+
/**
|
|
3
|
+
* Generates a discriminated union validation expression.
|
|
4
|
+
* Uses a discriminator property to efficiently choose the correct schema.
|
|
5
|
+
*
|
|
6
|
+
* The emitted expression is a single *nested* ternary in schema order — for
|
|
7
|
+
* branches `dog`, `cat` it reads `disc === "dog" ? v : disc === "cat" ? v :
|
|
8
|
+
* <fallback>`. Each branch tests its discriminant (plus any per-branch shape
|
|
9
|
+
* checks) and, on a match, passes the value through; the innermost `else` is the
|
|
10
|
+
* shared fallback taken when no discriminant matches. The previous
|
|
11
|
+
* `cases[i] + ' : ' + result` folding spliced complete ternaries together
|
|
12
|
+
* (`c1 ? v : d1 : c0 ? v : d0`), which is a parse error for >=2 branches and
|
|
13
|
+
* also evaluated branches in reverse with the wrong per-case default.
|
|
14
|
+
*/
|
|
15
|
+
export declare const generateDiscriminatedUnionValidation: (accessor: string, schemas: readonly JSONSchema[], discriminatorKey: string, defaultValue: string, isRequired: boolean) => string;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { getDiscriminatorValue } from "../helpers/get-discriminator-value.js";
|
|
2
|
+
import { generateSchemaChecks } from "./generate-schema-checks.js";
|
|
3
|
+
const generateDiscriminatedUnionValidation = (accessor, schemas, discriminatorKey, defaultValue, isRequired) => {
|
|
4
|
+
const discAccessor = `(${accessor} as Record<string, unknown> | null | undefined)?.[${JSON.stringify(discriminatorKey)}]`;
|
|
5
|
+
const branches = [];
|
|
6
|
+
for (const schema of schemas) {
|
|
7
|
+
const discriminatorValue = getDiscriminatorValue(schema, discriminatorKey);
|
|
8
|
+
if (discriminatorValue === null)
|
|
9
|
+
continue;
|
|
10
|
+
let condition = `${discAccessor} === ${JSON.stringify(discriminatorValue)}`;
|
|
11
|
+
const checks = generateSchemaChecks(accessor, schema);
|
|
12
|
+
for (const check of checks) {
|
|
13
|
+
condition += " && " + check;
|
|
14
|
+
}
|
|
15
|
+
branches.push(condition);
|
|
16
|
+
}
|
|
17
|
+
if (branches.length === 0) {
|
|
18
|
+
return isRequired ? `${accessor} ?? ${defaultValue}` : accessor;
|
|
19
|
+
}
|
|
20
|
+
let result = isRequired ? defaultValue : accessor;
|
|
21
|
+
for (let i = branches.length - 1; i >= 0; i--) {
|
|
22
|
+
result = `${branches[i]} ? ${accessor} : ${result}`;
|
|
23
|
+
}
|
|
24
|
+
return result;
|
|
25
|
+
};
|
|
26
|
+
export {
|
|
27
|
+
generateDiscriminatedUnionValidation
|
|
28
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates an enum validation check expression.
|
|
3
|
+
* Returns a string that checks if the value is in the allowed enum values.
|
|
4
|
+
*
|
|
5
|
+
* For the common all-primitive case this emits a parenthesized `x === a || x ===
|
|
6
|
+
* b` chain rather than `[a, b].includes(x)`. The `.includes` form allocates a
|
|
7
|
+
* fresh array on every call (this expression runs on the parser hot path); the
|
|
8
|
+
* `===` chain is allocation-free and matches the validators package's
|
|
9
|
+
* `enumMembershipExpr`.
|
|
10
|
+
*
|
|
11
|
+
* An object or array member is compared *structurally* via
|
|
12
|
+
* {@link generateDeepEqualCheck}. `.includes` was SameValueZero — reference
|
|
13
|
+
* equality — so `{ enum: [{ a: 1 }] }` could never match a freshly parsed
|
|
14
|
+
* `{ a: 1 }`: the fast path never fired and the strict parser threw on a document
|
|
15
|
+
* Ajv accepts. `NaN` keeps its own `Number.isNaN` test, the one case `===` cannot
|
|
16
|
+
* express.
|
|
17
|
+
*/
|
|
18
|
+
export declare const generateEnumCheck: (accessor: string, enumValues: readonly unknown[]) => string;
|
|
19
|
+
/**
|
|
20
|
+
* Builds a case-insensitive coercion expression for a string `enum`/`const`
|
|
21
|
+
* value whose exact-match check has already failed. Only the *string* members
|
|
22
|
+
* participate (a number/boolean/null member has no case to fold): a value that
|
|
23
|
+
* matches one case-insensitively normalizes to that member's exact casing, and
|
|
24
|
+
* everything else yields `fallback`.
|
|
25
|
+
*
|
|
26
|
+
* This is emitted ONLY on the already-invalid branch of the coercion ternary, so
|
|
27
|
+
* a correctly-cased value never reaches it — the exact `===` fast path (and the
|
|
28
|
+
* shape validators / deep guards built on it) is unchanged, so the hot path pays
|
|
29
|
+
* nothing. Only a mis-cased value, which today coerces straight to the default,
|
|
30
|
+
* now takes a single `toLowerCase()` and an O(1) map lookup instead.
|
|
31
|
+
*
|
|
32
|
+
* Returns `null` when no member is a string (nothing to fold), so the caller
|
|
33
|
+
* keeps its plain fallback and emits no extra code.
|
|
34
|
+
*/
|
|
35
|
+
export declare const generateEnumCaseInsensitiveCoercion: (accessor: string, enumValues: readonly unknown[], fallback: string) => string | null;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { generateDeepEqualCheck } from "./generate-deep-equal-check.js";
|
|
2
|
+
const generateEnumCheck = (accessor, enumValues) => {
|
|
3
|
+
if (enumValues.length === 0)
|
|
4
|
+
return "false";
|
|
5
|
+
const members = enumValues.map((value) => {
|
|
6
|
+
const check = generateDeepEqualCheck(accessor, value);
|
|
7
|
+
return value !== null && typeof value === "object" ? `(${check})` : check;
|
|
8
|
+
});
|
|
9
|
+
return `(${members.join(" || ")})`;
|
|
10
|
+
};
|
|
11
|
+
const generateEnumCaseInsensitiveCoercion = (accessor, enumValues, fallback) => {
|
|
12
|
+
const entries = [];
|
|
13
|
+
const seen = /* @__PURE__ */ new Set();
|
|
14
|
+
for (const value of enumValues) {
|
|
15
|
+
if (typeof value !== "string")
|
|
16
|
+
continue;
|
|
17
|
+
const lower = value.toLowerCase();
|
|
18
|
+
if (seen.has(lower))
|
|
19
|
+
continue;
|
|
20
|
+
seen.add(lower);
|
|
21
|
+
entries.push([lower, value]);
|
|
22
|
+
}
|
|
23
|
+
if (entries.length === 0)
|
|
24
|
+
return null;
|
|
25
|
+
const map = `new Map(${JSON.stringify(entries)})`;
|
|
26
|
+
return `(typeof ${accessor} === "string" ? (${map}.get((${accessor} as string).toLowerCase()) ?? ${fallback}) : ${fallback})`;
|
|
27
|
+
};
|
|
28
|
+
export {
|
|
29
|
+
generateEnumCaseInsensitiveCoercion,
|
|
30
|
+
generateEnumCheck
|
|
31
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { type UnknownKeysStrategy } from '@amritk/helpers/unknown-keys-strategy';
|
|
2
|
+
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
3
|
+
import { type HelpersMode, type RuntimeHelperName } from '../helpers/collect-helpers.js';
|
|
4
|
+
import { type ImportExtension } from '../helpers/collect-imports.js';
|
|
5
|
+
/**
|
|
6
|
+
* Options for controlling what gets generated in a file.
|
|
7
|
+
*/
|
|
8
|
+
type GenerateFileOptions = {
|
|
9
|
+
/**
|
|
10
|
+
* When true, only emit the TypeScript type definition and skip the parser function.
|
|
11
|
+
* Imports will be type-only as well, since there are no parsers to call.
|
|
12
|
+
*/
|
|
13
|
+
readonly typesOnly?: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* The $ref path of the schema being generated (e.g. `#/$defs/encoding`).
|
|
16
|
+
* When provided, any $ref that resolves to the same filename is excluded from
|
|
17
|
+
* the import list, preventing a file from importing itself.
|
|
18
|
+
*/
|
|
19
|
+
readonly selfRef?: string;
|
|
20
|
+
/**
|
|
21
|
+
* The filename this file is written as, without extension. Used for
|
|
22
|
+
* self-import detection when the schema has no `$ref` of its own — the root
|
|
23
|
+
* document — so a `$defs` entry that collides with the root type name cannot
|
|
24
|
+
* make the root file import itself.
|
|
25
|
+
*/
|
|
26
|
+
readonly selfFilename?: string;
|
|
27
|
+
/**
|
|
28
|
+
* The root schema document. When provided, URI refs that cannot be resolved
|
|
29
|
+
* within the root schema's $defs are excluded from the import list.
|
|
30
|
+
*/
|
|
31
|
+
readonly rootSchema?: Record<string, unknown>;
|
|
32
|
+
/**
|
|
33
|
+
* When true, the generated parser emits a console.warn for every input key
|
|
34
|
+
* that is not declared in the schema's properties.
|
|
35
|
+
*/
|
|
36
|
+
readonly logWarnings?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* When true, the generated parser throws on type/shape mismatches instead
|
|
39
|
+
* of coercing invalid input to default values.
|
|
40
|
+
*/
|
|
41
|
+
readonly strict?: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* When true, the generated parser builds its result from declared properties
|
|
44
|
+
* only, silently dropping undeclared input keys at every nesting level (zod's
|
|
45
|
+
* `.strip()`), without treating extras as a validation error. Composes with
|
|
46
|
+
* `strict` (still throws on wrong types / missing required properties) and
|
|
47
|
+
* yields to `additionalProperties: false` (which rejects rather than strips in
|
|
48
|
+
* strict mode).
|
|
49
|
+
*/
|
|
50
|
+
readonly stripUnknown?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Controls how runtime helpers are referenced.
|
|
53
|
+
* - `'package'` (default): emit imports from `@amritk/helpers/...`.
|
|
54
|
+
* - `'embedded'`: emit imports from `./_helpers/...` so the helper sources can
|
|
55
|
+
* be shipped alongside the generated files.
|
|
56
|
+
*/
|
|
57
|
+
readonly helpersMode?: HelpersMode;
|
|
58
|
+
/**
|
|
59
|
+
* Relative path prefix to the shared `_helpers/` directory in embedded mode.
|
|
60
|
+
* Defaults to `'./'`. The recursive multi-schema build passes `'../'`,
|
|
61
|
+
* `'../../'`, etc. so a nested parser can reach a single `_helpers/` directory
|
|
62
|
+
* at the output root.
|
|
63
|
+
*/
|
|
64
|
+
readonly helpersImportPrefix?: string;
|
|
65
|
+
/**
|
|
66
|
+
* When true, every property, array, and record in the generated type
|
|
67
|
+
* definitions is emitted as `readonly`.
|
|
68
|
+
*/
|
|
69
|
+
readonly readonly?: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Suffix appended to every type/parser name derived from a `$ref`.
|
|
72
|
+
* Defaults to `''` (no suffix). Set to e.g. `'Object'` to emit `ContactObject`.
|
|
73
|
+
*/
|
|
74
|
+
readonly typeSuffix?: string;
|
|
75
|
+
/**
|
|
76
|
+
* Extension used on every relative import specifier (cross-file `$ref`
|
|
77
|
+
* imports and embedded-helper imports). Defaults to `'js'` (the TS NodeNext
|
|
78
|
+
* form); `'ts'` makes the generated sources runnable under Node's type
|
|
79
|
+
* stripping.
|
|
80
|
+
*/
|
|
81
|
+
readonly importExt?: ImportExtension;
|
|
82
|
+
/**
|
|
83
|
+
* True when this file is the root document rather than a `$ref`-reached
|
|
84
|
+
* definition. Forwarded to the parser generator so the JSON Schema
|
|
85
|
+
* meta-schema special case (type name `Schema`) never applies to a
|
|
86
|
+
* user-named root.
|
|
87
|
+
*/
|
|
88
|
+
readonly isRoot?: boolean;
|
|
89
|
+
/**
|
|
90
|
+
* When true, a mis-cased string that matches a declared `enum`/`const` member
|
|
91
|
+
* case-insensitively is normalized to that member's exact casing (coerce mode
|
|
92
|
+
* only). The normalization is emitted on the coercion failure branch, so
|
|
93
|
+
* already-valid input keeps the exact-match fast path.
|
|
94
|
+
*/
|
|
95
|
+
readonly caseInsensitive?: boolean;
|
|
96
|
+
/**
|
|
97
|
+
* How the generated fast paths prove a closed object carries no undeclared
|
|
98
|
+
* key — `Object.keys` (the default) or a `for…in` count. See
|
|
99
|
+
* {@link UnknownKeysStrategy} for the trade-off between the two.
|
|
100
|
+
*/
|
|
101
|
+
readonly unknownKeys?: UnknownKeysStrategy;
|
|
102
|
+
};
|
|
103
|
+
/** Result of generating a single parser file. */
|
|
104
|
+
export type GeneratedFileContent = {
|
|
105
|
+
readonly content: string;
|
|
106
|
+
readonly usedHelpers: ReadonlySet<RuntimeHelperName>;
|
|
107
|
+
};
|
|
108
|
+
/**
|
|
109
|
+
* Generates a complete TypeScript file from a JSON Schema.
|
|
110
|
+
*
|
|
111
|
+
* The generated file contains imports, type definition, and parser function.
|
|
112
|
+
* When typesOnly is true, only the type definition is emitted without any parser.
|
|
113
|
+
*
|
|
114
|
+
* @param schema - The JSON Schema to generate code from
|
|
115
|
+
* @param typeName - The name to use for the generated TypeScript type
|
|
116
|
+
* @param options - Optional settings to control what gets generated
|
|
117
|
+
* @returns The generated file content and the set of runtime helpers it references
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* ```typescript
|
|
121
|
+
* const schema = {
|
|
122
|
+
* type: "object",
|
|
123
|
+
* properties: {
|
|
124
|
+
* name: { type: "string" },
|
|
125
|
+
* age: { type: "number" }
|
|
126
|
+
* },
|
|
127
|
+
* required: ["name"]
|
|
128
|
+
* };
|
|
129
|
+
*
|
|
130
|
+
* const result = generateFile(schema, "User");
|
|
131
|
+
* // result contains imports, type User, and parseUser function
|
|
132
|
+
*
|
|
133
|
+
* const typesOnly = generateFile(schema, "User", { typesOnly: true });
|
|
134
|
+
* // typesOnly contains only type-only imports and type User (no parser)
|
|
135
|
+
* ```
|
|
136
|
+
*/
|
|
137
|
+
export declare const generateFile: (schema: JSONSchema, typeName: string, options?: GenerateFileOptions) => GeneratedFileContent;
|
|
138
|
+
export {};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { generateTypeDefinition } from "@amritk/helpers/generate-type-definition";
|
|
2
|
+
import { DEFAULT_UNKNOWN_KEYS } from "@amritk/helpers/unknown-keys-strategy";
|
|
3
|
+
import { collectHelpers } from "../helpers/collect-helpers.js";
|
|
4
|
+
import { collectImports, collectImportTypeNames } from "../helpers/collect-imports.js";
|
|
5
|
+
import { generateParserFunction, generateShapeValidator } from "./generate-parser-function.js";
|
|
6
|
+
const generateFile = (schema, typeName, options) => {
|
|
7
|
+
const typesOnly = options?.typesOnly === true;
|
|
8
|
+
const selfRef = options?.selfRef;
|
|
9
|
+
const rootSchema = options?.rootSchema;
|
|
10
|
+
const helpersMode = options?.helpersMode ?? "package";
|
|
11
|
+
const typeSuffix = options?.typeSuffix ?? "";
|
|
12
|
+
const importExt = options?.importExt ?? "js";
|
|
13
|
+
const typeDefinition = generateTypeDefinition(schema, typeName, {
|
|
14
|
+
readonly: options?.readonly ?? false,
|
|
15
|
+
typeSuffix,
|
|
16
|
+
...rootSchema !== void 0 ? { rootSchema } : {}
|
|
17
|
+
});
|
|
18
|
+
if (typesOnly) {
|
|
19
|
+
const imports2 = collectImports(schema, {
|
|
20
|
+
typesOnly: true,
|
|
21
|
+
selfRef,
|
|
22
|
+
selfFilename: options?.selfFilename,
|
|
23
|
+
rootSchema,
|
|
24
|
+
typeSuffix,
|
|
25
|
+
importExt,
|
|
26
|
+
usedIn: typeDefinition
|
|
27
|
+
});
|
|
28
|
+
let result2 = "";
|
|
29
|
+
if (imports2.length > 0) {
|
|
30
|
+
result2 += imports2[0];
|
|
31
|
+
for (let i = 1; i < imports2.length; i++) {
|
|
32
|
+
result2 += "\n" + imports2[i];
|
|
33
|
+
}
|
|
34
|
+
result2 += "\n\n";
|
|
35
|
+
}
|
|
36
|
+
return { content: result2 + typeDefinition, usedHelpers: /* @__PURE__ */ new Set() };
|
|
37
|
+
}
|
|
38
|
+
const stripUnknown = options?.stripUnknown ?? false;
|
|
39
|
+
const reservedNames = collectImportTypeNames(schema, {
|
|
40
|
+
selfRef,
|
|
41
|
+
selfFilename: options?.selfFilename,
|
|
42
|
+
rootSchema,
|
|
43
|
+
typeSuffix
|
|
44
|
+
});
|
|
45
|
+
const unknownKeys = options?.unknownKeys ?? DEFAULT_UNKNOWN_KEYS;
|
|
46
|
+
const shapeValidator = generateShapeValidator(schema, typeName, true, typeSuffix, true, stripUnknown, reservedNames, unknownKeys);
|
|
47
|
+
const parserFunction = generateParserFunction(schema, typeName, {
|
|
48
|
+
useRefImports: true,
|
|
49
|
+
typeSuffix,
|
|
50
|
+
reservedNames,
|
|
51
|
+
shapeValidatorSource: shapeValidator,
|
|
52
|
+
unknownKeys,
|
|
53
|
+
...options?.isRoot !== void 0 ? { isRoot: options.isRoot } : {},
|
|
54
|
+
...rootSchema !== void 0 ? { rootSchema } : {},
|
|
55
|
+
...options?.logWarnings !== void 0 ? { logWarnings: options.logWarnings } : {},
|
|
56
|
+
...options?.strict !== void 0 ? { strict: options.strict } : {},
|
|
57
|
+
...options?.stripUnknown !== void 0 ? { stripUnknown: options.stripUnknown } : {},
|
|
58
|
+
...options?.caseInsensitive !== void 0 ? { caseInsensitive: options.caseInsensitive } : {}
|
|
59
|
+
});
|
|
60
|
+
const combinedFunctions = `${shapeValidator}
|
|
61
|
+
|
|
62
|
+
${parserFunction}`;
|
|
63
|
+
const helpers = collectHelpers(combinedFunctions, helpersMode, options?.helpersImportPrefix, importExt);
|
|
64
|
+
const imports = [
|
|
65
|
+
...collectImports(schema, {
|
|
66
|
+
selfRef,
|
|
67
|
+
selfFilename: options?.selfFilename,
|
|
68
|
+
rootSchema,
|
|
69
|
+
typeSuffix,
|
|
70
|
+
importExt,
|
|
71
|
+
// Both halves of the file: the type names the type definition spells and
|
|
72
|
+
// the functions the emitted code calls are one question about one text.
|
|
73
|
+
usedIn: typeDefinition + "\n\n" + combinedFunctions
|
|
74
|
+
}),
|
|
75
|
+
...helpers.imports
|
|
76
|
+
];
|
|
77
|
+
let result = "";
|
|
78
|
+
if (imports.length > 0) {
|
|
79
|
+
result += imports[0];
|
|
80
|
+
for (let i = 1; i < imports.length; i++) {
|
|
81
|
+
result += "\n" + imports[i];
|
|
82
|
+
}
|
|
83
|
+
result += "\n\n";
|
|
84
|
+
}
|
|
85
|
+
return { content: result + typeDefinition + "\n\n" + combinedFunctions, usedHelpers: helpers.used };
|
|
86
|
+
};
|
|
87
|
+
export {
|
|
88
|
+
generateFile
|
|
89
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
2
|
+
/**
|
|
3
|
+
* Generates a non-discriminated union validation expression.
|
|
4
|
+
* Tries each schema in order until one matches.
|
|
5
|
+
*/
|
|
6
|
+
export declare const generateNonDiscriminatedUnionValidation: (accessor: string, schemas: readonly JSONSchema[], defaultValue: string, isRequired: boolean) => string;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { generateSchemaChecks } from "./generate-schema-checks.js";
|
|
2
|
+
const generateNonDiscriminatedUnionValidation = (accessor, schemas, defaultValue, isRequired) => {
|
|
3
|
+
const cases = [];
|
|
4
|
+
for (const schema of schemas) {
|
|
5
|
+
const checks = generateSchemaChecks(accessor, schema);
|
|
6
|
+
if (checks.length > 0) {
|
|
7
|
+
let combinedChecks = checks[0];
|
|
8
|
+
for (let i = 1; i < checks.length; i++) {
|
|
9
|
+
combinedChecks += " && " + checks[i];
|
|
10
|
+
}
|
|
11
|
+
cases.push("(" + combinedChecks + ")");
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
if (cases.length === 0) {
|
|
15
|
+
return isRequired ? `${accessor} ?? ${defaultValue}` : accessor;
|
|
16
|
+
}
|
|
17
|
+
let combinedCheck = cases[0];
|
|
18
|
+
for (let i = 1; i < cases.length; i++) {
|
|
19
|
+
combinedCheck += " || " + cases[i];
|
|
20
|
+
}
|
|
21
|
+
const finalDefault = isRequired ? defaultValue : "undefined";
|
|
22
|
+
return `(${combinedCheck}) ? ${accessor} : ${finalDefault}`;
|
|
23
|
+
};
|
|
24
|
+
export {
|
|
25
|
+
generateNonDiscriminatedUnionValidation
|
|
26
|
+
};
|