ata-validator 1.52.0 → 1.54.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 +7 -0
- package/compat.js +325 -29
- package/lib/codegen-engine.js +2 -1
- package/lib/compat-errors.js +131 -17
- package/lib/dialect.js +36 -1
- package/lib/draft7.js +80 -61
- package/lib/enrich-site.js +29 -2
- package/lib/error-runtime.js +6 -2
- package/lib/formats-source.js +43 -66
- package/lib/formats.js +37 -231
- package/lib/interpreter-compat.js +144 -0
- package/lib/interpreter-core.js +204 -17
- package/lib/js-compiler-errors.js +52 -16
- package/lib/js-compiler.js +540 -66
- package/lib/keywords.js +24 -1
- package/lib/levenshtein.js +21 -5
- package/lib/metaschemas.js +5 -3
- package/lib/plan-compiler.js +28 -6
- package/lib/regex-linear.js +81 -1
- package/lib/safe-regex.js +14 -11
- package/lib/schema-scan.js +20 -1
- package/lib/tier0.js +1 -0
- package/lib/unique-items.js +16 -5
- package/lib/uri-core.js +155 -0
- package/lib/validator-core.js +118 -23
- package/lib/version.js +1 -1
- package/package.json +9 -9
package/lib/compat-errors.js
CHANGED
|
@@ -31,6 +31,8 @@ const REFERENCE_ORDER = [
|
|
|
31
31
|
'maxLength', 'minLength', 'pattern', 'format',
|
|
32
32
|
'maxItems', 'minItems', 'additionalItems', 'prefixItems', 'items', 'contains', 'uniqueItems', 'maxContains', 'minContains', 'unevaluatedItems',
|
|
33
33
|
'maxProperties', 'minProperties', 'required', 'propertyNames', 'additionalProperties', 'dependencies', 'dependentRequired', 'dependentSchemas', 'properties', 'patternProperties', 'unevaluatedProperties',
|
|
34
|
+
// The reference's `discriminator` option adds the keyword last.
|
|
35
|
+
'discriminator',
|
|
34
36
|
];
|
|
35
37
|
const REFERENCE_RANK = new Map(REFERENCE_ORDER.map((k, i) => [k, i]));
|
|
36
38
|
|
|
@@ -78,13 +80,26 @@ function classify(segs) {
|
|
|
78
80
|
// segments, and each segment's kind. Everything below used to re-derive these
|
|
79
81
|
// wherever it needed them, which for one shaped rejection meant splitting the
|
|
80
82
|
// same pointer three times per error.
|
|
83
|
+
// Memoized by schemaPath: a schema reports a fixed set of paths, and a
|
|
84
|
+
// rejection reshaped every error's path from scratch, which was a third of the
|
|
85
|
+
// compat error path. The key is read, never written, by its users. Bounded so
|
|
86
|
+
// a schema with paths that grow with the data (recursion) cannot fill memory.
|
|
87
|
+
const _keys = new Map();
|
|
81
88
|
function keyFor(e) {
|
|
82
|
-
const
|
|
89
|
+
const sp = e && e.schemaPath ? e.schemaPath : '#';
|
|
90
|
+
let k = _keys.get(sp);
|
|
91
|
+
if (k !== undefined) return k;
|
|
92
|
+
const rel = pointerOf(sp);
|
|
83
93
|
const segs = segments(rel);
|
|
84
|
-
|
|
94
|
+
k = { rel, segs, kinds: classify(segs) };
|
|
95
|
+
if (_keys.size >= 4096) _keys.clear();
|
|
96
|
+
_keys.set(sp, k);
|
|
97
|
+
return k;
|
|
85
98
|
}
|
|
86
99
|
|
|
87
|
-
|
|
100
|
+
// `rankAt(segs, i)`, when given, can rank a keyword by where it sits: a
|
|
101
|
+
// oneOf the discriminator decides is reported where the discriminator is.
|
|
102
|
+
function sortByKeys(errors, keys, rankAt) {
|
|
88
103
|
if (errors.length < 2) return errors.slice();
|
|
89
104
|
const keyed = errors.map((e) => ({ e, k: keys.get(e) }));
|
|
90
105
|
keyed.sort((A, B) => {
|
|
@@ -93,8 +108,8 @@ function sortByKeys(errors, keys) {
|
|
|
93
108
|
for (let i = 0; i < n; i++) {
|
|
94
109
|
if (pa[i] === pb[i]) continue;
|
|
95
110
|
if (ka[i] !== 'kw') return 0;
|
|
96
|
-
const ra = REFERENCE_RANK.get(pa[i]);
|
|
97
|
-
const rb = REFERENCE_RANK.get(pb[i]);
|
|
111
|
+
const ra = rankAt ? rankAt(pa, i) : REFERENCE_RANK.get(pa[i]);
|
|
112
|
+
const rb = rankAt ? rankAt(pb, i) : REFERENCE_RANK.get(pb[i]);
|
|
98
113
|
if (ra === undefined || rb === undefined) return 0;
|
|
99
114
|
return ra - rb;
|
|
100
115
|
}
|
|
@@ -143,7 +158,22 @@ function asReferenceKeyword(e) {
|
|
|
143
158
|
return e.keyword === 'not' && e.message === 'boolean schema is false' ? { ...e, keyword: 'false schema' } : e;
|
|
144
159
|
}
|
|
145
160
|
|
|
146
|
-
|
|
161
|
+
const FRAGMENT = 'ata-compat:fragment:';
|
|
162
|
+
|
|
163
|
+
// Whether a schema holds a reference anywhere, the reference's test for
|
|
164
|
+
// compiling a `$ref` target as a function of its own instead of inlining it.
|
|
165
|
+
const REF_KEYWORDS = new Set(['$ref', '$recursiveRef', '$recursiveAnchor', '$dynamicRef', '$dynamicAnchor']);
|
|
166
|
+
function hasRef(schema) {
|
|
167
|
+
if (schema === null || typeof schema !== 'object') return false;
|
|
168
|
+
for (const key in schema) {
|
|
169
|
+
if (REF_KEYWORDS.has(key)) return true;
|
|
170
|
+
const sch = schema[key];
|
|
171
|
+
if (Array.isArray(sch) ? sch.some(hasRef) : (sch !== null && typeof sch === 'object' && hasRef(sch))) return true;
|
|
172
|
+
}
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function createShaper({ Validator, rootId, rootDoc, schemas, options, allErrors, macroKeywords, discriminator }) {
|
|
147
177
|
const branchCache = new Map();
|
|
148
178
|
const branchValidator = (pointer) => {
|
|
149
179
|
let v = branchCache.get(pointer);
|
|
@@ -156,6 +186,74 @@ function createShaper({ Validator, rootId, rootDoc, schemas, options, allErrors,
|
|
|
156
186
|
const wrappers = new Set(WRAPPERS);
|
|
157
187
|
for (const k of macroKeywords) wrappers.add(k);
|
|
158
188
|
|
|
189
|
+
// Where a schema path really points. ata reports a keyword reached through
|
|
190
|
+
// a local `$ref` at the referring node (`#/properties/s/maxLength`), the
|
|
191
|
+
// reference at the node the `$ref` names (`#/definitions/S/maxLength`).
|
|
192
|
+
// Walking the document, a segment the node does not have, on a node with a
|
|
193
|
+
// `#/...` `$ref`, continues at the target. Anything else stays as it is.
|
|
194
|
+
// Under the discriminator option a oneOf beside a `discriminator` is the
|
|
195
|
+
// discriminator's: its branch errors come where that keyword runs.
|
|
196
|
+
const discriminated = discriminator === true;
|
|
197
|
+
const rankAt = (segs, i) => {
|
|
198
|
+
if (segs[i] === 'oneOf') {
|
|
199
|
+
const node = walkPointer(rootDoc, canonical(segs.length && i > 0 ? '/' + segs.slice(0, i).join('/') : ''));
|
|
200
|
+
if (node !== null && typeof node === 'object' && node.discriminator && typeof node.discriminator.propertyName === 'string') return REFERENCE_RANK.get('discriminator');
|
|
201
|
+
}
|
|
202
|
+
return REFERENCE_RANK.get(segs[i]);
|
|
203
|
+
};
|
|
204
|
+
const canonCache = new Map();
|
|
205
|
+
const reportCache = new Map();
|
|
206
|
+
// `reported` false: the pointer of the node itself, to read the schema at.
|
|
207
|
+
// `reported` true: the path the reference reports, which differs after a
|
|
208
|
+
// `$ref` it compiles as a function of its own rather than inlining (one
|
|
209
|
+
// whose target holds a `$ref`): errors from there carry the path from
|
|
210
|
+
// that target, `#/...`, as if it were a root.
|
|
211
|
+
function follow(pointer, reported) {
|
|
212
|
+
const cache = reported ? reportCache : canonCache;
|
|
213
|
+
let out = cache.get(pointer);
|
|
214
|
+
if (out !== undefined) return out;
|
|
215
|
+
out = pointer;
|
|
216
|
+
let node = rootDoc, path = '', moved = false, ok = true;
|
|
217
|
+
for (const raw of segments(pointer)) {
|
|
218
|
+
const seg = unescape(raw);
|
|
219
|
+
let first = null, hops = 0;
|
|
220
|
+
for (; hops < 32 && node !== null && typeof node === 'object' && !Array.isArray(node) &&
|
|
221
|
+
!Object.prototype.hasOwnProperty.call(node, seg) && typeof node.$ref === 'string' &&
|
|
222
|
+
(node.$ref.startsWith('#/') || node.$ref.startsWith(FRAGMENT)); hops++) {
|
|
223
|
+
// A schema registered under a fragment-only key (see fragmentKey in
|
|
224
|
+
// compat.js) is named by that key, as the reference names it.
|
|
225
|
+
const registered = node.$ref.startsWith(FRAGMENT);
|
|
226
|
+
if (registered && !reported) { ok = false; break; }
|
|
227
|
+
let target;
|
|
228
|
+
try { target = decodeURIComponent(registered ? node.$ref.slice(FRAGMENT.length) : node.$ref.slice(1)); } catch { target = null; }
|
|
229
|
+
if (target === null) { ok = false; break; }
|
|
230
|
+
node = registered ? (schemas ? schemas[node.$ref] : undefined) : walkPointer(rootDoc, target);
|
|
231
|
+
if (first === null) first = target;
|
|
232
|
+
if (!reported) path = target;
|
|
233
|
+
moved = true;
|
|
234
|
+
}
|
|
235
|
+
if (!ok) break;
|
|
236
|
+
// A `$ref` to a `$ref` is reported at the first target, as the
|
|
237
|
+
// reference reports it, unless the reference compiled the last one on
|
|
238
|
+
// its own.
|
|
239
|
+
if (reported && first !== null) path = hasRef(node) ? '' : first;
|
|
240
|
+
if (node === null || typeof node !== 'object') { ok = false; break; }
|
|
241
|
+
node = Array.isArray(node) ? node[Number(seg)] : node[seg];
|
|
242
|
+
path += '/' + raw;
|
|
243
|
+
}
|
|
244
|
+
if (ok && moved && node !== undefined) out = path;
|
|
245
|
+
if (cache.size >= 4096) cache.clear();
|
|
246
|
+
cache.set(pointer, out);
|
|
247
|
+
return out;
|
|
248
|
+
}
|
|
249
|
+
const canonical = (pointer) => follow(pointer, false);
|
|
250
|
+
function canonicalError(e) {
|
|
251
|
+
const sp = e.schemaPath;
|
|
252
|
+
if (typeof sp !== 'string' || !sp.startsWith('#')) return e;
|
|
253
|
+
const c = follow(sp.slice(1), true);
|
|
254
|
+
return c === sp.slice(1) ? e : { ...e, schemaPath: '#' + c };
|
|
255
|
+
}
|
|
256
|
+
|
|
159
257
|
// Errors whose schemaPath is already absolute (from the root), as opposed
|
|
160
258
|
// to relative to the branch validator that produced them.
|
|
161
259
|
const absolute = new WeakSet();
|
|
@@ -163,14 +261,16 @@ function createShaper({ Validator, rootId, rootDoc, schemas, options, allErrors,
|
|
|
163
261
|
// Returns the shaped errors of `value` against the subschema at `pointer`
|
|
164
262
|
// (absolute), with schemaPath made absolute and instancePath rebased onto
|
|
165
263
|
// the caller's.
|
|
166
|
-
|
|
264
|
+
// `pointer` is where the subschema is; `at`, when given, is the path its
|
|
265
|
+
// errors are reported under, the referring one ata's own errors use.
|
|
266
|
+
function subErrors(pointer, value, schemaPrefix, instancePrefix, at) {
|
|
167
267
|
const r = branchValidator(pointer).validate(value);
|
|
168
268
|
if (r.valid) return null;
|
|
169
269
|
const shaped = shape(r.errors, value, pointer);
|
|
170
270
|
return shaped.map((e) => {
|
|
171
271
|
const out = {
|
|
172
272
|
...e,
|
|
173
|
-
schemaPath: absolute.has(e) ? e.schemaPath : schemaPrefix + pointer + pointerOf(e.schemaPath || '#'),
|
|
273
|
+
schemaPath: absolute.has(e) ? e.schemaPath : schemaPrefix + (at === undefined ? pointer : at) + pointerOf(e.schemaPath || '#'),
|
|
174
274
|
instancePath: instancePrefix + (e.instancePath || ''),
|
|
175
275
|
};
|
|
176
276
|
absolute.add(out);
|
|
@@ -187,7 +287,7 @@ function createShaper({ Validator, rootId, rootDoc, schemas, options, allErrors,
|
|
|
187
287
|
function shape(errors, data, basePointer) {
|
|
188
288
|
const keys = new Map();
|
|
189
289
|
for (const e of errors) keys.set(e, keyFor(e));
|
|
190
|
-
const sorted = sortByKeys(errors, keys);
|
|
290
|
+
const sorted = sortByKeys(errors, keys, discriminated ? rankAt : undefined);
|
|
191
291
|
const out = [];
|
|
192
292
|
const consumed = new Set();
|
|
193
293
|
let thenGroup = null; // { prefix, which } for the pending `if` wrapper
|
|
@@ -208,7 +308,8 @@ function createShaper({ Validator, rootId, rootDoc, schemas, options, allErrors,
|
|
|
208
308
|
if (consumed.has(e)) continue;
|
|
209
309
|
const K = keys.get(e);
|
|
210
310
|
const rel = K.rel;
|
|
211
|
-
const
|
|
311
|
+
const raw = basePointer + rel;
|
|
312
|
+
const abs = canonical(raw);
|
|
212
313
|
const segs = K.segs;
|
|
213
314
|
const kinds = K.kinds;
|
|
214
315
|
const schemaPrefix = (e.schemaPath || '#').slice(0, (e.schemaPath || '#').indexOf('#') + 1);
|
|
@@ -241,14 +342,25 @@ function createShaper({ Validator, rootId, rootDoc, schemas, options, allErrors,
|
|
|
241
342
|
const value = walkData(data, e.instancePath || '');
|
|
242
343
|
const branchErrors = [];
|
|
243
344
|
const passing = [];
|
|
345
|
+
// The validator kept every branch's errors when keywords may have
|
|
346
|
+
// changed the data: validating a branch again would see the change.
|
|
347
|
+
const kept = basePointer === '' && Array.isArray(e._branches) && e._branches.length === branches.length ? e._branches : null;
|
|
244
348
|
for (let i = 0; i < branches.length; i++) {
|
|
245
|
-
|
|
349
|
+
let errs;
|
|
350
|
+
if (kept !== null) {
|
|
351
|
+
errs = kept[i].valid ? null : shape(kept[i].errors, data, '');
|
|
352
|
+
if (errs !== null) for (const x of errs) absolute.add(x);
|
|
353
|
+
} else errs = subErrors(abs + '/' + i, value, schemaPrefix, e.instancePath || '', raw + '/' + i);
|
|
246
354
|
if (errs === null) passing.push(i);
|
|
247
355
|
else branchErrors.push(allErrors ? errs : errs.slice(0, groupEnd(errs)));
|
|
248
356
|
}
|
|
249
357
|
if (passing.length === 0) {
|
|
250
358
|
for (const errs of branchErrors) out.push(...errs);
|
|
251
|
-
|
|
359
|
+
// The reference's own wording and params for no branch passing;
|
|
360
|
+
// the collapsed error validate() reports names the count instead.
|
|
361
|
+
out.push(kw === 'oneOf'
|
|
362
|
+
? keywordError('oneOf', e.instancePath || '', e.schemaPath, { passingSchemas: null }, 'must match exactly one schema in oneOf')
|
|
363
|
+
: keywordError('anyOf', e.instancePath || '', e.schemaPath, {}, 'must match a schema in anyOf'));
|
|
252
364
|
} else if (kw === 'oneOf') {
|
|
253
365
|
out.push({ ...e, params: { passingSchemas: passing }, message: 'must match exactly one schema in oneOf' });
|
|
254
366
|
} else {
|
|
@@ -291,7 +403,7 @@ function createShaper({ Validator, rootId, rootDoc, schemas, options, allErrors,
|
|
|
291
403
|
const items = walkData(data, e.instancePath || '');
|
|
292
404
|
if (Array.isArray(items)) {
|
|
293
405
|
for (let i = 0; i < items.length; i++) {
|
|
294
|
-
const errs = subErrors(abs, items[i], schemaPrefix, (e.instancePath || '') + '/' + i);
|
|
406
|
+
const errs = subErrors(abs, items[i], schemaPrefix, (e.instancePath || '') + '/' + i, raw);
|
|
295
407
|
if (errs !== null) out.push(...errs);
|
|
296
408
|
}
|
|
297
409
|
out.push(e);
|
|
@@ -304,7 +416,7 @@ function createShaper({ Validator, rootId, rootDoc, schemas, options, allErrors,
|
|
|
304
416
|
const depIndex = segs.findIndex((s, i) => kinds[i] === 'kw' && (s === 'dependentRequired' || s === 'dependentSchemas'));
|
|
305
417
|
if (depIndex >= 0) {
|
|
306
418
|
const nodePointer = basePointer + (depIndex === 0 ? '' : '/' + segs.slice(0, depIndex).join('/'));
|
|
307
|
-
const node = walkPointer(rootDoc, nodePointer);
|
|
419
|
+
const node = walkPointer(rootDoc, canonical(nodePointer));
|
|
308
420
|
if (node && typeof node === 'object' && node.dependencies && typeof node.dependencies === 'object') {
|
|
309
421
|
const rewritten = segs.slice();
|
|
310
422
|
rewritten[depIndex] = 'dependencies';
|
|
@@ -379,7 +491,7 @@ function createShaper({ Validator, rootId, rootDoc, schemas, options, allErrors,
|
|
|
379
491
|
if (e.keyword !== 'type' || typeof e.message !== 'string' || !e.message.startsWith('must be ')) return e;
|
|
380
492
|
const ptr = pointerOf(e.schemaPath || '');
|
|
381
493
|
if (!ptr.endsWith('/type')) return e;
|
|
382
|
-
const node = walkPointer(rootDoc, ptr.slice(0, -'/type'.length));
|
|
494
|
+
const node = walkPointer(rootDoc, canonical(ptr.slice(0, -'/type'.length)));
|
|
383
495
|
let t = node && typeof node === 'object' && node.type !== undefined ? node.type : e.params && e.params.type;
|
|
384
496
|
if (t === undefined) return e;
|
|
385
497
|
if (node && node.nullable === true && Array.isArray(t) && !t.includes('null')) t = [...t, 'null'];
|
|
@@ -387,10 +499,12 @@ function createShaper({ Validator, rootId, rootDoc, schemas, options, allErrors,
|
|
|
387
499
|
}
|
|
388
500
|
|
|
389
501
|
return function shapeErrors(errors, data) {
|
|
502
|
+
// Paths are made the reference's last: the grouping above reads them as
|
|
503
|
+
// ata reports them, through the referring node.
|
|
390
504
|
const shaped = shape(errors, data, '').map(asReferenceKeyword).map(asReferenceType);
|
|
391
|
-
if (allErrors) return shaped;
|
|
505
|
+
if (allErrors) return shaped.map(canonicalError);
|
|
392
506
|
const n = groupEnd(shaped);
|
|
393
|
-
return shaped.slice(0, n);
|
|
507
|
+
return shaped.slice(0, n).map(canonicalError);
|
|
394
508
|
};
|
|
395
509
|
}
|
|
396
510
|
|
package/lib/dialect.js
CHANGED
|
@@ -28,4 +28,39 @@ function isV1Dialect(schema) {
|
|
|
28
28
|
return V1_DIALECTS.has(uri);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
// The earlier dialects by their meta-schema URIs. draftCode is asked of every
|
|
32
|
+
// schema, so it lives here, in a file every validator loads, and the
|
|
33
|
+
// normalizers in draft7.js load only for a schema that declares one of these.
|
|
34
|
+
// draft-06 is read through the draft-07 rules (see draft7.js).
|
|
35
|
+
const DRAFT7_SCHEMAS = new Set([
|
|
36
|
+
'http://json-schema.org/draft-07/schema#',
|
|
37
|
+
'http://json-schema.org/draft-07/schema',
|
|
38
|
+
'http://json-schema.org/draft-06/schema#',
|
|
39
|
+
'http://json-schema.org/draft-06/schema',
|
|
40
|
+
]);
|
|
41
|
+
const DRAFT6_SCHEMAS = new Set([
|
|
42
|
+
'http://json-schema.org/draft-06/schema#',
|
|
43
|
+
'http://json-schema.org/draft-06/schema',
|
|
44
|
+
]);
|
|
45
|
+
// draft-04 is read through the draft-06 rules plus its own: `id` for `$id`,
|
|
46
|
+
// and no `const`, `contains` or `propertyNames` (see draft7.js).
|
|
47
|
+
const DRAFT4_SCHEMAS = new Set([
|
|
48
|
+
'http://json-schema.org/draft-04/schema#',
|
|
49
|
+
'http://json-schema.org/draft-04/schema',
|
|
50
|
+
]);
|
|
51
|
+
const DRAFT2019_SCHEMAS = new Set([
|
|
52
|
+
'https://json-schema.org/draft/2019-09/schema',
|
|
53
|
+
'https://json-schema.org/draft/2019-09/schema#',
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
// 0 for 2020-12 and later, 1 draft-07, 2 draft-06, 3 2019-09, 4 draft-04.
|
|
57
|
+
function draftCode(uri) {
|
|
58
|
+
if (typeof uri !== 'string') return 0;
|
|
59
|
+
if (DRAFT4_SCHEMAS.has(uri)) return 4;
|
|
60
|
+
if (DRAFT6_SCHEMAS.has(uri)) return 2;
|
|
61
|
+
if (DRAFT7_SCHEMAS.has(uri)) return 1;
|
|
62
|
+
if (DRAFT2019_SCHEMAS.has(uri)) return 3;
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { isV1Dialect, V1_DIALECTS, DRAFT7_SCHEMAS, DRAFT6_SCHEMAS, DRAFT4_SCHEMAS, DRAFT2019_SCHEMAS, draftCode };
|
package/lib/draft7.js
CHANGED
|
@@ -6,16 +6,7 @@
|
|
|
6
6
|
// this, a draft-06 schema was read under 2020-12 and its tuple `items`,
|
|
7
7
|
// `additionalItems` and `dependencies` were not applied: 30 cases of the
|
|
8
8
|
// official draft-06 suite accepted documents they must reject.
|
|
9
|
-
const DRAFT7_SCHEMAS =
|
|
10
|
-
'http://json-schema.org/draft-07/schema#',
|
|
11
|
-
'http://json-schema.org/draft-07/schema',
|
|
12
|
-
'http://json-schema.org/draft-06/schema#',
|
|
13
|
-
'http://json-schema.org/draft-06/schema',
|
|
14
|
-
])
|
|
15
|
-
const DRAFT6_SCHEMAS = new Set([
|
|
16
|
-
'http://json-schema.org/draft-06/schema#',
|
|
17
|
-
'http://json-schema.org/draft-06/schema',
|
|
18
|
-
])
|
|
9
|
+
const { DRAFT7_SCHEMAS, DRAFT6_SCHEMAS, DRAFT4_SCHEMAS, DRAFT2019_SCHEMAS, draftCode } = require('./dialect')
|
|
19
10
|
|
|
20
11
|
function isDraft7(schema) {
|
|
21
12
|
return !!(schema && typeof schema.$schema === 'string' && DRAFT7_SCHEMAS.has(schema.$schema))
|
|
@@ -28,9 +19,11 @@ function isDraft6(schema) {
|
|
|
28
19
|
// `force` applies the draft-07 rules to a document that declares no dialect
|
|
29
20
|
// of its own, which is how a retrieved schema inherits the root's draft.
|
|
30
21
|
// `draft6` says the document, or the root it inherits from, is draft-06.
|
|
31
|
-
|
|
22
|
+
// `draft4`: draft-04, read as draft-06 plus its own differences (below).
|
|
23
|
+
function normalizeDraft7(schema, force, draft6, draft4) {
|
|
32
24
|
if (!force && !isDraft7(schema)) return schema
|
|
33
|
-
|
|
25
|
+
const d4 = draft4 === true || (typeof schema.$schema === 'string' && DRAFT4_SCHEMAS.has(schema.$schema))
|
|
26
|
+
_normalize(schema, d4 || draft6 === true || isDraft6(schema), d4)
|
|
34
27
|
return schema
|
|
35
28
|
}
|
|
36
29
|
|
|
@@ -38,13 +31,21 @@ function normalizeDraft7(schema, force, draft6) {
|
|
|
38
31
|
// definitions are addressable by pointer, annotations are inert.
|
|
39
32
|
const REF_SIBLINGS_KEPT = new Set(['$ref', '$defs', 'definitions', '$schema', '$comment', 'title', 'description', 'examples', 'default', 'readOnly', 'writeOnly'])
|
|
40
33
|
|
|
41
|
-
function _normalize(schema, d6) {
|
|
34
|
+
function _normalize(schema, d6, d4) {
|
|
42
35
|
if (typeof schema !== 'object' || schema === null) return
|
|
43
36
|
|
|
44
37
|
// draft-06 has no conditional: `if`, `then` and `else` are unknown keywords
|
|
45
38
|
// there, so they are dropped rather than applied.
|
|
46
39
|
if (d6) { delete schema.if; delete schema.then; delete schema.else }
|
|
47
40
|
|
|
41
|
+
// draft-04 names a schema's identifier `id`, and has none of draft-06's
|
|
42
|
+
// `const`, `contains` or `propertyNames`: unknown keywords there.
|
|
43
|
+
if (d4) {
|
|
44
|
+
delete schema.const; delete schema.contains; delete schema.propertyNames
|
|
45
|
+
if (typeof schema.id === 'string' && schema.$id === undefined && typeof schema.$ref !== 'string') schema.$id = schema.id
|
|
46
|
+
delete schema.id
|
|
47
|
+
}
|
|
48
|
+
|
|
48
49
|
// In draft-07 an object with `$ref` is that reference and nothing else:
|
|
49
50
|
// sibling keywords, `$id` included, are ignored. Dropping them here gives
|
|
50
51
|
// every engine the same reading without each having to know the draft.
|
|
@@ -91,34 +92,38 @@ function _normalize(schema, d6) {
|
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
// Recurse into object-valued sub-schemas
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
if (
|
|
97
|
-
for (const
|
|
98
|
-
if (
|
|
95
|
+
for (const key of D7_OBJ_SUBS) {
|
|
96
|
+
const m = schema[key]
|
|
97
|
+
if (m && typeof m === 'object') {
|
|
98
|
+
for (const k in m) {
|
|
99
|
+
if (!Object.prototype.hasOwnProperty.call(m, k)) continue
|
|
100
|
+
const v = m[k]
|
|
101
|
+
if (typeof v === 'object' && v !== null) _normalize(v, d6, d4)
|
|
99
102
|
}
|
|
100
103
|
}
|
|
101
104
|
}
|
|
102
105
|
|
|
103
106
|
// Recurse into array-valued sub-schemas
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
if (Array.isArray(
|
|
107
|
-
for (
|
|
108
|
-
|
|
107
|
+
for (const key of D7_ARR_SUBS) {
|
|
108
|
+
const a = schema[key]
|
|
109
|
+
if (Array.isArray(a)) {
|
|
110
|
+
for (let i = 0; i < a.length; i++) {
|
|
111
|
+
const v = a[i]
|
|
112
|
+
if (typeof v === 'object' && v !== null) _normalize(v, d6, d4)
|
|
109
113
|
}
|
|
110
114
|
}
|
|
111
115
|
}
|
|
112
116
|
|
|
113
117
|
// Recurse into single sub-schemas
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
if (typeof schema[key] === 'object' && schema[key] !== null) {
|
|
118
|
-
_normalize(schema[key], d6)
|
|
119
|
-
}
|
|
118
|
+
for (const key of D7_SINGLE_SUBS) {
|
|
119
|
+
const v = schema[key]
|
|
120
|
+
if (typeof v === 'object' && v !== null) _normalize(v, d6, d4)
|
|
120
121
|
}
|
|
121
122
|
}
|
|
123
|
+
// The schema-bearing keywords _normalize walks, built once rather than per node.
|
|
124
|
+
const D7_OBJ_SUBS = ['properties', 'patternProperties', '$defs', 'definitions', 'dependentSchemas']
|
|
125
|
+
const D7_ARR_SUBS = ['allOf', 'anyOf', 'oneOf', 'prefixItems']
|
|
126
|
+
const D7_SINGLE_SUBS = ['items', 'contains', 'not', 'if', 'then', 'else', 'additionalProperties', 'propertyNames']
|
|
122
127
|
|
|
123
128
|
// OpenAPI `nullable: true` is not JSON Schema. Convert it to a union with
|
|
124
129
|
// 'null' (`{ type: 'X', nullable: true }` -> `{ type: ['X', 'null'] }`), the
|
|
@@ -170,12 +175,36 @@ function _walkExclusive(node, seen) {
|
|
|
170
175
|
|
|
171
176
|
function normalizeNullable(schema) {
|
|
172
177
|
if (typeof schema !== 'object' || schema === null) return schema
|
|
173
|
-
|
|
178
|
+
const st = { seen: new Set(), refs: [] }
|
|
179
|
+
_normalizeNullable(schema, st)
|
|
180
|
+
// A local `$ref` can point anywhere in the document, an OpenAPI
|
|
181
|
+
// `components` section among them, where the walk over subschema keywords
|
|
182
|
+
// does not go. A schema reached that way is a schema all the same, and its
|
|
183
|
+
// `nullable` is folded too.
|
|
184
|
+
while (st.refs.length > 0) {
|
|
185
|
+
const target = _localTarget(schema, st.refs.pop())
|
|
186
|
+
if (typeof target === 'object' && target !== null && !Array.isArray(target)) _normalizeNullable(target, st)
|
|
187
|
+
}
|
|
174
188
|
return schema
|
|
175
189
|
}
|
|
176
190
|
|
|
177
|
-
function
|
|
191
|
+
function _localTarget(root, ref) {
|
|
192
|
+
if (ref === '#') return root
|
|
193
|
+
if (!ref.startsWith('#/')) return undefined
|
|
194
|
+
let node = root
|
|
195
|
+
for (const raw of ref.slice(2).split('/')) {
|
|
196
|
+
let seg = raw.replace(/~1/g, '/').replace(/~0/g, '~')
|
|
197
|
+
if (seg.includes('%')) { try { seg = decodeURIComponent(seg) } catch { return undefined } }
|
|
198
|
+
if (node === null || typeof node !== 'object' || !Object.prototype.hasOwnProperty.call(node, seg)) return undefined
|
|
199
|
+
node = node[seg]
|
|
200
|
+
}
|
|
201
|
+
return node
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function _normalizeNullable(schema, st) {
|
|
178
205
|
if (typeof schema !== 'object' || schema === null) return
|
|
206
|
+
if (st.seen.has(schema)) return
|
|
207
|
+
st.seen.add(schema)
|
|
179
208
|
|
|
180
209
|
if (schema.nullable === true && schema.type !== undefined) {
|
|
181
210
|
if (Array.isArray(schema.type)) {
|
|
@@ -195,37 +224,39 @@ function _normalizeNullable(schema) {
|
|
|
195
224
|
// alone: the engines decode once more, and `100%` must not be decoded twice.
|
|
196
225
|
// This walk is the one every normalization runs, so it costs no second pass;
|
|
197
226
|
// a reference it does not reach keeps the old behaviour, the interpreter.
|
|
198
|
-
if (
|
|
227
|
+
if (typeof schema.$ref === 'string' && schema.$ref.charCodeAt(0) === 35 && schema.$ref.indexOf('%') > 0) {
|
|
199
228
|
try {
|
|
200
229
|
const decoded = decodeURIComponent(schema.$ref)
|
|
201
230
|
if (!decoded.includes('%')) schema.$ref = decoded
|
|
202
231
|
} catch {}
|
|
203
232
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
for (const key of
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
233
|
+
if (typeof schema.$ref === 'string' && schema.$ref.charCodeAt(0) === 35) st.refs.push(schema.$ref)
|
|
234
|
+
|
|
235
|
+
for (const key of D7_OBJ_SUBS) {
|
|
236
|
+
const m = schema[key]
|
|
237
|
+
if (m && typeof m === 'object') {
|
|
238
|
+
for (const k in m) {
|
|
239
|
+
if (!Object.prototype.hasOwnProperty.call(m, k)) continue
|
|
240
|
+
const v = m[k]
|
|
241
|
+
if (typeof v === 'object' && v !== null) _normalizeNullable(v, st)
|
|
210
242
|
}
|
|
211
243
|
}
|
|
212
244
|
}
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
if (Array.isArray(
|
|
216
|
-
for (
|
|
217
|
-
|
|
245
|
+
for (const key of D7_ARR_SUBS) {
|
|
246
|
+
const a = schema[key]
|
|
247
|
+
if (Array.isArray(a)) {
|
|
248
|
+
for (let i = 0; i < a.length; i++) {
|
|
249
|
+
const v = a[i]
|
|
250
|
+
if (typeof v === 'object' && v !== null) _normalizeNullable(v, st)
|
|
218
251
|
}
|
|
219
252
|
}
|
|
220
253
|
}
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
if (typeof schema[key] === 'object' && schema[key] !== null) {
|
|
225
|
-
_normalizeNullable(schema[key])
|
|
226
|
-
}
|
|
254
|
+
for (const key of NULLABLE_SINGLE_SUBS) {
|
|
255
|
+
const v = schema[key]
|
|
256
|
+
if (typeof v === 'object' && v !== null) _normalizeNullable(v, st)
|
|
227
257
|
}
|
|
228
258
|
}
|
|
259
|
+
const NULLABLE_SINGLE_SUBS = ['items', 'contains', 'not', 'if', 'then', 'else', 'additionalProperties', 'propertyNames', 'unevaluatedItems', 'unevaluatedProperties']
|
|
229
260
|
|
|
230
261
|
// Draft 2020-12 treats `format` as an annotation unless the assertion
|
|
231
262
|
// vocabulary is in use. Removing the keyword is exactly that reading, and it
|
|
@@ -283,10 +314,6 @@ function _stripFormat(schema, seen) {
|
|
|
283
314
|
// carries the anchor, and a plain `$ref: "#"` when it does not, which is what
|
|
284
315
|
// 2019-09 says it means then. The outward search for the outermost anchored
|
|
285
316
|
// resource is the same rule in both drafts.
|
|
286
|
-
const DRAFT2019_SCHEMAS = new Set([
|
|
287
|
-
'https://json-schema.org/draft/2019-09/schema',
|
|
288
|
-
'https://json-schema.org/draft/2019-09/schema#',
|
|
289
|
-
])
|
|
290
317
|
const RECURSIVE_ANCHOR = '__ata_recursive_anchor'
|
|
291
318
|
|
|
292
319
|
function isDraft2019(schema) {
|
|
@@ -338,12 +365,4 @@ function _normalize2019(node, resourceRoot, isRoot) {
|
|
|
338
365
|
// The draft a `$schema` value names, for the normalizers: 2 draft-06,
|
|
339
366
|
// 1 draft-07, 3 2019-09, 0 anything else. Takes the string so the caller
|
|
340
367
|
// reads the property once.
|
|
341
|
-
function draftCode(uri) {
|
|
342
|
-
if (typeof uri !== 'string') return 0
|
|
343
|
-
if (DRAFT6_SCHEMAS.has(uri)) return 2
|
|
344
|
-
if (DRAFT7_SCHEMAS.has(uri)) return 1
|
|
345
|
-
if (DRAFT2019_SCHEMAS.has(uri)) return 3
|
|
346
|
-
return 0
|
|
347
|
-
}
|
|
348
|
-
|
|
349
368
|
module.exports = { draftCode, isDraft7, isDraft6, isDraft2019, normalizeDraft2019, normalizeDraft7, normalizeNullable, normalizeExclusiveBounds, stripFormatAssertions }
|
package/lib/enrich-site.js
CHANGED
|
@@ -16,9 +16,31 @@ const { reprValue, expectedFor, detailFor, rankFor, MISSING, DOC_BASE } = requir
|
|
|
16
16
|
// output. A module of its own because only the code generator reaches it, and
|
|
17
17
|
// ata-validator/lite, which enriches through `enrich`, should not carry it.
|
|
18
18
|
const NO_EXPECTED = {};
|
|
19
|
+
// `detail` (see detailFor in lib/enrich-error.js), worked out at the site as
|
|
20
|
+
// far as the site allows: the whole string when it depends only on the
|
|
21
|
+
// params, its fixed head when only the received value is left to append, and
|
|
22
|
+
// neither when it depends on the value (type) or the params are not fixed.
|
|
23
|
+
const DETAIL_AT_RUN = 0;
|
|
24
|
+
const DETAIL_FIXED = 1;
|
|
25
|
+
const DETAIL_HEAD = 2;
|
|
26
|
+
const RANGE_KEYWORDS = new Set(['minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'minLength', 'maxLength']);
|
|
27
|
+
function siteDetail (keyword, params, expected) {
|
|
28
|
+
if (params === undefined || keyword === 'type') return [DETAIL_AT_RUN, undefined];
|
|
29
|
+
switch (keyword) {
|
|
30
|
+
case 'required': return [DETAIL_FIXED, `missing required property "${params.missingProperty}"`];
|
|
31
|
+
case 'additionalProperties': return [DETAIL_FIXED, `unknown property "${params.additionalProperty}"`];
|
|
32
|
+
case 'unevaluatedProperties': return [DETAIL_FIXED, `unevaluated property "${params.unevaluatedProperty}"`];
|
|
33
|
+
case 'format': return [DETAIL_HEAD, `not a valid ${params.format}: `];
|
|
34
|
+
}
|
|
35
|
+
if (RANGE_KEYWORDS.has(keyword) || expected) return [DETAIL_HEAD, `expected ${expected}, found `];
|
|
36
|
+
return [DETAIL_FIXED, undefined];
|
|
37
|
+
}
|
|
38
|
+
|
|
19
39
|
function errorSite (keyword, schemaPath, params, format) {
|
|
20
40
|
const dependent = keyword === 'required' && typeof schemaPath === 'string' && schemaPath.endsWith('/dependentRequired');
|
|
21
41
|
const code = (dependent && 'ATA7005') || codeFor(keyword, format) || 'ATA9001';
|
|
42
|
+
const expected = params === undefined ? NO_EXPECTED : expectedFor({ keyword, params });
|
|
43
|
+
const [detailKind, detail] = siteDetail(keyword, params, expected);
|
|
22
44
|
return {
|
|
23
45
|
code,
|
|
24
46
|
keyword,
|
|
@@ -26,7 +48,9 @@ function errorSite (keyword, schemaPath, params, format) {
|
|
|
26
48
|
docUrl: DOC_BASE + code,
|
|
27
49
|
rank: rankFor(keyword),
|
|
28
50
|
headline: (CODES[code] && CODES[code].headline) || 'validation failed',
|
|
29
|
-
expected
|
|
51
|
+
expected,
|
|
52
|
+
detailKind,
|
|
53
|
+
detail,
|
|
30
54
|
};
|
|
31
55
|
}
|
|
32
56
|
|
|
@@ -49,7 +73,10 @@ function makeRich (site, path, params, message, value, root, spPrefix) {
|
|
|
49
73
|
};
|
|
50
74
|
const sugg = suggestFor(out, root, at === MISSING ? undefined : at);
|
|
51
75
|
if (sugg) out.suggestion = sugg;
|
|
52
|
-
const
|
|
76
|
+
const kind = site.detailKind;
|
|
77
|
+
const detail = kind === DETAIL_FIXED ? site.detail
|
|
78
|
+
: kind === DETAIL_HEAD ? site.detail + out.received
|
|
79
|
+
: detailFor({ keyword: site.keyword, params }, out, at);
|
|
53
80
|
if (detail !== undefined) out.detail = detail;
|
|
54
81
|
out.rank = site.rank;
|
|
55
82
|
return out;
|
package/lib/error-runtime.js
CHANGED
|
@@ -23,6 +23,8 @@ const FILES = [
|
|
|
23
23
|
'safe-regex',
|
|
24
24
|
'unique-items',
|
|
25
25
|
'formats',
|
|
26
|
+
// The URI walk formats.js loads; left out with formats (see OPTIONAL).
|
|
27
|
+
'uri-core',
|
|
26
28
|
];
|
|
27
29
|
// The parts a build can leave out when none of its schemas needs them. The
|
|
28
30
|
// decision is made on the schemas the runtime walks (as embedded, see
|
|
@@ -91,8 +93,10 @@ function errorRuntimeSource(format, version, features, schemaMode) {
|
|
|
91
93
|
if (hit !== undefined) return hit;
|
|
92
94
|
const defs = [];
|
|
93
95
|
for (const name of schemaMode ? FILES.concat(SCHEMA_FILES) : FILES) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
+
// uri-core goes with formats: only formats.js requires it.
|
|
97
|
+
const part = name === 'uri-core' ? 'formats' : name;
|
|
98
|
+
const src = OPTIONAL[part] !== undefined && !want(part)
|
|
99
|
+
? (name === 'uri-core' ? 'module.exports = {};' : OPTIONAL[part].stub)
|
|
96
100
|
: dropCommentLines(fs.readFileSync(path.join(__dirname, name + '.js'), 'utf8'), name);
|
|
97
101
|
// The files' own require('./x') calls go to the table under another name:
|
|
98
102
|
// a tool that reads the runtime without scope (knip, a lint rule) took a
|