@crewhaus/tool-schema 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +127 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +678 -0
- package/dist/lib/assert.d.ts +62 -0
- package/dist/lib/assert.js +343 -0
- package/dist/lib/compare.d.ts +67 -0
- package/dist/lib/compare.js +298 -0
- package/dist/lib/formats.d.ts +115 -0
- package/dist/lib/formats.js +395 -0
- package/dist/lib/infer.d.ts +51 -0
- package/dist/lib/infer.js +201 -0
- package/dist/lib/jsonschema.d.ts +50 -0
- package/dist/lib/jsonschema.js +605 -0
- package/dist/lib/records.d.ts +114 -0
- package/dist/lib/records.js +234 -0
- package/dist/lib/schemadiff.d.ts +26 -0
- package/dist/lib/schemadiff.js +350 -0
- package/dist/lib/suggest.d.ts +25 -0
- package/dist/lib/suggest.js +65 -0
- package/dist/lib/summarize.d.ts +28 -0
- package/dist/lib/summarize.js +212 -0
- package/dist/lib/value.d.ts +84 -0
- package/dist/lib/value.js +241 -0
- package/package.json +45 -0
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A JSON Schema Draft-07 validator, written out in full here so the package
|
|
3
|
+
* has no runtime dependency and so its behaviour is auditable in one file.
|
|
4
|
+
*
|
|
5
|
+
* ## What is enforced
|
|
6
|
+
*
|
|
7
|
+
* | Group | Keywords |
|
|
8
|
+
* |---|---|
|
|
9
|
+
* | any | `type`, `enum`, `const` |
|
|
10
|
+
* | number | `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf` |
|
|
11
|
+
* | string | `minLength`, `maxLength`, `pattern`, `format` (opt-in) |
|
|
12
|
+
* | array | `items` (schema or tuple), `additionalItems`, `contains`, `minItems`, `maxItems`, `uniqueItems` |
|
|
13
|
+
* | object | `properties`, `patternProperties`, `additionalProperties`, `propertyNames`, `required`, `minProperties`, `maxProperties` |
|
|
14
|
+
* | logic | `allOf`, `anyOf`, `oneOf`, `not`, `if`/`then`/`else` |
|
|
15
|
+
* | reference | `$ref` to a local JSON Pointer, including `#/$defs/...` and `#/definitions/...` |
|
|
16
|
+
*
|
|
17
|
+
* Boolean schemas (`true` accepts everything, `false` rejects everything) are
|
|
18
|
+
* supported wherever a schema is expected.
|
|
19
|
+
*
|
|
20
|
+
* ## What is NOT enforced
|
|
21
|
+
*
|
|
22
|
+
* - **Annotations**, which carry no constraint and are skipped silently:
|
|
23
|
+
* `$id`, `$schema`, `$comment`, `title`, `description`, `default`,
|
|
24
|
+
* `examples`, `readOnly`, `writeOnly`, `deprecated`, `contentMediaType`,
|
|
25
|
+
* `contentEncoding`, `$defs`, `definitions`.
|
|
26
|
+
* - **`format`**, unless `assertFormat` is set. Draft-07 makes `format` an
|
|
27
|
+
* annotation by default and this validator follows that; the opt-in uses
|
|
28
|
+
* the checks in `./formats`, whose subsets are documented there.
|
|
29
|
+
* - **`dependencies` / `dependentRequired` / `dependentSchemas`**,
|
|
30
|
+
* **`unevaluatedProperties` / `unevaluatedItems`**, the draft-04 boolean
|
|
31
|
+
* form of `exclusiveMinimum`/`exclusiveMaximum`, and any other keyword not
|
|
32
|
+
* in the table above. These are never silently dropped: every one
|
|
33
|
+
* encountered is listed in the result's `unsupportedKeywords`, so a caller
|
|
34
|
+
* can tell a clean pass from an unenforced one. Note *encountered*: the
|
|
35
|
+
* list is built while walking, so a keyword sitting in a subschema this
|
|
36
|
+
* value never reached (a property it does not carry, a `$defs` entry no
|
|
37
|
+
* `$ref` took) is not in it. Validate a value that exercises the branch to
|
|
38
|
+
* see its keywords.
|
|
39
|
+
* - **Remote and non-pointer `$ref`** (`http://...`, `other.json#/x`, and
|
|
40
|
+
* `$id`-relative resolution). A `$ref` this validator cannot resolve
|
|
41
|
+
* locally is reported as an error rather than treated as `true`.
|
|
42
|
+
*
|
|
43
|
+
* ## Two deliberate deviations, both in the reporting
|
|
44
|
+
*
|
|
45
|
+
* - When `type` fails, the keywords specific to that type are not also run,
|
|
46
|
+
* so a number arriving where an object belongs produces one error rather
|
|
47
|
+
* than one per property. The verdict is identical; only the error list is
|
|
48
|
+
* shorter. `allOf`/`anyOf`/`oneOf`/`not` still run, since those can fail
|
|
49
|
+
* independently of the type.
|
|
50
|
+
* - Errors stop accumulating at `maxErrors`, and the result says `truncated`.
|
|
51
|
+
*
|
|
52
|
+
* `pattern` is an unanchored ECMA-262 match, per the spec — `"pattern": "a"`
|
|
53
|
+
* matches `"banana"`.
|
|
54
|
+
*/
|
|
55
|
+
import { checkFormat, isFormatName } from "./formats";
|
|
56
|
+
import { canonicalize, deepEqual, isPlainObject, joinPointer, matchesType, parsePointer, preview, resolveSegments, typeOf, } from "./value";
|
|
57
|
+
/** Keywords that constrain a value and are implemented here. */
|
|
58
|
+
const SUPPORTED = new Set([
|
|
59
|
+
"type",
|
|
60
|
+
"enum",
|
|
61
|
+
"const",
|
|
62
|
+
"minimum",
|
|
63
|
+
"maximum",
|
|
64
|
+
"exclusiveMinimum",
|
|
65
|
+
"exclusiveMaximum",
|
|
66
|
+
"multipleOf",
|
|
67
|
+
"minLength",
|
|
68
|
+
"maxLength",
|
|
69
|
+
"pattern",
|
|
70
|
+
"format",
|
|
71
|
+
"items",
|
|
72
|
+
"additionalItems",
|
|
73
|
+
"contains",
|
|
74
|
+
"minItems",
|
|
75
|
+
"maxItems",
|
|
76
|
+
"uniqueItems",
|
|
77
|
+
"properties",
|
|
78
|
+
"patternProperties",
|
|
79
|
+
"additionalProperties",
|
|
80
|
+
"propertyNames",
|
|
81
|
+
"required",
|
|
82
|
+
"minProperties",
|
|
83
|
+
"maxProperties",
|
|
84
|
+
"allOf",
|
|
85
|
+
"anyOf",
|
|
86
|
+
"oneOf",
|
|
87
|
+
"not",
|
|
88
|
+
"if",
|
|
89
|
+
"then",
|
|
90
|
+
"else",
|
|
91
|
+
"$ref",
|
|
92
|
+
]);
|
|
93
|
+
/** Keywords that carry no constraint; skipping them changes nothing. */
|
|
94
|
+
const ANNOTATIONS = new Set([
|
|
95
|
+
"$id",
|
|
96
|
+
"$schema",
|
|
97
|
+
"$comment",
|
|
98
|
+
"title",
|
|
99
|
+
"description",
|
|
100
|
+
"default",
|
|
101
|
+
"examples",
|
|
102
|
+
"readOnly",
|
|
103
|
+
"writeOnly",
|
|
104
|
+
"deprecated",
|
|
105
|
+
"contentMediaType",
|
|
106
|
+
"contentEncoding",
|
|
107
|
+
"$defs",
|
|
108
|
+
"definitions",
|
|
109
|
+
]);
|
|
110
|
+
/**
|
|
111
|
+
* Depth ceiling for the walk. It guards a schema that references itself
|
|
112
|
+
* through a value that never shrinks, and it also stops a value nested past
|
|
113
|
+
* this many levels — both are counted, so the message names either cause.
|
|
114
|
+
*/
|
|
115
|
+
const MAX_DEPTH = 100;
|
|
116
|
+
function fail(ctx, path, keyword, schemaPath, message) {
|
|
117
|
+
if (ctx.errors.length >= ctx.opts.maxErrors) {
|
|
118
|
+
ctx.truncated = true;
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
ctx.errors.push({ path, keyword, message, schemaPath });
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
/** Does `schema` reject `value`? Runs in a scratch context so no errors leak. */
|
|
125
|
+
function branchErrors(value, schema, ctx, schemaPath) {
|
|
126
|
+
const scratch = {
|
|
127
|
+
root: ctx.root,
|
|
128
|
+
opts: { assertFormat: ctx.opts.assertFormat, maxErrors: 20 },
|
|
129
|
+
errors: [],
|
|
130
|
+
truncated: false,
|
|
131
|
+
unsupported: ctx.unsupported,
|
|
132
|
+
refStack: ctx.refStack,
|
|
133
|
+
depth: ctx.depth,
|
|
134
|
+
};
|
|
135
|
+
validateNode(value, schema, "", schemaPath, scratch);
|
|
136
|
+
return scratch.errors;
|
|
137
|
+
}
|
|
138
|
+
function summarizeBranch(errors) {
|
|
139
|
+
if (errors.length === 0)
|
|
140
|
+
return "no error";
|
|
141
|
+
const first = errors[0];
|
|
142
|
+
const where = first.path === "" ? "" : ` at ${first.path}`;
|
|
143
|
+
return `${first.message}${where}`;
|
|
144
|
+
}
|
|
145
|
+
function validateNumber(value, schema, path, sp, ctx) {
|
|
146
|
+
const { minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf } = schema;
|
|
147
|
+
if (typeof minimum === "number" && value < minimum) {
|
|
148
|
+
fail(ctx, path, "minimum", joinPointer(sp, "minimum"), `${value} is below the minimum of ${minimum}`);
|
|
149
|
+
}
|
|
150
|
+
if (typeof maximum === "number" && value > maximum) {
|
|
151
|
+
fail(ctx, path, "maximum", joinPointer(sp, "maximum"), `${value} is above the maximum of ${maximum}`);
|
|
152
|
+
}
|
|
153
|
+
if (typeof exclusiveMinimum === "number" && value <= exclusiveMinimum) {
|
|
154
|
+
fail(ctx, path, "exclusiveMinimum", joinPointer(sp, "exclusiveMinimum"), `${value} must be greater than ${exclusiveMinimum}`);
|
|
155
|
+
}
|
|
156
|
+
if (typeof exclusiveMaximum === "number" && value >= exclusiveMaximum) {
|
|
157
|
+
fail(ctx, path, "exclusiveMaximum", joinPointer(sp, "exclusiveMaximum"), `${value} must be less than ${exclusiveMaximum}`);
|
|
158
|
+
}
|
|
159
|
+
if (typeof multipleOf === "number" && multipleOf > 0) {
|
|
160
|
+
// Two integers divide exactly in floating point, so `%` is both correct
|
|
161
|
+
// and free of the tolerance below. Taking this path matters beyond
|
|
162
|
+
// tidiness: once the quotient passes 2^53 every double is an integer, so
|
|
163
|
+
// the tolerance test would call 1e20 a multiple of 7.
|
|
164
|
+
const exact = Number.isInteger(value) && Number.isInteger(multipleOf);
|
|
165
|
+
const quotient = value / multipleOf;
|
|
166
|
+
// Binary floating point makes an exact remainder test unreliable for
|
|
167
|
+
// fractions (0.3 / 0.1 is 2.9999...), so there the quotient is compared
|
|
168
|
+
// to its nearest integer within a tolerance.
|
|
169
|
+
const off = exact ? value % multipleOf !== 0 : Math.abs(quotient - Math.round(quotient)) > 1e-9;
|
|
170
|
+
if (off) {
|
|
171
|
+
fail(ctx, path, "multipleOf", joinPointer(sp, "multipleOf"), `${value} is not a multiple of ${multipleOf}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function validateString(value, schema, path, sp, ctx) {
|
|
176
|
+
const { minLength, maxLength, pattern, format } = schema;
|
|
177
|
+
// Length is counted in code points, as the spec requires, so an emoji
|
|
178
|
+
// counts as one character rather than as its two UTF-16 units.
|
|
179
|
+
const length = [...value].length;
|
|
180
|
+
if (typeof minLength === "number" && length < minLength) {
|
|
181
|
+
fail(ctx, path, "minLength", joinPointer(sp, "minLength"), `length ${length} is under the minimum of ${minLength}`);
|
|
182
|
+
}
|
|
183
|
+
if (typeof maxLength === "number" && length > maxLength) {
|
|
184
|
+
fail(ctx, path, "maxLength", joinPointer(sp, "maxLength"), `length ${length} is over the maximum of ${maxLength}`);
|
|
185
|
+
}
|
|
186
|
+
if (typeof pattern === "string") {
|
|
187
|
+
let re = null;
|
|
188
|
+
try {
|
|
189
|
+
re = new RegExp(pattern);
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
fail(ctx, path, "pattern", joinPointer(sp, "pattern"), `the schema's pattern is not a valid regular expression: ${err.message}`);
|
|
193
|
+
}
|
|
194
|
+
if (re !== null && !re.test(value)) {
|
|
195
|
+
fail(ctx, path, "pattern", joinPointer(sp, "pattern"), `${preview(value, 60)} does not match /${pattern}/`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (typeof format === "string" && ctx.opts.assertFormat) {
|
|
199
|
+
if (!isFormatName(format)) {
|
|
200
|
+
ctx.unsupported.add(`format:${format}`);
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
const result = checkFormat(value, format);
|
|
204
|
+
if (!result.valid) {
|
|
205
|
+
fail(ctx, path, "format", joinPointer(sp, "format"), `${preview(value, 60)} is not a valid ${format}: ${result.reason}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function validateArray(value, schema, path, sp, ctx) {
|
|
211
|
+
const { items, additionalItems, contains, minItems, maxItems, uniqueItems } = schema;
|
|
212
|
+
if (typeof minItems === "number" && value.length < minItems) {
|
|
213
|
+
fail(ctx, path, "minItems", joinPointer(sp, "minItems"), `${value.length} items, fewer than ${minItems}`);
|
|
214
|
+
}
|
|
215
|
+
if (typeof maxItems === "number" && value.length > maxItems) {
|
|
216
|
+
fail(ctx, path, "maxItems", joinPointer(sp, "maxItems"), `${value.length} items, more than ${maxItems}`);
|
|
217
|
+
}
|
|
218
|
+
if (uniqueItems === true) {
|
|
219
|
+
const seen = new Map();
|
|
220
|
+
for (let i = 0; i < value.length; i++) {
|
|
221
|
+
const key = canonicalize(value[i]);
|
|
222
|
+
const previous = seen.get(key);
|
|
223
|
+
if (previous !== undefined) {
|
|
224
|
+
fail(ctx, path, "uniqueItems", joinPointer(sp, "uniqueItems"), `items ${previous} and ${i} are equal (${preview(value[i], 60)})`);
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
seen.set(key, i);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (Array.isArray(items)) {
|
|
231
|
+
// Tuple form: schema i applies to item i; the rest fall to additionalItems.
|
|
232
|
+
for (let i = 0; i < value.length; i++) {
|
|
233
|
+
const itemSchema = items[i];
|
|
234
|
+
if (itemSchema !== undefined) {
|
|
235
|
+
validateNode(value[i], itemSchema, joinPointer(path, i), joinPointer(joinPointer(sp, "items"), i), ctx);
|
|
236
|
+
}
|
|
237
|
+
else if (additionalItems === false) {
|
|
238
|
+
// Named here rather than left to the generic `false` schema, so the
|
|
239
|
+
// error's keyword matches the one the schema author wrote — the same
|
|
240
|
+
// courtesy `additionalProperties: false` already gets.
|
|
241
|
+
fail(ctx, joinPointer(path, i), "additionalItems", joinPointer(sp, "additionalItems"), `item ${i} is beyond the ${items.length} the tuple describes, and additionalItems is false`);
|
|
242
|
+
}
|
|
243
|
+
else if (additionalItems !== undefined) {
|
|
244
|
+
validateNode(value[i], additionalItems, joinPointer(path, i), joinPointer(sp, "additionalItems"), ctx);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
else if (items !== undefined) {
|
|
249
|
+
for (let i = 0; i < value.length; i++) {
|
|
250
|
+
validateNode(value[i], items, joinPointer(path, i), joinPointer(sp, "items"), ctx);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (contains !== undefined) {
|
|
254
|
+
const matched = value.some((item) => branchErrors(item, contains, ctx, joinPointer(sp, "contains")).length === 0);
|
|
255
|
+
if (!matched) {
|
|
256
|
+
fail(ctx, path, "contains", joinPointer(sp, "contains"), "no item matches the contains schema");
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function validateObject(value, schema, path, sp, ctx) {
|
|
261
|
+
const { properties, patternProperties, additionalProperties, propertyNames, required, minProperties, maxProperties, } = schema;
|
|
262
|
+
const keys = Object.keys(value);
|
|
263
|
+
if (Array.isArray(required)) {
|
|
264
|
+
for (const key of required) {
|
|
265
|
+
if (typeof key === "string" && !Object.hasOwn(value, key)) {
|
|
266
|
+
fail(ctx, path, "required", joinPointer(sp, "required"), `required property "${key}" is missing`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (typeof minProperties === "number" && keys.length < minProperties) {
|
|
271
|
+
fail(ctx, path, "minProperties", joinPointer(sp, "minProperties"), `${keys.length} properties, fewer than ${minProperties}`);
|
|
272
|
+
}
|
|
273
|
+
if (typeof maxProperties === "number" && keys.length > maxProperties) {
|
|
274
|
+
fail(ctx, path, "maxProperties", joinPointer(sp, "maxProperties"), `${keys.length} properties, more than ${maxProperties}`);
|
|
275
|
+
}
|
|
276
|
+
if (propertyNames !== undefined) {
|
|
277
|
+
for (const key of keys) {
|
|
278
|
+
const errors = branchErrors(key, propertyNames, ctx, joinPointer(sp, "propertyNames"));
|
|
279
|
+
if (errors.length > 0) {
|
|
280
|
+
fail(ctx, joinPointer(path, key), "propertyNames", joinPointer(sp, "propertyNames"), `property name "${key}" is not allowed: ${summarizeBranch(errors)}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const patterns = [];
|
|
285
|
+
if (isPlainObject(patternProperties)) {
|
|
286
|
+
for (const source of Object.keys(patternProperties)) {
|
|
287
|
+
try {
|
|
288
|
+
patterns.push([source, new RegExp(source)]);
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
fail(ctx, path, "patternProperties", joinPointer(joinPointer(sp, "patternProperties"), source), `the schema's pattern is not a valid regular expression: ${err.message}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
for (const key of keys) {
|
|
296
|
+
let covered = false;
|
|
297
|
+
if (isPlainObject(properties) && Object.hasOwn(properties, key)) {
|
|
298
|
+
covered = true;
|
|
299
|
+
validateNode(value[key], properties[key], joinPointer(path, key), joinPointer(joinPointer(sp, "properties"), key), ctx);
|
|
300
|
+
}
|
|
301
|
+
for (const [source, re] of patterns) {
|
|
302
|
+
if (!re.test(key))
|
|
303
|
+
continue;
|
|
304
|
+
covered = true;
|
|
305
|
+
validateNode(value[key], patternProperties[source], joinPointer(path, key), joinPointer(joinPointer(sp, "patternProperties"), source), ctx);
|
|
306
|
+
}
|
|
307
|
+
if (covered || additionalProperties === undefined)
|
|
308
|
+
continue;
|
|
309
|
+
if (additionalProperties === false) {
|
|
310
|
+
fail(ctx, joinPointer(path, key), "additionalProperties", joinPointer(sp, "additionalProperties"), `property "${key}" is not allowed here`);
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (additionalProperties !== true) {
|
|
314
|
+
validateNode(value[key], additionalProperties, joinPointer(path, key), joinPointer(sp, "additionalProperties"), ctx);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
function validateLogic(value, schema, path, sp, ctx) {
|
|
319
|
+
const { allOf, anyOf, oneOf, not } = schema;
|
|
320
|
+
if (Array.isArray(allOf)) {
|
|
321
|
+
for (let i = 0; i < allOf.length; i++) {
|
|
322
|
+
validateNode(value, allOf[i], path, joinPointer(joinPointer(sp, "allOf"), i), ctx);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (Array.isArray(anyOf)) {
|
|
326
|
+
const reasons = [];
|
|
327
|
+
let anyPassed = false;
|
|
328
|
+
for (let i = 0; i < anyOf.length && !anyPassed; i++) {
|
|
329
|
+
const errors = branchErrors(value, anyOf[i], ctx, joinPointer(joinPointer(sp, "anyOf"), i));
|
|
330
|
+
if (errors.length === 0)
|
|
331
|
+
anyPassed = true;
|
|
332
|
+
else
|
|
333
|
+
reasons.push(`[${i}] ${summarizeBranch(errors)}`);
|
|
334
|
+
}
|
|
335
|
+
if (!anyPassed) {
|
|
336
|
+
fail(ctx, path, "anyOf", joinPointer(sp, "anyOf"), `matched none of the ${anyOf.length} alternatives — ${reasons.join("; ")}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (Array.isArray(oneOf)) {
|
|
340
|
+
const passing = [];
|
|
341
|
+
const reasons = [];
|
|
342
|
+
for (let i = 0; i < oneOf.length; i++) {
|
|
343
|
+
const errors = branchErrors(value, oneOf[i], ctx, joinPointer(joinPointer(sp, "oneOf"), i));
|
|
344
|
+
if (errors.length === 0)
|
|
345
|
+
passing.push(i);
|
|
346
|
+
else
|
|
347
|
+
reasons.push(`[${i}] ${summarizeBranch(errors)}`);
|
|
348
|
+
}
|
|
349
|
+
if (passing.length === 0) {
|
|
350
|
+
fail(ctx, path, "oneOf", joinPointer(sp, "oneOf"), `matched none of the ${oneOf.length} alternatives — ${reasons.join("; ")}`);
|
|
351
|
+
}
|
|
352
|
+
else if (passing.length > 1) {
|
|
353
|
+
fail(ctx, path, "oneOf", joinPointer(sp, "oneOf"), `matched ${passing.length} alternatives (${passing.join(", ")}) but oneOf allows exactly one`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (not !== undefined) {
|
|
357
|
+
if (branchErrors(value, not, ctx, joinPointer(sp, "not")).length === 0) {
|
|
358
|
+
fail(ctx, path, "not", joinPointer(sp, "not"), "matched a schema it must not match");
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (schema["if"] !== undefined) {
|
|
362
|
+
const conditionHolds = branchErrors(value, schema["if"], ctx, joinPointer(sp, "if")).length === 0;
|
|
363
|
+
const branch = conditionHolds ? schema["then"] : schema["else"];
|
|
364
|
+
if (branch !== undefined) {
|
|
365
|
+
validateNode(value, branch, path, joinPointer(sp, conditionHolds ? "then" : "else"), ctx);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/** Resolve a local `$ref`. Returns the schema, or a message saying why not. */
|
|
370
|
+
function resolveRef(ref, ctx) {
|
|
371
|
+
if (!ref.startsWith("#")) {
|
|
372
|
+
return {
|
|
373
|
+
error: `only local $ref is supported, and "${ref}" points elsewhere — inline the target or use #/$defs/...`,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
let segments;
|
|
377
|
+
try {
|
|
378
|
+
segments = parsePointer(ref);
|
|
379
|
+
}
|
|
380
|
+
catch (err) {
|
|
381
|
+
return { error: `$ref "${ref}" is not a JSON Pointer: ${err.message}` };
|
|
382
|
+
}
|
|
383
|
+
const found = resolveSegments(ctx.root, segments);
|
|
384
|
+
if (!found.found)
|
|
385
|
+
return { error: `$ref "${ref}" does not resolve inside this schema` };
|
|
386
|
+
const target = found.value;
|
|
387
|
+
if (typeof target !== "boolean" && !isPlainObject(target)) {
|
|
388
|
+
return { error: `$ref "${ref}" resolves to a ${typeOf(target)}, which is not a schema` };
|
|
389
|
+
}
|
|
390
|
+
return { schema: target };
|
|
391
|
+
}
|
|
392
|
+
/** Validate one value against one schema, appending any failures to `ctx`. */
|
|
393
|
+
function validateNode(value, schema, path, sp, ctx) {
|
|
394
|
+
if (ctx.depth > MAX_DEPTH) {
|
|
395
|
+
fail(ctx, path, "depth", sp, `nesting passed ${MAX_DEPTH} levels — either the value is that deep or a $ref is cycling`);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
if (schema === true)
|
|
399
|
+
return;
|
|
400
|
+
if (schema === false) {
|
|
401
|
+
fail(ctx, path, "false", sp, "this position accepts no value at all");
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
if (!isPlainObject(schema)) {
|
|
405
|
+
fail(ctx, path, "schema", sp, `expected a schema object or boolean, found ${typeOf(schema)}`);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
for (const keyword of Object.keys(schema)) {
|
|
409
|
+
if (!SUPPORTED.has(keyword) && !ANNOTATIONS.has(keyword))
|
|
410
|
+
ctx.unsupported.add(keyword);
|
|
411
|
+
}
|
|
412
|
+
// Draft-04 wrote exclusive bounds as booleans beside minimum/maximum; that
|
|
413
|
+
// form means something different and is not enforced here.
|
|
414
|
+
if (typeof schema["exclusiveMinimum"] === "boolean")
|
|
415
|
+
ctx.unsupported.add("exclusiveMinimum (boolean form)");
|
|
416
|
+
if (typeof schema["exclusiveMaximum"] === "boolean")
|
|
417
|
+
ctx.unsupported.add("exclusiveMaximum (boolean form)");
|
|
418
|
+
// Draft-07: a `$ref` replaces its sibling keywords entirely.
|
|
419
|
+
if (typeof schema["$ref"] === "string") {
|
|
420
|
+
const ref = schema["$ref"];
|
|
421
|
+
const marker = `${ref}@${path}`;
|
|
422
|
+
if (ctx.refStack.includes(marker)) {
|
|
423
|
+
fail(ctx, path, "$ref", joinPointer(sp, "$ref"), `$ref "${ref}" cycles at this position`);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const resolved = resolveRef(ref, ctx);
|
|
427
|
+
if ("error" in resolved) {
|
|
428
|
+
fail(ctx, path, "$ref", joinPointer(sp, "$ref"), resolved.error);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
ctx.refStack.push(marker);
|
|
432
|
+
ctx.depth += 1;
|
|
433
|
+
validateNode(value, resolved.schema, path, ref, ctx);
|
|
434
|
+
ctx.depth -= 1;
|
|
435
|
+
ctx.refStack.pop();
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
const actual = typeOf(value);
|
|
439
|
+
const declared = schema["type"];
|
|
440
|
+
if (typeof declared === "string" || Array.isArray(declared)) {
|
|
441
|
+
const allowed = (Array.isArray(declared) ? declared : [declared]).filter((t) => typeof t === "string");
|
|
442
|
+
if (allowed.length > 0 && !allowed.some((t) => matchesType(actual, t))) {
|
|
443
|
+
fail(ctx, path, "type", joinPointer(sp, "type"), `expected ${allowed.join(" or ")}, found ${actual} (${preview(value, 60)})`);
|
|
444
|
+
// A wrong type makes every type-specific keyword below noise.
|
|
445
|
+
validateLogic(value, schema, path, sp, ctx);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (Array.isArray(schema["enum"])) {
|
|
450
|
+
if (!schema["enum"].some((candidate) => deepEqual(candidate, value))) {
|
|
451
|
+
fail(ctx, path, "enum", joinPointer(sp, "enum"), `${preview(value, 60)} is not one of ${preview(schema["enum"], 120)}`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
if (Object.hasOwn(schema, "const") && !deepEqual(schema["const"], value)) {
|
|
455
|
+
fail(ctx, path, "const", joinPointer(sp, "const"), `expected ${preview(schema["const"], 60)}, found ${preview(value, 60)}`);
|
|
456
|
+
}
|
|
457
|
+
ctx.depth += 1;
|
|
458
|
+
if (typeof value === "number")
|
|
459
|
+
validateNumber(value, schema, path, sp, ctx);
|
|
460
|
+
else if (typeof value === "string")
|
|
461
|
+
validateString(value, schema, path, sp, ctx);
|
|
462
|
+
else if (Array.isArray(value))
|
|
463
|
+
validateArray(value, schema, path, sp, ctx);
|
|
464
|
+
else if (isPlainObject(value))
|
|
465
|
+
validateObject(value, schema, path, sp, ctx);
|
|
466
|
+
validateLogic(value, schema, path, sp, ctx);
|
|
467
|
+
ctx.depth -= 1;
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Validate `value` against `schema`, returning every failure with a JSON
|
|
471
|
+
* Pointer path. See the module comment for the exact supported subset; the
|
|
472
|
+
* result's `unsupportedKeywords` names anything in the schema that this
|
|
473
|
+
* validator did not enforce, so a pass can be read honestly.
|
|
474
|
+
*/
|
|
475
|
+
export function validateValue(value, schema, options = {}) {
|
|
476
|
+
const ctx = {
|
|
477
|
+
root: schema,
|
|
478
|
+
opts: { assertFormat: options.assertFormat ?? false, maxErrors: options.maxErrors ?? 100 },
|
|
479
|
+
errors: [],
|
|
480
|
+
truncated: false,
|
|
481
|
+
unsupported: new Set(),
|
|
482
|
+
refStack: [],
|
|
483
|
+
depth: 0,
|
|
484
|
+
};
|
|
485
|
+
validateNode(value, schema, "", "", ctx);
|
|
486
|
+
return {
|
|
487
|
+
valid: ctx.errors.length === 0,
|
|
488
|
+
errors: ctx.errors,
|
|
489
|
+
truncated: ctx.truncated,
|
|
490
|
+
unsupportedKeywords: [...ctx.unsupported].sort(),
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* A structural sanity check on the schema itself, run before validation so a
|
|
495
|
+
* typo like `{"type": "sting"}` is reported as a bad schema rather than as
|
|
496
|
+
* every value being wrong. Returns the problems found; an empty array means
|
|
497
|
+
* the schema is well formed as far as this validator is concerned.
|
|
498
|
+
*
|
|
499
|
+
* Every position where the spec expects a subschema is descended into,
|
|
500
|
+
* including `$defs` and `definitions` — a typo in a definition is invisible
|
|
501
|
+
* at the top level and rejects every value that reaches it through a `$ref`,
|
|
502
|
+
* which is exactly the failure this check exists to catch.
|
|
503
|
+
*/
|
|
504
|
+
export function checkSchemaShape(schema, path = "", depth = 0) {
|
|
505
|
+
const problems = [];
|
|
506
|
+
if (typeof schema === "boolean")
|
|
507
|
+
return problems;
|
|
508
|
+
if (!isPlainObject(schema)) {
|
|
509
|
+
problems.push(`${path || "/"}: expected a schema object or boolean, found ${typeOf(schema)}`);
|
|
510
|
+
return problems;
|
|
511
|
+
}
|
|
512
|
+
// A schema is finite JSON, so this only guards against a hand-built cyclic
|
|
513
|
+
// object; it costs nothing and cannot be hit by a parsed document.
|
|
514
|
+
if (depth > MAX_DEPTH)
|
|
515
|
+
return problems;
|
|
516
|
+
const validTypes = ["null", "boolean", "object", "array", "number", "string", "integer"];
|
|
517
|
+
const declared = schema["type"];
|
|
518
|
+
const declaredList = Array.isArray(declared)
|
|
519
|
+
? declared
|
|
520
|
+
: declared === undefined
|
|
521
|
+
? []
|
|
522
|
+
: [declared];
|
|
523
|
+
for (const t of declaredList) {
|
|
524
|
+
if (typeof t !== "string" || !validTypes.includes(t)) {
|
|
525
|
+
problems.push(`${joinPointer(path, "type")}: "${String(t)}" is not a JSON Schema type`);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
if (Object.hasOwn(schema, "enum") && !Array.isArray(schema["enum"])) {
|
|
529
|
+
problems.push(`${joinPointer(path, "enum")}: enum must be an array`);
|
|
530
|
+
}
|
|
531
|
+
if (Object.hasOwn(schema, "required")) {
|
|
532
|
+
const required = schema["required"];
|
|
533
|
+
if (!Array.isArray(required)) {
|
|
534
|
+
problems.push(`${joinPointer(path, "required")}: required must be an array of property names`);
|
|
535
|
+
}
|
|
536
|
+
else {
|
|
537
|
+
// A non-string entry is silently ignored during validation, so a
|
|
538
|
+
// `required: [{"name": "id"}]` would claim to require nothing.
|
|
539
|
+
required.forEach((name, i) => {
|
|
540
|
+
if (typeof name !== "string") {
|
|
541
|
+
problems.push(`${joinPointer(joinPointer(path, "required"), i)}: "${String(name)}" is not a property name`);
|
|
542
|
+
}
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
if (typeof schema["pattern"] === "string") {
|
|
547
|
+
try {
|
|
548
|
+
new RegExp(schema["pattern"]);
|
|
549
|
+
}
|
|
550
|
+
catch (err) {
|
|
551
|
+
problems.push(`${joinPointer(path, "pattern")}: ${err.message}`);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
const descend = (child, childPath) => {
|
|
555
|
+
problems.push(...checkSchemaShape(child, childPath, depth + 1));
|
|
556
|
+
};
|
|
557
|
+
// Keyword values that are a map of name to subschema.
|
|
558
|
+
for (const keyword of ["properties", "patternProperties", "$defs", "definitions"]) {
|
|
559
|
+
const map = schema[keyword];
|
|
560
|
+
if (!isPlainObject(map))
|
|
561
|
+
continue;
|
|
562
|
+
if (keyword === "patternProperties") {
|
|
563
|
+
for (const source of Object.keys(map)) {
|
|
564
|
+
try {
|
|
565
|
+
new RegExp(source);
|
|
566
|
+
}
|
|
567
|
+
catch (err) {
|
|
568
|
+
problems.push(`${joinPointer(joinPointer(path, keyword), source)}: ${err.message}`);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
for (const key of Object.keys(map)) {
|
|
573
|
+
descend(map[key], joinPointer(joinPointer(path, keyword), key));
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
// Keyword values that are a single subschema, or (for `items`) a tuple.
|
|
577
|
+
for (const keyword of [
|
|
578
|
+
"items",
|
|
579
|
+
"additionalItems",
|
|
580
|
+
"additionalProperties",
|
|
581
|
+
"contains",
|
|
582
|
+
"propertyNames",
|
|
583
|
+
"not",
|
|
584
|
+
"if",
|
|
585
|
+
"then",
|
|
586
|
+
"else",
|
|
587
|
+
]) {
|
|
588
|
+
const child = schema[keyword];
|
|
589
|
+
if (child === undefined)
|
|
590
|
+
continue;
|
|
591
|
+
if (keyword === "items" && Array.isArray(child)) {
|
|
592
|
+
child.forEach((item, i) => descend(item, joinPointer(joinPointer(path, keyword), i)));
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
descend(child, joinPointer(path, keyword));
|
|
596
|
+
}
|
|
597
|
+
// Keyword values that are an array of subschemas.
|
|
598
|
+
for (const keyword of ["allOf", "anyOf", "oneOf"]) {
|
|
599
|
+
const branches = schema[keyword];
|
|
600
|
+
if (!Array.isArray(branches))
|
|
601
|
+
continue;
|
|
602
|
+
branches.forEach((branch, i) => descend(branch, joinPointer(joinPointer(path, keyword), i)));
|
|
603
|
+
}
|
|
604
|
+
return problems;
|
|
605
|
+
}
|