@oh-my-pi/omptype 18.16.0 → 18.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/CHANGELOG.md +18 -0
- package/README.md +33 -0
- package/dist/js/compile.js +25 -3
- package/dist/js/from-json-schema.js +61 -216
- package/dist/js/interp.js +2 -0
- package/dist/js/jsonschema/codegen.js +514 -0
- package/dist/js/jsonschema/interpret.js +729 -0
- package/dist/js/jsonschema/pattern.js +221 -0
- package/dist/js/jsonschema/plan.js +448 -0
- package/dist/js/jsonschema/resolve.js +321 -0
- package/dist/js/jsonschema/runtime.js +129 -0
- package/dist/js/jsonschema/validator.js +106 -0
- package/dist/js/typebox.js +5 -5
- package/dist/types/from-json-schema.d.ts +8 -3
- package/dist/types/ir.d.ts +2 -0
- package/dist/types/jsonschema/codegen.d.ts +6 -0
- package/dist/types/jsonschema/interpret.d.ts +48 -0
- package/dist/types/jsonschema/pattern.d.ts +17 -0
- package/dist/types/jsonschema/plan.d.ts +128 -0
- package/dist/types/jsonschema/resolve.d.ts +43 -0
- package/dist/types/jsonschema/runtime.d.ts +30 -0
- package/dist/types/jsonschema/validator.d.ts +33 -0
- package/package.json +2 -5
- package/src/compile.ts +24 -3
- package/src/from-json-schema.ts +64 -213
- package/src/interp.ts +1 -0
- package/src/ir.ts +2 -0
- package/src/jsonschema/codegen.ts +495 -0
- package/src/jsonschema/interpret.ts +724 -0
- package/src/jsonschema/pattern.ts +202 -0
- package/src/jsonschema/plan.ts +538 -0
- package/src/jsonschema/resolve.ts +333 -0
- package/src/jsonschema/runtime.ts +117 -0
- package/src/jsonschema/validator.ts +113 -0
- package/src/typebox.ts +9 -5
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compiles simple anchored `pattern`s into char-code checks for generated
|
|
3
|
+
* validators: `^[0-9a-f]{8}-…$`, `^\d{4}-\d{2}-\d{2}$`, `^[A-Za-z0-9._-]+$`.
|
|
4
|
+
*
|
|
5
|
+
* Supported: ASCII literals and escaped punctuation, `\d`, `\w`, non-negated
|
|
6
|
+
* ASCII classes, and quantifiers, with at most one variable-length segment.
|
|
7
|
+
* Fixed segments then pin every position (from the start before the variable
|
|
8
|
+
* segment, from the end after it), so the check is exact without
|
|
9
|
+
* backtracking. ASCII-only classes reject every UTF-16 surrogate, so counting
|
|
10
|
+
* code units agrees with the `u` flag's code points. Anything else returns
|
|
11
|
+
* `undefined` and stays a `RegExp`.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Source of `function name(s)` testing a string against `source` with the
|
|
15
|
+
* `u` flag's semantics, or `undefined` when the pattern is not simple.
|
|
16
|
+
*/
|
|
17
|
+
export declare function compileSimplePattern(source: string, name: string): string | undefined;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { type Registry } from "./resolve.js";
|
|
2
|
+
/** Boolean check for one `format` name; returning false rejects the string. */
|
|
3
|
+
export type FormatCheck = (value: string) => boolean;
|
|
4
|
+
export declare const T_NULL = 1;
|
|
5
|
+
export declare const T_BOOLEAN = 2;
|
|
6
|
+
export declare const T_INTEGER = 4;
|
|
7
|
+
/** Any finite number, integers included. */
|
|
8
|
+
export declare const T_NUMBER = 8;
|
|
9
|
+
export declare const T_STRING = 16;
|
|
10
|
+
export declare const T_ARRAY = 32;
|
|
11
|
+
export declare const T_OBJECT = 64;
|
|
12
|
+
export declare const T_ANY = 127;
|
|
13
|
+
/** Every number type: `type: "number"`. */
|
|
14
|
+
export declare const T_NUMERIC: number;
|
|
15
|
+
/** The single JSON kind a passing value of `types` has, as a mask; 0 when several. */
|
|
16
|
+
export declare function kindOf(types: number): number;
|
|
17
|
+
/** A `properties`/`dependentSchemas` entry. */
|
|
18
|
+
export interface KeyPlan {
|
|
19
|
+
readonly key: string;
|
|
20
|
+
readonly plan: Plan;
|
|
21
|
+
/** `key` is inherited by plain objects (`constructor`, `__proto__`, ...): reads need an own check. */
|
|
22
|
+
readonly inherited: boolean;
|
|
23
|
+
}
|
|
24
|
+
/** A `patternProperties` entry. */
|
|
25
|
+
export interface PatternPlan {
|
|
26
|
+
readonly pattern: RegExp;
|
|
27
|
+
readonly plan: Plan;
|
|
28
|
+
}
|
|
29
|
+
/** A `dependentRequired` entry. */
|
|
30
|
+
export interface DependentRequired {
|
|
31
|
+
readonly key: string;
|
|
32
|
+
readonly required: readonly string[];
|
|
33
|
+
}
|
|
34
|
+
/** A property `default` declared under `properties`. */
|
|
35
|
+
export interface PropertyDefault {
|
|
36
|
+
readonly key: string;
|
|
37
|
+
readonly value: unknown;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Normalized keywords of one schema location in one dynamic scope. Absent
|
|
41
|
+
* fields impose nothing; type-specific keywords only constrain values of
|
|
42
|
+
* their type.
|
|
43
|
+
*/
|
|
44
|
+
export interface Plan {
|
|
45
|
+
/** Accepted JSON types as `T_*` flags; `T_ANY` when unconstrained, 0 for `false`. */
|
|
46
|
+
types: number;
|
|
47
|
+
/** No keyword constrains the value (`true`, `{}`, annotation-only schemas). */
|
|
48
|
+
empty: boolean;
|
|
49
|
+
/** `F_*` keyword groups present; 0 when `type` is the only constraint. */
|
|
50
|
+
flags: number;
|
|
51
|
+
/** `$ref` and resolved `$dynamicRef` targets, applied in place. */
|
|
52
|
+
refs?: Plan[];
|
|
53
|
+
hasConst?: boolean;
|
|
54
|
+
constValue?: unknown;
|
|
55
|
+
enumValues?: readonly unknown[];
|
|
56
|
+
multipleOf?: number;
|
|
57
|
+
minimum?: number;
|
|
58
|
+
maximum?: number;
|
|
59
|
+
exclusiveMinimum?: number;
|
|
60
|
+
exclusiveMaximum?: number;
|
|
61
|
+
minLength?: number;
|
|
62
|
+
maxLength?: number;
|
|
63
|
+
pattern?: RegExp;
|
|
64
|
+
formatName?: string;
|
|
65
|
+
format?: FormatCheck;
|
|
66
|
+
minItems?: number;
|
|
67
|
+
maxItems?: number;
|
|
68
|
+
uniqueItems?: boolean;
|
|
69
|
+
prefixItems?: Plan[];
|
|
70
|
+
/** Schema for elements after `prefixItems` (or every element). */
|
|
71
|
+
items?: Plan;
|
|
72
|
+
contains?: Plan;
|
|
73
|
+
minContains?: number;
|
|
74
|
+
maxContains?: number;
|
|
75
|
+
unevaluatedItems?: Plan;
|
|
76
|
+
minProperties?: number;
|
|
77
|
+
maxProperties?: number;
|
|
78
|
+
required?: string[];
|
|
79
|
+
properties?: KeyPlan[];
|
|
80
|
+
patternProperties?: PatternPlan[];
|
|
81
|
+
additionalProperties?: Plan;
|
|
82
|
+
propertyNames?: Plan;
|
|
83
|
+
dependentRequired?: DependentRequired[];
|
|
84
|
+
dependentSchemas?: KeyPlan[];
|
|
85
|
+
unevaluatedProperties?: Plan;
|
|
86
|
+
allOf?: Plan[];
|
|
87
|
+
anyOf?: Plan[];
|
|
88
|
+
oneOf?: Plan[];
|
|
89
|
+
not?: Plan;
|
|
90
|
+
if?: Plan;
|
|
91
|
+
/** `then`, applied when `if` passes. */
|
|
92
|
+
ifThen?: Plan;
|
|
93
|
+
/** `else`, applied when `if` fails. */
|
|
94
|
+
ifElse?: Plan;
|
|
95
|
+
defaults?: PropertyDefault[];
|
|
96
|
+
}
|
|
97
|
+
/** The `true` schema. */
|
|
98
|
+
export declare const TRUE_PLAN: Plan;
|
|
99
|
+
/** The `false` schema. */
|
|
100
|
+
export declare const FALSE_PLAN: Plan;
|
|
101
|
+
export declare const F_CONST = 1;
|
|
102
|
+
export declare const F_ENUM = 2;
|
|
103
|
+
/** Numeric keywords. */
|
|
104
|
+
export declare const F_NUMBER = 4;
|
|
105
|
+
/** String keywords. */
|
|
106
|
+
export declare const F_STRING = 8;
|
|
107
|
+
/** Array keywords. */
|
|
108
|
+
export declare const F_ARRAY = 16;
|
|
109
|
+
/** `required` and `properties`. */
|
|
110
|
+
export declare const F_PROPERTIES = 32;
|
|
111
|
+
/** Keywords that visit every key: `patternProperties`, `additionalProperties`, `propertyNames`, property counts. */
|
|
112
|
+
export declare const F_KEYS = 64;
|
|
113
|
+
/** `dependentRequired`, `dependentSchemas`. */
|
|
114
|
+
export declare const F_DEPENDENT = 128;
|
|
115
|
+
export declare const F_REFS = 256;
|
|
116
|
+
/** `allOf`, `anyOf`, `oneOf`, `not`, `if`. */
|
|
117
|
+
export declare const F_APPLICATORS = 512;
|
|
118
|
+
/** `unevaluatedProperties`, `unevaluatedItems`. */
|
|
119
|
+
export declare const F_UNEVALUATED = 1024;
|
|
120
|
+
/** Lowers one registry's documents into plans. */
|
|
121
|
+
export declare class Planner {
|
|
122
|
+
#private;
|
|
123
|
+
/** Whether any `properties` subschema declares a `default`. */
|
|
124
|
+
sawDefaults: boolean;
|
|
125
|
+
constructor(registry: Registry, formats: Readonly<Record<string, FormatCheck>> | undefined);
|
|
126
|
+
/** Plan the registry's root document. */
|
|
127
|
+
root(schema: unknown): Plan;
|
|
128
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Keyword semantics: draft 2020-12, 2019-09 (array `items`, no `$dynamicRef`), or 7 (also 4/6). */
|
|
2
|
+
export type Draft = "2020-12" | "2019-09" | "7";
|
|
3
|
+
/** Vocabulary flags gating keyword groups; resources default to all of them. */
|
|
4
|
+
export declare const VOCAB_APPLICATOR = 1;
|
|
5
|
+
export declare const VOCAB_VALIDATION = 2;
|
|
6
|
+
export declare const VOCAB_UNEVALUATED = 4;
|
|
7
|
+
/** A schema resource: a document root or a subschema with its own `$id`. */
|
|
8
|
+
export interface Resource {
|
|
9
|
+
/** Absolute URI without fragment. */
|
|
10
|
+
readonly uri: string;
|
|
11
|
+
readonly root: object;
|
|
12
|
+
readonly draft: Draft;
|
|
13
|
+
readonly vocab: number;
|
|
14
|
+
/** `$dynamicAnchor` names declared in this resource, with their subschemas. */
|
|
15
|
+
readonly dynamicAnchors: Map<string, object>;
|
|
16
|
+
}
|
|
17
|
+
/** Base URI and enclosing resource of a subschema. */
|
|
18
|
+
export interface Location {
|
|
19
|
+
readonly base: string;
|
|
20
|
+
readonly resource: Resource;
|
|
21
|
+
}
|
|
22
|
+
/** A schema reached through a reference, with the location it evaluates in. */
|
|
23
|
+
export interface Target {
|
|
24
|
+
readonly schema: unknown;
|
|
25
|
+
readonly location: Location;
|
|
26
|
+
}
|
|
27
|
+
/** Registry of the documents one `fromJsonSchema` call can reference. */
|
|
28
|
+
export declare class Registry {
|
|
29
|
+
#private;
|
|
30
|
+
constructor(draft: Draft, documents: Readonly<Record<string, unknown>> | undefined);
|
|
31
|
+
/** Index `schema` as the root document and return its target. */
|
|
32
|
+
addRoot(schema: unknown): Target;
|
|
33
|
+
/** Location recorded for an indexed subschema. */
|
|
34
|
+
location(schema: object): Location | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* Resolve `ref` against `base` to a schema and its location.
|
|
37
|
+
*
|
|
38
|
+
* # Errors
|
|
39
|
+
* Throws {@link OmpTypeError} for malformed URI references or fragments, and
|
|
40
|
+
* for targets no known document contains.
|
|
41
|
+
*/
|
|
42
|
+
resolve(ref: string, base: string): Target;
|
|
43
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime helpers shared by the closure validators (`closures.ts`) and the
|
|
3
|
+
* generated ones (`codegen.ts`): JSON value equality, `uniqueItems`,
|
|
4
|
+
* `multipleOf`, code-point string length, and the pattern cache.
|
|
5
|
+
*/
|
|
6
|
+
/** JSON equality: numbers by value, arrays by order, objects by key set regardless of order. */
|
|
7
|
+
export declare function jsonEqual(a: unknown, b: unknown): boolean;
|
|
8
|
+
/** True when `values` contains `value` under {@link jsonEqual}. */
|
|
9
|
+
export declare function jsonIncludes(values: readonly unknown[], value: unknown): boolean;
|
|
10
|
+
/** `uniqueItems`: no two elements are {@link jsonEqual}. */
|
|
11
|
+
export declare function uniqueItems(items: readonly unknown[]): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* `multipleOf` with float tolerance: `0.0075` is a multiple of `0.0001` even
|
|
14
|
+
* though the IEEE quotient is `74.99999999999999`. An overflowing quotient is
|
|
15
|
+
* a multiple only when `divisor` divides 1 (every integer is a multiple of 0.5).
|
|
16
|
+
*/
|
|
17
|
+
export declare function isMultipleOf(value: number, divisor: number): boolean;
|
|
18
|
+
/** String length in Unicode code points, as `minLength`/`maxLength` count it. */
|
|
19
|
+
export declare function codePointLength(value: string): number;
|
|
20
|
+
/**
|
|
21
|
+
* Compile a JSON Schema `pattern` as an ECMAScript regex with the `u` flag,
|
|
22
|
+
* falling back to legacy syntax for patterns that only parse without it.
|
|
23
|
+
* Regexes are stateless (no `g`/`y`), so equal sources share one instance.
|
|
24
|
+
*
|
|
25
|
+
* # Errors
|
|
26
|
+
* Throws `SyntaxError` when the source is not a regular expression at all.
|
|
27
|
+
*/
|
|
28
|
+
export declare function compilePattern(source: string): RegExp;
|
|
29
|
+
/** Property names inherited by plain objects; reading them needs an own-property check. */
|
|
30
|
+
export declare function isInheritedName(key: string): boolean;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type Issue } from "./interpret.js";
|
|
2
|
+
import { type FormatCheck } from "./plan.js";
|
|
3
|
+
/** Options shared by the validator engine and `fromJsonSchema`. */
|
|
4
|
+
export interface ValidatorOptions {
|
|
5
|
+
/** Semantics for resources that declare no `$schema`: `"2020-12"` (default) or `"7"`. */
|
|
6
|
+
readonly draft?: "2020-12" | "7";
|
|
7
|
+
/** Documents by URI that `$ref`s may name: remote schemas, meta-schemas. */
|
|
8
|
+
readonly schemas?: Readonly<Record<string, unknown>>;
|
|
9
|
+
/** Assertions by `format` name. Without one, `format` is an annotation, as draft 2020-12 specifies. */
|
|
10
|
+
readonly formats?: Readonly<Record<string, FormatCheck>>;
|
|
11
|
+
}
|
|
12
|
+
/** A compiled JSON Schema. */
|
|
13
|
+
export declare class Validator {
|
|
14
|
+
#private;
|
|
15
|
+
/** Allocation-free validity test; safe to detach (`const check = validator.check`). */
|
|
16
|
+
readonly check: (value: unknown) => boolean;
|
|
17
|
+
/** Whether some `properties` subschema declares a `default` for {@link fillDefaults} to apply. */
|
|
18
|
+
readonly hasDefaults: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* `onCompile` receives the steady-state check once the schema turns hot
|
|
21
|
+
* (generated code, or the interpreter where code generation is forbidden), so
|
|
22
|
+
* callers holding their own reference can drop the dispatch through `check`.
|
|
23
|
+
*
|
|
24
|
+
* # Errors
|
|
25
|
+
* Throws `OmpTypeError` for unresolvable or malformed `$ref`s, invalid
|
|
26
|
+
* patterns, and schema nodes that are neither booleans nor objects.
|
|
27
|
+
*/
|
|
28
|
+
constructor(schema: unknown, options?: ValidatorOptions, onCompile?: (check: (value: unknown) => value is unknown) => void);
|
|
29
|
+
/** Every issue that makes `value` invalid (empty when it is valid). */
|
|
30
|
+
explain(value: unknown): Issue[];
|
|
31
|
+
/** `value` with absent declared properties set to their `default`, copied on write. */
|
|
32
|
+
fillDefaults(value: unknown): unknown;
|
|
33
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/omptype",
|
|
4
|
-
"version": "18.
|
|
4
|
+
"version": "18.18.0",
|
|
5
5
|
"description": "ArkType-compatible runtime schema validation with lazy JIT compilation",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"types": "./dist/types/index.d.ts",
|
|
28
28
|
"scripts": {
|
|
29
29
|
"check": "oxlint . && oxfmt --check --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts' && bun run check:types",
|
|
30
|
-
"check:types": "
|
|
30
|
+
"check:types": "bun check --no-script -p tsconfig.json",
|
|
31
31
|
"lint": "oxlint .",
|
|
32
32
|
"fix": "oxlint --fix --fix-suggestions . && bun run fmt",
|
|
33
33
|
"fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'",
|
|
@@ -35,9 +35,6 @@
|
|
|
35
35
|
"bench": "bun bench/bench.ts"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
|
-
"@ark/attest": "0.56.3",
|
|
39
|
-
"@ark/schema": "0.56.2",
|
|
40
|
-
"@ark/util": "0.56.2",
|
|
41
38
|
"@sinclair/typebox": "^0.34.0",
|
|
42
39
|
"@types/bun": "^1.3.14",
|
|
43
40
|
"arkregex": "0.0.8",
|
package/src/compile.ts
CHANGED
|
@@ -263,6 +263,9 @@ class Builder {
|
|
|
263
263
|
}
|
|
264
264
|
case "sub":
|
|
265
265
|
return `!(${this.ref(node.schema.run)}(${v}) instanceof AE)`;
|
|
266
|
+
case "refine":
|
|
267
|
+
if (node.check !== undefined) return `(${this.predicate(node.base, v)})&&${this.ref(node.check)}(${v})`;
|
|
268
|
+
return `!(${this.ref(boundWalk(node))}(${v}) instanceof AE)`;
|
|
266
269
|
default:
|
|
267
270
|
return `!(${this.ref(boundWalk(node))}(${v}) instanceof AE)`;
|
|
268
271
|
}
|
|
@@ -1095,6 +1098,21 @@ function resolvedRoot(ir: IR): IR {
|
|
|
1095
1098
|
return ir.k === "alias" ? ir.resolve() : ir;
|
|
1096
1099
|
}
|
|
1097
1100
|
|
|
1101
|
+
/** Set once the runtime refuses code generation from strings (strict CSP); the interpreter serves from then on. */
|
|
1102
|
+
let jitBlocked = false;
|
|
1103
|
+
|
|
1104
|
+
/** Build with `build`, or `undefined` when code generation from strings is forbidden. */
|
|
1105
|
+
function tryBuild<T>(build: () => T): T | undefined {
|
|
1106
|
+
if (jitBlocked) return undefined;
|
|
1107
|
+
try {
|
|
1108
|
+
return build();
|
|
1109
|
+
} catch (error) {
|
|
1110
|
+
if (!(error instanceof EvalError)) throw error;
|
|
1111
|
+
jitBlocked = true;
|
|
1112
|
+
return undefined;
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1098
1116
|
const compiledCache = new WeakMap<IR, (value: unknown) => unknown>();
|
|
1099
1117
|
const allowsCache = new WeakMap<IR, (value: unknown) => value is unknown>();
|
|
1100
1118
|
|
|
@@ -1109,8 +1127,9 @@ export function compile(ir: IR): (value: unknown) => unknown {
|
|
|
1109
1127
|
// fresh one forever. The wrapper resolves to the built validator by call
|
|
1110
1128
|
// time; the interpreter is a safety net that never triggers post-build.
|
|
1111
1129
|
const built: { value?: (value: unknown) => unknown } = {};
|
|
1112
|
-
|
|
1113
|
-
|
|
1130
|
+
const deferred = (value: unknown) => (built.value === undefined ? walk(root, value) : built.value(value));
|
|
1131
|
+
compiledCache.set(root, deferred);
|
|
1132
|
+
const compiled = tryBuild(() => new Builder().build(root)) ?? ((value: unknown) => walk(root, value));
|
|
1114
1133
|
built.value = compiled;
|
|
1115
1134
|
compiledCache.set(root, compiled);
|
|
1116
1135
|
return compiled;
|
|
@@ -1128,7 +1147,9 @@ export function compileAllows(ir: IR): (value: unknown) => value is unknown {
|
|
|
1128
1147
|
built.value === undefined ? !(walk(root, value) instanceof OmpErrors) : built.value(value)) as (
|
|
1129
1148
|
value: unknown,
|
|
1130
1149
|
) => value is unknown);
|
|
1131
|
-
const compiled =
|
|
1150
|
+
const compiled =
|
|
1151
|
+
tryBuild(() => new Builder().buildAllows(root)) ??
|
|
1152
|
+
((value: unknown): value is unknown => !(walk(root, value) instanceof OmpErrors));
|
|
1132
1153
|
built.value = compiled;
|
|
1133
1154
|
allowsCache.set(root, compiled);
|
|
1134
1155
|
return compiled;
|
package/src/from-json-schema.ts
CHANGED
|
@@ -1,233 +1,84 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* JSON Schema → omptype importer.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Validates exactly as JSON Schema drafts 2020-12 and 7 specify: every
|
|
5
|
+
* keyword including `$dynamicRef`, `unevaluatedProperties`/`unevaluatedItems`,
|
|
6
|
+
* `$id`/`$anchor` resolution, remote references, and vocabularies declared by
|
|
7
|
+
* meta-schemas. Validation runs on an eval-free closure engine and moves to
|
|
8
|
+
* generated code once a schema is hot (see `jsonschema/`).
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* The result is a regular omptype schema: callable, composable, emitting the
|
|
11
|
+
* source document from `toJsonSchema()`, and filling absent properties from
|
|
12
|
+
* their `default` when called (never in `allows`).
|
|
13
|
+
*
|
|
14
|
+
* # Example
|
|
15
|
+
* ```ts
|
|
16
|
+
* const read = fromJsonSchema({
|
|
17
|
+
* type: "object",
|
|
18
|
+
* properties: { path: { type: "string" }, offset: { type: "integer", minimum: 0 } },
|
|
19
|
+
* required: ["path"],
|
|
20
|
+
* });
|
|
21
|
+
* read.allows({ path: "a.ts" }); // true
|
|
22
|
+
* read({ offset: -1 }); // OmpErrors: path missing, offset below 0
|
|
23
|
+
* ```
|
|
13
24
|
*/
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import
|
|
17
|
-
import {
|
|
25
|
+
import { OmpErrors } from "./errors";
|
|
26
|
+
import { type EmbeddableSchema, type IR, IR_BRAND } from "./ir";
|
|
27
|
+
import type { Issue } from "./jsonschema/interpret";
|
|
28
|
+
import { Validator, type ValidatorOptions } from "./jsonschema/validator";
|
|
18
29
|
import { type BaseType, type } from "./type";
|
|
19
30
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
/** `format` values lowered to built-in keyword validators. */
|
|
23
|
-
const FORMAT_KEYWORDS: Record<string, string> = {
|
|
24
|
-
email: "string.email",
|
|
25
|
-
uuid: "string.uuid",
|
|
26
|
-
"date-time": "string.date.iso",
|
|
27
|
-
date: "string.date.iso",
|
|
28
|
-
ipv4: "string.ip.v4",
|
|
29
|
-
ipv6: "string.ip.v6",
|
|
30
|
-
regex: "string.regex",
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
class Importer {
|
|
34
|
-
readonly #root: JsonSchema;
|
|
35
|
-
readonly #aliases = new Map<string, IR>();
|
|
36
|
-
|
|
37
|
-
constructor(root: JsonSchema) {
|
|
38
|
-
this.#root = root;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
resolveRef(ref: string): IR {
|
|
42
|
-
const cached = this.#aliases.get(ref);
|
|
43
|
-
if (cached !== undefined) return cached;
|
|
44
|
-
|
|
45
|
-
let target: unknown;
|
|
46
|
-
if (ref === "#") {
|
|
47
|
-
target = this.#root;
|
|
48
|
-
} else {
|
|
49
|
-
const pointer = parseLocalRefPointer(ref);
|
|
50
|
-
if (pointer === undefined || pointer.length !== 2 || (pointer[0] !== "$defs" && pointer[0] !== "definitions"))
|
|
51
|
-
throw new OmpTypeError(`unsupported $ref: ${ref}`);
|
|
52
|
-
const defs = this.#root[pointer[0]];
|
|
53
|
-
target = typeof defs === "object" && defs !== null ? (defs as JsonSchema)[pointer[1]!] : undefined;
|
|
54
|
-
if (target === undefined) throw new OmpTypeError(`unresolved $ref: ${ref}`);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Register the alias before lowering so recursive references resolve
|
|
58
|
-
// to the same node instead of recursing forever.
|
|
59
|
-
let lowered: IR | undefined;
|
|
60
|
-
const alias: IR = {
|
|
61
|
-
k: "alias",
|
|
62
|
-
name: ref,
|
|
63
|
-
resolve: () => {
|
|
64
|
-
lowered ??= this.lower(target);
|
|
65
|
-
return lowered;
|
|
66
|
-
},
|
|
67
|
-
};
|
|
68
|
-
this.#aliases.set(ref, alias);
|
|
69
|
-
return alias;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
lower(schema: unknown): IR {
|
|
73
|
-
if (schema === true) return { k: "unknown" };
|
|
74
|
-
if (schema === false) return { k: "never" };
|
|
75
|
-
if (typeof schema !== "object" || schema === null) {
|
|
76
|
-
throw new OmpTypeError("JSON Schema nodes must be booleans or objects");
|
|
77
|
-
}
|
|
78
|
-
const node = schema as JsonSchema;
|
|
79
|
-
const desc = typeof node.description === "string" ? node.description : undefined;
|
|
80
|
-
const ir = this.#lowerNode(node);
|
|
81
|
-
return desc === undefined ? ir : { ...ir, desc };
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
#lowerNode(node: JsonSchema): IR {
|
|
85
|
-
if (typeof node.$ref === "string") return this.resolveRef(node.$ref);
|
|
86
|
-
|
|
87
|
-
if (Array.isArray(node.enum)) {
|
|
88
|
-
const members = node.enum.map((v): IR => ({ k: "lit", v }));
|
|
89
|
-
return members.length === 1 ? members[0] : { k: "union", members };
|
|
90
|
-
}
|
|
91
|
-
if ("const" in node) return { k: "lit", v: node.const };
|
|
92
|
-
|
|
93
|
-
const branches = node.anyOf ?? node.oneOf;
|
|
94
|
-
if (Array.isArray(branches)) {
|
|
95
|
-
return { k: "union", members: branches.map(branch => this.lower(branch)) };
|
|
96
|
-
}
|
|
97
|
-
if (Array.isArray(node.allOf)) {
|
|
98
|
-
const members = node.allOf.map(branch => this.lower(branch));
|
|
99
|
-
return members.length === 1 ? members[0] : { k: "intersection", members };
|
|
100
|
-
}
|
|
101
|
-
if (typeof node.not === "object" && node.not !== null && Object.keys(node.not).length === 0) {
|
|
102
|
-
return { k: "never" };
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
if (Array.isArray(node.type)) {
|
|
106
|
-
const members = node.type.map(t => this.#lowerTyped(node, String(t)));
|
|
107
|
-
return members.length === 1 ? members[0] : { k: "union", members };
|
|
108
|
-
}
|
|
109
|
-
if (typeof node.type === "string") return this.#lowerTyped(node, node.type);
|
|
110
|
-
|
|
111
|
-
// No explicit type: infer from structural keywords, else accept anything.
|
|
112
|
-
if (node.properties !== undefined || node.required !== undefined) return this.#lowerObject(node);
|
|
113
|
-
if (node.items !== undefined || node.prefixItems !== undefined) return this.#lowerArray(node);
|
|
114
|
-
return { k: "unknown" };
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
#lowerTyped(node: JsonSchema, kind: string): IR {
|
|
118
|
-
switch (kind) {
|
|
119
|
-
case "null":
|
|
120
|
-
return { k: "null" };
|
|
121
|
-
case "boolean":
|
|
122
|
-
return { k: "boolean" };
|
|
123
|
-
case "string":
|
|
124
|
-
return this.#lowerString(node);
|
|
125
|
-
case "number":
|
|
126
|
-
case "integer": {
|
|
127
|
-
const ir: IR = { k: "number" };
|
|
128
|
-
if (kind === "integer") ir.int = true;
|
|
129
|
-
if (typeof node.minimum === "number") ir.min = node.minimum;
|
|
130
|
-
if (typeof node.maximum === "number") ir.max = node.maximum;
|
|
131
|
-
if (typeof node.exclusiveMinimum === "number") {
|
|
132
|
-
ir.min = node.exclusiveMinimum;
|
|
133
|
-
ir.xmin = true;
|
|
134
|
-
}
|
|
135
|
-
if (typeof node.exclusiveMaximum === "number") {
|
|
136
|
-
ir.max = node.exclusiveMaximum;
|
|
137
|
-
ir.xmax = true;
|
|
138
|
-
}
|
|
139
|
-
if (typeof node.multipleOf === "number") ir.divisor = node.multipleOf;
|
|
140
|
-
return ir;
|
|
141
|
-
}
|
|
142
|
-
case "object":
|
|
143
|
-
return this.#lowerObject(node);
|
|
144
|
-
case "array":
|
|
145
|
-
return this.#lowerArray(node);
|
|
146
|
-
default:
|
|
147
|
-
throw new OmpTypeError(`unsupported JSON Schema type: ${kind}`);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
#lowerString(node: JsonSchema): IR {
|
|
152
|
-
const base: IR = { k: "string" };
|
|
153
|
-
if (typeof node.minLength === "number") base.min = node.minLength;
|
|
154
|
-
if (typeof node.maxLength === "number") base.max = node.maxLength;
|
|
155
|
-
if (node.format === "uri" || node.format === "url") base.url = true;
|
|
156
|
-
|
|
157
|
-
const members: IR[] = [];
|
|
158
|
-
if (typeof node.format === "string") {
|
|
159
|
-
const keyword = FORMAT_KEYWORDS[node.format];
|
|
160
|
-
if (keyword !== undefined) {
|
|
161
|
-
const formatIR = keywordIR(keyword);
|
|
162
|
-
if (formatIR !== undefined) members.push(formatIR);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
if (typeof node.pattern === "string") members.push(patternIR(new RegExp(node.pattern)));
|
|
166
|
-
|
|
167
|
-
if (members.length === 0) return base;
|
|
168
|
-
if (base.min !== undefined || base.max !== undefined || base.url) members.unshift(base);
|
|
169
|
-
return members.length === 1 ? members[0] : { k: "intersection", members };
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
#lowerObject(node: JsonSchema): IR {
|
|
173
|
-
const required = new Set(Array.isArray(node.required) ? node.required.map(String) : []);
|
|
174
|
-
const props: PropIR[] = [];
|
|
175
|
-
if (typeof node.properties === "object" && node.properties !== null) {
|
|
176
|
-
for (const [key, value] of Object.entries(node.properties)) {
|
|
177
|
-
const prop: PropIR = { key, opt: !required.has(key), val: this.lower(value) };
|
|
178
|
-
if (typeof value === "object" && value !== null && "default" in value) {
|
|
179
|
-
prop.hasDefault = true;
|
|
180
|
-
prop.def = (value as JsonSchema).default;
|
|
181
|
-
prop.opt = true;
|
|
182
|
-
}
|
|
183
|
-
props.push(prop);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
const extra = node.additionalProperties;
|
|
187
|
-
return {
|
|
188
|
-
k: "object",
|
|
189
|
-
props,
|
|
190
|
-
extras: extra === false ? "reject" : "keep",
|
|
191
|
-
...(typeof extra === "object" && extra !== null ? { index: this.lower(extra) } : {}),
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
#lowerArray(node: JsonSchema): IR {
|
|
196
|
-
if (Array.isArray(node.prefixItems)) {
|
|
197
|
-
const minItems = typeof node.minItems === "number" ? node.minItems : node.prefixItems.length;
|
|
198
|
-
const prefix: TupleItemIR[] = node.prefixItems.map((item, index) => ({
|
|
199
|
-
val: this.lower(item),
|
|
200
|
-
opt: index >= minItems,
|
|
201
|
-
}));
|
|
202
|
-
return {
|
|
203
|
-
k: "tuple",
|
|
204
|
-
prefix,
|
|
205
|
-
postfix: [],
|
|
206
|
-
...(node.items !== undefined && node.items !== false ? { variadic: this.lower(node.items) } : {}),
|
|
207
|
-
};
|
|
208
|
-
}
|
|
209
|
-
const ir: IR = { k: "array", el: node.items === undefined ? { k: "unknown" } : this.lower(node.items) };
|
|
210
|
-
if (typeof node.minItems === "number") ir.min = node.minItems;
|
|
211
|
-
if (typeof node.maxItems === "number") ir.max = node.maxItems;
|
|
212
|
-
return ir;
|
|
213
|
-
}
|
|
214
|
-
}
|
|
31
|
+
/** Options for {@link fromJsonSchema}. */
|
|
32
|
+
export type FromJsonSchemaOptions = ValidatorOptions;
|
|
215
33
|
|
|
216
34
|
/**
|
|
217
35
|
* Build a callable omptype schema from a JSON Schema document.
|
|
218
36
|
*
|
|
219
37
|
* # Errors
|
|
220
|
-
* Throws
|
|
221
|
-
*
|
|
38
|
+
* Throws `OmpTypeError` when a `$ref` is malformed or names a document or
|
|
39
|
+
* location that neither `schema` nor `options.schemas` contains, when a
|
|
40
|
+
* `pattern` is not a regular expression, or when a schema node is neither a
|
|
41
|
+
* boolean nor an object.
|
|
222
42
|
*/
|
|
223
|
-
export function fromJsonSchema(schema: unknown): BaseType {
|
|
224
|
-
|
|
43
|
+
export function fromJsonSchema(schema: unknown, options?: FromJsonSchemaOptions): BaseType {
|
|
44
|
+
// Runs after 64 checks, long after `imported` below exists.
|
|
45
|
+
const validator = new Validator(schema, options, compiled => {
|
|
46
|
+
imported.allows = compiled;
|
|
47
|
+
});
|
|
48
|
+
const check = validator.check;
|
|
49
|
+
const node = typeof schema === "object" && schema !== null ? (schema as Record<string, unknown>) : undefined;
|
|
50
|
+
const refine: IR = {
|
|
51
|
+
k: "refine",
|
|
52
|
+
base: { k: "unknown" },
|
|
53
|
+
check,
|
|
54
|
+
pred: value => check(value) || toErrors(validator.explain(value), value),
|
|
55
|
+
expected: "a value matching the JSON Schema",
|
|
56
|
+
json: node ?? (schema === false ? { not: {} } : {}),
|
|
57
|
+
};
|
|
58
|
+
if (typeof node?.description === "string") refine.desc = node.description;
|
|
59
|
+
const ir: IR = validator.hasDefaults
|
|
60
|
+
? { k: "morph", input: refine, fn: value => validator.fillDefaults(value) }
|
|
61
|
+
: refine;
|
|
225
62
|
const embedded: EmbeddableSchema = {
|
|
226
63
|
[IR_BRAND]: true,
|
|
227
|
-
ir
|
|
64
|
+
ir,
|
|
228
65
|
hasSteps: false,
|
|
229
66
|
hasDefault: false,
|
|
230
67
|
run: value => value,
|
|
231
68
|
};
|
|
232
|
-
|
|
69
|
+
const imported = type.raw(embedded);
|
|
70
|
+
// The engine's check is the whole boolean path; skip the generic dispatcher.
|
|
71
|
+
// Once generated code exists, `onCompile` above rebinds `allows` to it.
|
|
72
|
+
Object.defineProperty(imported, "allows", { value: check, writable: true, configurable: true });
|
|
73
|
+
return imported;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function toErrors(issues: readonly Issue[], value: unknown): OmpErrors {
|
|
77
|
+
if (issues.length === 0) return new OmpErrors([], "a value matching the JSON Schema", value);
|
|
78
|
+
const errors = new OmpErrors(issues[0].path, issues[0].expected, issues[0].data);
|
|
79
|
+
for (let index = 1; index < issues.length; index++) {
|
|
80
|
+
const issue = issues[index];
|
|
81
|
+
errors.append(new OmpErrors(issue.path, issue.expected, issue.data));
|
|
82
|
+
}
|
|
83
|
+
return errors;
|
|
233
84
|
}
|
package/src/interp.ts
CHANGED
package/src/ir.ts
CHANGED
|
@@ -136,6 +136,8 @@ export type IR = IRAnalysis &
|
|
|
136
136
|
k: "refine";
|
|
137
137
|
base: IR;
|
|
138
138
|
pred: (value: unknown) => boolean | OmpErrors;
|
|
139
|
+
/** Allocation-free equivalent of `pred(value) === true` for boolean-only paths (`allows`, unions). */
|
|
140
|
+
check?: (value: unknown) => boolean;
|
|
139
141
|
expected: string;
|
|
140
142
|
json?: Record<string, unknown>;
|
|
141
143
|
desc?: string;
|