@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,70 @@
|
|
|
1
|
+
import { MJST_EXTENSION_KEY } from "@amritk/helpers/mjst-extension";
|
|
2
|
+
import { isSchemaObject } from "@amritk/helpers/schema-guards";
|
|
3
|
+
const ENFORCED_KEYWORDS = /* @__PURE__ */ new Set([
|
|
4
|
+
"$ref",
|
|
5
|
+
"type",
|
|
6
|
+
"enum",
|
|
7
|
+
"const",
|
|
8
|
+
MJST_EXTENSION_KEY,
|
|
9
|
+
"pattern",
|
|
10
|
+
"minLength",
|
|
11
|
+
"maxLength",
|
|
12
|
+
"minimum",
|
|
13
|
+
"maximum",
|
|
14
|
+
"exclusiveMinimum",
|
|
15
|
+
"exclusiveMaximum",
|
|
16
|
+
"multipleOf",
|
|
17
|
+
"items",
|
|
18
|
+
"prefixItems",
|
|
19
|
+
"additionalItems",
|
|
20
|
+
"contains",
|
|
21
|
+
"minContains",
|
|
22
|
+
"maxContains",
|
|
23
|
+
"minItems",
|
|
24
|
+
"maxItems",
|
|
25
|
+
"uniqueItems",
|
|
26
|
+
"properties",
|
|
27
|
+
"patternProperties",
|
|
28
|
+
"additionalProperties",
|
|
29
|
+
"required",
|
|
30
|
+
"propertyNames",
|
|
31
|
+
"dependentRequired",
|
|
32
|
+
"dependentSchemas",
|
|
33
|
+
"dependencies",
|
|
34
|
+
"minProperties",
|
|
35
|
+
"maxProperties",
|
|
36
|
+
"allOf",
|
|
37
|
+
"anyOf",
|
|
38
|
+
"oneOf",
|
|
39
|
+
"not",
|
|
40
|
+
"if",
|
|
41
|
+
"then",
|
|
42
|
+
"else",
|
|
43
|
+
"unevaluatedProperties",
|
|
44
|
+
"unevaluatedItems"
|
|
45
|
+
]);
|
|
46
|
+
const declaresKeywordOutside = (schema, owned, formats = NO_FORMATS) => {
|
|
47
|
+
if (!isSchemaObject(schema))
|
|
48
|
+
return false;
|
|
49
|
+
const record = schema;
|
|
50
|
+
for (const keyword of Object.keys(record)) {
|
|
51
|
+
if (owned.includes(keyword))
|
|
52
|
+
continue;
|
|
53
|
+
if (ENFORCED_KEYWORDS.has(keyword))
|
|
54
|
+
return true;
|
|
55
|
+
if (keyword === "format" && enforcesFormat(record, formats))
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
};
|
|
60
|
+
const NO_FORMATS = /* @__PURE__ */ new Set();
|
|
61
|
+
const enforcesFormat = (schema, formats) => {
|
|
62
|
+
const format = schema["format"];
|
|
63
|
+
return typeof format === "string" && formats.has(format);
|
|
64
|
+
};
|
|
65
|
+
export {
|
|
66
|
+
ENFORCED_KEYWORDS,
|
|
67
|
+
NO_FORMATS,
|
|
68
|
+
declaresKeywordOutside,
|
|
69
|
+
enforcesFormat
|
|
70
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a subschema's verdict is decidable without looking at the instance —
|
|
3
|
+
* `true` when it accepts everything, `false` when it accepts nothing, and
|
|
4
|
+
* `undefined` when it depends on the value.
|
|
5
|
+
*
|
|
6
|
+
* The emitter asks the same question by building the subschema's checks and
|
|
7
|
+
* seeing whether any came out (`generateMatchesExpr` returns the literal
|
|
8
|
+
* `'true'`), and folds the branch away when they did not. Anything reading the
|
|
9
|
+
* emitter's *output* — which arms it emitted, which `validateX` calls it made —
|
|
10
|
+
* has to agree with it, and a predicate that under-answers costs only a branch
|
|
11
|
+
* kept alive, while one that over-answers drops a call that really is emitted.
|
|
12
|
+
* Hence the conservative `undefined`: this recognises the spellings that are
|
|
13
|
+
* decidable from the node alone and declines everything else.
|
|
14
|
+
*/
|
|
15
|
+
export declare const foldsToConstant: (schema: unknown, formats?: ReadonlySet<string>) => boolean | undefined;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { isSchemaObject } from "@amritk/helpers/schema-guards";
|
|
2
|
+
import { enforcesFormat, NO_FORMATS } from "./enforced-keywords.js";
|
|
3
|
+
const ANNOTATION_KEYWORDS = /* @__PURE__ */ new Set([
|
|
4
|
+
"$anchor",
|
|
5
|
+
"$comment",
|
|
6
|
+
"$defs",
|
|
7
|
+
"$dynamicAnchor",
|
|
8
|
+
"$id",
|
|
9
|
+
"$schema",
|
|
10
|
+
"$vocabulary",
|
|
11
|
+
"contentEncoding",
|
|
12
|
+
"contentMediaType",
|
|
13
|
+
"contentSchema",
|
|
14
|
+
"default",
|
|
15
|
+
"definitions",
|
|
16
|
+
"deprecated",
|
|
17
|
+
"description",
|
|
18
|
+
"discriminator",
|
|
19
|
+
"example",
|
|
20
|
+
"examples",
|
|
21
|
+
"externalDocs",
|
|
22
|
+
"format",
|
|
23
|
+
"nullable",
|
|
24
|
+
"readOnly",
|
|
25
|
+
"title",
|
|
26
|
+
"writeOnly",
|
|
27
|
+
"xml"
|
|
28
|
+
]);
|
|
29
|
+
const foldsToConstant = (schema, formats = NO_FORMATS) => {
|
|
30
|
+
if (schema === true)
|
|
31
|
+
return true;
|
|
32
|
+
if (schema === false)
|
|
33
|
+
return false;
|
|
34
|
+
if (!isSchemaObject(schema))
|
|
35
|
+
return void 0;
|
|
36
|
+
const record = schema;
|
|
37
|
+
if (enforcesFormat(record, formats))
|
|
38
|
+
return void 0;
|
|
39
|
+
return Object.keys(record).every((key) => ANNOTATION_KEYWORDS.has(key)) ? true : void 0;
|
|
40
|
+
};
|
|
41
|
+
export {
|
|
42
|
+
foldsToConstant
|
|
43
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
2
|
+
/** The name of the value-coercing half generated for a `$ref`'s target. */
|
|
3
|
+
export declare const coercerNameFor: (ref: string, typeSuffix: string) => string;
|
|
4
|
+
/**
|
|
5
|
+
* Generates the coercing half of a validator file: a value-coercing walk, and
|
|
6
|
+
* the `coerceX` that runs it and then hands the result to `validateX`.
|
|
7
|
+
*
|
|
8
|
+
* Coercion is a separate pass rather than something threaded through the
|
|
9
|
+
* validator, which is what keeps every error identical to the one `validateX`
|
|
10
|
+
* already produced — same path, same keyword, same params, same `anyOf` branch
|
|
11
|
+
* selection. The validator is the only thing that decides anything; this pass
|
|
12
|
+
* only moves scalars toward what the schema asked for, and where it cannot, it
|
|
13
|
+
* hands the original value straight back so the validator rejects what the
|
|
14
|
+
* caller actually wrote rather than something substituted for it.
|
|
15
|
+
*
|
|
16
|
+
* The walk is exported because a `$ref` in another generated file needs it: a
|
|
17
|
+
* coercion has to cross a file boundary the same way validation does.
|
|
18
|
+
*/
|
|
19
|
+
export declare const generateCoerceFunction: (schema: JSONSchema, typeName: string, typeSuffix?: string) => {
|
|
20
|
+
code: string;
|
|
21
|
+
usesCoerceScalar: boolean;
|
|
22
|
+
};
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { regexFlagsFor } from "@amritk/helpers/escape-regex-pattern";
|
|
2
|
+
import { declaresKey, readKey } from "@amritk/helpers/read-key";
|
|
3
|
+
import { refToName } from "@amritk/helpers/ref-to-name";
|
|
4
|
+
import { isSchemaObject } from "@amritk/helpers/schema-guards";
|
|
5
|
+
const COERCIBLE_TYPES = /* @__PURE__ */ new Set(["string", "number", "integer", "boolean", "null"]);
|
|
6
|
+
const coercerNameFor = (ref, typeSuffix) => `coerce${refToName(ref, typeSuffix)}Value`;
|
|
7
|
+
const declaresObjectKeys = (schema) => declaresKey(schema, "properties") || declaresKey(schema, "patternProperties") || declaresKey(schema, "additionalProperties");
|
|
8
|
+
const declaresArrayItems = (schema) => declaresKey(schema, "items") || declaresKey(schema, "prefixItems");
|
|
9
|
+
const schemaMap = (schema, key) => {
|
|
10
|
+
const value = readKey(schema, key);
|
|
11
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
|
|
12
|
+
};
|
|
13
|
+
const soleScalarType = (schema) => {
|
|
14
|
+
const type = readKey(schema, "type");
|
|
15
|
+
return typeof type === "string" && COERCIBLE_TYPES.has(type) ? type : null;
|
|
16
|
+
};
|
|
17
|
+
const unionScalarTypes = (schema) => {
|
|
18
|
+
const declared = readKey(schema, "type");
|
|
19
|
+
if (Array.isArray(declared)) {
|
|
20
|
+
const types2 = declared.filter((entry) => typeof entry === "string");
|
|
21
|
+
return types2.length > 0 && types2.every((type) => COERCIBLE_TYPES.has(type)) ? types2 : null;
|
|
22
|
+
}
|
|
23
|
+
if (declared !== void 0)
|
|
24
|
+
return null;
|
|
25
|
+
const branches = readKey(schema, "anyOf") ?? readKey(schema, "oneOf");
|
|
26
|
+
if (!Array.isArray(branches) || branches.length === 0)
|
|
27
|
+
return null;
|
|
28
|
+
const types = [];
|
|
29
|
+
for (const branch of branches) {
|
|
30
|
+
if (!isSchemaObject(branch))
|
|
31
|
+
return null;
|
|
32
|
+
const type = soleScalarType(branch);
|
|
33
|
+
if (type !== null)
|
|
34
|
+
types.push(type);
|
|
35
|
+
else if (declaresObjectKeys(branch) || declaresArrayItems(branch))
|
|
36
|
+
continue;
|
|
37
|
+
else if (readKey(branch, "type") === "object")
|
|
38
|
+
continue;
|
|
39
|
+
else if (readKey(branch, "type") === "array")
|
|
40
|
+
continue;
|
|
41
|
+
else
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return types.length > 0 ? types : null;
|
|
45
|
+
};
|
|
46
|
+
const emitObjectCoercer = (schema, ctx) => {
|
|
47
|
+
const properties = schemaMap(schema, "properties") ?? {};
|
|
48
|
+
const patternProperties = schemaMap(schema, "patternProperties") ?? {};
|
|
49
|
+
const additional = readKey(schema, "additionalProperties");
|
|
50
|
+
const declared = [];
|
|
51
|
+
for (const key of Object.keys(properties)) {
|
|
52
|
+
const coerce = coercerFor(readKey(properties, key), ctx);
|
|
53
|
+
if (coerce !== null)
|
|
54
|
+
declared.push({ key, coerce });
|
|
55
|
+
}
|
|
56
|
+
const patterns = [];
|
|
57
|
+
for (const source of Object.keys(patternProperties)) {
|
|
58
|
+
const coerce = coercerFor(readKey(patternProperties, source), ctx);
|
|
59
|
+
if (coerce !== null)
|
|
60
|
+
patterns.push({ source, coerce });
|
|
61
|
+
}
|
|
62
|
+
const additionalCoercer = typeof additional === "object" && additional !== null ? coercerFor(additional, ctx) : null;
|
|
63
|
+
if (declared.length === 0 && patterns.length === 0 && additionalCoercer === null)
|
|
64
|
+
return null;
|
|
65
|
+
const id = ctx.next();
|
|
66
|
+
const name = `_coerceObject${id}`;
|
|
67
|
+
const lines = [
|
|
68
|
+
`const ${name} = (input: unknown): unknown => {`,
|
|
69
|
+
` if (typeof input !== 'object' || input === null || Array.isArray(input)) return input`,
|
|
70
|
+
` const obj = input as Record<string, unknown>`,
|
|
71
|
+
` let out: Record<string, unknown> | null = null`
|
|
72
|
+
];
|
|
73
|
+
declared.forEach(({ key, coerce }, index) => {
|
|
74
|
+
const quoted = JSON.stringify(key);
|
|
75
|
+
const local = `_value${id}_${index}`;
|
|
76
|
+
lines.push(
|
|
77
|
+
// Own properties only. A plain `obj[key]` walks the prototype chain, and
|
|
78
|
+
// this pass *writes*: with `Object.prototype.flag` set by any dependency, an
|
|
79
|
+
// empty object was read as carrying `flag`, coerced, and handed back with
|
|
80
|
+
// `flag` as its own — inventing data the caller never sent, and satisfying
|
|
81
|
+
// a `required` the input actually violates. An absent key is not a value to
|
|
82
|
+
// coerce, and `{ a: undefined }` counts as absent everywhere else in this
|
|
83
|
+
// package, so it counts as absent here too.
|
|
84
|
+
` const ${local} = Object.hasOwn(obj, ${quoted}) ? obj[${quoted}] : undefined`,
|
|
85
|
+
` if (${local} !== undefined) {`,
|
|
86
|
+
` const next = ${coerce(local)}`,
|
|
87
|
+
` if (next !== ${local}) (out ??= { ...obj })[${quoted}] = next`,
|
|
88
|
+
` }`
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
if (patterns.length > 0 || additionalCoercer !== null) {
|
|
92
|
+
const declaredKeys = Object.keys(properties);
|
|
93
|
+
const known = `_declared${id}`;
|
|
94
|
+
if (declaredKeys.length > 0) {
|
|
95
|
+
ctx.hoisted.push(`const ${known} = new Set(${JSON.stringify(declaredKeys)})`);
|
|
96
|
+
}
|
|
97
|
+
const patternNames = patterns.map((pattern, index) => {
|
|
98
|
+
const regexName = `_pattern${id}_${index}`;
|
|
99
|
+
ctx.hoisted.push(`const ${regexName} = new RegExp(${JSON.stringify(pattern.source)}, ${JSON.stringify(regexFlagsFor(pattern.source))})`);
|
|
100
|
+
return regexName;
|
|
101
|
+
});
|
|
102
|
+
lines.push(` for (const key of Object.keys(obj)) {`);
|
|
103
|
+
if (declaredKeys.length > 0)
|
|
104
|
+
lines.push(` if (${known}.has(key)) continue`);
|
|
105
|
+
lines.push(
|
|
106
|
+
` const value = obj[key]`,
|
|
107
|
+
` if (value === undefined) continue`,
|
|
108
|
+
// Annotated, not inferred: narrowing `unknown` by `!== undefined` leaves
|
|
109
|
+
// `{} | null`, and a coercion returns `unknown`, so the inferred binding
|
|
110
|
+
// rejected the very thing it exists to hold.
|
|
111
|
+
` let next: unknown = value`
|
|
112
|
+
);
|
|
113
|
+
patterns.forEach((pattern, index) => {
|
|
114
|
+
lines.push(` if (${patternNames[index]}.test(key)) next = ${pattern.coerce("next")}`);
|
|
115
|
+
});
|
|
116
|
+
if (additionalCoercer !== null) {
|
|
117
|
+
const guard = patternNames.length === 0 ? "" : `if (!(${patternNames.map((p) => `${p}.test(key)`).join(" || ")})) `;
|
|
118
|
+
lines.push(` ${guard}next = ${additionalCoercer("next")}`);
|
|
119
|
+
}
|
|
120
|
+
lines.push(` if (next !== value) (out ??= { ...obj })[key] = next`, ` }`);
|
|
121
|
+
}
|
|
122
|
+
lines.push(` return out ?? input`, `}`);
|
|
123
|
+
ctx.helpers.push(lines.join("\n"));
|
|
124
|
+
return (valueExpr) => `${name}(${valueExpr})`;
|
|
125
|
+
};
|
|
126
|
+
const emitArrayCoercer = (schema, ctx) => {
|
|
127
|
+
const prefixItems = readKey(schema, "prefixItems");
|
|
128
|
+
const tuple = Array.isArray(prefixItems) ? prefixItems : [];
|
|
129
|
+
const tupleCoercers = tuple.map((item) => coercerFor(item, ctx));
|
|
130
|
+
const items = readKey(schema, "items");
|
|
131
|
+
const restCoercer = typeof items === "object" && items !== null && !Array.isArray(items) ? coercerFor(items, ctx) : null;
|
|
132
|
+
if (tupleCoercers.every((coerce) => coerce === null) && restCoercer === null)
|
|
133
|
+
return null;
|
|
134
|
+
const id = ctx.next();
|
|
135
|
+
const name = `_coerceArray${id}`;
|
|
136
|
+
const lines = [
|
|
137
|
+
`const ${name} = (input: unknown): unknown => {`,
|
|
138
|
+
` if (!Array.isArray(input)) return input`,
|
|
139
|
+
` let out: unknown[] | null = null`
|
|
140
|
+
];
|
|
141
|
+
tupleCoercers.forEach((coerce, index) => {
|
|
142
|
+
if (coerce === null)
|
|
143
|
+
return;
|
|
144
|
+
const read = `input[${index}]`;
|
|
145
|
+
lines.push(
|
|
146
|
+
// `Object.hasOwn` rather than a length test, for the same reason the
|
|
147
|
+
// properties above use it: a hole reads through `Array.prototype`, and
|
|
148
|
+
// coercing what it finds there would turn a hole into an own element.
|
|
149
|
+
` if (Object.hasOwn(input, ${index})) {`,
|
|
150
|
+
` const next = ${coerce(read)}`,
|
|
151
|
+
` if (next !== ${read}) (out ??= [...input])[${index}] = next`,
|
|
152
|
+
` }`
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
if (restCoercer !== null) {
|
|
156
|
+
lines.push(` for (let i = ${tuple.length}; i < input.length; i++) {`, ` if (!Object.hasOwn(input, i)) continue`, ` const value = input[i]`, ` const next = ${restCoercer("value")}`, ` if (next !== value) (out ??= [...input])[i] = next`, ` }`);
|
|
157
|
+
}
|
|
158
|
+
lines.push(` return out ?? input`, `}`);
|
|
159
|
+
ctx.helpers.push(lines.join("\n"));
|
|
160
|
+
return (valueExpr) => `${name}(${valueExpr})`;
|
|
161
|
+
};
|
|
162
|
+
const coercerFor = (schema, ctx) => {
|
|
163
|
+
if (!isSchemaObject(schema))
|
|
164
|
+
return null;
|
|
165
|
+
const node = schema;
|
|
166
|
+
const ref = readKey(node, "$ref");
|
|
167
|
+
if (typeof ref === "string") {
|
|
168
|
+
const name = coercerNameFor(ref, ctx.typeSuffix);
|
|
169
|
+
return (valueExpr) => `${name}(${valueExpr})`;
|
|
170
|
+
}
|
|
171
|
+
const scalar = soleScalarType(node);
|
|
172
|
+
if (scalar !== null)
|
|
173
|
+
return (valueExpr) => `coerceScalar(${valueExpr}, ${JSON.stringify(scalar)})`;
|
|
174
|
+
const union = unionScalarTypes(node);
|
|
175
|
+
if (union !== null)
|
|
176
|
+
return (valueExpr) => `coerceUnion(${valueExpr}, ${JSON.stringify(union)})`;
|
|
177
|
+
const type = readKey(node, "type");
|
|
178
|
+
if (type === "object" || type === void 0 && declaresObjectKeys(node))
|
|
179
|
+
return emitObjectCoercer(node, ctx);
|
|
180
|
+
if (type === "array" || type === void 0 && declaresArrayItems(node))
|
|
181
|
+
return emitArrayCoercer(node, ctx);
|
|
182
|
+
return null;
|
|
183
|
+
};
|
|
184
|
+
const generateCoerceFunction = (schema, typeName, typeSuffix = "") => {
|
|
185
|
+
const helpers = [];
|
|
186
|
+
const hoisted = [];
|
|
187
|
+
let counter = 0;
|
|
188
|
+
const ctx = { typeSuffix, helpers, hoisted, next: () => counter++ };
|
|
189
|
+
const coerce = coercerFor(schema, ctx);
|
|
190
|
+
const valueName = `coerce${typeName}Value`;
|
|
191
|
+
const body = coerce === null ? "input" : coerce("input");
|
|
192
|
+
const parts = [...hoisted, ...helpers];
|
|
193
|
+
const walk = [
|
|
194
|
+
...parts.length > 0 ? [parts.join("\n\n"), ""] : [],
|
|
195
|
+
`export const ${valueName} = (input: unknown): unknown => ${body}`,
|
|
196
|
+
"",
|
|
197
|
+
`export const coerce${typeName} = (input: unknown, _path = ''): CoercionResult<${typeName}> => {`,
|
|
198
|
+
` const value = ${valueName}(input)`,
|
|
199
|
+
` const result = validate${typeName}(value, _path)`,
|
|
200
|
+
` return result === true ? { valid: true, value: value as ${typeName} } : { valid: false, errors: result.errors }`,
|
|
201
|
+
`}`
|
|
202
|
+
].join("\n");
|
|
203
|
+
return { code: walk, usesCoerceScalar: walk.includes("coerceScalar(") || walk.includes("coerceUnion(") };
|
|
204
|
+
};
|
|
205
|
+
export {
|
|
206
|
+
coercerNameFor,
|
|
207
|
+
generateCoerceFunction
|
|
208
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { type UnknownKeysStrategy } from '@amritk/helpers/unknown-keys-strategy';
|
|
2
|
+
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
3
|
+
/**
|
|
4
|
+
* Options for controlling what gets generated in a validator file.
|
|
5
|
+
*/
|
|
6
|
+
type GenerateValidatorFileOptions = {
|
|
7
|
+
/**
|
|
8
|
+
* The $ref path of the schema being generated (e.g. `#/$defs/info`).
|
|
9
|
+
* Prevents the file from importing itself.
|
|
10
|
+
*/
|
|
11
|
+
readonly selfRef?: string;
|
|
12
|
+
/**
|
|
13
|
+
* The root schema document. Used to filter out unresolvable refs.
|
|
14
|
+
*/
|
|
15
|
+
readonly rootSchema?: Record<string, unknown>;
|
|
16
|
+
/**
|
|
17
|
+
* Suffix appended to every type/validator name derived from a `$ref`.
|
|
18
|
+
* Defaults to `''` (no suffix).
|
|
19
|
+
*/
|
|
20
|
+
readonly typeSuffix?: string;
|
|
21
|
+
/**
|
|
22
|
+
* How the fast paths prove a closed object carries no undeclared key —
|
|
23
|
+
* `Object.keys(obj).length` (the default) or a `for…in` count. See
|
|
24
|
+
* {@link UnknownKeysStrategy} for the trade-off between the two.
|
|
25
|
+
*/
|
|
26
|
+
readonly unknownKeys?: UnknownKeysStrategy;
|
|
27
|
+
/**
|
|
28
|
+
* The `format` names this build enforces. Empty (the default) leaves `format`
|
|
29
|
+
* an annotation, as 2020-12 reads it; a name here makes the generated
|
|
30
|
+
* `validateX` and `isX` both check it, against a `formats.ts` emitted
|
|
31
|
+
* alongside.
|
|
32
|
+
*/
|
|
33
|
+
readonly formats?: ReadonlySet<string>;
|
|
34
|
+
/**
|
|
35
|
+
* Whether to emit the fail-fast half — `checkX`, which answers with the same
|
|
36
|
+
* `ValidationResult` as `validateX` but stops at the first violation, so its
|
|
37
|
+
* `errors` array holds exactly one error. Off by default: a caller who wants
|
|
38
|
+
* every error should not carry a second body, and `validateX` / `isX` are
|
|
39
|
+
* byte-for-byte the same either way.
|
|
40
|
+
*/
|
|
41
|
+
readonly check?: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Whether to emit the coercing half — `coerceX`, and the value walk a `$ref`
|
|
44
|
+
* in another file calls. Off by default: a caller who does not coerce should
|
|
45
|
+
* not carry the code, and `validateX` / `isX` are byte-for-byte the same
|
|
46
|
+
* either way.
|
|
47
|
+
*/
|
|
48
|
+
readonly coerce?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Whether to emit the repairing half — `repairX`, and the position lookup a
|
|
51
|
+
* `$ref` in another file calls. Implies {@link coerce}: `repairX` coerces
|
|
52
|
+
* first, so anything merely written in the wrong type is right before the
|
|
53
|
+
* validator ever sees it, and never shows up as a repair.
|
|
54
|
+
*/
|
|
55
|
+
readonly repair?: boolean;
|
|
56
|
+
/** Extension on every emitted relative specifier: `'js'` (default) or `'ts'`. */
|
|
57
|
+
readonly importExt?: 'js' | 'ts';
|
|
58
|
+
/**
|
|
59
|
+
* Whether a failing `anyOf` / `oneOf` also reports the errors of the branch it
|
|
60
|
+
* meant. Off by default, and off is free — the emitted text is exactly what it
|
|
61
|
+
* was before the option existed.
|
|
62
|
+
*/
|
|
63
|
+
readonly branchErrors?: boolean;
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Generates a complete TypeScript validator file from a JSON Schema.
|
|
67
|
+
*
|
|
68
|
+
* The file contains:
|
|
69
|
+
* - Imports for the ValidationResult/ValidationError types
|
|
70
|
+
* - Imports for any $ref types and their validator functions
|
|
71
|
+
* - The exported TypeScript type definition
|
|
72
|
+
* - The exported validator function (`validateX`, rich `ValidationResult`)
|
|
73
|
+
* - The exported boolean type-guard (`isX`, a flat `input is X` predicate)
|
|
74
|
+
* - Optionally the fail-fast validator (`checkX`, the same `ValidationResult`
|
|
75
|
+
* carrying only the first error)
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* ```typescript
|
|
79
|
+
* const schema = {
|
|
80
|
+
* type: 'object',
|
|
81
|
+
* properties: { title: { type: 'string' } },
|
|
82
|
+
* required: ['title'],
|
|
83
|
+
* }
|
|
84
|
+
* generateValidatorFile(schema, 'Info')
|
|
85
|
+
* // import type { ValidationResult, ValidationError } from './validation-result'
|
|
86
|
+
* // export type Info = { title: string }
|
|
87
|
+
* // export const validateInfo = (input: unknown, _path = ''): ValidationResult => { ... }
|
|
88
|
+
* // export const isInfo = (input: unknown): input is Info => { ... }
|
|
89
|
+
* ```
|
|
90
|
+
*/
|
|
91
|
+
export declare const generateValidatorFile: (schema: JSONSchema, typeName: string, options?: GenerateValidatorFileOptions) => string;
|
|
92
|
+
export {};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { generateTypeDefinition } from "@amritk/helpers/generate-type-definition";
|
|
2
|
+
import { identifierMentions } from "@amritk/helpers/identifier-mentions";
|
|
3
|
+
import { DEFAULT_UNKNOWN_KEYS } from "@amritk/helpers/unknown-keys-strategy";
|
|
4
|
+
import { collectValidatorImports } from "./collect-validator-imports.js";
|
|
5
|
+
import { formatCheckName } from "./emit-format-checks.js";
|
|
6
|
+
import { NO_FORMATS } from "./enforced-keywords.js";
|
|
7
|
+
import { generateCoerceFunction } from "./generate-coerce-function.js";
|
|
8
|
+
import { generateRepairFunction } from "./generate-repair-function.js";
|
|
9
|
+
import { generateBooleanGuard, generateCheckFunction, generateValidatorFunction } from "./generate-validator-function.js";
|
|
10
|
+
const generateValidatorFile = (schema, typeName, options) => {
|
|
11
|
+
const typeSuffix = options?.typeSuffix ?? "";
|
|
12
|
+
const typeDefinition = generateTypeDefinition(schema, typeName, {
|
|
13
|
+
typeSuffix,
|
|
14
|
+
...options?.rootSchema !== void 0 ? { rootSchema: options.rootSchema } : {}
|
|
15
|
+
});
|
|
16
|
+
const unknownKeys = options?.unknownKeys ?? DEFAULT_UNKNOWN_KEYS;
|
|
17
|
+
const importExt = options?.importExt ?? "js";
|
|
18
|
+
const formats = options?.formats ?? NO_FORMATS;
|
|
19
|
+
const validatorFunction = generateValidatorFunction(schema, typeName, typeSuffix, options?.rootSchema, unknownKeys, formats, options?.branchErrors === true);
|
|
20
|
+
const booleanGuard = generateBooleanGuard(schema, typeName, typeSuffix, unknownKeys, formats);
|
|
21
|
+
const checker = options?.check === true ? generateCheckFunction(schema, typeName, typeSuffix, options?.rootSchema, unknownKeys, formats) : "";
|
|
22
|
+
const wantsCoerce = options?.coerce === true || options?.repair === true;
|
|
23
|
+
const coercer = wantsCoerce ? generateCoerceFunction(schema, typeName, typeSuffix).code : "";
|
|
24
|
+
const repairer = options?.repair === true ? generateRepairFunction(schema, typeName, typeSuffix).code : "";
|
|
25
|
+
const appended = [checker, coercer, repairer].filter((part) => part !== "");
|
|
26
|
+
const body = validatorFunction + booleanGuard + (appended.length === 0 ? "" : "\n\n" + appended.join("\n\n"));
|
|
27
|
+
const mentions = identifierMentions(typeDefinition + body);
|
|
28
|
+
const refImports = collectValidatorImports(schema, {
|
|
29
|
+
selfRef: options?.selfRef,
|
|
30
|
+
rootSchema: options?.rootSchema,
|
|
31
|
+
typeSuffix,
|
|
32
|
+
importExt,
|
|
33
|
+
reads: ({ typeName: name, validatorName, checkerName, coercerName, repairerName }) => ({
|
|
34
|
+
type: mentions(name),
|
|
35
|
+
validator: mentions(validatorName),
|
|
36
|
+
checker: mentions(checkerName),
|
|
37
|
+
coercer: mentions(coercerName),
|
|
38
|
+
repairer: mentions(repairerName)
|
|
39
|
+
})
|
|
40
|
+
});
|
|
41
|
+
const resultTypes = [
|
|
42
|
+
"ValidationResult",
|
|
43
|
+
.../\bValidationError\b/.test(body) ? ["ValidationError"] : [],
|
|
44
|
+
.../\bCoercionResult\b/.test(body) ? ["CoercionResult"] : [],
|
|
45
|
+
.../\bRepairResult\b/.test(body) ? ["RepairResult"] : [],
|
|
46
|
+
.../\bRepairLookup\b/.test(body) ? ["RepairLookup"] : []
|
|
47
|
+
];
|
|
48
|
+
let result = `import type { ${resultTypes.join(", ")} } from './validation-result.${importExt}'
|
|
49
|
+
`;
|
|
50
|
+
const runtimeHelpers = [
|
|
51
|
+
"valuesEqual",
|
|
52
|
+
"allUnique",
|
|
53
|
+
"escapePointer",
|
|
54
|
+
"everyItem",
|
|
55
|
+
"selectBranchErrors",
|
|
56
|
+
"coerceScalar",
|
|
57
|
+
"coerceUnion",
|
|
58
|
+
"applyRepairs"
|
|
59
|
+
].filter((name) => body.includes(`${name}(`));
|
|
60
|
+
if (body.includes("MAX_REPAIR_PASSES"))
|
|
61
|
+
runtimeHelpers.push("MAX_REPAIR_PASSES");
|
|
62
|
+
if (runtimeHelpers.length > 0) {
|
|
63
|
+
result += `import { ${runtimeHelpers.join(", ")} } from './validation-result.${importExt}'
|
|
64
|
+
`;
|
|
65
|
+
}
|
|
66
|
+
const formatChecks = [...formats].map(formatCheckName).filter((name) => body.includes(`${name}(`)).sort();
|
|
67
|
+
if (formatChecks.length > 0) {
|
|
68
|
+
result += `import { ${formatChecks.join(", ")} } from './formats.${importExt}'
|
|
69
|
+
`;
|
|
70
|
+
}
|
|
71
|
+
for (const imp of refImports) {
|
|
72
|
+
result += imp + "\n";
|
|
73
|
+
}
|
|
74
|
+
if (refImports.length > 0) {
|
|
75
|
+
result += "\n";
|
|
76
|
+
} else {
|
|
77
|
+
result += "\n";
|
|
78
|
+
}
|
|
79
|
+
result += typeDefinition + "\n\n" + validatorFunction + "\n\n" + booleanGuard;
|
|
80
|
+
for (const part of appended)
|
|
81
|
+
result += "\n\n" + part;
|
|
82
|
+
return result;
|
|
83
|
+
};
|
|
84
|
+
export {
|
|
85
|
+
generateValidatorFile
|
|
86
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
2
|
+
/**
|
|
3
|
+
* The name of the position-lookup half generated for a `$ref`'s target. A repair
|
|
4
|
+
* has to cross a file boundary the same way validation and coercion do.
|
|
5
|
+
*/
|
|
6
|
+
export declare const repairLookupNameFor: (ref: string, typeSuffix: string) => string;
|
|
7
|
+
/**
|
|
8
|
+
* Generates the repairing half of a validator file: the position lookup, and the
|
|
9
|
+
* `repairX` that drives validate-repair-revalidate over it.
|
|
10
|
+
*
|
|
11
|
+
* The loop is the whole design. Coercion runs first, so anything that was only
|
|
12
|
+
* written in the wrong type is already right and never counts as a repair. What
|
|
13
|
+
* is left is judged by the very same `validateX` the package already emits, and
|
|
14
|
+
* *its errors* are what drive the repair: each one names a position and a
|
|
15
|
+
* reason, the lookup answers with a value for that position, and the error the
|
|
16
|
+
* repair came from is what gets reported. Nothing second-guesses the validator,
|
|
17
|
+
* and there is no parallel set of rules about what is wrong that could disagree
|
|
18
|
+
* with it.
|
|
19
|
+
*
|
|
20
|
+
* Re-validating after a round is what catches the cases one pass cannot see: a
|
|
21
|
+
* required object that was missing entirely is repaired into existence, and only
|
|
22
|
+
* then can its own children be judged. A position is repaired at most once, so
|
|
23
|
+
* the loop terminates on progress rather than on the pass cap.
|
|
24
|
+
*/
|
|
25
|
+
export declare const generateRepairFunction: (schema: JSONSchema, typeName: string, typeSuffix?: string) => {
|
|
26
|
+
code: string;
|
|
27
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { getDefaultValue } from "@amritk/helpers/get-default-value";
|
|
2
|
+
import { readKey } from "@amritk/helpers/read-key";
|
|
3
|
+
import { refToName } from "@amritk/helpers/ref-to-name";
|
|
4
|
+
import { isSchemaObject } from "@amritk/helpers/schema-guards";
|
|
5
|
+
const repairLookupNameFor = (ref, typeSuffix) => `repair${refToName(ref, typeSuffix)}At`;
|
|
6
|
+
const fallbackLiteral = (schema) => {
|
|
7
|
+
const literal = getDefaultValue(schema);
|
|
8
|
+
return literal === "undefined" ? null : literal;
|
|
9
|
+
};
|
|
10
|
+
const schemaMap = (schema, key) => {
|
|
11
|
+
const value = readKey(schema, key);
|
|
12
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
|
|
13
|
+
};
|
|
14
|
+
const emitWalker = (schema, ctx) => {
|
|
15
|
+
if (!isSchemaObject(schema))
|
|
16
|
+
return null;
|
|
17
|
+
const node = schema;
|
|
18
|
+
const ref = readKey(node, "$ref");
|
|
19
|
+
if (typeof ref === "string")
|
|
20
|
+
return repairLookupNameFor(ref, ctx.typeSuffix);
|
|
21
|
+
const own = fallbackLiteral(schema);
|
|
22
|
+
const properties = schemaMap(node, "properties") ?? {};
|
|
23
|
+
const children = Object.entries(properties).map(([key, child]) => [key, emitWalker(child, ctx)]).filter((entry) => entry[1] !== null);
|
|
24
|
+
const prefixItems = readKey(node, "prefixItems");
|
|
25
|
+
const tuple = Array.isArray(prefixItems) ? prefixItems : [];
|
|
26
|
+
const tupleWalkers = tuple.map((item) => emitWalker(item, ctx));
|
|
27
|
+
const items = readKey(node, "items");
|
|
28
|
+
const itemsWalker = typeof items === "object" && items !== null && !Array.isArray(items) ? emitWalker(items, ctx) : null;
|
|
29
|
+
const hasChildren = children.length > 0 || itemsWalker !== null || tupleWalkers.some((walker) => walker !== null);
|
|
30
|
+
if (own === null && !hasChildren)
|
|
31
|
+
return null;
|
|
32
|
+
const id = ctx.next();
|
|
33
|
+
const name = `_repairAt${id}`;
|
|
34
|
+
const lines = [
|
|
35
|
+
`const ${name}: RepairLookup = (segments) => {`,
|
|
36
|
+
` if (segments.length === 0) return ${own === null ? "null" : `() => (${own})`}`
|
|
37
|
+
];
|
|
38
|
+
if (hasChildren) {
|
|
39
|
+
lines.push(` const [head, ...rest] = segments as [string, ...string[]]`);
|
|
40
|
+
if (children.length > 0) {
|
|
41
|
+
lines.push(` switch (head) {`);
|
|
42
|
+
for (const [key, walker] of children)
|
|
43
|
+
lines.push(` case ${JSON.stringify(key)}: return ${walker}(rest)`);
|
|
44
|
+
lines.push(` }`);
|
|
45
|
+
}
|
|
46
|
+
const indexed = tupleWalkers.some((walker) => walker !== null) || itemsWalker !== null;
|
|
47
|
+
if (indexed) {
|
|
48
|
+
lines.push(` const index = Number(head)`);
|
|
49
|
+
lines.push(` if (Number.isInteger(index) && index >= 0) {`);
|
|
50
|
+
tupleWalkers.forEach((walker, position) => {
|
|
51
|
+
if (walker === null)
|
|
52
|
+
return;
|
|
53
|
+
lines.push(` if (index === ${position}) return ${walker}(rest)`);
|
|
54
|
+
});
|
|
55
|
+
if (itemsWalker !== null) {
|
|
56
|
+
lines.push(` if (index >= ${tuple.length}) return ${itemsWalker}(rest)`);
|
|
57
|
+
}
|
|
58
|
+
lines.push(` }`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
lines.push(` return null`, `}`);
|
|
62
|
+
ctx.helpers.push(lines.join("\n"));
|
|
63
|
+
return name;
|
|
64
|
+
};
|
|
65
|
+
const generateRepairFunction = (schema, typeName, typeSuffix = "") => {
|
|
66
|
+
const helpers = [];
|
|
67
|
+
let counter = 0;
|
|
68
|
+
const ctx = { typeSuffix, helpers, next: () => counter++ };
|
|
69
|
+
const walker = emitWalker(schema, ctx);
|
|
70
|
+
const lookupName = `repair${typeName}At`;
|
|
71
|
+
const parts = [
|
|
72
|
+
...helpers.length > 0 ? [helpers.join("\n\n"), ""] : [],
|
|
73
|
+
`export const ${lookupName}: RepairLookup = ${walker === null ? "() => null" : walker}`,
|
|
74
|
+
"",
|
|
75
|
+
`export const repair${typeName} = (input: unknown, _path = ''): RepairResult<${typeName}> => {`,
|
|
76
|
+
` let value = coerce${typeName}Value(input)`,
|
|
77
|
+
` const repairs: ValidationError[] = []`,
|
|
78
|
+
` const done = new Set<string>()`,
|
|
79
|
+
``,
|
|
80
|
+
` for (let pass = 0; pass < MAX_REPAIR_PASSES; pass++) {`,
|
|
81
|
+
` const result = validate${typeName}(value, _path)`,
|
|
82
|
+
` if (result === true) return { valid: true, value: value as ${typeName}, repairs }`,
|
|
83
|
+
``,
|
|
84
|
+
` const applied = applyRepairs(value, result.errors, ${lookupName}, done)`,
|
|
85
|
+
` // Nothing moved, so nothing on a further pass would either.`,
|
|
86
|
+
` if (applied.repaired.length === 0) return { valid: false, value, errors: result.errors, repairs }`,
|
|
87
|
+
` value = applied.value`,
|
|
88
|
+
` repairs.push(...applied.repaired)`,
|
|
89
|
+
` }`,
|
|
90
|
+
``,
|
|
91
|
+
` const final = validate${typeName}(value, _path)`,
|
|
92
|
+
` return final === true`,
|
|
93
|
+
` ? { valid: true, value: value as ${typeName}, repairs }`,
|
|
94
|
+
` : { valid: false, value, errors: final.errors, repairs }`,
|
|
95
|
+
`}`
|
|
96
|
+
];
|
|
97
|
+
return { code: parts.join("\n") };
|
|
98
|
+
};
|
|
99
|
+
export {
|
|
100
|
+
generateRepairFunction,
|
|
101
|
+
repairLookupNameFor
|
|
102
|
+
};
|