ata-validator 1.51.0 → 1.53.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 +26 -20
- package/compat.js +314 -29
- package/index.bundle.mjs +15 -0
- package/lib/aot-impl.js +2 -2
- package/lib/combined-runtime.js +4 -1
- package/lib/compat-errors.js +131 -17
- package/lib/draft7.js +65 -35
- package/lib/error-runtime.js +5 -1
- package/lib/interpreter-core.js +297 -14
- package/lib/js-compiler-errors.js +15 -15
- package/lib/js-compiler.js +196 -45
- package/lib/keywords.js +24 -1
- package/lib/levenshtein.js +21 -5
- package/lib/plan-compiler.js +8 -3
- package/lib/safe-regex.js +33 -14
- package/lib/schema-scan.js +29 -1
- package/lib/validator-core.js +99 -13
- package/lib/version.js +1 -1
- package/package.json +10 -8
package/README.md
CHANGED
|
@@ -138,18 +138,19 @@ errors (7.6M with `richErrors: false`).
|
|
|
138
138
|
|
|
139
139
|
| Dimension | Schema | ata-AOT | runtime validator | Difference |
|
|
140
140
|
|---|---|---|---|---|
|
|
141
|
-
| Bundle (gzipped) | simple | 1.
|
|
142
|
-
| Bundle (gzipped) | complex | 3.0 KB | 52.7 KB | 17.
|
|
143
|
-
| Bundle (gzipped) | nested | 4.5 KB | 52.7 KB | 11.
|
|
141
|
+
| Bundle (gzipped) | simple | 1.3 KB | 52.7 KB | 39.0x smaller |
|
|
142
|
+
| Bundle (gzipped) | complex | 3.0 KB | 52.7 KB | 17.7x smaller |
|
|
143
|
+
| Bundle (gzipped) | nested | 4.5 KB | 52.7 KB | 11.8x smaller |
|
|
144
144
|
| Cold start | simple | 20 ms | 37 ms | 1.9x faster |
|
|
145
|
-
| Throughput (1M ops) | simple |
|
|
146
|
-
| Compile time | simple |
|
|
145
|
+
| Throughput (1M ops) | simple | 315 Mops/s | 99 Mops/s | 3.2x faster |
|
|
146
|
+
| Compile time | simple | 24 µs | 1.57 ms | 65x faster |
|
|
147
147
|
|
|
148
148
|
The runtime column is the default validator most frameworks ship. Reproduce on your machine
|
|
149
|
-
with `npm run bench:aot-vs-ajv`. Numbers from one run on Apple M4 Pro, Node 25.2.1, 2026-10-
|
|
150
|
-
on ata-validator 1.
|
|
151
|
-
|
|
152
|
-
and
|
|
149
|
+
with `npm run bench:aot-vs-ajv`. Numbers from one run on Apple M4 Pro, Node 25.2.1, 2026-10-11,
|
|
150
|
+
on ata-validator 1.51.0. Across five runs throughput moved between 263 and 325 Mops/s against
|
|
151
|
+
97 to 110, cold start between 20 and 22 ms against 37 to 40, and the compile ratio between 61x
|
|
152
|
+
and 65x. The compile row is the time to write a module, a build-time cost; it was 18 µs on
|
|
153
|
+
1.46.0, and modules have carried the one-pass error function since 1.47.0. The throughput row times a single one-million-call
|
|
153
154
|
loop of a few milliseconds, so it moves the most from run to run; treat the last three rows as
|
|
154
155
|
an order of magnitude rather than a constant.
|
|
155
156
|
|
|
@@ -279,13 +280,13 @@ is the most common way to get a misleading number out of this library.
|
|
|
279
280
|
|
|
280
281
|
| | compiled with `ata build` | runtime `new Validator(schema)` |
|
|
281
282
|
|---|---|---|
|
|
282
|
-
| In a bundle, gzipped | **2.1 KB** |
|
|
283
|
+
| In a bundle, gzipped | **2.1 KB** | 123.9 KB |
|
|
283
284
|
| Time to a served request | **3.5 ms** | 7.6 ms |
|
|
284
285
|
| Schema known when | build time | any time |
|
|
285
286
|
|
|
286
287
|
The bundle row is the ten-field user schema in
|
|
287
288
|
`tests/fixtures/error-dx/user.schema.json`, every export of the compiled module against
|
|
288
|
-
`new Validator(schema)`, built with `bun build --minify --target=browser` on ata 1.
|
|
289
|
+
`new Validator(schema)`, built with `bun build --minify --target=browser` on ata 1.51.0.
|
|
289
290
|
The startup row is a Hono route on Bun 1.4, the median of three rounds of best-of-seven, from
|
|
290
291
|
`benchmark/bundle`, against 3.5 ms for the same app doing no validation at all, so the
|
|
291
292
|
compiled path costs nothing measurable to start. The runtime figure is what it is because
|
|
@@ -293,7 +294,7 @@ a schema that arrives at run time can use any keyword, so the whole engine has t
|
|
|
293
294
|
there. The compiled module imports nothing and contains only the checks your schema asks
|
|
294
295
|
for.
|
|
295
296
|
|
|
296
|
-
**On a server, use whichever fits your schemas.**
|
|
297
|
+
**On a server, use whichever fits your schemas.** 124 KB of JavaScript on a Node or Bun
|
|
297
298
|
process is not a cost anyone notices, and the runtime API is the simpler thing to reach
|
|
298
299
|
for. Speed is the same either way once warm.
|
|
299
300
|
|
|
@@ -535,7 +536,7 @@ const v = new Validator(schema, {
|
|
|
535
536
|
|
|
536
537
|
### Build-time compile (`ata compile`)
|
|
537
538
|
|
|
538
|
-
The `ata` CLI turns a JSON Schema file into a self-contained JavaScript module. No runtime dependency on `ata-validator`, so only the generated validator ships to the browser. For the ten-field user schema in `tests/fixtures/error-dx/user.schema.json` the module is 2.1 KB gzipped, full error detail included, against
|
|
539
|
+
The `ata` CLI turns a JSON Schema file into a self-contained JavaScript module. No runtime dependency on `ata-validator`, so only the generated validator ships to the browser. For the ten-field user schema in `tests/fixtures/error-dx/user.schema.json` the module is 2.1 KB gzipped, full error detail included, against 123.9 KB for the runtime bundled for the browser (ata 1.51.0).
|
|
539
540
|
|
|
540
541
|
```bash
|
|
541
542
|
npx ata compile schemas/user.json -o src/generated/user.validator.mjs
|
|
@@ -581,20 +582,25 @@ error collector out of the modules: each one carries its verdict and its schema,
|
|
|
581
582
|
its errors from one `ata-errors.mjs` the build writes beside them. The build does this only
|
|
582
583
|
where it makes the output smaller, so a single small schema keeps its self-contained module.
|
|
583
584
|
The errors are the ones `new Validator(schema, { richErrors: false })` reports, in the same
|
|
584
|
-
order.
|
|
585
|
-
to
|
|
585
|
+
order. Forty SchemaStore schemas imported into one app, minified with esbuild, go from 513.8 KB
|
|
586
|
+
to 213.3 KB gzipped, and from 60.4 ms to 28.4 ms to import with code generation blocked. A
|
|
586
587
|
rejected document costs more this way, since its errors come from an interpreted walk:
|
|
587
588
|
`--errors inline` keeps every module self-contained. `--mode schema` writes each module as
|
|
588
589
|
its schema and takes the verdict from the runtime too: forty SchemaStore schemas in one app
|
|
589
|
-
come to
|
|
590
|
-
23.
|
|
590
|
+
come to 102.4 KB gzipped against 213.3 KB as compact modules, and load and check each once in
|
|
591
|
+
23.9 ms against 34.5, while a check once warm costs about five times what generated code costs.
|
|
592
|
+
Schemas that reference each other by `$id` are built against each other, and in schema mode
|
|
593
|
+
they are written once to a shared registry that keeps only the definitions the modules reach;
|
|
594
|
+
`--base-uri <uri>` names files that declare no `$id`. Eight Kubernetes kinds, whose schemas
|
|
595
|
+
share one 1.1 MB definitions file, come to 32.6 KB gzipped as schema modules against 68.6 KB
|
|
596
|
+
self-contained, on ata 1.51.0.
|
|
591
597
|
|
|
592
598
|
Bundle sizes for the 10-field user schema in `tests/fixtures/error-dx/user.schema.json`,
|
|
593
|
-
minified and gzipped, measured with `bun build --minify --target=browser` on ata 1.
|
|
599
|
+
minified and gzipped, measured with `bun build --minify --target=browser` on ata 1.51.0:
|
|
594
600
|
|
|
595
601
|
| What the app imports | Size | Notes |
|
|
596
602
|
|---|---|---|
|
|
597
|
-
| `Validator` from `ata-validator` |
|
|
603
|
+
| `Validator` from `ata-validator` | 123.9 KB | The compiler ships with it, because a runtime schema can use any keyword |
|
|
598
604
|
| `isValid` from the compiled module | **1.3 KB** | Nothing else is reachable, so the error collector is dropped |
|
|
599
605
|
| `validate` from the compiled module | **1.9 KB** | Adds the detailed error collector |
|
|
600
606
|
|
|
@@ -632,7 +638,7 @@ through a `setFormats()` export the module carries. `docs/API.md` has the
|
|
|
632
638
|
details.
|
|
633
639
|
|
|
634
640
|
**Fastify startup, 10 route schemas, from a cold process to the first validated request:
|
|
635
|
-
ajv 19.
|
|
641
|
+
ajv 19.0 ms, ata 2.81 ms, no build step required.** ata registers in 0.20 ms of that and
|
|
636
642
|
compiles on the first request, so counting only registration would overstate the gap.
|
|
637
643
|
Reproduce with `node benchmark/bench_fastify_boot.mjs`.
|
|
638
644
|
|
package/compat.js
CHANGED
|
@@ -22,11 +22,26 @@ const { resolvePointer } = require('./lib/pointer.js');
|
|
|
22
22
|
|
|
23
23
|
const { Validator } = require('./index');
|
|
24
24
|
const { METASCHEMAS } = require('./lib/metaschemas');
|
|
25
|
+
const { normalizeExclusiveBounds } = require('./lib/draft7');
|
|
25
26
|
const { createShaper, asReferenceKeyword } = require('./lib/compat-errors');
|
|
26
27
|
|
|
27
28
|
const DRAFT7 = 'http://json-schema.org/draft-07/schema#';
|
|
28
29
|
const DRAFT2020 = 'https://json-schema.org/draft/2020-12/schema';
|
|
29
30
|
|
|
31
|
+
// draft-04, which OpenAPI 3.0 builds on, is read as draft-07, the nearest
|
|
32
|
+
// dialect ata implements: its boolean exclusiveMinimum and exclusiveMaximum
|
|
33
|
+
// are normalized to the numeric form, and its `id` names the schema as `$id`
|
|
34
|
+
// does. The reference ships a separate class for it (ajv-draft-04).
|
|
35
|
+
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']);
|
|
36
|
+
function isDraft4(schema) {
|
|
37
|
+
return schema !== null && typeof schema === 'object' && typeof schema.$schema === 'string' && DRAFT4_IDS.has(schema.$schema);
|
|
38
|
+
}
|
|
39
|
+
function draft4AsDraft7(schema) {
|
|
40
|
+
const out = { ...schema, $schema: DRAFT7 };
|
|
41
|
+
if (typeof schema.id === 'string' && schema.$id === undefined) { out.$id = schema.id; delete out.id; }
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
30
45
|
function isDraft7Id(id) {
|
|
31
46
|
return id === DRAFT7 || id === 'http://json-schema.org/draft-07/schema';
|
|
32
47
|
}
|
|
@@ -58,17 +73,12 @@ class Ata {
|
|
|
58
73
|
throw new Error('ata compat: $data references are not supported');
|
|
59
74
|
}
|
|
60
75
|
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;
|
|
76
|
+
// The formats plugin of the reference assigns into `opts.code.formats` and
|
|
77
|
+
// then registers each of its formats through addFormat, which is what it
|
|
78
|
+
// does here too. It is not needed (the formats are built in), but a
|
|
79
|
+
// library that calls it inside its own setup, as express-openapi-validator
|
|
80
|
+
// does, could not take this class at all while the call was refused.
|
|
81
|
+
this.opts.code = { ...(opts.code || {}) };
|
|
72
82
|
|
|
73
83
|
this.errors = null;
|
|
74
84
|
this._schemas = new Map(); // key or $id -> schema object
|
|
@@ -97,9 +107,13 @@ class Ata {
|
|
|
97
107
|
const o = this.opts;
|
|
98
108
|
const out = {
|
|
99
109
|
useDefaults: !!o.useDefaults,
|
|
100
|
-
coerceTypes: !!o.coerceTypes,
|
|
110
|
+
coerceTypes: o.coerceTypes === 'array' ? 'array' : !!o.coerceTypes,
|
|
101
111
|
removeAdditional: !!o.removeAdditional,
|
|
102
112
|
verbose: !!o.verbose,
|
|
113
|
+
// The errors are reshaped into the reference's own; the fields only
|
|
114
|
+
// rich errors carry (code, received, suggestion, docUrl) are dropped on
|
|
115
|
+
// the way, so they are not built at all.
|
|
116
|
+
richErrors: false,
|
|
103
117
|
};
|
|
104
118
|
// `strict` and `strictSchema`: the unknown-keyword and dangling-local-$ref
|
|
105
119
|
// checks are enforced; `strictTypes`, `strictTuples` and `strictRequired`
|
|
@@ -112,11 +126,9 @@ class Ata {
|
|
|
112
126
|
const formatNames = Object.keys(this._formats);
|
|
113
127
|
if (formatNames.length > 0) out.formats = { ...this._formats };
|
|
114
128
|
const keywordNames = Object.keys(this._keywords);
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if (Object.keys(kws).length > 0) out.keywords = kws;
|
|
119
|
-
}
|
|
129
|
+
const kws = {};
|
|
130
|
+
for (const name of keywordNames) if (this._keywords[name] !== true) kws[name] = this._keywords[name];
|
|
131
|
+
if (Object.keys(kws).length > 0) out.keywords = kws;
|
|
120
132
|
const registry = this._registrySnapshot();
|
|
121
133
|
if (registry) out.schemas = registry;
|
|
122
134
|
return out;
|
|
@@ -126,23 +138,111 @@ class Ata {
|
|
|
126
138
|
if (this._schemas.size === 0) return null;
|
|
127
139
|
if (this._registry === null) {
|
|
128
140
|
const map = {};
|
|
129
|
-
for (const [key, schema] of this._schemas) map[key] = this._withDialect(schema);
|
|
141
|
+
for (const [key, schema] of this._schemas) map[fragmentKey(key)] = this._fragmentRefs(this._withDialect(schema));
|
|
130
142
|
this._registry = map;
|
|
131
143
|
}
|
|
132
144
|
return this._registry;
|
|
133
145
|
}
|
|
134
146
|
|
|
147
|
+
// A schema registered under a key that is only a fragment
|
|
148
|
+
// (`addSchema(s, '#/components/schemas/Pet')`, as OpenAPI tools do) is
|
|
149
|
+
// found by a `$ref` of that exact string that does not resolve in its own
|
|
150
|
+
// document: the reference looks it up among the registered keys then. ata
|
|
151
|
+
// resolves a fragment in the document alone, so such a `$ref` is pointed at
|
|
152
|
+
// the key's entry in the registry (see fragmentKey).
|
|
153
|
+
_fragmentRefs(schema) {
|
|
154
|
+
if (schema === null || typeof schema !== 'object') return schema;
|
|
155
|
+
let keys = this._fragmentKeys;
|
|
156
|
+
if (keys === undefined || this._fragmentStale) {
|
|
157
|
+
// The copies stand while the fragment-only keys are the same ones.
|
|
158
|
+
const now = new Set();
|
|
159
|
+
for (const key of this._schemas.keys()) if (key.startsWith('#')) now.add(key);
|
|
160
|
+
this._fragmentStale = false;
|
|
161
|
+
if (keys === undefined || keys.size !== now.size || [...now].some((k) => !keys.has(k))) {
|
|
162
|
+
// Each key's pointer, split and decoded once.
|
|
163
|
+
keys = this._fragmentKeys = new Map();
|
|
164
|
+
for (const key of now) {
|
|
165
|
+
let segs = null;
|
|
166
|
+
if (key.startsWith('#/')) {
|
|
167
|
+
try { segs = key.slice(2).split('/').map((raw) => decodeURIComponent(raw.replace(/~1/g, '/').replace(/~0/g, '~'))); } catch { segs = null; }
|
|
168
|
+
}
|
|
169
|
+
keys.set(key, segs);
|
|
170
|
+
}
|
|
171
|
+
this._fragmentCopies = new WeakMap();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (keys.size === 0) return schema;
|
|
175
|
+
const hit = this._fragmentCopies.get(schema);
|
|
176
|
+
if (hit !== undefined) return hit;
|
|
177
|
+
if ((features(schema) & F_REF) === 0) { this._fragmentCopies.set(schema, schema); return schema; }
|
|
178
|
+
const resolves = (key, segs) => {
|
|
179
|
+
if (key === '#') return true;
|
|
180
|
+
if (segs === null) return !key.startsWith('#/');
|
|
181
|
+
let node = schema;
|
|
182
|
+
for (let i = 0; i < segs.length; i++) {
|
|
183
|
+
if (node === null || typeof node !== 'object' || !Object.prototype.hasOwnProperty.call(node, segs[i])) return false;
|
|
184
|
+
node = node[segs[i]];
|
|
185
|
+
}
|
|
186
|
+
return true;
|
|
187
|
+
};
|
|
188
|
+
// Only a key that does not resolve in this document can need pointing
|
|
189
|
+
// elsewhere. A document that holds every registered fragment itself, as
|
|
190
|
+
// an OpenAPI tool's schema carrying the whole `components` does, is left
|
|
191
|
+
// as it is without a walk.
|
|
192
|
+
const pointed = new Set();
|
|
193
|
+
for (const [key, segs] of keys) if (!resolves(key, segs)) pointed.add(key);
|
|
194
|
+
if (pointed.size === 0) { this._fragmentCopies.set(schema, schema); return schema; }
|
|
195
|
+
let changed = false;
|
|
196
|
+
const copy = (n, data) => {
|
|
197
|
+
if (n === null || typeof n !== 'object') return n;
|
|
198
|
+
if (Array.isArray(n)) return n.map((x) => copy(x, data));
|
|
199
|
+
const out = {};
|
|
200
|
+
for (const k of Object.keys(n)) {
|
|
201
|
+
let v;
|
|
202
|
+
if (!data && k === '$ref' && typeof n[k] === 'string' && pointed.has(n[k])) { v = fragmentKey(n[k]); changed = true; }
|
|
203
|
+
else v = copy(n[k], !data && (k === 'const' || k === 'enum' || k === 'default' || k === 'examples' || k === 'example'));
|
|
204
|
+
if (k === '__proto__') Object.defineProperty(out, k, { value: v, writable: true, enumerable: true, configurable: true });
|
|
205
|
+
else out[k] = v;
|
|
206
|
+
}
|
|
207
|
+
return out;
|
|
208
|
+
};
|
|
209
|
+
const out = copy(schema, false);
|
|
210
|
+
const result = changed ? out : schema;
|
|
211
|
+
this._fragmentCopies.set(schema, result);
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
214
|
+
|
|
135
215
|
_touchRegistry() {
|
|
216
|
+
this._fragmentStale = true;
|
|
136
217
|
this._registry = null;
|
|
137
218
|
this._registryEpoch++;
|
|
138
219
|
}
|
|
139
220
|
|
|
140
221
|
// A schema with no `$schema` is read as draft-07, which is what the
|
|
141
|
-
// reference class does
|
|
222
|
+
// reference class does, or as `defaultMeta` when that names a dialect: the
|
|
223
|
+
// reference's 2020-12 class reads one as 2020-12, which is how an OpenAPI
|
|
224
|
+
// 3.1 document's schemas are meant. The copy is shallow: only the root
|
|
225
|
+
// changes.
|
|
226
|
+
// The copy is kept per schema object, so a schema compiled again or
|
|
227
|
+
// registered again is the same object to everything that remembers work
|
|
228
|
+
// per object below (the scan, normalization, the fragment copies).
|
|
142
229
|
_withDialect(schema) {
|
|
143
230
|
if (typeof schema !== 'object' || schema === null || Array.isArray(schema)) return schema;
|
|
231
|
+
if (this._dialectCopies === undefined) this._dialectCopies = new WeakMap();
|
|
232
|
+
let out = this._dialectCopies.get(schema);
|
|
233
|
+
if (out === undefined) {
|
|
234
|
+
out = this._withDialectCopy(schema);
|
|
235
|
+
this._dialectCopies.set(schema, out);
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
_withDialectCopy(schema) {
|
|
241
|
+
if (isDraft4(schema)) return draft4AsDraft7(schema);
|
|
144
242
|
if (typeof schema.$schema === 'string') return schema;
|
|
145
|
-
|
|
243
|
+
const meta = typeof this.opts.defaultMeta === 'string' ? this.opts.defaultMeta : DRAFT7;
|
|
244
|
+
if (DRAFT4_IDS.has(meta)) return draft4AsDraft7({ $schema: meta, ...schema });
|
|
245
|
+
return { $schema: meta, ...schema };
|
|
146
246
|
}
|
|
147
247
|
|
|
148
248
|
// ----- compile / validate --------------------------------------------
|
|
@@ -163,9 +263,20 @@ class Ata {
|
|
|
163
263
|
}
|
|
164
264
|
}
|
|
165
265
|
}
|
|
166
|
-
|
|
266
|
+
if (this._codeOnly && typeof schema === 'object' && schema !== null) {
|
|
267
|
+
const { schemaUsesKeywords } = require('./lib/keywords');
|
|
268
|
+
for (const name of Object.keys(this._codeOnly)) {
|
|
269
|
+
if (schemaUsesKeywords(schema, { [name]: this._codeOnly[name] }) || [...this._schemas.values()].some((s) => schemaUsesKeywords(s, { [name]: this._codeOnly[name] }))) {
|
|
270
|
+
throw new Error(`keyword "${name}": code-generating keywords are not supported; define it with validate, compile or macro`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const rootDoc = this._fragmentRefs(this._withDialect(schema));
|
|
167
275
|
const options = this._validatorOptions();
|
|
168
276
|
const allErrors = !!this.opts.allErrors;
|
|
277
|
+
// The reference's `discriminator` option, for a schema that has one.
|
|
278
|
+
const discriminated = this.opts.discriminator === true &&
|
|
279
|
+
((features(rootDoc) & F_DISCR) !== 0 || Object.values(options.schemas || {}).some((d) => features(d) & F_DISCR));
|
|
169
280
|
let shaper = null;
|
|
170
281
|
if (typeof rootDoc === 'object' && rootDoc !== null) {
|
|
171
282
|
// The root is registered under a private id so the error shaper can
|
|
@@ -174,18 +285,47 @@ class Ata {
|
|
|
174
285
|
const rootId = 'ata-compat:root';
|
|
175
286
|
options.schemas = { ...(options.schemas || {}), [rootId]: rootDoc };
|
|
176
287
|
shaper = createShaper({
|
|
177
|
-
Validator, rootId, rootDoc, options, allErrors,
|
|
288
|
+
Validator, rootId, rootDoc, options, allErrors, schemas: options.schemas, discriminator: discriminated,
|
|
178
289
|
macroKeywords: Object.keys(this._keywords).filter((k) => this._keywords[k] !== true && this._keywords[k].macro),
|
|
179
290
|
});
|
|
180
291
|
}
|
|
292
|
+
// coerceTypes reaching a value behind allOf, anyOf, oneOf, $ref and the
|
|
293
|
+
// rest, where ata's coercion pass, which runs before validation and
|
|
294
|
+
// follows `properties` and `items`, does not: the interpreted engine then
|
|
295
|
+
// coerces where each `type` is checked, as the reference does.
|
|
296
|
+
if (options.coerceTypes && (features(rootDoc) & F_BEYOND) !== 0) {
|
|
297
|
+
options._coerceInWalk = this.opts.coerceTypes === 'array' ? 'array' : true;
|
|
298
|
+
options.coerceTypes = false;
|
|
299
|
+
options.engine = 'interpreter';
|
|
300
|
+
}
|
|
301
|
+
// The reference's `discriminator` option: the interpreted engine reads the
|
|
302
|
+
// keyword (see discriminate in lib/interpreter-core.js).
|
|
303
|
+
// A format registered for numbers, for a schema that names a format.
|
|
304
|
+
if (this._numFormats && Object.keys(this._numFormats).length > 0 &&
|
|
305
|
+
((features(rootDoc) & F_FORMAT) !== 0 || Object.values(options.schemas || {}).some((d) => features(d) & F_FORMAT))) {
|
|
306
|
+
options._numberFormats = { ...this._numFormats };
|
|
307
|
+
options.engine = 'interpreter';
|
|
308
|
+
}
|
|
309
|
+
if (discriminated) {
|
|
310
|
+
options._discriminator = true;
|
|
311
|
+
options.engine = 'interpreter';
|
|
312
|
+
}
|
|
181
313
|
const v = new Validator(rootDoc, options);
|
|
314
|
+
const defaults = this.opts.useDefaults ? referencedDefaults(rootDoc, options.schemas) : null;
|
|
315
|
+
// With keywords that read their data's context, the shaper is given the
|
|
316
|
+
// errors as the validator made them: those carry each anyOf and oneOf
|
|
317
|
+
// branch's errors (see keepBranches in lib/interpreter-core.js), which the
|
|
318
|
+
// copies handed out do not.
|
|
319
|
+
const ctxKeywords = options.keywords !== undefined && Object.values(options.keywords).some((k) => k && k.dataContext === true);
|
|
182
320
|
const validate = (data) => {
|
|
321
|
+
if (defaults !== null) defaults(data);
|
|
183
322
|
const result = v.validate(data);
|
|
184
323
|
if (result.valid) {
|
|
185
324
|
validate.errors = null;
|
|
186
325
|
return true;
|
|
187
326
|
}
|
|
188
|
-
|
|
327
|
+
const raw = ctxKeywords && shaper !== null && typeof result._ataRaw === 'function' ? result._ataRaw() : result.errors;
|
|
328
|
+
let errors = shaper === null ? result.errors.map(asReferenceKeyword) : shaper(raw, data);
|
|
189
329
|
// Errors the shaper synthesises, the `if` wrapper among them, are built
|
|
190
330
|
// from the group rather than carried up from the validator, so verbose
|
|
191
331
|
// mode has to fill in the value they point at here or they would be the
|
|
@@ -195,7 +335,7 @@ class Ata {
|
|
|
195
335
|
? { ...e, data: resolvePointer(data, e.instancePath || '', undefined) }
|
|
196
336
|
: e));
|
|
197
337
|
}
|
|
198
|
-
validate.errors = shaper === null && !allErrors ? errors.slice(0, 1) : errors;
|
|
338
|
+
validate.errors = inReferenceOrder(shaper === null && !allErrors ? errors.slice(0, 1) : errors);
|
|
199
339
|
return false;
|
|
200
340
|
};
|
|
201
341
|
validate.errors = null;
|
|
@@ -231,7 +371,8 @@ class Ata {
|
|
|
231
371
|
for (const s of schema) this.addSchema(s);
|
|
232
372
|
return this;
|
|
233
373
|
}
|
|
234
|
-
const id = schema && typeof schema === 'object' && typeof schema.$id === 'string' ? schema.$id
|
|
374
|
+
const id = schema && typeof schema === 'object' && typeof schema.$id === 'string' ? schema.$id
|
|
375
|
+
: isDraft4(schema) && typeof schema.id === 'string' ? schema.id : undefined;
|
|
235
376
|
const k = key !== undefined ? String(key) : id;
|
|
236
377
|
if (k === undefined) throw new Error('schema with key or id "undefined" cannot be added: no key and no $id');
|
|
237
378
|
if (this._schemas.has(k) && this._schemas.get(k) !== schema) {
|
|
@@ -308,7 +449,11 @@ class Ata {
|
|
|
308
449
|
this.errors = [{ keyword: 'type', instancePath: '', schemaPath: '#/type', params: { type: 'object' }, message: 'must be object,boolean' }];
|
|
309
450
|
return false;
|
|
310
451
|
}
|
|
311
|
-
|
|
452
|
+
// A draft-04 schema is checked as the draft-07 schema it is read as: the
|
|
453
|
+
// boolean exclusive bounds in their numeric form, against draft-07's
|
|
454
|
+
// meta-schema (ata carries no draft-04 meta-schema).
|
|
455
|
+
if (isDraft4(schema)) schema = normalizeExclusiveBounds(structuredClone(draft4AsDraft7(schema)));
|
|
456
|
+
const declared = typeof schema.$schema === 'string' ? schema.$schema : typeof this.opts.defaultMeta === 'string' && !DRAFT4_IDS.has(this.opts.defaultMeta) ? this.opts.defaultMeta : DRAFT7;
|
|
312
457
|
const metaId = METASCHEMAS.has(declared) ? declared : isDraft7Id(declared) ? DRAFT7 : DRAFT2020;
|
|
313
458
|
const meta = metaValidator(metaId);
|
|
314
459
|
const result = meta.validate(schema);
|
|
@@ -330,6 +475,15 @@ class Ata {
|
|
|
330
475
|
|
|
331
476
|
addFormat(name, format) {
|
|
332
477
|
this._formats[name] = formatToFunction(name, format);
|
|
478
|
+
// The reference applies a format declared `type: 'number'` to numbers,
|
|
479
|
+
// where JSON Schema, and ata, apply formats to strings only. Such a
|
|
480
|
+
// format is also checked on numbers here, by the interpreted engine (see
|
|
481
|
+
// compile).
|
|
482
|
+
if (format && typeof format === 'object' && format.type === 'number') {
|
|
483
|
+
this._numFormats = this._numFormats || Object.create(null);
|
|
484
|
+
this._numFormats[name] = this._formats[name];
|
|
485
|
+
} else if (this._numFormats) delete this._numFormats[name];
|
|
486
|
+
this._touchRegistry();
|
|
333
487
|
this._compiled = new WeakMap();
|
|
334
488
|
return this;
|
|
335
489
|
}
|
|
@@ -346,6 +500,12 @@ class Ata {
|
|
|
346
500
|
keyword = kwdOrDef.keyword;
|
|
347
501
|
definition = kwdOrDef;
|
|
348
502
|
}
|
|
503
|
+
// The reference takes a list of names for one definition (the formats
|
|
504
|
+
// plugin registers formatMinimum and its kin that way).
|
|
505
|
+
if (Array.isArray(keyword)) {
|
|
506
|
+
for (const k of keyword) this.addKeyword({ ...definition, keyword: k });
|
|
507
|
+
return this;
|
|
508
|
+
}
|
|
349
509
|
if (typeof keyword !== 'string' || keyword === '') {
|
|
350
510
|
throw new Error('addKeyword: keyword must be a non-empty string');
|
|
351
511
|
}
|
|
@@ -358,14 +518,28 @@ class Ata {
|
|
|
358
518
|
return this;
|
|
359
519
|
}
|
|
360
520
|
if (typeof definition.code === 'function' && !definition.validate && !definition.compile && !definition.macro) {
|
|
361
|
-
|
|
521
|
+
// A keyword defined only as generated code cannot run here. It is
|
|
522
|
+
// registered as known and refused when a schema uses it (see compile),
|
|
523
|
+
// so a library that defines such keywords for options it may never use
|
|
524
|
+
// can still take this class, and a schema that relies on one is never
|
|
525
|
+
// accepted without the check.
|
|
526
|
+
this._codeOnly = this._codeOnly || Object.create(null);
|
|
527
|
+
this._codeOnly[keyword] = { name: keyword, types: null, validate: null, compile: null, macro: null };
|
|
528
|
+
this._compiled = new WeakMap();
|
|
529
|
+
return this;
|
|
362
530
|
}
|
|
363
531
|
const mapped = {};
|
|
364
532
|
if (definition.type !== undefined) mapped.type = definition.type;
|
|
365
533
|
if (typeof definition.validate === 'function') mapped.validate = definition.validate;
|
|
366
|
-
if (typeof definition.compile === 'function') mapped.compile = definition.compile;
|
|
534
|
+
if (typeof definition.compile === 'function') mapped.compile = compileWithContext(definition.compile);
|
|
367
535
|
if (typeof definition.macro === 'function') mapped.macro = definition.macro;
|
|
536
|
+
if (typeof definition.before === 'string') mapped.before = definition.before;
|
|
537
|
+
if (definition.post === true) mapped.post = true;
|
|
368
538
|
if (mapped.validate && mapped.compile) delete mapped.validate;
|
|
539
|
+
// The reference hands every keyword function the data's context
|
|
540
|
+
// (parentData, parentDataProperty, instancePath, rootData), and keywords
|
|
541
|
+
// that remove or rewrite the value they check rely on it.
|
|
542
|
+
if (mapped.validate || mapped.compile) mapped.dataContext = true;
|
|
369
543
|
this._keywords[keyword] = mapped;
|
|
370
544
|
this._keywordDefs = this._keywordDefs || Object.create(null);
|
|
371
545
|
this._keywordDefs[keyword] = definition;
|
|
@@ -386,6 +560,7 @@ class Ata {
|
|
|
386
560
|
|
|
387
561
|
removeKeyword(keyword) {
|
|
388
562
|
delete this._keywords[keyword];
|
|
563
|
+
if (this._codeOnly) delete this._codeOnly[keyword];
|
|
389
564
|
if (this._keywordDefs) delete this._keywordDefs[keyword];
|
|
390
565
|
this._compiled = new WeakMap();
|
|
391
566
|
return this;
|
|
@@ -399,11 +574,121 @@ class Ata {
|
|
|
399
574
|
}
|
|
400
575
|
}
|
|
401
576
|
|
|
577
|
+
// A reference keyword's compile(schema, parentSchema, it) returns a function
|
|
578
|
+
// of (data, dataCxt). `it.schemaPath.str` is read by keywords that build their
|
|
579
|
+
// own error objects, at the time they fail: it names the schema the check
|
|
580
|
+
// sits in, which is known per call here, so it is read from the context the
|
|
581
|
+
// call was given.
|
|
582
|
+
function compileWithContext(compile) {
|
|
583
|
+
return (value, parentSchema) => {
|
|
584
|
+
let current = '#';
|
|
585
|
+
const it = { get schemaPath() { return { str: current }; } };
|
|
586
|
+
const inner = compile(value, parentSchema, it);
|
|
587
|
+
if (typeof inner !== 'function') return inner;
|
|
588
|
+
const wrapped = (data, ctx) => {
|
|
589
|
+
if (ctx && typeof ctx.schemaPath === 'string') current = ctx.schemaPath.slice(0, ctx.schemaPath.lastIndexOf('/')) || '#';
|
|
590
|
+
return inner(data, ctx);
|
|
591
|
+
};
|
|
592
|
+
// Errors the keyword writes land on its own function, read from here.
|
|
593
|
+
wrapped.source = inner;
|
|
594
|
+
return wrapped;
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// The reference writes an error's fields as instancePath, schemaPath,
|
|
599
|
+
// keyword, params, message; code that serializes errors sees the order. The
|
|
600
|
+
// errors a custom keyword reports come out in another, and are put in this one.
|
|
601
|
+
const REFERENCE_FIELDS = ['instancePath', 'schemaPath', 'keyword', 'params', 'message'];
|
|
602
|
+
function inReferenceOrder(errors) {
|
|
603
|
+
for (let i = 0; i < errors.length; i++) {
|
|
604
|
+
const e = errors[i];
|
|
605
|
+
if (e === null || typeof e !== 'object') continue;
|
|
606
|
+
const keys = Object.keys(e);
|
|
607
|
+
let ordered = true;
|
|
608
|
+
for (let j = 0; j < REFERENCE_FIELDS.length && j < keys.length; j++) if (keys[j] !== REFERENCE_FIELDS[j]) { ordered = false; break; }
|
|
609
|
+
if (ordered) continue;
|
|
610
|
+
const out = {};
|
|
611
|
+
for (const k of REFERENCE_FIELDS) if (k in e) out[k] = e[k];
|
|
612
|
+
for (const k of keys) if (!(k in out)) out[k] = e[k];
|
|
613
|
+
errors[i] = out;
|
|
614
|
+
}
|
|
615
|
+
return errors;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// useDefaults the way the reference applies it: in the properties of every
|
|
619
|
+
// subschema the data is validated against outside anyOf, oneOf, not and if,
|
|
620
|
+
// which includes those behind `allOf` and a `$ref`. ata's own pass fills the
|
|
621
|
+
// `properties` it can reach by nesting alone; this one, run before it, also
|
|
622
|
+
// goes through `allOf` and `$ref`. Both fill only a key the object does not
|
|
623
|
+
// have, so what one fills the other leaves. Null when the schema has no
|
|
624
|
+
// default anywhere such a walk could reach that ata's own pass would not.
|
|
625
|
+
function referencedDefaults(rootDoc, registry) {
|
|
626
|
+
if (rootDoc === null || typeof rootDoc !== 'object') return null;
|
|
627
|
+
const f = features(rootDoc);
|
|
628
|
+
if ((f & F_DEFAULT) === 0 && !Object.values(registry || {}).some((d) => features(d) & F_DEFAULT)) return null;
|
|
629
|
+
if ((f & (F_REF | F_ALLOF)) === 0) return null;
|
|
630
|
+
const resolve = (ref) => {
|
|
631
|
+
const hash = ref.indexOf('#');
|
|
632
|
+
const base = hash < 0 ? ref : ref.slice(0, hash);
|
|
633
|
+
const doc = base === '' ? rootDoc : registry && registry[base];
|
|
634
|
+
if (doc === undefined || doc === null) return null;
|
|
635
|
+
const frag = hash < 0 ? '' : ref.slice(hash + 1);
|
|
636
|
+
if (frag !== '' && !frag.startsWith('/')) return null;
|
|
637
|
+
let node = doc;
|
|
638
|
+
for (const raw of frag === '' ? [] : frag.slice(1).split('/')) {
|
|
639
|
+
let seg = raw.replace(/~1/g, '/').replace(/~0/g, '~');
|
|
640
|
+
try { seg = decodeURIComponent(seg); } catch { return null; }
|
|
641
|
+
if (node === null || typeof node !== 'object' || !Object.prototype.hasOwnProperty.call(node, seg)) return null;
|
|
642
|
+
node = node[seg];
|
|
643
|
+
}
|
|
644
|
+
return node;
|
|
645
|
+
};
|
|
646
|
+
const fill = (schema, data, active) => {
|
|
647
|
+
if (schema === null || typeof schema !== 'object' || data === null || typeof data !== 'object' || active.has(schema)) return;
|
|
648
|
+
active.add(schema);
|
|
649
|
+
if (typeof schema.$ref === 'string') fill(resolve(schema.$ref), data, active);
|
|
650
|
+
if (Array.isArray(schema.allOf)) for (const sub of schema.allOf) fill(sub, data, active);
|
|
651
|
+
const props = schema.properties;
|
|
652
|
+
if (props !== null && typeof props === 'object' && !Array.isArray(data)) {
|
|
653
|
+
for (const key of Object.keys(props)) {
|
|
654
|
+
const prop = props[key];
|
|
655
|
+
if (prop === null || typeof prop !== 'object') continue;
|
|
656
|
+
if (prop.default !== undefined && !Object.hasOwn(data, key)) {
|
|
657
|
+
const value = prop.default !== null && typeof prop.default === 'object' ? JSON.parse(JSON.stringify(prop.default)) : prop.default;
|
|
658
|
+
Object.defineProperty(data, key, { value, writable: true, enumerable: true, configurable: true });
|
|
659
|
+
}
|
|
660
|
+
// Into the value with a fresh set: the same schema may apply again
|
|
661
|
+
// one level down, in a recursive one.
|
|
662
|
+
if (Object.hasOwn(data, key)) fill(prop, data[key], new Set());
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
if (Array.isArray(data) && schema.items !== null && typeof schema.items === 'object' && !Array.isArray(schema.items)) {
|
|
666
|
+
for (const item of data) fill(schema.items, item, new Set());
|
|
667
|
+
}
|
|
668
|
+
active.delete(schema);
|
|
669
|
+
};
|
|
670
|
+
return (data) => fill(rootDoc, data, new Set());
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// The registry id a fragment-only key is kept under: a `#...` string is not an
|
|
674
|
+
// id a document can be found by, so it is given one.
|
|
675
|
+
function fragmentKey(key) {
|
|
676
|
+
return key.startsWith('#') ? 'ata-compat:fragment:' + encodeURIComponent(key.slice(1)) : key;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
// What a schema holds anywhere below it, read from the walk normalization
|
|
681
|
+
// makes of it anyway (lib/schema-scan.js), which remembers it per object.
|
|
682
|
+
const _scan = require('./lib/schema-scan');
|
|
683
|
+
const F_BEYOND = _scan.BEYOND_ITEMS, F_DISCR = _scan.DISCRIMINATOR, F_DEFAULT = _scan.HAS_DEFAULT;
|
|
684
|
+
const F_REF = _scan.HAS_REF, F_ALLOF = _scan.HAS_ALLOF, F_FORMAT = _scan.HAS_FORMAT;
|
|
685
|
+
const features = _scan.scan;
|
|
686
|
+
|
|
402
687
|
const _metaValidators = new Map();
|
|
403
688
|
function metaValidator(id) {
|
|
404
689
|
let v = _metaValidators.get(id);
|
|
405
690
|
if (!v) {
|
|
406
|
-
v = new Validator({ $ref: id }, { useDefaults: false });
|
|
691
|
+
v = new Validator({ $ref: id }, { useDefaults: false, engine: 'interpreter' });
|
|
407
692
|
_metaValidators.set(id, v);
|
|
408
693
|
}
|
|
409
694
|
return v;
|
package/index.bundle.mjs
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// ESM entry for bundlers that target a server (Bun, esbuild or Rollup for
|
|
2
|
+
// Node) and for Node before 22.10, which does not match `module-sync`: the
|
|
3
|
+
// validator core with the code generator registered, as index.js, but with
|
|
4
|
+
// each tool imported on its own (lib/tools.mjs), so the tools an application
|
|
5
|
+
// does not import stay out of its bundle. Through index.js a bundler sees one
|
|
6
|
+
// CommonJS module and keeps all of them.
|
|
7
|
+
import core from './lib/validator-core.js';
|
|
8
|
+
import engine from './lib/codegen-engine.js';
|
|
9
|
+
import { toTypeScript, renderPretty, renderCompact, toOutput, toRetryMessage, describeSchema, renderJSON } from './lib/tools.mjs';
|
|
10
|
+
|
|
11
|
+
core._registerCodegen(engine);
|
|
12
|
+
|
|
13
|
+
export const { Validator, compile, validate, validateAsync, parseAsync, version, createPaddedBuffer, SIMDJSON_PADDING, parseJSON, defineSchema } = core;
|
|
14
|
+
export { toTypeScript, renderPretty, renderCompact, toOutput, toRetryMessage, describeSchema, renderJSON };
|
|
15
|
+
export default { Validator, compile, validate, validateAsync, parseAsync, version, createPaddedBuffer, SIMDJSON_PADDING, parseJSON, toTypeScript, defineSchema, renderPretty, renderCompact, toOutput, toRetryMessage, describeSchema, renderJSON };
|
package/lib/aot-impl.js
CHANGED
|
@@ -251,7 +251,7 @@ function onePassCore(validator, opts) {
|
|
|
251
251
|
const decls = [];
|
|
252
252
|
const args = [];
|
|
253
253
|
const own = new Set();
|
|
254
|
-
const addOwn = (n) => { if (own.has(n)) return; own.add(n); if (n === '_h' || n === '_hall') addOwn('_hop'); if (n === '_ok') { addOwn('_PN'); addOwn('_hop'); } decls.push(OWN_HELPER_CODE[n]); };
|
|
254
|
+
const addOwn = (n) => { if (own.has(n)) return; own.add(n); if (n === '_h' || n === '_hall') addOwn('_hop'); if (n === '_ok') { addOwn('_PN'); addOwn('_hop'); } if (n === '_nk') addOwn('_PN'); decls.push(OWN_HELPER_CODE[n]); };
|
|
255
255
|
let usesSafeRe = emit.usesSafeRe, usesCollapse = false;
|
|
256
256
|
for (let i = 0; i < emit.names.length; i++) {
|
|
257
257
|
const name = emit.names[i], val = emit.values[i];
|
|
@@ -272,7 +272,7 @@ function onePassCore(validator, opts) {
|
|
|
272
272
|
if (name === '_VF') { args.push('null'); continue; }
|
|
273
273
|
if (name === '_PN') { addOwn('_PN'); args.push('_PN'); continue; }
|
|
274
274
|
if (name === '_OP' || name === '_gp') { addOwn('_PN'); args.push(name); continue; }
|
|
275
|
-
if (name === '_hop' || name === '_h' || name === '_ok' || name === '_hall') { addOwn(name); args.push(name); continue; }
|
|
275
|
+
if (name === '_hop' || name === '_h' || name === '_ok' || name === '_hall' || name === '_nk') { addOwn(name); args.push(name); continue; }
|
|
276
276
|
if (name === '_pe') { decls.push(PTR_ESC_HELPER); args.push('_pe'); continue; }
|
|
277
277
|
if (name === '_deq') { decls.push(DEQ_HELPER); args.push('_deq'); continue; }
|
|
278
278
|
if (name === '_anT') { decls.push('function _anT(){return true}'); args.push('_anT'); continue; }
|
package/lib/combined-runtime.js
CHANGED
|
@@ -121,6 +121,9 @@ const _hop = Object.prototype.hasOwnProperty
|
|
|
121
121
|
function _h (o, k) { return _hop.call(o, k) }
|
|
122
122
|
function _ok (o, k) { return (_PN ? o.__proto__ : _gp(o)) === _OP || _hop.call(o, k) } // eslint-disable-line no-proto
|
|
123
123
|
function _hall (o, ks) { for (let i = 0; i < ks.length; i++) if (!_hop.call(o, ks[i])) return false; return true }
|
|
124
|
+
// A key count that came out wrong, counted again over own keys unless the
|
|
125
|
+
// object is ordinary.
|
|
126
|
+
function _nk (o, n) { return (_PN ? o.__proto__ : _gp(o)) === _OP || Object.keys(o).length !== n } // eslint-disable-line no-proto
|
|
124
127
|
|
|
125
128
|
// RFC 6901 escaping for a key only known at run time (the static ones are
|
|
126
129
|
// escaped when the code is written).
|
|
@@ -140,7 +143,7 @@ function _rb (S, m) { if (S.length !== m) S.length = m }
|
|
|
140
143
|
|
|
141
144
|
module.exports = {
|
|
142
145
|
apState, ap, sr, srX, multiBranch,
|
|
143
|
-
_PN, _OP, _gp, _hop, _h, _ok, _hall, _pe, _anT, _anF, _rb,
|
|
146
|
+
_PN, _OP, _gp, _hop, _h, _ok, _hall, _nk, _pe, _anT, _anF, _rb,
|
|
144
147
|
}
|
|
145
148
|
// Loaded when a program first asks for them: a schema without const, enum,
|
|
146
149
|
// uniqueItems, oneOf or anyOf never needs these modules on its cold path.
|