@zocomputer/system-api 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -2
- package/dist/catalog/index.d.ts +106 -0
- package/dist/catalog/index.js +237 -0
- package/dist/core/catalog.d.ts +155 -0
- package/dist/core/catalog.js +24 -0
- package/dist/core/client.d.ts +156 -0
- package/dist/core/client.js +87 -0
- package/dist/core/database.d.ts +124 -0
- package/dist/core/database.js +314 -0
- package/dist/core/envelope.d.ts +45 -0
- package/dist/core/envelope.js +112 -0
- package/dist/core/errors.d.ts +52 -0
- package/dist/core/errors.js +33 -0
- package/dist/core/index.d.ts +14 -0
- package/dist/core/index.js +13 -0
- package/dist/core/json-call.d.ts +4 -0
- package/dist/core/json-call.js +109 -0
- package/dist/core/json.d.ts +17 -0
- package/dist/core/json.js +4 -0
- package/dist/core/transport.d.ts +47 -0
- package/dist/core/transport.js +367 -0
- package/dist/core/validator.d.ts +52 -0
- package/dist/core/validator.js +1 -0
- package/dist/generate/cli.d.ts +44 -0
- package/dist/generate/cli.js +358 -0
- package/dist/generate/generate.d.ts +46 -0
- package/dist/generate/generate.js +215 -0
- package/dist/generate/identifiers.d.ts +27 -0
- package/dist/generate/identifiers.js +67 -0
- package/dist/generate/index.d.ts +15 -0
- package/dist/generate/index.js +15 -0
- package/dist/generate/manifest.d.ts +1 -0
- package/dist/generate/manifest.js +1 -0
- package/dist/generate/types.d.ts +57 -0
- package/dist/generate/types.js +357 -0
- package/dist/generated/catalog.d.ts +2 -0
- package/dist/generated/catalog.js +854 -0
- package/dist/generated/index.d.ts +2 -0
- package/dist/generated/index.js +4 -0
- package/dist/generated/manifest.json +799 -0
- package/dist/generated/operations.d.ts +8450 -0
- package/dist/generated/operations.js +3 -0
- package/dist/generated/schema.json +39451 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/json/index.d.ts +81 -0
- package/dist/json/index.js +404 -0
- package/dist/validation/index.d.ts +19 -0
- package/dist/validation/index.js +149 -0
- package/dist/validation/json.d.ts +1 -0
- package/dist/validation/json.js +1 -0
- package/dist/validation/pattern.d.ts +56 -0
- package/dist/validation/pattern.js +382 -0
- package/dist/validation/schema.d.ts +103 -0
- package/dist/validation/schema.js +1300 -0
- package/openapi.json +40754 -0
- package/package.json +93 -4
|
@@ -0,0 +1,1300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded JSON Schema 2020-12 compiler and validator for the System API SDK — a
|
|
3
|
+
* faithful port of `hostagent.system_api.schema`, so the client accepts and
|
|
4
|
+
* rejects exactly the schemas and instances the server does, and returns the
|
|
5
|
+
* same canonical value.
|
|
6
|
+
*
|
|
7
|
+
* `compileSchema` admits one schema document (everything outside the supported
|
|
8
|
+
* dialect throws {@link SchemaDefinitionError}), normalizes it into one
|
|
9
|
+
* canonical form, and returns an immutable {@link CompiledSchema} whose
|
|
10
|
+
* `validate` returns a detached canonical value or throws
|
|
11
|
+
* {@link SchemaValidationError}.
|
|
12
|
+
*
|
|
13
|
+
* Structural traversal is delegated to Ajv 2020 (branch/sibling/oneOf/anyOf,
|
|
14
|
+
* `$ref`, `required`, `additionalProperties`, `items`, item/property/count
|
|
15
|
+
* limits, and `type`). Custom keywords override only where Ajv cannot meet the
|
|
16
|
+
* exact contract: every numeric comparison (`minimum`, `maximum`,
|
|
17
|
+
* `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf`) and `enum`/`const`
|
|
18
|
+
* equality read the instance's exact decimal lexeme (a JS `number` loses
|
|
19
|
+
* precision), `pattern` runs the portable linear-time engine, `minLength` /
|
|
20
|
+
* `maxLength` count code points, integers are range-checked against
|
|
21
|
+
* ±(2**53 - 1), and `x-zo-json-value` admits any bounded JSON. Ajv still owns
|
|
22
|
+
* `oneOf`, so a discriminated union accepts/rejects by exact branch match.
|
|
23
|
+
*/
|
|
24
|
+
import { Decimal } from "decimal.js";
|
|
25
|
+
import Ajv2020, {} from "ajv/dist/2020.js";
|
|
26
|
+
import { JsonError, JsonNumber, canonicalNumberFromToken, canonicalizeJson, checkJsonValue, formatPointer, loadsJson, measureJson, } from "./json.js";
|
|
27
|
+
import { PatternError, compilePattern, } from "./pattern.js";
|
|
28
|
+
/** Exact decimal arithmetic for numeric keywords; matches the Python precision. */
|
|
29
|
+
const Dec = Decimal.clone({ precision: 500 });
|
|
30
|
+
export const SUPPORTED_DIALECT = "https://json-schema.org/draft/2020-12/schema";
|
|
31
|
+
export const MAX_SCHEMA_NODES = 10_000;
|
|
32
|
+
export const MAX_SCHEMA_DEPTH = 32;
|
|
33
|
+
/** Longest permitted chain of definitions reachable through `$ref` hops. */
|
|
34
|
+
export const MAX_REF_CHAIN_DEPTH = MAX_SCHEMA_DEPTH;
|
|
35
|
+
export const MAX_VALIDATION_ERRORS = 20;
|
|
36
|
+
export const MAX_SAFE_INTEGER_EXACT = 2 ** 53 - 1;
|
|
37
|
+
export const MAX_ENUM_VALUES = 256;
|
|
38
|
+
export const MAX_UNION_BRANCHES = 64;
|
|
39
|
+
export const MAX_TEXT_LENGTH = 4096;
|
|
40
|
+
export const MAX_PROPERTY_NAME_LENGTH = 256;
|
|
41
|
+
const MAX_SAFE_D = new Dec(MAX_SAFE_INTEGER_EXACT.toString());
|
|
42
|
+
const REF_PREFIX = "#/$defs/";
|
|
43
|
+
const DEF_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
44
|
+
const ANNOTATIONS = new Set(["title", "description", "examples", "deprecated"]);
|
|
45
|
+
const WRAPPER_KEYS = new Set([...ANNOTATIONS, "default"]);
|
|
46
|
+
const NUMERIC_KEYWORDS = new Set([
|
|
47
|
+
"minimum",
|
|
48
|
+
"maximum",
|
|
49
|
+
"exclusiveMinimum",
|
|
50
|
+
"exclusiveMaximum",
|
|
51
|
+
"multipleOf",
|
|
52
|
+
]);
|
|
53
|
+
const TYPE_KEYWORDS = {
|
|
54
|
+
string: new Set(["minLength", "maxLength", "pattern"]),
|
|
55
|
+
number: new Set([...NUMERIC_KEYWORDS]),
|
|
56
|
+
integer: new Set([...NUMERIC_KEYWORDS]),
|
|
57
|
+
boolean: new Set(),
|
|
58
|
+
null: new Set(),
|
|
59
|
+
array: new Set(["items", "minItems", "maxItems"]),
|
|
60
|
+
object: new Set([
|
|
61
|
+
"properties",
|
|
62
|
+
"required",
|
|
63
|
+
"additionalProperties",
|
|
64
|
+
"minProperties",
|
|
65
|
+
"maxProperties",
|
|
66
|
+
]),
|
|
67
|
+
};
|
|
68
|
+
const TYPED_COMMON = new Set([...WRAPPER_KEYS, "type", "enum", "const"]);
|
|
69
|
+
// --- errors ------------------------------------------------------------------
|
|
70
|
+
/** Base for schema definition and validation failures. */
|
|
71
|
+
export class SchemaError extends Error {
|
|
72
|
+
code;
|
|
73
|
+
path;
|
|
74
|
+
schemaPath;
|
|
75
|
+
keyword;
|
|
76
|
+
constructor(code, message, options = {}) {
|
|
77
|
+
const path = options.path ?? [];
|
|
78
|
+
const schemaPath = options.schemaPath ?? [];
|
|
79
|
+
let suffix = "";
|
|
80
|
+
if (path.length)
|
|
81
|
+
suffix += ` at ${formatPointer(path)}`;
|
|
82
|
+
if (schemaPath.length)
|
|
83
|
+
suffix += ` (schema ${formatPointer(schemaPath)})`;
|
|
84
|
+
super(message + suffix);
|
|
85
|
+
this.name = "SchemaError";
|
|
86
|
+
this.code = code;
|
|
87
|
+
this.path = path;
|
|
88
|
+
this.schemaPath = schemaPath;
|
|
89
|
+
this.keyword = options.keyword;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** The schema document is outside the supported dialect or inconsistent. */
|
|
93
|
+
export class SchemaDefinitionError extends SchemaError {
|
|
94
|
+
constructor(code, message, options = {}) {
|
|
95
|
+
super(code, message, options);
|
|
96
|
+
this.name = "SchemaDefinitionError";
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** A value does not conform to the compiled schema. */
|
|
100
|
+
export class SchemaValidationError extends SchemaError {
|
|
101
|
+
constructor(code, message, options = {}) {
|
|
102
|
+
super(code, message, options);
|
|
103
|
+
this.name = "SchemaValidationError";
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function pathKey(path) {
|
|
107
|
+
return JSON.stringify(path);
|
|
108
|
+
}
|
|
109
|
+
// --- type matching -----------------------------------------------------------
|
|
110
|
+
function isPlainObject(value) {
|
|
111
|
+
return (value !== null &&
|
|
112
|
+
typeof value === "object" &&
|
|
113
|
+
!Array.isArray(value) &&
|
|
114
|
+
!(value instanceof JsonNumber));
|
|
115
|
+
}
|
|
116
|
+
function matchesType(value, typeName) {
|
|
117
|
+
if (typeName === "null")
|
|
118
|
+
return value === null;
|
|
119
|
+
if (typeName === "boolean")
|
|
120
|
+
return typeof value === "boolean";
|
|
121
|
+
if (typeof value === "boolean")
|
|
122
|
+
return false;
|
|
123
|
+
if (typeName === "integer")
|
|
124
|
+
return value instanceof JsonNumber && value.isInteger;
|
|
125
|
+
if (typeName === "number")
|
|
126
|
+
return value instanceof JsonNumber;
|
|
127
|
+
if (typeName === "string")
|
|
128
|
+
return typeof value === "string";
|
|
129
|
+
if (typeName === "array")
|
|
130
|
+
return Array.isArray(value);
|
|
131
|
+
return isPlainObject(value);
|
|
132
|
+
}
|
|
133
|
+
function isNullNode(node) {
|
|
134
|
+
return (isPlainObject(node) &&
|
|
135
|
+
Object.keys(node).length === 1 &&
|
|
136
|
+
node["type"] === "null");
|
|
137
|
+
}
|
|
138
|
+
// --- exact value equality ----------------------------------------------------
|
|
139
|
+
/** Deep equality that compares numbers by exact decimal value, not JS float. */
|
|
140
|
+
function zoEqual(a, b) {
|
|
141
|
+
if (a instanceof JsonNumber || b instanceof JsonNumber) {
|
|
142
|
+
return (a instanceof JsonNumber &&
|
|
143
|
+
b instanceof JsonNumber &&
|
|
144
|
+
new Dec(a.text).eq(new Dec(b.text)));
|
|
145
|
+
}
|
|
146
|
+
if (a === null || b === null)
|
|
147
|
+
return a === b;
|
|
148
|
+
const ta = typeof a;
|
|
149
|
+
if (ta !== typeof b)
|
|
150
|
+
return false;
|
|
151
|
+
if (ta === "boolean" || ta === "string")
|
|
152
|
+
return a === b;
|
|
153
|
+
if (Array.isArray(a)) {
|
|
154
|
+
if (!Array.isArray(b) || a.length !== b.length)
|
|
155
|
+
return false;
|
|
156
|
+
return a.every((item, index) => zoEqual(item, b[index]));
|
|
157
|
+
}
|
|
158
|
+
if (isPlainObject(a) && isPlainObject(b)) {
|
|
159
|
+
const keys = Object.keys(a);
|
|
160
|
+
if (keys.length !== Object.keys(b).length)
|
|
161
|
+
return false;
|
|
162
|
+
return keys.every((key) => Object.hasOwn(b, key) && zoEqual(a[key], b[key]));
|
|
163
|
+
}
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
// --- pattern cache -----------------------------------------------------------
|
|
167
|
+
const patternCache = new Map();
|
|
168
|
+
function getPattern(pattern) {
|
|
169
|
+
let compiled = patternCache.get(pattern);
|
|
170
|
+
if (compiled === undefined) {
|
|
171
|
+
try {
|
|
172
|
+
compiled = compilePattern(pattern);
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
if (error instanceof PatternError)
|
|
176
|
+
throw new SchemaDefinitionError("invalid_pattern", error.message, {
|
|
177
|
+
keyword: "pattern",
|
|
178
|
+
});
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
patternCache.set(pattern, compiled);
|
|
182
|
+
}
|
|
183
|
+
return compiled;
|
|
184
|
+
}
|
|
185
|
+
// --- schema preparation ------------------------------------------------------
|
|
186
|
+
/**
|
|
187
|
+
* Convert a raw schema input into a {@link JsonValue} tree: finite JS numbers
|
|
188
|
+
* become exact {@link JsonNumber}s (through their shortest round-trip text, as
|
|
189
|
+
* pydantic-emitted floats are normalized server-side); existing `JsonNumber`s
|
|
190
|
+
* are kept. Mirrors the Python `_prepare` float normalization.
|
|
191
|
+
*/
|
|
192
|
+
function prepareSchema(value, path, depth) {
|
|
193
|
+
if (depth > MAX_SCHEMA_DEPTH + 1)
|
|
194
|
+
throw new SchemaDefinitionError("too_deep", "schema nesting exceeds the supported depth", { schemaPath: path });
|
|
195
|
+
if (value === null || typeof value === "boolean" || typeof value === "string")
|
|
196
|
+
return value;
|
|
197
|
+
if (value instanceof JsonNumber)
|
|
198
|
+
return value;
|
|
199
|
+
if (typeof value === "number") {
|
|
200
|
+
if (!Number.isFinite(value))
|
|
201
|
+
throw new SchemaDefinitionError("nonfinite_number", "schema numbers must be finite", { schemaPath: path });
|
|
202
|
+
try {
|
|
203
|
+
return canonicalNumberFromToken(JSON.stringify(value), path);
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
if (error instanceof JsonError)
|
|
207
|
+
throw new SchemaDefinitionError(error.code, error.message, {
|
|
208
|
+
schemaPath: path,
|
|
209
|
+
});
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (typeof value === "bigint")
|
|
214
|
+
return canonicalNumberFromToken(value.toString(), path);
|
|
215
|
+
if (Array.isArray(value))
|
|
216
|
+
return value.map((item, index) => prepareSchema(item, [...path, index], depth + 1));
|
|
217
|
+
if (typeof value === "object") {
|
|
218
|
+
// Null-prototype so a schema key literally named `__proto__` (a data field
|
|
219
|
+
// named `__proto__` in `properties`, or a `$def` so named) is assigned as an
|
|
220
|
+
// own data property rather than invoking the inherited `__proto__` setter,
|
|
221
|
+
// which would drop the key (losing its rule) and mutate the prototype. This
|
|
222
|
+
// runs before canonicalizeJson, so the key must survive here to survive at
|
|
223
|
+
// all. Matches the null-proto materialization used throughout the validator.
|
|
224
|
+
const out = Object.create(null);
|
|
225
|
+
for (const [key, item] of Object.entries(value))
|
|
226
|
+
out[key] = prepareSchema(item, [...path, key], depth + 1);
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
throw new SchemaDefinitionError("unsupported_schema", "schema contains an unsupported value", { schemaPath: path });
|
|
230
|
+
}
|
|
231
|
+
// --- the compiler ------------------------------------------------------------
|
|
232
|
+
class Compiler {
|
|
233
|
+
schema;
|
|
234
|
+
defs = new Map();
|
|
235
|
+
refEdges = new Map();
|
|
236
|
+
unions = new Map();
|
|
237
|
+
defaults = new Map();
|
|
238
|
+
constructor(schema) {
|
|
239
|
+
this.schema = schema;
|
|
240
|
+
this.refEdges.set(null, new Set());
|
|
241
|
+
}
|
|
242
|
+
fail(code, message, schemaPath, keyword) {
|
|
243
|
+
throw new SchemaDefinitionError(code, message, { schemaPath, keyword });
|
|
244
|
+
}
|
|
245
|
+
expectDict(node, schemaPath) {
|
|
246
|
+
if (!isPlainObject(node))
|
|
247
|
+
this.fail("unsupported_schema", "schema node must be an object", schemaPath);
|
|
248
|
+
return node;
|
|
249
|
+
}
|
|
250
|
+
checkAnnotations(node, schemaPath, allowDefault) {
|
|
251
|
+
for (const key of ["title", "description"]) {
|
|
252
|
+
if (key in node) {
|
|
253
|
+
const text = node[key];
|
|
254
|
+
if (!(typeof text === "string" &&
|
|
255
|
+
Array.from(text).length <= MAX_TEXT_LENGTH))
|
|
256
|
+
this.fail("invalid_annotation", `'${key}' must be a bounded string`, schemaPath, key);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if ("examples" in node && !Array.isArray(node["examples"]))
|
|
260
|
+
this.fail("invalid_annotation", "'examples' must be an array", schemaPath, "examples");
|
|
261
|
+
if ("deprecated" in node && typeof node["deprecated"] !== "boolean")
|
|
262
|
+
this.fail("invalid_annotation", "'deprecated' must be a boolean", schemaPath, "deprecated");
|
|
263
|
+
if ("default" in node) {
|
|
264
|
+
if (!allowDefault)
|
|
265
|
+
this.fail("default_not_allowed", "'default' is only supported on object properties", schemaPath, "default");
|
|
266
|
+
this.defaults.set(pathKey(schemaPath), {
|
|
267
|
+
path: schemaPath,
|
|
268
|
+
value: node["default"],
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
rejectUnknown(node, allowed, schemaPath) {
|
|
273
|
+
for (const key of Object.keys(node))
|
|
274
|
+
if (!allowed.has(key))
|
|
275
|
+
this.fail("unsupported_keyword", `keyword '${key}' is not supported here`, schemaPath, key);
|
|
276
|
+
}
|
|
277
|
+
compile() {
|
|
278
|
+
const root = this.expectDict(this.schema, []);
|
|
279
|
+
if ("$schema" in root) {
|
|
280
|
+
const dialect = root["$schema"];
|
|
281
|
+
delete root["$schema"];
|
|
282
|
+
if (dialect !== null && dialect !== SUPPORTED_DIALECT)
|
|
283
|
+
this.fail("unsupported_dialect", "only the JSON Schema 2020-12 dialect is supported", ["$schema"], "$schema");
|
|
284
|
+
}
|
|
285
|
+
if ("$defs" in root) {
|
|
286
|
+
const rawDefs = this.expectDict(root["$defs"], ["$defs"]);
|
|
287
|
+
delete root["$defs"];
|
|
288
|
+
for (const name of Object.keys(rawDefs))
|
|
289
|
+
if (!DEF_NAME.test(name))
|
|
290
|
+
this.fail("invalid_ref", "definition names must be identifiers", ["$defs"], "$defs");
|
|
291
|
+
for (const [name, definition] of Object.entries(rawDefs))
|
|
292
|
+
this.defs.set(name, this.expectDict(definition, ["$defs", name]));
|
|
293
|
+
for (const [name, definition] of this.defs) {
|
|
294
|
+
this.refEdges.set(name, new Set());
|
|
295
|
+
this.normalize(definition, ["$defs", name], name, false);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
this.normalize(root, [], null, false, true);
|
|
299
|
+
if (this.defs.size) {
|
|
300
|
+
// Null-prototype so a `$def` literally named `__proto__` survives as an own
|
|
301
|
+
// key (Ajv resolves a `$ref` to it correctly) instead of hitting the
|
|
302
|
+
// inherited setter and being dropped before the Ajv schema is built.
|
|
303
|
+
const defsObject = Object.create(null);
|
|
304
|
+
for (const [name, definition] of this.defs)
|
|
305
|
+
defsObject[name] = definition;
|
|
306
|
+
root["$defs"] = defsObject;
|
|
307
|
+
}
|
|
308
|
+
this.checkAcyclic();
|
|
309
|
+
}
|
|
310
|
+
normalize(raw, schemaPath, context, allowDefault, isRoot = false) {
|
|
311
|
+
const node = this.expectDict(raw, schemaPath);
|
|
312
|
+
if (isRoot)
|
|
313
|
+
for (const key of [
|
|
314
|
+
"$id",
|
|
315
|
+
"$anchor",
|
|
316
|
+
"$comment",
|
|
317
|
+
"$vocabulary",
|
|
318
|
+
"$dynamicAnchor",
|
|
319
|
+
])
|
|
320
|
+
if (key in node)
|
|
321
|
+
this.fail("unsupported_keyword", `keyword '${key}' is not supported`, schemaPath, key);
|
|
322
|
+
if ("$ref" in node) {
|
|
323
|
+
this.rejectUnknown(node, new Set([...WRAPPER_KEYS, "$ref"]), schemaPath);
|
|
324
|
+
const ref = node["$ref"];
|
|
325
|
+
if (typeof ref !== "string" || !ref.startsWith(REF_PREFIX))
|
|
326
|
+
this.fail("invalid_ref", "only local '#/$defs/<name>' references are supported", schemaPath, "$ref");
|
|
327
|
+
const name = ref.slice(REF_PREFIX.length);
|
|
328
|
+
if (!this.defs.has(name))
|
|
329
|
+
this.fail("invalid_ref", "reference names an undefined definition", schemaPath, "$ref");
|
|
330
|
+
this.refEdges.get(context).add(name);
|
|
331
|
+
this.checkAnnotations(node, schemaPath, allowDefault);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if ("x-zo-json-value" in node) {
|
|
335
|
+
this.rejectUnknown(node, new Set([...WRAPPER_KEYS, "x-zo-json-value"]), schemaPath);
|
|
336
|
+
if (node["x-zo-json-value"] !== true)
|
|
337
|
+
this.fail("unsupported_keyword", "'x-zo-json-value' must be true", schemaPath, "x-zo-json-value");
|
|
338
|
+
this.checkAnnotations(node, schemaPath, allowDefault);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if ("anyOf" in node) {
|
|
342
|
+
this.normalizeNullable(node, schemaPath, context, allowDefault);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
if ("oneOf" in node) {
|
|
346
|
+
this.normalizeUnion(node, schemaPath, context, allowDefault);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (!("type" in node))
|
|
350
|
+
this.fail("unsupported_schema", "schema node needs 'type', '$ref', 'anyOf', 'oneOf' or 'x-zo-json-value'", schemaPath);
|
|
351
|
+
if (Array.isArray(node["type"])) {
|
|
352
|
+
this.rewriteTypeList(node, schemaPath);
|
|
353
|
+
this.normalize(node, schemaPath, context, allowDefault, isRoot);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
this.normalizeTyped(node, schemaPath, context, allowDefault);
|
|
357
|
+
}
|
|
358
|
+
rewriteTypeList(node, schemaPath) {
|
|
359
|
+
const types = node["type"];
|
|
360
|
+
if (types.length !== 2 || !types.includes("null") || types[0] === types[1])
|
|
361
|
+
this.fail("unsupported_type", "a type list must be exactly [<type>, 'null']", schemaPath, "type");
|
|
362
|
+
const innerType = types[1] === "null" ? types[0] : types[1];
|
|
363
|
+
const inner = { type: innerType };
|
|
364
|
+
const wrapper = {};
|
|
365
|
+
for (const [key, value] of Object.entries(node)) {
|
|
366
|
+
if (key === "type")
|
|
367
|
+
continue;
|
|
368
|
+
if (WRAPPER_KEYS.has(key))
|
|
369
|
+
wrapper[key] = value;
|
|
370
|
+
else
|
|
371
|
+
inner[key] = value;
|
|
372
|
+
}
|
|
373
|
+
const enumValues = inner["enum"];
|
|
374
|
+
const enumPresent = Array.isArray(enumValues);
|
|
375
|
+
let nullAllowed = enumPresent
|
|
376
|
+
? enumValues.some((item) => item === null)
|
|
377
|
+
: true;
|
|
378
|
+
const nonNull = enumPresent
|
|
379
|
+
? enumValues.filter((item) => item !== null)
|
|
380
|
+
: null;
|
|
381
|
+
let nullOnly = enumPresent && nullAllowed && nonNull.length === 0;
|
|
382
|
+
if ("const" in inner) {
|
|
383
|
+
if (inner["const"] === null) {
|
|
384
|
+
if (!nullAllowed)
|
|
385
|
+
this.fail("invalid_const", "'const' null contradicts an 'enum' without null", schemaPath, "const");
|
|
386
|
+
nullOnly = true;
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
if (nullOnly)
|
|
390
|
+
this.fail("invalid_const", "'const' contradicts a null-only 'enum'", schemaPath, "const");
|
|
391
|
+
nullAllowed = false;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
for (const key of Object.keys(node))
|
|
395
|
+
delete node[key];
|
|
396
|
+
if (nullOnly) {
|
|
397
|
+
node["type"] = "null";
|
|
398
|
+
const innerKeywords = typeof innerType === "string"
|
|
399
|
+
? (TYPE_KEYWORDS[innerType] ?? new Set())
|
|
400
|
+
: new Set();
|
|
401
|
+
for (const [key, value] of Object.entries(inner))
|
|
402
|
+
if (key !== "type" &&
|
|
403
|
+
key !== "enum" &&
|
|
404
|
+
key !== "const" &&
|
|
405
|
+
!innerKeywords.has(key))
|
|
406
|
+
node[key] = value;
|
|
407
|
+
if (enumPresent)
|
|
408
|
+
node["enum"] = [null];
|
|
409
|
+
if ("const" in inner)
|
|
410
|
+
node["const"] = null;
|
|
411
|
+
}
|
|
412
|
+
else {
|
|
413
|
+
if (nonNull !== null)
|
|
414
|
+
inner["enum"] = nonNull;
|
|
415
|
+
if (nullAllowed)
|
|
416
|
+
node["anyOf"] = [inner, { type: "null" }];
|
|
417
|
+
else
|
|
418
|
+
Object.assign(node, inner);
|
|
419
|
+
}
|
|
420
|
+
Object.assign(node, wrapper);
|
|
421
|
+
}
|
|
422
|
+
normalizeNullable(node, schemaPath, context, allowDefault) {
|
|
423
|
+
this.rejectUnknown(node, new Set([...WRAPPER_KEYS, "anyOf"]), schemaPath);
|
|
424
|
+
const branches = node["anyOf"];
|
|
425
|
+
if (!(Array.isArray(branches) &&
|
|
426
|
+
branches.length === 2 &&
|
|
427
|
+
branches.filter((branch) => isNullNode(branch)).length === 1))
|
|
428
|
+
this.fail("unsupported_keyword", "'anyOf' is supported only as the nullable form [X, {'type': 'null'}]", schemaPath, "anyOf");
|
|
429
|
+
const list = branches;
|
|
430
|
+
const innerRaw = isNullNode(list[1]) ? list[0] : list[1];
|
|
431
|
+
const inner = this.expectDict(innerRaw, [...schemaPath, "anyOf", 0]);
|
|
432
|
+
if (inner["type"] === "null" || "anyOf" in inner)
|
|
433
|
+
this.fail("unsupported_type", "a nullable node must wrap a non-null type", schemaPath, "anyOf");
|
|
434
|
+
node["anyOf"] = [inner, { type: "null" }];
|
|
435
|
+
this.normalize(inner, [...schemaPath, "anyOf", 0], context, false);
|
|
436
|
+
this.checkAnnotations(node, schemaPath, allowDefault);
|
|
437
|
+
}
|
|
438
|
+
normalizeUnion(node, schemaPath, context, allowDefault) {
|
|
439
|
+
this.rejectUnknown(node, new Set([...WRAPPER_KEYS, "oneOf", "discriminator"]), schemaPath);
|
|
440
|
+
const branches = node["oneOf"];
|
|
441
|
+
if (!Array.isArray(branches) ||
|
|
442
|
+
branches.length < 2 ||
|
|
443
|
+
branches.length > MAX_UNION_BRANCHES)
|
|
444
|
+
this.fail("unsupported_keyword", "'oneOf' needs between 2 and 64 branches", schemaPath, "oneOf");
|
|
445
|
+
branches.forEach((branch, index) => {
|
|
446
|
+
this.normalize(branch, [...schemaPath, "oneOf", index], context, false);
|
|
447
|
+
});
|
|
448
|
+
if ("discriminator" in node) {
|
|
449
|
+
const discriminator = this.expectDict(node["discriminator"], [
|
|
450
|
+
...schemaPath,
|
|
451
|
+
"discriminator",
|
|
452
|
+
]);
|
|
453
|
+
this.rejectUnknown(discriminator, new Set(["propertyName", "mapping"]), [
|
|
454
|
+
...schemaPath,
|
|
455
|
+
"discriminator",
|
|
456
|
+
]);
|
|
457
|
+
if (typeof discriminator["propertyName"] !== "string")
|
|
458
|
+
this.fail("invalid_discriminator", "'discriminator.propertyName' must be a string", schemaPath, "discriminator");
|
|
459
|
+
if ("mapping" in discriminator &&
|
|
460
|
+
!isPlainObject(discriminator["mapping"]))
|
|
461
|
+
this.fail("invalid_discriminator", "'discriminator.mapping' must be an object", schemaPath, "discriminator");
|
|
462
|
+
}
|
|
463
|
+
this.checkAnnotations(node, schemaPath, allowDefault);
|
|
464
|
+
this.unions.set(pathKey(schemaPath), { node, path: schemaPath });
|
|
465
|
+
}
|
|
466
|
+
normalizeTyped(node, schemaPath, context, allowDefault) {
|
|
467
|
+
const typeName = node["type"];
|
|
468
|
+
if (typeof typeName !== "string" || !(typeName in TYPE_KEYWORDS))
|
|
469
|
+
this.fail("unsupported_type", "unsupported 'type'", schemaPath, "type");
|
|
470
|
+
const allowed = new Set([
|
|
471
|
+
...TYPED_COMMON,
|
|
472
|
+
...TYPE_KEYWORDS[typeName],
|
|
473
|
+
]);
|
|
474
|
+
this.rejectUnknown(node, allowed, schemaPath);
|
|
475
|
+
this.checkAnnotations(node, schemaPath, allowDefault);
|
|
476
|
+
if ("enum" in node) {
|
|
477
|
+
const values = node["enum"];
|
|
478
|
+
if (!Array.isArray(values) ||
|
|
479
|
+
values.length < 1 ||
|
|
480
|
+
values.length > MAX_ENUM_VALUES)
|
|
481
|
+
this.fail("invalid_enum", "'enum' must be a non-empty bounded array", schemaPath, "enum");
|
|
482
|
+
for (const value of values)
|
|
483
|
+
if (!matchesType(value, typeName))
|
|
484
|
+
this.fail("invalid_enum", "'enum' values must match the declared type", schemaPath, "enum");
|
|
485
|
+
}
|
|
486
|
+
if ("const" in node && !matchesType(node["const"], typeName))
|
|
487
|
+
this.fail("invalid_const", "'const' must match the declared type", schemaPath, "const");
|
|
488
|
+
if (typeName === "string") {
|
|
489
|
+
this.checkLengthPair(node, "minLength", "maxLength", schemaPath);
|
|
490
|
+
if ("pattern" in node) {
|
|
491
|
+
if (typeof node["pattern"] !== "string")
|
|
492
|
+
this.fail("invalid_pattern", "'pattern' must be a string", schemaPath, "pattern");
|
|
493
|
+
getPattern(node["pattern"]);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
else if (typeName === "number" || typeName === "integer") {
|
|
497
|
+
this.checkNumericKeywords(node, schemaPath);
|
|
498
|
+
}
|
|
499
|
+
else if (typeName === "array") {
|
|
500
|
+
if (!("items" in node))
|
|
501
|
+
this.fail("unsupported_schema", "array schemas must declare 'items'", schemaPath, "items");
|
|
502
|
+
this.checkLengthPair(node, "minItems", "maxItems", schemaPath);
|
|
503
|
+
this.normalize(node["items"], [...schemaPath, "items"], context, false);
|
|
504
|
+
}
|
|
505
|
+
else if (typeName === "object") {
|
|
506
|
+
this.normalizeObject(node, schemaPath, context);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
normalizeObject(node, schemaPath, context) {
|
|
510
|
+
if (!("additionalProperties" in node))
|
|
511
|
+
this.fail("open_object", "object schemas must declare 'additionalProperties' (false, or a schema for a map)", schemaPath, "additionalProperties");
|
|
512
|
+
this.checkLengthPair(node, "minProperties", "maxProperties", schemaPath);
|
|
513
|
+
const extra = node["additionalProperties"];
|
|
514
|
+
if (extra === false) {
|
|
515
|
+
const properties = node["properties"] ?? {};
|
|
516
|
+
if (!isPlainObject(properties))
|
|
517
|
+
this.fail("unsupported_schema", "'properties' must be an object", schemaPath, "properties");
|
|
518
|
+
node["properties"] = properties;
|
|
519
|
+
for (const [name, sub] of Object.entries(properties)) {
|
|
520
|
+
const nameLength = Array.from(name).length;
|
|
521
|
+
if (nameLength < 1 || nameLength > MAX_PROPERTY_NAME_LENGTH)
|
|
522
|
+
this.fail("invalid_property_name", "property names must be 1-256 characters", schemaPath, "properties");
|
|
523
|
+
this.normalize(sub, [...schemaPath, "properties", name], context, true);
|
|
524
|
+
}
|
|
525
|
+
const required = node["required"] ?? [];
|
|
526
|
+
if (!(Array.isArray(required) &&
|
|
527
|
+
required.every((name) => typeof name === "string")))
|
|
528
|
+
this.fail("unsupported_schema", "'required' must be an array of property names", schemaPath, "required");
|
|
529
|
+
const names = required;
|
|
530
|
+
if (new Set(names).size !== names.length ||
|
|
531
|
+
names.some((name) => !(name in properties)))
|
|
532
|
+
this.fail("unsupported_schema", "'required' must list distinct declared properties", schemaPath, "required");
|
|
533
|
+
node["required"] = required;
|
|
534
|
+
}
|
|
535
|
+
else if (extra === true) {
|
|
536
|
+
this.fail("open_object", "'additionalProperties: true' is not supported", schemaPath, "additionalProperties");
|
|
537
|
+
}
|
|
538
|
+
else {
|
|
539
|
+
if ("properties" in node || "required" in node)
|
|
540
|
+
this.fail("unsupported_schema", "a map schema (additionalProperties: <schema>) cannot also declare 'properties' or 'required'", schemaPath, "additionalProperties");
|
|
541
|
+
this.normalize(extra, [...schemaPath, "additionalProperties"], context, false);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
checkLengthPair(node, lowKey, highKey, schemaPath) {
|
|
545
|
+
for (const key of [lowKey, highKey]) {
|
|
546
|
+
if (!(key in node))
|
|
547
|
+
continue;
|
|
548
|
+
const value = node[key];
|
|
549
|
+
if (!(value instanceof JsonNumber &&
|
|
550
|
+
value.isInteger &&
|
|
551
|
+
!new Dec(value.text).isNegative()))
|
|
552
|
+
this.fail("invalid_limit", `'${key}' must be a non-negative integer`, schemaPath, key);
|
|
553
|
+
}
|
|
554
|
+
const low = node[lowKey];
|
|
555
|
+
const high = node[highKey];
|
|
556
|
+
if (low instanceof JsonNumber &&
|
|
557
|
+
high instanceof JsonNumber &&
|
|
558
|
+
new Dec(low.text).gt(new Dec(high.text)))
|
|
559
|
+
this.fail("invalid_limit", `'${lowKey}' exceeds '${highKey}'`, schemaPath, lowKey);
|
|
560
|
+
}
|
|
561
|
+
checkNumericKeywords(node, schemaPath) {
|
|
562
|
+
for (const key of NUMERIC_KEYWORDS) {
|
|
563
|
+
if (!(key in node))
|
|
564
|
+
continue;
|
|
565
|
+
if (!(node[key] instanceof JsonNumber))
|
|
566
|
+
this.fail("invalid_limit", `'${key}' must be a number`, schemaPath, key);
|
|
567
|
+
}
|
|
568
|
+
const step = node["multipleOf"];
|
|
569
|
+
if (step instanceof JsonNumber && !new Dec(step.text).gt(0))
|
|
570
|
+
this.fail("invalid_limit", "'multipleOf' must be positive", schemaPath, "multipleOf");
|
|
571
|
+
const lows = ["minimum", "exclusiveMinimum"]
|
|
572
|
+
.filter((key) => node[key] instanceof JsonNumber)
|
|
573
|
+
.map((key) => new Dec(node[key].text));
|
|
574
|
+
const highs = ["maximum", "exclusiveMaximum"]
|
|
575
|
+
.filter((key) => node[key] instanceof JsonNumber)
|
|
576
|
+
.map((key) => new Dec(node[key].text));
|
|
577
|
+
if (lows.length && highs.length) {
|
|
578
|
+
const maxLow = lows.reduce((a, b) => (a.gt(b) ? a : b));
|
|
579
|
+
const minHigh = highs.reduce((a, b) => (a.lt(b) ? a : b));
|
|
580
|
+
if (maxLow.gt(minHigh))
|
|
581
|
+
this.fail("invalid_limit", "numeric lower bound exceeds upper bound", schemaPath, "minimum");
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
checkAcyclic() {
|
|
585
|
+
const indegree = new Map();
|
|
586
|
+
for (const name of this.defs.keys())
|
|
587
|
+
indegree.set(name, 0);
|
|
588
|
+
for (const [source, targets] of this.refEdges) {
|
|
589
|
+
if (source === null)
|
|
590
|
+
continue;
|
|
591
|
+
for (const target of targets)
|
|
592
|
+
indegree.set(target, (indegree.get(target) ?? 0) + 1);
|
|
593
|
+
}
|
|
594
|
+
const ready = [...indegree]
|
|
595
|
+
.filter(([, count]) => count === 0)
|
|
596
|
+
.map(([name]) => name)
|
|
597
|
+
.sort();
|
|
598
|
+
const order = [];
|
|
599
|
+
while (ready.length) {
|
|
600
|
+
const name = ready.pop();
|
|
601
|
+
order.push(name);
|
|
602
|
+
for (const target of [...(this.refEdges.get(name) ?? [])].sort()) {
|
|
603
|
+
indegree.set(target, indegree.get(target) - 1);
|
|
604
|
+
if (indegree.get(target) === 0)
|
|
605
|
+
ready.push(target);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
if (order.length !== this.defs.size) {
|
|
609
|
+
const stuck = [...indegree]
|
|
610
|
+
.filter(([, count]) => count > 0)
|
|
611
|
+
.map(([name]) => name)
|
|
612
|
+
.sort()[0];
|
|
613
|
+
this.fail("recursive_ref", "recursive definitions are not supported", ["$defs", stuck], "$ref");
|
|
614
|
+
}
|
|
615
|
+
const height = new Map();
|
|
616
|
+
for (const name of [...order].reverse()) {
|
|
617
|
+
let best = 0;
|
|
618
|
+
for (const target of this.refEdges.get(name) ?? [])
|
|
619
|
+
best = Math.max(best, height.get(target) ?? 0);
|
|
620
|
+
height.set(name, 1 + best);
|
|
621
|
+
if (height.get(name) > MAX_REF_CHAIN_DEPTH)
|
|
622
|
+
this.fail("ref_chain_too_deep", `definitions chain through more than ${MAX_REF_CHAIN_DEPTH} '$ref' hops`, ["$defs", name], "$ref");
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
/** Analyze a normalized `oneOf` node into its discriminator + branch mapping. */
|
|
627
|
+
function computeUnion(defs, unionNode, unionPath) {
|
|
628
|
+
const resolve = (node) => {
|
|
629
|
+
let current = node;
|
|
630
|
+
while ("$ref" in current) {
|
|
631
|
+
const ref = current["$ref"];
|
|
632
|
+
current = defs.get(ref.slice(REF_PREFIX.length));
|
|
633
|
+
}
|
|
634
|
+
return current;
|
|
635
|
+
};
|
|
636
|
+
const fail = (message, path, keyword) => {
|
|
637
|
+
throw new SchemaDefinitionError("ambiguous_union", message, {
|
|
638
|
+
schemaPath: path,
|
|
639
|
+
keyword,
|
|
640
|
+
});
|
|
641
|
+
};
|
|
642
|
+
const branches = unionNode["oneOf"];
|
|
643
|
+
const resolved = [];
|
|
644
|
+
branches.forEach((branch, index) => {
|
|
645
|
+
const node = resolve(branch);
|
|
646
|
+
if (node["type"] !== "object" || !isPlainObject(node["properties"]))
|
|
647
|
+
fail("every 'oneOf' branch must be a closed object", [...unionPath, "oneOf", index], "oneOf");
|
|
648
|
+
resolved.push(node);
|
|
649
|
+
});
|
|
650
|
+
const constOf = (node, name) => {
|
|
651
|
+
const properties = node["properties"];
|
|
652
|
+
const required = (node["required"] ?? []);
|
|
653
|
+
if (!(name in properties) || !required.includes(name))
|
|
654
|
+
return null;
|
|
655
|
+
const prop = resolve(properties[name]);
|
|
656
|
+
const constant = prop["const"];
|
|
657
|
+
return prop["type"] === "string" && typeof constant === "string"
|
|
658
|
+
? constant
|
|
659
|
+
: null;
|
|
660
|
+
};
|
|
661
|
+
const firstProps = resolved[0]["properties"];
|
|
662
|
+
let candidates = [];
|
|
663
|
+
for (const name of Object.keys(firstProps)) {
|
|
664
|
+
const consts = resolved.map((node) => constOf(node, name));
|
|
665
|
+
if (consts.every((constant) => typeof constant === "string") &&
|
|
666
|
+
new Set(consts).size === consts.length)
|
|
667
|
+
candidates.push(name);
|
|
668
|
+
}
|
|
669
|
+
const discriminator = unionNode["discriminator"];
|
|
670
|
+
if (isPlainObject(discriminator)) {
|
|
671
|
+
const declared = discriminator["propertyName"];
|
|
672
|
+
if (!candidates.includes(declared))
|
|
673
|
+
throw new SchemaDefinitionError("invalid_discriminator", "'discriminator.propertyName' is not a required string constant distinct in every branch", { schemaPath: unionPath, keyword: "discriminator" });
|
|
674
|
+
candidates = [declared];
|
|
675
|
+
}
|
|
676
|
+
if (candidates.length !== 1)
|
|
677
|
+
fail("'oneOf' branches need exactly one shared required string 'const' property (or a 'discriminator')", unionPath, "oneOf");
|
|
678
|
+
const name = candidates[0];
|
|
679
|
+
const mapping = new Map();
|
|
680
|
+
resolved.forEach((node, index) => {
|
|
681
|
+
mapping.set(constOf(node, name), index);
|
|
682
|
+
});
|
|
683
|
+
if (isPlainObject(discriminator) &&
|
|
684
|
+
isPlainObject(discriminator["mapping"])) {
|
|
685
|
+
const declaredMapping = discriminator["mapping"];
|
|
686
|
+
const declaredKeys = new Set(Object.keys(declaredMapping));
|
|
687
|
+
if (declaredKeys.size !== mapping.size ||
|
|
688
|
+
[...mapping.keys()].some((tag) => !declaredKeys.has(tag)))
|
|
689
|
+
throw new SchemaDefinitionError("invalid_discriminator", "'discriminator.mapping' keys must equal the branch constants", { schemaPath: unionPath, keyword: "discriminator" });
|
|
690
|
+
for (const [tag, target] of Object.entries(declaredMapping)) {
|
|
691
|
+
const branch = branches[mapping.get(tag)];
|
|
692
|
+
if (branch["$ref"] !== target)
|
|
693
|
+
throw new SchemaDefinitionError("invalid_discriminator", "'discriminator.mapping' must reference the branch carrying each constant", { schemaPath: unionPath, keyword: "discriminator" });
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
return { property: name, branches: mapping };
|
|
697
|
+
}
|
|
698
|
+
function unescapePointerToken(token) {
|
|
699
|
+
return token.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
700
|
+
}
|
|
701
|
+
/** Resolve an Ajv `instancePath` (a JSON pointer) into the canonical tree. */
|
|
702
|
+
function resolveInstance(root, instancePath) {
|
|
703
|
+
if (instancePath === "")
|
|
704
|
+
return root;
|
|
705
|
+
let current = root;
|
|
706
|
+
for (const raw of instancePath.split("/").slice(1)) {
|
|
707
|
+
const token = unescapePointerToken(raw);
|
|
708
|
+
if (Array.isArray(current))
|
|
709
|
+
current = current[Number(token)];
|
|
710
|
+
else if (isPlainObject(current))
|
|
711
|
+
current = current[token];
|
|
712
|
+
else
|
|
713
|
+
return undefined;
|
|
714
|
+
}
|
|
715
|
+
return current;
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Build the plain schema handed to Ajv from the normalized canonical schema.
|
|
719
|
+
* Exact-decimal keywords carry their canonical text as a string so a custom
|
|
720
|
+
* keyword can recover the exact value; `enum` / `const` carry the exact
|
|
721
|
+
* members serialized as JSON text; integer nodes gain a range check.
|
|
722
|
+
*/
|
|
723
|
+
function buildAjvSchema(node) {
|
|
724
|
+
if (!isPlainObject(node))
|
|
725
|
+
return node;
|
|
726
|
+
const out = {};
|
|
727
|
+
for (const [key, value] of Object.entries(node)) {
|
|
728
|
+
if (key === "$ref") {
|
|
729
|
+
out["$ref"] = value;
|
|
730
|
+
}
|
|
731
|
+
else if (key === "$defs") {
|
|
732
|
+
// Null-prototype: a `$def` name is user-controlled, so a `__proto__` def
|
|
733
|
+
// name must land as an own key rather than the inherited setter.
|
|
734
|
+
const defs = Object.create(null);
|
|
735
|
+
for (const [name, definition] of Object.entries(value))
|
|
736
|
+
defs[name] = buildAjvSchema(definition);
|
|
737
|
+
out["$defs"] = defs;
|
|
738
|
+
}
|
|
739
|
+
else if (key === "type") {
|
|
740
|
+
out["type"] = value;
|
|
741
|
+
if (value === "integer")
|
|
742
|
+
out["zoIntegerRange"] = true;
|
|
743
|
+
}
|
|
744
|
+
else if (key === "anyOf" || key === "oneOf") {
|
|
745
|
+
out[key] = value.map(buildAjvSchema);
|
|
746
|
+
}
|
|
747
|
+
else if (key === "properties") {
|
|
748
|
+
// Ajv's `properties` keyword intentionally ignores a member literally
|
|
749
|
+
// named `__proto__` (its own prototype-pollution guard), so a declared
|
|
750
|
+
// `__proto__` property would go unenforced — with `additionalProperties:
|
|
751
|
+
// false` it wrongly rejects the valid field, otherwise the field goes
|
|
752
|
+
// unchecked. Emit that one member as an anchored `patternProperties` entry
|
|
753
|
+
// instead: Ajv honors `__proto__` there and in `required`, so the field's
|
|
754
|
+
// rule and its `additionalProperties` exemption are preserved with
|
|
755
|
+
// equivalent semantics. Every other name (constructor/toString included)
|
|
756
|
+
// validates correctly under `properties`. The map is null-prototype for
|
|
757
|
+
// the same reason the rest of the validator is.
|
|
758
|
+
const properties = Object.create(null);
|
|
759
|
+
let protoAjvSchema;
|
|
760
|
+
for (const [name, sub] of Object.entries(value)) {
|
|
761
|
+
if (name === "__proto__")
|
|
762
|
+
protoAjvSchema = buildAjvSchema(sub);
|
|
763
|
+
else
|
|
764
|
+
properties[name] = buildAjvSchema(sub);
|
|
765
|
+
}
|
|
766
|
+
out["properties"] = properties;
|
|
767
|
+
if (protoAjvSchema !== undefined)
|
|
768
|
+
out["patternProperties"] = { "^__proto__$": protoAjvSchema };
|
|
769
|
+
}
|
|
770
|
+
else if (key === "items" || key === "additionalProperties") {
|
|
771
|
+
out[key] = typeof value === "boolean" ? value : buildAjvSchema(value);
|
|
772
|
+
}
|
|
773
|
+
else if (key === "required") {
|
|
774
|
+
out["required"] = value;
|
|
775
|
+
}
|
|
776
|
+
else if (NUMERIC_KEYWORDS.has(key)) {
|
|
777
|
+
out[key] = value.text;
|
|
778
|
+
}
|
|
779
|
+
else if (key === "minLength" || key === "maxLength") {
|
|
780
|
+
out[key] = Number(value.text);
|
|
781
|
+
}
|
|
782
|
+
else if (key === "minItems" ||
|
|
783
|
+
key === "maxItems" ||
|
|
784
|
+
key === "minProperties" ||
|
|
785
|
+
key === "maxProperties") {
|
|
786
|
+
out[key] = Number(value.text);
|
|
787
|
+
}
|
|
788
|
+
else if (key === "enum") {
|
|
789
|
+
out["enum"] = serializeMembers(value);
|
|
790
|
+
}
|
|
791
|
+
else if (key === "const") {
|
|
792
|
+
out["const"] = serializeMembers([value]);
|
|
793
|
+
}
|
|
794
|
+
else if (key === "pattern") {
|
|
795
|
+
out["pattern"] = value;
|
|
796
|
+
}
|
|
797
|
+
else if (key === "x-zo-json-value") {
|
|
798
|
+
out["x-zo-json-value"] = true;
|
|
799
|
+
}
|
|
800
|
+
// annotations (title/description/examples/deprecated), `default` and
|
|
801
|
+
// `discriminator` are dropped: they never affect accept/reject, and Ajv
|
|
802
|
+
// owns `oneOf` branch selection directly.
|
|
803
|
+
}
|
|
804
|
+
return out;
|
|
805
|
+
}
|
|
806
|
+
/** Serialize exact members to a JSON string the keyword recovers losslessly. */
|
|
807
|
+
function serializeMembers(members) {
|
|
808
|
+
const parts = [];
|
|
809
|
+
const write = (value) => {
|
|
810
|
+
if (value === null)
|
|
811
|
+
parts.push("null");
|
|
812
|
+
else if (value === true)
|
|
813
|
+
parts.push("true");
|
|
814
|
+
else if (value === false)
|
|
815
|
+
parts.push("false");
|
|
816
|
+
else if (typeof value === "string")
|
|
817
|
+
parts.push(JSON.stringify(value));
|
|
818
|
+
else if (value instanceof JsonNumber)
|
|
819
|
+
parts.push(value.text);
|
|
820
|
+
else if (Array.isArray(value)) {
|
|
821
|
+
parts.push("[");
|
|
822
|
+
value.forEach((item, index) => {
|
|
823
|
+
if (index)
|
|
824
|
+
parts.push(",");
|
|
825
|
+
write(item);
|
|
826
|
+
});
|
|
827
|
+
parts.push("]");
|
|
828
|
+
}
|
|
829
|
+
else {
|
|
830
|
+
const keys = Object.keys(value);
|
|
831
|
+
parts.push("{");
|
|
832
|
+
keys.forEach((key, index) => {
|
|
833
|
+
if (index)
|
|
834
|
+
parts.push(",");
|
|
835
|
+
parts.push(JSON.stringify(key), ":");
|
|
836
|
+
write(value[key]);
|
|
837
|
+
});
|
|
838
|
+
parts.push("}");
|
|
839
|
+
}
|
|
840
|
+
};
|
|
841
|
+
parts.push("[");
|
|
842
|
+
members.forEach((member, index) => {
|
|
843
|
+
if (index)
|
|
844
|
+
parts.push(",");
|
|
845
|
+
write(member);
|
|
846
|
+
});
|
|
847
|
+
parts.push("]");
|
|
848
|
+
return parts.join("");
|
|
849
|
+
}
|
|
850
|
+
function buildValidator(ajvSchema) {
|
|
851
|
+
// `passContext` forwards the `this` given to `validator.call(ctx, data)` into
|
|
852
|
+
// every custom keyword, so the exact-value keywords read a fresh per-call
|
|
853
|
+
// `{ root }` instead of instance-shared mutable state. The keyword callbacks
|
|
854
|
+
// are therefore plain functions (not arrows) so `this` binds to that context.
|
|
855
|
+
const ajv = new Ajv2020({
|
|
856
|
+
strict: false,
|
|
857
|
+
allErrors: false,
|
|
858
|
+
validateSchema: false,
|
|
859
|
+
useDefaults: false,
|
|
860
|
+
passContext: true,
|
|
861
|
+
});
|
|
862
|
+
for (const keyword of NUMERIC_KEYWORDS)
|
|
863
|
+
ajv.removeKeyword(keyword);
|
|
864
|
+
ajv.removeKeyword("enum");
|
|
865
|
+
ajv.removeKeyword("const");
|
|
866
|
+
ajv.removeKeyword("minLength");
|
|
867
|
+
ajv.removeKeyword("maxLength");
|
|
868
|
+
ajv.removeKeyword("pattern");
|
|
869
|
+
const numericCompare = (keyword, test) => {
|
|
870
|
+
ajv.addKeyword({
|
|
871
|
+
keyword,
|
|
872
|
+
schemaType: "string",
|
|
873
|
+
errors: false,
|
|
874
|
+
compile(schemaText) {
|
|
875
|
+
const limit = new Dec(schemaText);
|
|
876
|
+
return function (_data, dataCxt) {
|
|
877
|
+
const instance = resolveInstance(this.root, dataCxt?.instancePath ?? "");
|
|
878
|
+
if (!(instance instanceof JsonNumber))
|
|
879
|
+
return true;
|
|
880
|
+
return test(new Dec(instance.text), limit);
|
|
881
|
+
};
|
|
882
|
+
},
|
|
883
|
+
});
|
|
884
|
+
};
|
|
885
|
+
numericCompare("minimum", (value, limit) => value.gte(limit));
|
|
886
|
+
numericCompare("maximum", (value, limit) => value.lte(limit));
|
|
887
|
+
numericCompare("exclusiveMinimum", (value, limit) => value.gt(limit));
|
|
888
|
+
numericCompare("exclusiveMaximum", (value, limit) => value.lt(limit));
|
|
889
|
+
ajv.addKeyword({
|
|
890
|
+
keyword: "multipleOf",
|
|
891
|
+
schemaType: "string",
|
|
892
|
+
errors: false,
|
|
893
|
+
compile(schemaText) {
|
|
894
|
+
const step = new Dec(schemaText);
|
|
895
|
+
return function (_data, dataCxt) {
|
|
896
|
+
const instance = resolveInstance(this.root, dataCxt?.instancePath ?? "");
|
|
897
|
+
if (!(instance instanceof JsonNumber))
|
|
898
|
+
return true;
|
|
899
|
+
try {
|
|
900
|
+
return new Dec(instance.text).mod(step).isZero();
|
|
901
|
+
}
|
|
902
|
+
catch {
|
|
903
|
+
return false;
|
|
904
|
+
}
|
|
905
|
+
};
|
|
906
|
+
},
|
|
907
|
+
});
|
|
908
|
+
ajv.addKeyword({
|
|
909
|
+
keyword: "zoIntegerRange",
|
|
910
|
+
schemaType: "boolean",
|
|
911
|
+
errors: false,
|
|
912
|
+
validate(_flag, _data, _schema, dataCxt) {
|
|
913
|
+
const instance = resolveInstance(this.root, dataCxt?.instancePath ?? "");
|
|
914
|
+
// A non-number instance is the `type` keyword's concern, not ours.
|
|
915
|
+
if (!(instance instanceof JsonNumber))
|
|
916
|
+
return true;
|
|
917
|
+
// Judge integerness on the lossless lexeme, not Ajv's rounded native: a
|
|
918
|
+
// decimal such as 1.0000000000000001 rounds to an integer float and slips
|
|
919
|
+
// past Ajv's `type: "integer"`, so reject a non-integer node here where the
|
|
920
|
+
// exact token is visible.
|
|
921
|
+
if (!instance.isInteger)
|
|
922
|
+
return false;
|
|
923
|
+
return !new Dec(instance.text).abs().gt(MAX_SAFE_D);
|
|
924
|
+
},
|
|
925
|
+
});
|
|
926
|
+
ajv.addKeyword({
|
|
927
|
+
keyword: "enum",
|
|
928
|
+
schemaType: "string",
|
|
929
|
+
errors: false,
|
|
930
|
+
compile(schemaText) {
|
|
931
|
+
const members = loadsJson(schemaText, { maxNodes: null });
|
|
932
|
+
return function (_data, dataCxt) {
|
|
933
|
+
const instance = resolveInstance(this.root, dataCxt?.instancePath ?? "");
|
|
934
|
+
return members.some((member) => zoEqual(instance, member));
|
|
935
|
+
};
|
|
936
|
+
},
|
|
937
|
+
});
|
|
938
|
+
ajv.addKeyword({
|
|
939
|
+
keyword: "const",
|
|
940
|
+
schemaType: "string",
|
|
941
|
+
errors: false,
|
|
942
|
+
compile(schemaText) {
|
|
943
|
+
const member = loadsJson(schemaText, { maxNodes: null })[0];
|
|
944
|
+
return function (_data, dataCxt) {
|
|
945
|
+
const instance = resolveInstance(this.root, dataCxt?.instancePath ?? "");
|
|
946
|
+
return zoEqual(instance, member);
|
|
947
|
+
};
|
|
948
|
+
},
|
|
949
|
+
});
|
|
950
|
+
const lengthKeyword = (keyword, ok) => {
|
|
951
|
+
ajv.addKeyword({
|
|
952
|
+
keyword,
|
|
953
|
+
schemaType: "number",
|
|
954
|
+
errors: false,
|
|
955
|
+
compile(limit) {
|
|
956
|
+
return (data) => {
|
|
957
|
+
if (typeof data !== "string")
|
|
958
|
+
return true;
|
|
959
|
+
return ok(Array.from(data).length, limit);
|
|
960
|
+
};
|
|
961
|
+
},
|
|
962
|
+
});
|
|
963
|
+
};
|
|
964
|
+
lengthKeyword("minLength", (count, limit) => count >= limit);
|
|
965
|
+
lengthKeyword("maxLength", (count, limit) => count <= limit);
|
|
966
|
+
ajv.addKeyword({
|
|
967
|
+
keyword: "pattern",
|
|
968
|
+
schemaType: "string",
|
|
969
|
+
errors: false,
|
|
970
|
+
compile(pattern) {
|
|
971
|
+
const compiled = getPattern(pattern);
|
|
972
|
+
return (data) => typeof data !== "string" || compiled.search(data);
|
|
973
|
+
},
|
|
974
|
+
});
|
|
975
|
+
ajv.addKeyword({
|
|
976
|
+
keyword: "x-zo-json-value",
|
|
977
|
+
schemaType: "boolean",
|
|
978
|
+
errors: false,
|
|
979
|
+
validate(_flag, _data, _schema, dataCxt) {
|
|
980
|
+
const instance = resolveInstance(this.root, dataCxt?.instancePath ?? "");
|
|
981
|
+
try {
|
|
982
|
+
checkJsonValue(instance);
|
|
983
|
+
return true;
|
|
984
|
+
}
|
|
985
|
+
catch {
|
|
986
|
+
return false;
|
|
987
|
+
}
|
|
988
|
+
},
|
|
989
|
+
});
|
|
990
|
+
return ajv.compile(ajvSchema);
|
|
991
|
+
}
|
|
992
|
+
// --- compiled schema ---------------------------------------------------------
|
|
993
|
+
/** An immutable compiled schema. Construct through {@link compileSchema}. */
|
|
994
|
+
export class CompiledSchema {
|
|
995
|
+
schema;
|
|
996
|
+
defs;
|
|
997
|
+
unions;
|
|
998
|
+
defaults;
|
|
999
|
+
hasDefaults;
|
|
1000
|
+
validator;
|
|
1001
|
+
constructor(schema, defs, unions, defaults, hasDefaults) {
|
|
1002
|
+
this.schema = schema;
|
|
1003
|
+
this.defs = defs;
|
|
1004
|
+
this.unions = unions;
|
|
1005
|
+
this.defaults = defaults;
|
|
1006
|
+
this.hasDefaults = hasDefaults;
|
|
1007
|
+
this.validator = buildValidator(buildAjvSchema(schema));
|
|
1008
|
+
}
|
|
1009
|
+
static build(compiler, schema) {
|
|
1010
|
+
const unions = new Map();
|
|
1011
|
+
for (const [key, { node, path }] of compiler.unions)
|
|
1012
|
+
unions.set(key, computeUnion(compiler.defs, node, path));
|
|
1013
|
+
const compiled = new CompiledSchema(schema, compiler.defs, unions, compiler.defaults, compiler.defaults.size > 0);
|
|
1014
|
+
compiled.validateDefaults();
|
|
1015
|
+
return compiled;
|
|
1016
|
+
}
|
|
1017
|
+
/** A fresh detached copy of the canonical schema. */
|
|
1018
|
+
get canonicalSchema() {
|
|
1019
|
+
return canonicalizeJson(this.schema, { maxNodes: null });
|
|
1020
|
+
}
|
|
1021
|
+
get isObject() {
|
|
1022
|
+
// Mirror Python `_node_types(root) == {"object"}` on the resolved node:
|
|
1023
|
+
// `anyOf` always admits `null`, `x-zo-json-value` is the `json` type, but a
|
|
1024
|
+
// discriminated `oneOf` in this subset is always over object branches, so a
|
|
1025
|
+
// direct or `$ref`-referenced object-only union is an object.
|
|
1026
|
+
const [root] = this.resolve(this.schema);
|
|
1027
|
+
if ("anyOf" in root)
|
|
1028
|
+
return false;
|
|
1029
|
+
if ("oneOf" in root)
|
|
1030
|
+
return true;
|
|
1031
|
+
if ("x-zo-json-value" in root)
|
|
1032
|
+
return false;
|
|
1033
|
+
return root["type"] === "object";
|
|
1034
|
+
}
|
|
1035
|
+
resolve(node) {
|
|
1036
|
+
let current = node;
|
|
1037
|
+
let schemaPath = [];
|
|
1038
|
+
while ("$ref" in current) {
|
|
1039
|
+
const ref = current["$ref"];
|
|
1040
|
+
const name = ref.slice(REF_PREFIX.length);
|
|
1041
|
+
current = this.defs.get(name);
|
|
1042
|
+
schemaPath = ["$defs", name];
|
|
1043
|
+
}
|
|
1044
|
+
return [current, schemaPath];
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* Return a detached canonical copy of `value` if it conforms. With
|
|
1048
|
+
* `applyDefaults` the copy first receives defaults for absent object
|
|
1049
|
+
* properties (choosing a union branch only by its discriminator).
|
|
1050
|
+
*/
|
|
1051
|
+
validate(value, options = {}) {
|
|
1052
|
+
let canonical;
|
|
1053
|
+
try {
|
|
1054
|
+
// Always enforce the selected-tree node/depth budget on the value itself
|
|
1055
|
+
// (matching Python `canonicalize_json(value)`), independent of the
|
|
1056
|
+
// envelope wrapper the caller parsed with the node bound disabled.
|
|
1057
|
+
canonical = canonicalizeJson(value);
|
|
1058
|
+
if (options.applyDefaults && this.hasDefaults) {
|
|
1059
|
+
const [nodes] = measureJson(canonical);
|
|
1060
|
+
const budget = new Budget(nodes);
|
|
1061
|
+
this.fill(canonical, this.schema, [], budget, 1, []);
|
|
1062
|
+
// Re-charge the filled tree against the same bound.
|
|
1063
|
+
checkJsonValue(canonical);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
catch (error) {
|
|
1067
|
+
if (error instanceof JsonError)
|
|
1068
|
+
throw new SchemaValidationError(error.code, stripLocation(error), {});
|
|
1069
|
+
throw error;
|
|
1070
|
+
}
|
|
1071
|
+
// A fresh context per call (via Ajv `passContext`) — no instance-shared
|
|
1072
|
+
// mutable state, so concurrent or reentrant validations never collide.
|
|
1073
|
+
const context = { root: canonical };
|
|
1074
|
+
if (!this.validator.call(context, buildNative(canonical))) {
|
|
1075
|
+
const first = this.validator.errors?.[0];
|
|
1076
|
+
throw new SchemaValidationError("schema_violation", first?.message ?? "value does not conform to the schema", { path: first ? splitInstancePath(first.instancePath) : [] });
|
|
1077
|
+
}
|
|
1078
|
+
return canonical;
|
|
1079
|
+
}
|
|
1080
|
+
fill(value, rawNode, rawSchemaPath, budget, depth, path) {
|
|
1081
|
+
const [node, schemaPath] = this.resolveFrom(rawNode, rawSchemaPath);
|
|
1082
|
+
if ("x-zo-json-value" in node)
|
|
1083
|
+
return;
|
|
1084
|
+
if ("anyOf" in node) {
|
|
1085
|
+
if (value !== null)
|
|
1086
|
+
this.fill(value, branch(node, "anyOf", 0), [...schemaPath, "anyOf", 0], budget, depth, path);
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
if ("oneOf" in node) {
|
|
1090
|
+
const info = this.unions.get(pathKey(schemaPath));
|
|
1091
|
+
if (info && isPlainObject(value)) {
|
|
1092
|
+
const tag = value[info.property];
|
|
1093
|
+
if (typeof tag === "string" && info.branches.has(tag)) {
|
|
1094
|
+
const index = info.branches.get(tag);
|
|
1095
|
+
this.fill(value, branch(node, "oneOf", index), [...schemaPath, "oneOf", index], budget, depth, path);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
const typeName = node["type"];
|
|
1101
|
+
if (typeName === "object" && isPlainObject(value)) {
|
|
1102
|
+
const properties = node["properties"];
|
|
1103
|
+
if (isPlainObject(properties)) {
|
|
1104
|
+
for (const [name, sub] of Object.entries(properties)) {
|
|
1105
|
+
const subPath = [...schemaPath, "properties", name];
|
|
1106
|
+
const childPath = [...path, name];
|
|
1107
|
+
if (Object.hasOwn(value, name)) {
|
|
1108
|
+
this.fill(value[name], sub, subPath, budget, depth + 1, childPath);
|
|
1109
|
+
}
|
|
1110
|
+
else {
|
|
1111
|
+
const def = this.defaults.get(pathKey(subPath));
|
|
1112
|
+
if (def) {
|
|
1113
|
+
const measured = this.measuredDefault(def.value);
|
|
1114
|
+
budget.charge(measured, depth + 1, childPath);
|
|
1115
|
+
const inserted = deepCopy(def.value);
|
|
1116
|
+
value[name] = inserted;
|
|
1117
|
+
this.fill(inserted, sub, subPath, budget, depth + 1, childPath);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
else {
|
|
1123
|
+
const extra = node["additionalProperties"];
|
|
1124
|
+
const extraPath = [...schemaPath, "additionalProperties"];
|
|
1125
|
+
for (const [name, item] of Object.entries(value))
|
|
1126
|
+
this.fill(item, extra, extraPath, budget, depth + 1, [...path, name]);
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
else if (typeName === "array" && Array.isArray(value)) {
|
|
1130
|
+
const items = node["items"];
|
|
1131
|
+
const itemsPath = [...schemaPath, "items"];
|
|
1132
|
+
value.forEach((item, index) => {
|
|
1133
|
+
this.fill(item, items, itemsPath, budget, depth + 1, [...path, index]);
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
resolveFrom(node, schemaPath) {
|
|
1138
|
+
let current = node;
|
|
1139
|
+
let currentPath = schemaPath;
|
|
1140
|
+
while ("$ref" in current) {
|
|
1141
|
+
const ref = current["$ref"];
|
|
1142
|
+
const name = ref.slice(REF_PREFIX.length);
|
|
1143
|
+
current = this.defs.get(name);
|
|
1144
|
+
currentPath = ["$defs", name];
|
|
1145
|
+
}
|
|
1146
|
+
return [current, currentPath];
|
|
1147
|
+
}
|
|
1148
|
+
measuredCache = new Map();
|
|
1149
|
+
measuredDefault(value) {
|
|
1150
|
+
let measured = this.measuredCache.get(value);
|
|
1151
|
+
if (!measured) {
|
|
1152
|
+
const [nodes, depth] = measureJson(value);
|
|
1153
|
+
measured = { nodes, depth };
|
|
1154
|
+
this.measuredCache.set(value, measured);
|
|
1155
|
+
}
|
|
1156
|
+
return measured;
|
|
1157
|
+
}
|
|
1158
|
+
/** Publication check: every default conforms to its property schema. */
|
|
1159
|
+
validateDefaults() {
|
|
1160
|
+
for (const { path, value } of this.defaults.values()) {
|
|
1161
|
+
const [node] = this.locate(path);
|
|
1162
|
+
const filled = deepCopy(value);
|
|
1163
|
+
const [nodes] = measureJson(value);
|
|
1164
|
+
const budget = new Budget(nodes);
|
|
1165
|
+
try {
|
|
1166
|
+
this.fill(filled, node, path, budget, 1, []);
|
|
1167
|
+
}
|
|
1168
|
+
catch (error) {
|
|
1169
|
+
if (error instanceof JsonError)
|
|
1170
|
+
throw new SchemaDefinitionError("invalid_default", `'default' expansion exceeds the supported bounds (${error.code})`, { schemaPath: path, keyword: "default" });
|
|
1171
|
+
throw error;
|
|
1172
|
+
}
|
|
1173
|
+
const subValidator = buildValidator(buildAjvSchema(this.subSchema(node)));
|
|
1174
|
+
if (!subValidator.call({ root: filled }, buildNative(filled)))
|
|
1175
|
+
throw new SchemaDefinitionError("invalid_default", "'default' does not conform to its property schema", { schemaPath: path, keyword: "default" });
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
/** A node reparented under the root's `$defs` so its `$ref`s still resolve. */
|
|
1179
|
+
subSchema(node) {
|
|
1180
|
+
if (this.defs.size === 0)
|
|
1181
|
+
return node;
|
|
1182
|
+
// Null-prototype so a `$def` literally named `__proto__` is copied as an own
|
|
1183
|
+
// key here too — this map feeds buildAjvSchema on the default-publication
|
|
1184
|
+
// path (validateDefaults), so an ordinary `{}` would drop such a def via the
|
|
1185
|
+
// inherited setter and a property default referencing `#/$defs/__proto__`
|
|
1186
|
+
// could not be validated even though root validation resolves it.
|
|
1187
|
+
const defs = Object.create(null);
|
|
1188
|
+
for (const [name, definition] of this.defs)
|
|
1189
|
+
defs[name] = definition;
|
|
1190
|
+
return { ...node, $defs: defs };
|
|
1191
|
+
}
|
|
1192
|
+
/** Follow a normalized schema path from the root, resolving `$ref` hops. */
|
|
1193
|
+
locate(schemaPath) {
|
|
1194
|
+
let node = this.schema;
|
|
1195
|
+
let canonical = [];
|
|
1196
|
+
let inMap = false;
|
|
1197
|
+
for (const part of schemaPath) {
|
|
1198
|
+
if (Array.isArray(node)) {
|
|
1199
|
+
node = node[part];
|
|
1200
|
+
inMap = false;
|
|
1201
|
+
}
|
|
1202
|
+
else {
|
|
1203
|
+
const object = node;
|
|
1204
|
+
if (!inMap && part !== "$defs")
|
|
1205
|
+
[node, canonical] = this.resolve(object);
|
|
1206
|
+
node = node[part];
|
|
1207
|
+
inMap = !inMap && (part === "properties" || part === "$defs");
|
|
1208
|
+
}
|
|
1209
|
+
canonical = [...canonical, part];
|
|
1210
|
+
}
|
|
1211
|
+
return this.resolve(node);
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
// --- helpers -----------------------------------------------------------------
|
|
1215
|
+
class Budget {
|
|
1216
|
+
nodes;
|
|
1217
|
+
constructor(nodes) {
|
|
1218
|
+
this.nodes = nodes;
|
|
1219
|
+
}
|
|
1220
|
+
charge(def, depth, path) {
|
|
1221
|
+
if (depth + def.depth - 1 > MAX_SCHEMA_DEPTH)
|
|
1222
|
+
throw new JsonError("too_deep", "default insertion exceeds the supported depth", path);
|
|
1223
|
+
if (this.nodes + def.nodes > MAX_SCHEMA_NODES)
|
|
1224
|
+
throw new JsonError("too_many_nodes", "default insertion exceeds the supported node count", path);
|
|
1225
|
+
this.nodes += def.nodes;
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
function branch(node, key, index) {
|
|
1229
|
+
return node[key][index];
|
|
1230
|
+
}
|
|
1231
|
+
function deepCopy(value) {
|
|
1232
|
+
if (value === null ||
|
|
1233
|
+
typeof value !== "object" ||
|
|
1234
|
+
value instanceof JsonNumber)
|
|
1235
|
+
return value;
|
|
1236
|
+
if (Array.isArray(value))
|
|
1237
|
+
return value.map(deepCopy);
|
|
1238
|
+
// Null-prototype record so a copied `__proto__` key stays an own data
|
|
1239
|
+
// property instead of mutating the prototype chain.
|
|
1240
|
+
const out = Object.create(null);
|
|
1241
|
+
for (const [key, item] of Object.entries(value))
|
|
1242
|
+
out[key] = deepCopy(item);
|
|
1243
|
+
return out;
|
|
1244
|
+
}
|
|
1245
|
+
/** Map the canonical tree to plain JS numbers so Ajv can traverse it. */
|
|
1246
|
+
function buildNative(value) {
|
|
1247
|
+
if (value instanceof JsonNumber)
|
|
1248
|
+
return Number(value.text);
|
|
1249
|
+
if (Array.isArray(value))
|
|
1250
|
+
return value.map(buildNative);
|
|
1251
|
+
if (isPlainObject(value)) {
|
|
1252
|
+
// Null-prototype record so a `__proto__` key is materialized as an own data
|
|
1253
|
+
// property Ajv traverses, rather than reassigning the object's prototype.
|
|
1254
|
+
const out = Object.create(null);
|
|
1255
|
+
for (const [key, item] of Object.entries(value))
|
|
1256
|
+
out[key] = buildNative(item);
|
|
1257
|
+
return out;
|
|
1258
|
+
}
|
|
1259
|
+
return value;
|
|
1260
|
+
}
|
|
1261
|
+
function splitInstancePath(instancePath) {
|
|
1262
|
+
if (instancePath === "")
|
|
1263
|
+
return [];
|
|
1264
|
+
return instancePath
|
|
1265
|
+
.split("/")
|
|
1266
|
+
.slice(1)
|
|
1267
|
+
.map((token) => {
|
|
1268
|
+
const unescaped = unescapePointerToken(token);
|
|
1269
|
+
return /^\d+$/.test(unescaped) ? Number(unescaped) : unescaped;
|
|
1270
|
+
});
|
|
1271
|
+
}
|
|
1272
|
+
function stripLocation(error) {
|
|
1273
|
+
const message = error.message;
|
|
1274
|
+
const marker = " at /";
|
|
1275
|
+
return message.includes(marker) ? message.split(marker, 1)[0] : message;
|
|
1276
|
+
}
|
|
1277
|
+
// --- public entry ------------------------------------------------------------
|
|
1278
|
+
/** Compile a schema document into an immutable {@link CompiledSchema}. */
|
|
1279
|
+
export function compileSchema(schema) {
|
|
1280
|
+
const prepared = prepareSchema(schema, [], 1);
|
|
1281
|
+
let canonical;
|
|
1282
|
+
try {
|
|
1283
|
+
canonical = canonicalizeJson(prepared, {
|
|
1284
|
+
maxNodes: MAX_SCHEMA_NODES,
|
|
1285
|
+
maxDepth: MAX_SCHEMA_DEPTH,
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
catch (error) {
|
|
1289
|
+
if (error instanceof JsonError)
|
|
1290
|
+
throw new SchemaDefinitionError(error.code, stripLocation(error), {
|
|
1291
|
+
schemaPath: error.path,
|
|
1292
|
+
});
|
|
1293
|
+
throw error;
|
|
1294
|
+
}
|
|
1295
|
+
if (!isPlainObject(canonical))
|
|
1296
|
+
throw new SchemaDefinitionError("unsupported_schema", "schema must be an object");
|
|
1297
|
+
const compiler = new Compiler(canonical);
|
|
1298
|
+
compiler.compile();
|
|
1299
|
+
return CompiledSchema.build(compiler, canonical);
|
|
1300
|
+
}
|