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/README.md
CHANGED
|
@@ -298,6 +298,13 @@ for.
|
|
|
298
298
|
process is not a cost anyone notices, and the runtime API is the simpler thing to reach
|
|
299
299
|
for. Speed is the same either way once warm.
|
|
300
300
|
|
|
301
|
+
**On Node 22 or later, a process that starts often can keep V8's compiled code between
|
|
302
|
+
starts.** Point `NODE_COMPILE_CACHE` at a directory that survives the restart, or call
|
|
303
|
+
`module.enableCompileCache()` in your entry. Measured on an Apple M4 Pro, Node 25.2.1:
|
|
304
|
+
loading ata goes from 4.9 to 2.7 ms, and a fresh process reaches its first validated
|
|
305
|
+
document in 5.3 ms instead of 7.8. This is Node's feature, it covers every module the
|
|
306
|
+
process loads, and ata does not turn it on for you.
|
|
307
|
+
|
|
301
308
|
**In a browser, on an edge runtime, or anywhere cold starts are charged, compile.** This
|
|
302
309
|
is where the difference is the whole story, and it is also where `new Function` is often
|
|
303
310
|
blocked outright, which the compiled module does not need. With a bundler, adding `@ata-project/unplugin`
|
package/compat.js
CHANGED
|
@@ -21,12 +21,30 @@ const { resolvePointer } = require('./lib/pointer.js');
|
|
|
21
21
|
// `strictTuples` and `strictRequired` are accepted and ignored.
|
|
22
22
|
|
|
23
23
|
const { Validator } = require('./index');
|
|
24
|
+
// The interpreted engine's coercion where each type is checked and its
|
|
25
|
+
// discriminator, which only compat asks for (see lib/interpreter-compat.js).
|
|
26
|
+
require('./lib/interpreter-core')._setCompat(require('./lib/interpreter-compat'));
|
|
24
27
|
const { METASCHEMAS } = require('./lib/metaschemas');
|
|
28
|
+
const { normalizeExclusiveBounds } = require('./lib/draft7');
|
|
25
29
|
const { createShaper, asReferenceKeyword } = require('./lib/compat-errors');
|
|
26
30
|
|
|
27
31
|
const DRAFT7 = 'http://json-schema.org/draft-07/schema#';
|
|
28
32
|
const DRAFT2020 = 'https://json-schema.org/draft/2020-12/schema';
|
|
29
33
|
|
|
34
|
+
// draft-04, which OpenAPI 3.0 builds on, is read as draft-07, the nearest
|
|
35
|
+
// dialect ata implements: its boolean exclusiveMinimum and exclusiveMaximum
|
|
36
|
+
// are normalized to the numeric form, and its `id` names the schema as `$id`
|
|
37
|
+
// does. The reference ships a separate class for it (ajv-draft-04).
|
|
38
|
+
const DRAFT4_IDS = new Set(['http://json-schema.org/draft-04/schema#', 'http://json-schema.org/draft-04/schema', 'https://json-schema.org/draft-04/schema#', 'https://json-schema.org/draft-04/schema']);
|
|
39
|
+
function isDraft4(schema) {
|
|
40
|
+
return schema !== null && typeof schema === 'object' && typeof schema.$schema === 'string' && DRAFT4_IDS.has(schema.$schema);
|
|
41
|
+
}
|
|
42
|
+
function draft4AsDraft7(schema) {
|
|
43
|
+
const out = { ...schema, $schema: DRAFT7 };
|
|
44
|
+
if (typeof schema.id === 'string' && schema.$id === undefined) { out.$id = schema.id; delete out.id; }
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
30
48
|
function isDraft7Id(id) {
|
|
31
49
|
return id === DRAFT7 || id === 'http://json-schema.org/draft-07/schema';
|
|
32
50
|
}
|
|
@@ -58,17 +76,12 @@ class Ata {
|
|
|
58
76
|
throw new Error('ata compat: $data references are not supported');
|
|
59
77
|
}
|
|
60
78
|
this.opts = { ...opts };
|
|
61
|
-
// The formats plugin of the reference assigns into `opts.code.formats`
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
set() {
|
|
68
|
-
throw new Error('ata compat: the formats plugin is not needed, formats such as email, uri, date-time and uuid are built in; remove addFormats()');
|
|
69
|
-
},
|
|
70
|
-
});
|
|
71
|
-
this.opts.code = code;
|
|
79
|
+
// The formats plugin of the reference assigns into `opts.code.formats` and
|
|
80
|
+
// then registers each of its formats through addFormat, which is what it
|
|
81
|
+
// does here too. It is not needed (the formats are built in), but a
|
|
82
|
+
// library that calls it inside its own setup, as express-openapi-validator
|
|
83
|
+
// does, could not take this class at all while the call was refused.
|
|
84
|
+
this.opts.code = { ...(opts.code || {}) };
|
|
72
85
|
|
|
73
86
|
this.errors = null;
|
|
74
87
|
this._schemas = new Map(); // key or $id -> schema object
|
|
@@ -97,9 +110,13 @@ class Ata {
|
|
|
97
110
|
const o = this.opts;
|
|
98
111
|
const out = {
|
|
99
112
|
useDefaults: !!o.useDefaults,
|
|
100
|
-
coerceTypes: !!o.coerceTypes,
|
|
113
|
+
coerceTypes: o.coerceTypes === 'array' ? 'array' : !!o.coerceTypes,
|
|
101
114
|
removeAdditional: !!o.removeAdditional,
|
|
102
115
|
verbose: !!o.verbose,
|
|
116
|
+
// The errors are reshaped into the reference's own; the fields only
|
|
117
|
+
// rich errors carry (code, received, suggestion, docUrl) are dropped on
|
|
118
|
+
// the way, so they are not built at all.
|
|
119
|
+
richErrors: false,
|
|
103
120
|
};
|
|
104
121
|
// `strict` and `strictSchema`: the unknown-keyword and dangling-local-$ref
|
|
105
122
|
// checks are enforced; `strictTypes`, `strictTuples` and `strictRequired`
|
|
@@ -112,11 +129,9 @@ class Ata {
|
|
|
112
129
|
const formatNames = Object.keys(this._formats);
|
|
113
130
|
if (formatNames.length > 0) out.formats = { ...this._formats };
|
|
114
131
|
const keywordNames = Object.keys(this._keywords);
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if (Object.keys(kws).length > 0) out.keywords = kws;
|
|
119
|
-
}
|
|
132
|
+
const kws = {};
|
|
133
|
+
for (const name of keywordNames) if (this._keywords[name] !== true) kws[name] = this._keywords[name];
|
|
134
|
+
if (Object.keys(kws).length > 0) out.keywords = kws;
|
|
120
135
|
const registry = this._registrySnapshot();
|
|
121
136
|
if (registry) out.schemas = registry;
|
|
122
137
|
return out;
|
|
@@ -126,23 +141,111 @@ class Ata {
|
|
|
126
141
|
if (this._schemas.size === 0) return null;
|
|
127
142
|
if (this._registry === null) {
|
|
128
143
|
const map = {};
|
|
129
|
-
for (const [key, schema] of this._schemas) map[key] = this._withDialect(schema);
|
|
144
|
+
for (const [key, schema] of this._schemas) map[fragmentKey(key)] = this._fragmentRefs(this._withDialect(schema));
|
|
130
145
|
this._registry = map;
|
|
131
146
|
}
|
|
132
147
|
return this._registry;
|
|
133
148
|
}
|
|
134
149
|
|
|
150
|
+
// A schema registered under a key that is only a fragment
|
|
151
|
+
// (`addSchema(s, '#/components/schemas/Pet')`, as OpenAPI tools do) is
|
|
152
|
+
// found by a `$ref` of that exact string that does not resolve in its own
|
|
153
|
+
// document: the reference looks it up among the registered keys then. ata
|
|
154
|
+
// resolves a fragment in the document alone, so such a `$ref` is pointed at
|
|
155
|
+
// the key's entry in the registry (see fragmentKey).
|
|
156
|
+
_fragmentRefs(schema) {
|
|
157
|
+
if (schema === null || typeof schema !== 'object') return schema;
|
|
158
|
+
let keys = this._fragmentKeys;
|
|
159
|
+
if (keys === undefined || this._fragmentStale) {
|
|
160
|
+
// The copies stand while the fragment-only keys are the same ones.
|
|
161
|
+
const now = new Set();
|
|
162
|
+
for (const key of this._schemas.keys()) if (key.startsWith('#')) now.add(key);
|
|
163
|
+
this._fragmentStale = false;
|
|
164
|
+
if (keys === undefined || keys.size !== now.size || [...now].some((k) => !keys.has(k))) {
|
|
165
|
+
// Each key's pointer, split and decoded once.
|
|
166
|
+
keys = this._fragmentKeys = new Map();
|
|
167
|
+
for (const key of now) {
|
|
168
|
+
let segs = null;
|
|
169
|
+
if (key.startsWith('#/')) {
|
|
170
|
+
try { segs = key.slice(2).split('/').map((raw) => decodeURIComponent(raw.replace(/~1/g, '/').replace(/~0/g, '~'))); } catch { segs = null; }
|
|
171
|
+
}
|
|
172
|
+
keys.set(key, segs);
|
|
173
|
+
}
|
|
174
|
+
this._fragmentCopies = new WeakMap();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (keys.size === 0) return schema;
|
|
178
|
+
const hit = this._fragmentCopies.get(schema);
|
|
179
|
+
if (hit !== undefined) return hit;
|
|
180
|
+
if ((features(schema) & F_REF) === 0) { this._fragmentCopies.set(schema, schema); return schema; }
|
|
181
|
+
const resolves = (key, segs) => {
|
|
182
|
+
if (key === '#') return true;
|
|
183
|
+
if (segs === null) return !key.startsWith('#/');
|
|
184
|
+
let node = schema;
|
|
185
|
+
for (let i = 0; i < segs.length; i++) {
|
|
186
|
+
if (node === null || typeof node !== 'object' || !Object.prototype.hasOwnProperty.call(node, segs[i])) return false;
|
|
187
|
+
node = node[segs[i]];
|
|
188
|
+
}
|
|
189
|
+
return true;
|
|
190
|
+
};
|
|
191
|
+
// Only a key that does not resolve in this document can need pointing
|
|
192
|
+
// elsewhere. A document that holds every registered fragment itself, as
|
|
193
|
+
// an OpenAPI tool's schema carrying the whole `components` does, is left
|
|
194
|
+
// as it is without a walk.
|
|
195
|
+
const pointed = new Set();
|
|
196
|
+
for (const [key, segs] of keys) if (!resolves(key, segs)) pointed.add(key);
|
|
197
|
+
if (pointed.size === 0) { this._fragmentCopies.set(schema, schema); return schema; }
|
|
198
|
+
let changed = false;
|
|
199
|
+
const copy = (n, data) => {
|
|
200
|
+
if (n === null || typeof n !== 'object') return n;
|
|
201
|
+
if (Array.isArray(n)) return n.map((x) => copy(x, data));
|
|
202
|
+
const out = {};
|
|
203
|
+
for (const k of Object.keys(n)) {
|
|
204
|
+
let v;
|
|
205
|
+
if (!data && k === '$ref' && typeof n[k] === 'string' && pointed.has(n[k])) { v = fragmentKey(n[k]); changed = true; }
|
|
206
|
+
else v = copy(n[k], !data && (k === 'const' || k === 'enum' || k === 'default' || k === 'examples' || k === 'example'));
|
|
207
|
+
if (k === '__proto__') Object.defineProperty(out, k, { value: v, writable: true, enumerable: true, configurable: true });
|
|
208
|
+
else out[k] = v;
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
};
|
|
212
|
+
const out = copy(schema, false);
|
|
213
|
+
const result = changed ? out : schema;
|
|
214
|
+
this._fragmentCopies.set(schema, result);
|
|
215
|
+
return result;
|
|
216
|
+
}
|
|
217
|
+
|
|
135
218
|
_touchRegistry() {
|
|
219
|
+
this._fragmentStale = true;
|
|
136
220
|
this._registry = null;
|
|
137
221
|
this._registryEpoch++;
|
|
138
222
|
}
|
|
139
223
|
|
|
140
224
|
// A schema with no `$schema` is read as draft-07, which is what the
|
|
141
|
-
// reference class does
|
|
225
|
+
// reference class does, or as `defaultMeta` when that names a dialect: the
|
|
226
|
+
// reference's 2020-12 class reads one as 2020-12, which is how an OpenAPI
|
|
227
|
+
// 3.1 document's schemas are meant. The copy is shallow: only the root
|
|
228
|
+
// changes.
|
|
229
|
+
// The copy is kept per schema object, so a schema compiled again or
|
|
230
|
+
// registered again is the same object to everything that remembers work
|
|
231
|
+
// per object below (the scan, normalization, the fragment copies).
|
|
142
232
|
_withDialect(schema) {
|
|
143
233
|
if (typeof schema !== 'object' || schema === null || Array.isArray(schema)) return schema;
|
|
234
|
+
if (this._dialectCopies === undefined) this._dialectCopies = new WeakMap();
|
|
235
|
+
let out = this._dialectCopies.get(schema);
|
|
236
|
+
if (out === undefined) {
|
|
237
|
+
out = this._withDialectCopy(schema);
|
|
238
|
+
this._dialectCopies.set(schema, out);
|
|
239
|
+
}
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
_withDialectCopy(schema) {
|
|
244
|
+
if (isDraft4(schema)) return draft4AsDraft7(schema);
|
|
144
245
|
if (typeof schema.$schema === 'string') return schema;
|
|
145
|
-
|
|
246
|
+
const meta = typeof this.opts.defaultMeta === 'string' ? this.opts.defaultMeta : DRAFT7;
|
|
247
|
+
if (DRAFT4_IDS.has(meta)) return draft4AsDraft7({ $schema: meta, ...schema });
|
|
248
|
+
return { $schema: meta, ...schema };
|
|
146
249
|
}
|
|
147
250
|
|
|
148
251
|
// ----- compile / validate --------------------------------------------
|
|
@@ -163,9 +266,20 @@ class Ata {
|
|
|
163
266
|
}
|
|
164
267
|
}
|
|
165
268
|
}
|
|
166
|
-
|
|
269
|
+
if (this._codeOnly && typeof schema === 'object' && schema !== null) {
|
|
270
|
+
const { schemaUsesKeywords } = require('./lib/keywords');
|
|
271
|
+
for (const name of Object.keys(this._codeOnly)) {
|
|
272
|
+
if (schemaUsesKeywords(schema, { [name]: this._codeOnly[name] }) || [...this._schemas.values()].some((s) => schemaUsesKeywords(s, { [name]: this._codeOnly[name] }))) {
|
|
273
|
+
throw new Error(`keyword "${name}": code-generating keywords are not supported; define it with validate, compile or macro`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const rootDoc = this._fragmentRefs(this._withDialect(schema));
|
|
167
278
|
const options = this._validatorOptions();
|
|
168
279
|
const allErrors = !!this.opts.allErrors;
|
|
280
|
+
// The reference's `discriminator` option, for a schema that has one.
|
|
281
|
+
const discriminated = this.opts.discriminator === true &&
|
|
282
|
+
((features(rootDoc) & F_DISCR) !== 0 || Object.values(options.schemas || {}).some((d) => features(d) & F_DISCR));
|
|
169
283
|
let shaper = null;
|
|
170
284
|
if (typeof rootDoc === 'object' && rootDoc !== null) {
|
|
171
285
|
// The root is registered under a private id so the error shaper can
|
|
@@ -174,18 +288,47 @@ class Ata {
|
|
|
174
288
|
const rootId = 'ata-compat:root';
|
|
175
289
|
options.schemas = { ...(options.schemas || {}), [rootId]: rootDoc };
|
|
176
290
|
shaper = createShaper({
|
|
177
|
-
Validator, rootId, rootDoc, options, allErrors,
|
|
291
|
+
Validator, rootId, rootDoc, options, allErrors, schemas: options.schemas, discriminator: discriminated,
|
|
178
292
|
macroKeywords: Object.keys(this._keywords).filter((k) => this._keywords[k] !== true && this._keywords[k].macro),
|
|
179
293
|
});
|
|
180
294
|
}
|
|
295
|
+
// coerceTypes reaching a value behind allOf, anyOf, oneOf, $ref and the
|
|
296
|
+
// rest, where ata's coercion pass, which runs before validation and
|
|
297
|
+
// follows `properties` and `items`, does not: the interpreted engine then
|
|
298
|
+
// coerces where each `type` is checked, as the reference does.
|
|
299
|
+
if (options.coerceTypes && (features(rootDoc) & F_BEYOND) !== 0) {
|
|
300
|
+
options._coerceInWalk = this.opts.coerceTypes === 'array' ? 'array' : true;
|
|
301
|
+
options.coerceTypes = false;
|
|
302
|
+
options.engine = 'interpreter';
|
|
303
|
+
}
|
|
304
|
+
// The reference's `discriminator` option: the interpreted engine reads the
|
|
305
|
+
// keyword (see discriminate in lib/interpreter-core.js).
|
|
306
|
+
// A format registered for numbers, for a schema that names a format.
|
|
307
|
+
if (this._numFormats && Object.keys(this._numFormats).length > 0 &&
|
|
308
|
+
((features(rootDoc) & F_FORMAT) !== 0 || Object.values(options.schemas || {}).some((d) => features(d) & F_FORMAT))) {
|
|
309
|
+
options._numberFormats = { ...this._numFormats };
|
|
310
|
+
options.engine = 'interpreter';
|
|
311
|
+
}
|
|
312
|
+
if (discriminated) {
|
|
313
|
+
options._discriminator = true;
|
|
314
|
+
options.engine = 'interpreter';
|
|
315
|
+
}
|
|
181
316
|
const v = new Validator(rootDoc, options);
|
|
317
|
+
const defaults = this.opts.useDefaults ? referencedDefaults(rootDoc, options.schemas) : null;
|
|
318
|
+
// With keywords that read their data's context, the shaper is given the
|
|
319
|
+
// errors as the validator made them: those carry each anyOf and oneOf
|
|
320
|
+
// branch's errors (see keepBranches in lib/interpreter-core.js), which the
|
|
321
|
+
// copies handed out do not.
|
|
322
|
+
const ctxKeywords = options.keywords !== undefined && Object.values(options.keywords).some((k) => k && k.dataContext === true);
|
|
182
323
|
const validate = (data) => {
|
|
324
|
+
if (defaults !== null) defaults(data);
|
|
183
325
|
const result = v.validate(data);
|
|
184
326
|
if (result.valid) {
|
|
185
327
|
validate.errors = null;
|
|
186
328
|
return true;
|
|
187
329
|
}
|
|
188
|
-
|
|
330
|
+
const raw = ctxKeywords && shaper !== null && typeof result._ataRaw === 'function' ? result._ataRaw() : result.errors;
|
|
331
|
+
let errors = shaper === null ? result.errors.map(asReferenceKeyword) : shaper(raw, data);
|
|
189
332
|
// Errors the shaper synthesises, the `if` wrapper among them, are built
|
|
190
333
|
// from the group rather than carried up from the validator, so verbose
|
|
191
334
|
// mode has to fill in the value they point at here or they would be the
|
|
@@ -195,7 +338,7 @@ class Ata {
|
|
|
195
338
|
? { ...e, data: resolvePointer(data, e.instancePath || '', undefined) }
|
|
196
339
|
: e));
|
|
197
340
|
}
|
|
198
|
-
validate.errors = shaper === null && !allErrors ? errors.slice(0, 1) : errors;
|
|
341
|
+
validate.errors = inReferenceOrder(shaper === null && !allErrors ? errors.slice(0, 1) : errors);
|
|
199
342
|
return false;
|
|
200
343
|
};
|
|
201
344
|
validate.errors = null;
|
|
@@ -231,7 +374,8 @@ class Ata {
|
|
|
231
374
|
for (const s of schema) this.addSchema(s);
|
|
232
375
|
return this;
|
|
233
376
|
}
|
|
234
|
-
const id = schema && typeof schema === 'object' && typeof schema.$id === 'string' ? schema.$id
|
|
377
|
+
const id = schema && typeof schema === 'object' && typeof schema.$id === 'string' ? schema.$id
|
|
378
|
+
: isDraft4(schema) && typeof schema.id === 'string' ? schema.id : undefined;
|
|
235
379
|
const k = key !== undefined ? String(key) : id;
|
|
236
380
|
if (k === undefined) throw new Error('schema with key or id "undefined" cannot be added: no key and no $id');
|
|
237
381
|
if (this._schemas.has(k) && this._schemas.get(k) !== schema) {
|
|
@@ -308,7 +452,11 @@ class Ata {
|
|
|
308
452
|
this.errors = [{ keyword: 'type', instancePath: '', schemaPath: '#/type', params: { type: 'object' }, message: 'must be object,boolean' }];
|
|
309
453
|
return false;
|
|
310
454
|
}
|
|
311
|
-
|
|
455
|
+
// A draft-04 schema is checked as the draft-07 schema it is read as: the
|
|
456
|
+
// boolean exclusive bounds in their numeric form, against draft-07's
|
|
457
|
+
// meta-schema (ata carries no draft-04 meta-schema).
|
|
458
|
+
if (isDraft4(schema)) schema = normalizeExclusiveBounds(structuredClone(draft4AsDraft7(schema)));
|
|
459
|
+
const declared = typeof schema.$schema === 'string' ? schema.$schema : typeof this.opts.defaultMeta === 'string' && !DRAFT4_IDS.has(this.opts.defaultMeta) ? this.opts.defaultMeta : DRAFT7;
|
|
312
460
|
const metaId = METASCHEMAS.has(declared) ? declared : isDraft7Id(declared) ? DRAFT7 : DRAFT2020;
|
|
313
461
|
const meta = metaValidator(metaId);
|
|
314
462
|
const result = meta.validate(schema);
|
|
@@ -330,6 +478,15 @@ class Ata {
|
|
|
330
478
|
|
|
331
479
|
addFormat(name, format) {
|
|
332
480
|
this._formats[name] = formatToFunction(name, format);
|
|
481
|
+
// The reference applies a format declared `type: 'number'` to numbers,
|
|
482
|
+
// where JSON Schema, and ata, apply formats to strings only. Such a
|
|
483
|
+
// format is also checked on numbers here, by the interpreted engine (see
|
|
484
|
+
// compile).
|
|
485
|
+
if (format && typeof format === 'object' && format.type === 'number') {
|
|
486
|
+
this._numFormats = this._numFormats || Object.create(null);
|
|
487
|
+
this._numFormats[name] = this._formats[name];
|
|
488
|
+
} else if (this._numFormats) delete this._numFormats[name];
|
|
489
|
+
this._touchRegistry();
|
|
333
490
|
this._compiled = new WeakMap();
|
|
334
491
|
return this;
|
|
335
492
|
}
|
|
@@ -346,6 +503,12 @@ class Ata {
|
|
|
346
503
|
keyword = kwdOrDef.keyword;
|
|
347
504
|
definition = kwdOrDef;
|
|
348
505
|
}
|
|
506
|
+
// The reference takes a list of names for one definition (the formats
|
|
507
|
+
// plugin registers formatMinimum and its kin that way).
|
|
508
|
+
if (Array.isArray(keyword)) {
|
|
509
|
+
for (const k of keyword) this.addKeyword({ ...definition, keyword: k });
|
|
510
|
+
return this;
|
|
511
|
+
}
|
|
349
512
|
if (typeof keyword !== 'string' || keyword === '') {
|
|
350
513
|
throw new Error('addKeyword: keyword must be a non-empty string');
|
|
351
514
|
}
|
|
@@ -358,14 +521,28 @@ class Ata {
|
|
|
358
521
|
return this;
|
|
359
522
|
}
|
|
360
523
|
if (typeof definition.code === 'function' && !definition.validate && !definition.compile && !definition.macro) {
|
|
361
|
-
|
|
524
|
+
// A keyword defined only as generated code cannot run here. It is
|
|
525
|
+
// registered as known and refused when a schema uses it (see compile),
|
|
526
|
+
// so a library that defines such keywords for options it may never use
|
|
527
|
+
// can still take this class, and a schema that relies on one is never
|
|
528
|
+
// accepted without the check.
|
|
529
|
+
this._codeOnly = this._codeOnly || Object.create(null);
|
|
530
|
+
this._codeOnly[keyword] = { name: keyword, types: null, validate: null, compile: null, macro: null };
|
|
531
|
+
this._compiled = new WeakMap();
|
|
532
|
+
return this;
|
|
362
533
|
}
|
|
363
534
|
const mapped = {};
|
|
364
535
|
if (definition.type !== undefined) mapped.type = definition.type;
|
|
365
536
|
if (typeof definition.validate === 'function') mapped.validate = definition.validate;
|
|
366
|
-
if (typeof definition.compile === 'function') mapped.compile = definition.compile;
|
|
537
|
+
if (typeof definition.compile === 'function') mapped.compile = compileWithContext(definition.compile);
|
|
367
538
|
if (typeof definition.macro === 'function') mapped.macro = definition.macro;
|
|
539
|
+
if (typeof definition.before === 'string') mapped.before = definition.before;
|
|
540
|
+
if (definition.post === true) mapped.post = true;
|
|
368
541
|
if (mapped.validate && mapped.compile) delete mapped.validate;
|
|
542
|
+
// The reference hands every keyword function the data's context
|
|
543
|
+
// (parentData, parentDataProperty, instancePath, rootData), and keywords
|
|
544
|
+
// that remove or rewrite the value they check rely on it.
|
|
545
|
+
if (mapped.validate || mapped.compile) mapped.dataContext = true;
|
|
369
546
|
this._keywords[keyword] = mapped;
|
|
370
547
|
this._keywordDefs = this._keywordDefs || Object.create(null);
|
|
371
548
|
this._keywordDefs[keyword] = definition;
|
|
@@ -386,6 +563,7 @@ class Ata {
|
|
|
386
563
|
|
|
387
564
|
removeKeyword(keyword) {
|
|
388
565
|
delete this._keywords[keyword];
|
|
566
|
+
if (this._codeOnly) delete this._codeOnly[keyword];
|
|
389
567
|
if (this._keywordDefs) delete this._keywordDefs[keyword];
|
|
390
568
|
this._compiled = new WeakMap();
|
|
391
569
|
return this;
|
|
@@ -399,11 +577,129 @@ class Ata {
|
|
|
399
577
|
}
|
|
400
578
|
}
|
|
401
579
|
|
|
580
|
+
// A reference keyword's compile(schema, parentSchema, it) returns a function
|
|
581
|
+
// of (data, dataCxt). `it.schemaPath.str` is read by keywords that build their
|
|
582
|
+
// own error objects, at the time they fail: it names the schema the check
|
|
583
|
+
// sits in, which is known per call here, so it is read from the context the
|
|
584
|
+
// call was given.
|
|
585
|
+
function compileWithContext(compile) {
|
|
586
|
+
return (value, parentSchema) => {
|
|
587
|
+
let current = '#';
|
|
588
|
+
const it = { get schemaPath() { return { str: current }; } };
|
|
589
|
+
const inner = compile(value, parentSchema, it);
|
|
590
|
+
if (typeof inner !== 'function') return inner;
|
|
591
|
+
const wrapped = (data, ctx) => {
|
|
592
|
+
if (ctx && typeof ctx.schemaPath === 'string') current = ctx.schemaPath.slice(0, ctx.schemaPath.lastIndexOf('/')) || '#';
|
|
593
|
+
return inner(data, ctx);
|
|
594
|
+
};
|
|
595
|
+
// Errors the keyword writes land on its own function, read from here.
|
|
596
|
+
wrapped.source = inner;
|
|
597
|
+
return wrapped;
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// The reference writes an error's fields as instancePath, schemaPath,
|
|
602
|
+
// keyword, params, message; code that serializes errors sees the order. The
|
|
603
|
+
// errors a custom keyword reports come out in another, and are put in this one.
|
|
604
|
+
const REFERENCE_FIELDS = ['instancePath', 'schemaPath', 'keyword', 'params', 'message'];
|
|
605
|
+
function inReferenceOrder(errors) {
|
|
606
|
+
for (let i = 0; i < errors.length; i++) {
|
|
607
|
+
const e = errors[i];
|
|
608
|
+
if (e === null || typeof e !== 'object') continue;
|
|
609
|
+
const keys = Object.keys(e);
|
|
610
|
+
let ordered = true;
|
|
611
|
+
for (let j = 0; j < REFERENCE_FIELDS.length && j < keys.length; j++) if (keys[j] !== REFERENCE_FIELDS[j]) { ordered = false; break; }
|
|
612
|
+
if (ordered) continue;
|
|
613
|
+
const out = {};
|
|
614
|
+
for (const k of REFERENCE_FIELDS) if (k in e) out[k] = e[k];
|
|
615
|
+
for (const k of keys) if (!(k in out)) out[k] = e[k];
|
|
616
|
+
errors[i] = out;
|
|
617
|
+
}
|
|
618
|
+
return errors;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// useDefaults the way the reference applies it: in the properties of every
|
|
622
|
+
// subschema the data is validated against outside anyOf, oneOf, not and if,
|
|
623
|
+
// which includes those behind `allOf` and a `$ref`. ata's own pass fills the
|
|
624
|
+
// `properties` it can reach by nesting alone; this one, run before it, also
|
|
625
|
+
// goes through `allOf` and `$ref`. Both fill only a key the object does not
|
|
626
|
+
// have, so what one fills the other leaves. Null when the schema has no
|
|
627
|
+
// default anywhere such a walk could reach that ata's own pass would not.
|
|
628
|
+
function referencedDefaults(rootDoc, registry) {
|
|
629
|
+
if (rootDoc === null || typeof rootDoc !== 'object') return null;
|
|
630
|
+
const f = features(rootDoc);
|
|
631
|
+
if ((f & F_DEFAULT) === 0 && !Object.values(registry || {}).some((d) => features(d) & F_DEFAULT)) return null;
|
|
632
|
+
if ((f & (F_REF | F_ALLOF)) === 0) return null;
|
|
633
|
+
const resolve = (ref) => {
|
|
634
|
+
const hash = ref.indexOf('#');
|
|
635
|
+
const base = hash < 0 ? ref : ref.slice(0, hash);
|
|
636
|
+
const doc = base === '' ? rootDoc : registry && registry[base];
|
|
637
|
+
if (doc === undefined || doc === null) return null;
|
|
638
|
+
const frag = hash < 0 ? '' : ref.slice(hash + 1);
|
|
639
|
+
if (frag !== '' && !frag.startsWith('/')) return null;
|
|
640
|
+
let node = doc;
|
|
641
|
+
for (const raw of frag === '' ? [] : frag.slice(1).split('/')) {
|
|
642
|
+
let seg = raw.replace(/~1/g, '/').replace(/~0/g, '~');
|
|
643
|
+
try { seg = decodeURIComponent(seg); } catch { return null; }
|
|
644
|
+
if (node === null || typeof node !== 'object' || !Object.prototype.hasOwnProperty.call(node, seg)) return null;
|
|
645
|
+
node = node[seg];
|
|
646
|
+
}
|
|
647
|
+
return node;
|
|
648
|
+
};
|
|
649
|
+
const fill = (schema, data, active) => {
|
|
650
|
+
if (schema === null || typeof schema !== 'object' || data === null || typeof data !== 'object' || active.has(schema)) return;
|
|
651
|
+
active.add(schema);
|
|
652
|
+
if (typeof schema.$ref === 'string') fill(resolve(schema.$ref), data, active);
|
|
653
|
+
if (Array.isArray(schema.allOf)) for (const sub of schema.allOf) fill(sub, data, active);
|
|
654
|
+
const props = schema.properties;
|
|
655
|
+
if (props !== null && typeof props === 'object' && !Array.isArray(data)) {
|
|
656
|
+
for (const key of Object.keys(props)) {
|
|
657
|
+
const prop = props[key];
|
|
658
|
+
if (prop === null || typeof prop !== 'object') continue;
|
|
659
|
+
if (prop.default !== undefined && !Object.hasOwn(data, key)) {
|
|
660
|
+
const value = prop.default !== null && typeof prop.default === 'object' ? JSON.parse(JSON.stringify(prop.default)) : prop.default;
|
|
661
|
+
Object.defineProperty(data, key, { value, writable: true, enumerable: true, configurable: true });
|
|
662
|
+
}
|
|
663
|
+
// Into the value with a fresh set: the same schema may apply again
|
|
664
|
+
// one level down, in a recursive one.
|
|
665
|
+
if (Object.hasOwn(data, key)) fill(prop, data[key], new Set());
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
if (Array.isArray(data) && schema.items !== null && typeof schema.items === 'object' && !Array.isArray(schema.items)) {
|
|
669
|
+
for (const item of data) fill(schema.items, item, new Set());
|
|
670
|
+
}
|
|
671
|
+
active.delete(schema);
|
|
672
|
+
};
|
|
673
|
+
return (data) => fill(rootDoc, data, new Set());
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// The registry id a fragment-only key is kept under: a `#...` string is not an
|
|
677
|
+
// id a document can be found by, so it is given one.
|
|
678
|
+
function fragmentKey(key) {
|
|
679
|
+
return key.startsWith('#') ? 'ata-compat:fragment:' + encodeURIComponent(key.slice(1)) : key;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
// What a schema holds anywhere below it, read from the walk normalization
|
|
684
|
+
// makes of it anyway (lib/schema-scan.js), which remembers it per object.
|
|
685
|
+
const _scan = require('./lib/schema-scan');
|
|
686
|
+
const F_REF = 1 << 16, F_BEYOND = 1 << 17, F_DISCR = 1 << 18, F_DEFAULT = 1 << 19, F_ALLOF = 1 << 20, F_FORMAT = 1 << 21;
|
|
687
|
+
_scan._setExtraKeys(new Map([
|
|
688
|
+
['$ref', F_REF | F_BEYOND], ['$dynamicRef', F_BEYOND], ['allOf', F_ALLOF | F_BEYOND],
|
|
689
|
+
['anyOf', F_BEYOND], ['oneOf', F_BEYOND], ['not', F_BEYOND], ['if', F_BEYOND],
|
|
690
|
+
['then', F_BEYOND], ['else', F_BEYOND], ['additionalProperties', F_BEYOND],
|
|
691
|
+
['patternProperties', F_BEYOND], ['prefixItems', F_BEYOND], ['additionalItems', F_BEYOND],
|
|
692
|
+
['contains', F_BEYOND], ['dependentSchemas', F_BEYOND], ['dependencies', F_BEYOND],
|
|
693
|
+
['propertyNames', F_BEYOND], ['unevaluatedProperties', F_BEYOND], ['unevaluatedItems', F_BEYOND],
|
|
694
|
+
['discriminator', F_DISCR], ['default', F_DEFAULT], ['format', F_FORMAT],
|
|
695
|
+
]));
|
|
696
|
+
const features = _scan.scan;
|
|
697
|
+
|
|
402
698
|
const _metaValidators = new Map();
|
|
403
699
|
function metaValidator(id) {
|
|
404
700
|
let v = _metaValidators.get(id);
|
|
405
701
|
if (!v) {
|
|
406
|
-
v = new Validator({ $ref: id }, { useDefaults: false });
|
|
702
|
+
v = new Validator({ $ref: id }, { useDefaults: false, engine: 'interpreter' });
|
|
407
703
|
_metaValidators.set(id, v);
|
|
408
704
|
}
|
|
409
705
|
return v;
|
package/lib/codegen-engine.js
CHANGED
|
@@ -69,7 +69,8 @@ function richBuilder(combRich, root, presentFallback, fallback, plain, cleanPlai
|
|
|
69
69
|
return (data) => {
|
|
70
70
|
const r = combRich(data);
|
|
71
71
|
if (r.valid || !r.errors || !r.errors.length) return presentFallback(fallback, data);
|
|
72
|
-
|
|
72
|
+
// `mo`: the list came out in the reader's order (see compileToJSCombined).
|
|
73
|
+
const out = r.mo === true || !_needsOrdering(r.errors) ? r.errors : _sortErrors(root, r.errors);
|
|
73
74
|
// A collapsed oneOf or anyOf comes out plain (no docUrl) and is enriched
|
|
74
75
|
// here, its branch errors with it, as presentErrors would.
|
|
75
76
|
for (let i = 0; i < out.length; i++) {
|