@jarenjs/validate 0.8.4 → 0.34.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/ARCHITECTURE.md +1131 -0
- package/LICENSE +21 -0
- package/README.md +796 -2
- package/dist/types/array.d.ts +2 -0
- package/dist/types/bigint.d.ts +1 -0
- package/dist/types/combine.d.ts +1 -0
- package/dist/types/condition.d.ts +1 -0
- package/dist/types/content.d.ts +3 -0
- package/dist/types/data.d.ts +7 -0
- package/dist/types/dollar-data.d.ts +11 -0
- package/dist/types/dynamic-ref.d.ts +44 -0
- package/dist/types/enum.d.ts +1 -0
- package/dist/types/format.d.ts +21 -0
- package/dist/types/index.d.ts +972 -0
- package/dist/types/messages.d.ts +142 -0
- package/dist/types/normalize.d.ts +107 -0
- package/dist/types/number.d.ts +1 -0
- package/dist/types/object.d.ts +3 -0
- package/dist/types/query-keyword.d.ts +19 -0
- package/dist/types/query.d.ts +29 -0
- package/dist/types/schema.d.ts +1 -0
- package/dist/types/string.d.ts +1 -0
- package/dist/types/tools.d.ts +109 -0
- package/dist/types/traverse.d.ts +32 -0
- package/dist/types/unevaluated.d.ts +12 -0
- package/docs/ERROR-MESSAGES.md +251 -0
- package/package.json +37 -7
- package/src/array.js +610 -0
- package/src/bigint.js +108 -0
- package/src/combine.js +276 -0
- package/src/condition.js +129 -0
- package/src/content.js +83 -0
- package/src/data.js +101 -0
- package/src/dollar-data.js +212 -0
- package/src/dynamic-ref.js +121 -0
- package/src/enum.js +147 -0
- package/src/format.js +108 -0
- package/src/index.js +1896 -0
- package/src/messages.js +497 -0
- package/src/normalize.js +585 -0
- package/src/number.js +169 -0
- package/src/object.js +848 -0
- package/src/query-keyword.js +99 -0
- package/src/query.js +85 -0
- package/src/schema.js +690 -0
- package/src/string.js +164 -0
- package/src/tools.js +397 -0
- package/src/traverse.js +442 -0
- package/src/unevaluated.js +173 -0
- package/dist/index.js +0 -1998
- package/dist/index.js.map +0 -7
- package/dist/index.min.js +0 -2
- package/dist/index.min.js.map +0 -7
package/src/normalize.js
ADDED
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
//#region schema-driven normalization
|
|
2
|
+
// Normalization is a SEPARATE pass from validation, compiled from the same
|
|
3
|
+
// schema. `@jarenjs/validate`'s compiled validators are pure predicates and
|
|
4
|
+
// stay that way: they never apply a `default`, never coerce, never trim and
|
|
5
|
+
// never strip. This module is where a consumer that needs Zod-style
|
|
6
|
+
// normalized *output* gets it, without the validator growing a side effect.
|
|
7
|
+
//
|
|
8
|
+
// Two properties are load-bearing:
|
|
9
|
+
//
|
|
10
|
+
// 1. The input is never mutated. Ajv's `useDefaults`/`coerceTypes` write
|
|
11
|
+
// into the document they were handed; this compiles to a copy-on-write
|
|
12
|
+
// walk instead, so `normalize(input)` returns a new value and `input`
|
|
13
|
+
// is exactly as it was. A caller can hand the same document to two
|
|
14
|
+
// normalizers, or keep it as an audit record, without defensive copying.
|
|
15
|
+
// 2. Untouched subtrees keep their identity. A node whose whole subtree is
|
|
16
|
+
// unchanged is returned by reference, and a document that needs no
|
|
17
|
+
// change at all returns the input reference itself (`normalize(x) === x`).
|
|
18
|
+
// That makes a no-op cheap and lets downstream memoization by identity
|
|
19
|
+
// keep working.
|
|
20
|
+
//
|
|
21
|
+
// House style: the same two-stage shape as the validator. `compileNormalizer`
|
|
22
|
+
// walks the schema once and specializes one closure per node; the returned
|
|
23
|
+
// function just runs closures. No `eval`, no `new Function`, no dependencies
|
|
24
|
+
// outside `@jarenjs/*`.
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
cloneJson,
|
|
28
|
+
isJsonContainer,
|
|
29
|
+
isJsonObject,
|
|
30
|
+
setObjectMember,
|
|
31
|
+
} from '@jarenjs/core/object';
|
|
32
|
+
|
|
33
|
+
import { parseJSONPointer } from '@jarenjs/json';
|
|
34
|
+
|
|
35
|
+
const hasOwn = Object.hasOwn;
|
|
36
|
+
|
|
37
|
+
// The JSON number grammar (RFC 8259 section 6). A string is coerced to a
|
|
38
|
+
// number only when it is exactly a JSON number - so '1e5' and '-0.5' coerce
|
|
39
|
+
// while '0x10', '1_000', 'Infinity', ' ' and '' stay strings and fail
|
|
40
|
+
// validation with a type error the caller can report.
|
|
41
|
+
const RE_JSON_NUMBER = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][-+]?[0-9]+)?$/;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A compiled normalizer. `In` is what the caller accepts, `Out` what the
|
|
45
|
+
* normalized value is; they differ whenever defaults are materialized, which
|
|
46
|
+
* is exactly the distinction a contract wrapper needs to expose.
|
|
47
|
+
* @template [In=unknown]
|
|
48
|
+
* @template [Out=In]
|
|
49
|
+
* @typedef {(data: In) => Out} Normalizer
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Options for {@link compileNormalizer}. Every normalization is off by
|
|
54
|
+
* default: each one changes the meaning of the caller's data, so each is a
|
|
55
|
+
* decision the caller makes rather than inherits. With no options the
|
|
56
|
+
* compiled normalizer is the identity.
|
|
57
|
+
* Three of the four also accept a **predicate** `(schemaNode) => boolean`
|
|
58
|
+
* instead of a boolean, evaluated once per node at compile time. That is how
|
|
59
|
+
* a consumer turns a whole-schema switch into a per-field decision — "trim
|
|
60
|
+
* these string fields, leave those alone" — without the option becoming a
|
|
61
|
+
* blunt instrument and without any runtime cost.
|
|
62
|
+
* @typedef {object} NormalizeOptions
|
|
63
|
+
* @property {boolean|((schemaNode: Record<string, unknown>) => boolean)} [useDefaults=false] - Materialize `default` for absent object properties, recursively
|
|
64
|
+
* @property {boolean|'all'} [removeAdditional=false] - Strip unknown properties: `true` only where `additionalProperties: false`, `'all'` wherever an object shape is declared
|
|
65
|
+
* @property {boolean|((schemaNode: Record<string, unknown>) => boolean)} [coerceTypes=false] - Convert a value to the node's declared scalar `type` when it is convertible
|
|
66
|
+
* @property {boolean|((schemaNode: Record<string, unknown>) => boolean)} [trimStrings=false] - Trim leading/trailing whitespace from strings, before coercion
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Convert `value` to `type` when the conversion is unambiguous, otherwise
|
|
71
|
+
* return it unchanged so validation reports the type error.
|
|
72
|
+
*
|
|
73
|
+
* The table is deliberately conservative - it exists to decode transport
|
|
74
|
+
* encodings (query strings, form fields, environment variables, CSV cells)
|
|
75
|
+
* where everything arrives as a string, not to paper over wrong data. Ajv
|
|
76
|
+
* additionally maps `null`/`0`/`1`/`''` across types; those conversions lose
|
|
77
|
+
* the difference between "absent", "empty" and "false", so they are not
|
|
78
|
+
* reproduced here.
|
|
79
|
+
* @param {any} value - The value to convert
|
|
80
|
+
* @param {string} type - The declared JSON Schema scalar type
|
|
81
|
+
* @returns {any} The converted value, or `value` when no conversion applies
|
|
82
|
+
*/
|
|
83
|
+
function coerceToType(value, type) {
|
|
84
|
+
switch (type) {
|
|
85
|
+
case 'number':
|
|
86
|
+
case 'integer': {
|
|
87
|
+
if (typeof value === 'number') return value;
|
|
88
|
+
if (typeof value !== 'string' || !RE_JSON_NUMBER.test(value)) return value;
|
|
89
|
+
const num = Number(value);
|
|
90
|
+
if (!Number.isFinite(num)) return value;
|
|
91
|
+
if (type === 'integer' && !Number.isInteger(num)) return value;
|
|
92
|
+
return num;
|
|
93
|
+
}
|
|
94
|
+
case 'boolean':
|
|
95
|
+
if (typeof value === 'boolean') return value;
|
|
96
|
+
if (value === 'true') return true;
|
|
97
|
+
if (value === 'false') return false;
|
|
98
|
+
return value;
|
|
99
|
+
case 'string':
|
|
100
|
+
if (typeof value === 'string') return value;
|
|
101
|
+
if (typeof value === 'boolean') return String(value);
|
|
102
|
+
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
|
103
|
+
return value;
|
|
104
|
+
case 'null':
|
|
105
|
+
return value === 'null' ? null : value;
|
|
106
|
+
default:
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Collect the `$anchor` declarations of one schema document: a map from
|
|
113
|
+
* anchor name to the schema node that declares it. Exported because
|
|
114
|
+
* `@jarenjs/emit` resolves the same references when it derives types, and two
|
|
115
|
+
* walks with different scope rules would make a generated type disagree with
|
|
116
|
+
* this normalizer — the one defect class that package must not have.
|
|
117
|
+
*
|
|
118
|
+
* The scope is the same-document scope the rest of this module uses: a
|
|
119
|
+
* subtree that declares its own `$id` is an embedded resource with its own
|
|
120
|
+
* anchor scope, so it is not descended. First declaration wins, which keeps
|
|
121
|
+
* the map deterministic for a document that (invalidly) repeats a name.
|
|
122
|
+
* @param {object|boolean} root - The root schema of the document
|
|
123
|
+
* @returns {Map<string, object>} anchor name -> schema node
|
|
124
|
+
*/
|
|
125
|
+
export function collectSameDocumentAnchors(root) {
|
|
126
|
+
/** @type {Map<string, object>} */
|
|
127
|
+
const anchors = new Map();
|
|
128
|
+
if (!isJsonObject(root)) return anchors;
|
|
129
|
+
const seen = new Set();
|
|
130
|
+
/** @param {any} node @param {boolean} isRoot */
|
|
131
|
+
const walk = (node, isRoot) => {
|
|
132
|
+
if (!isJsonContainer(node) || seen.has(node)) return;
|
|
133
|
+
seen.add(node);
|
|
134
|
+
if (Array.isArray(node)) {
|
|
135
|
+
for (let i = 0; i < node.length; i++) walk(node[i], false);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (!isRoot && typeof node.$id === 'string') return;
|
|
139
|
+
if (typeof node.$anchor === 'string' && !anchors.has(node.$anchor))
|
|
140
|
+
anchors.set(node.$anchor, node);
|
|
141
|
+
const keys = Object.getOwnPropertyNames(node);
|
|
142
|
+
for (let i = 0; i < keys.length; i++) walk(node[keys[i]], false);
|
|
143
|
+
};
|
|
144
|
+
walk(root, true);
|
|
145
|
+
return anchors;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Resolve a same-document `$ref` — `#`, `#/` followed by a JSON Pointer, or
|
|
150
|
+
* `#name` for a plain `$anchor` — to the schema it addresses. Refs into other
|
|
151
|
+
* documents are not followed: a normalizer compiles one schema, and reaching
|
|
152
|
+
* a registered sibling would mean owning the whole resolution scope that
|
|
153
|
+
* `compile` owns. Exported for `@jarenjs/emit`, which must resolve references
|
|
154
|
+
* with exactly these rules when it derives the accepted/normalized variants.
|
|
155
|
+
* @param {string} ref - The reference
|
|
156
|
+
* @param {object|boolean} root - The root schema being compiled
|
|
157
|
+
* @param {Map<string, object>} [anchors] - The document's anchor map, from
|
|
158
|
+
* {@link collectSameDocumentAnchors}; omit to skip anchor resolution
|
|
159
|
+
* @returns {object|boolean|undefined} The addressed schema, or undefined
|
|
160
|
+
*/
|
|
161
|
+
export function resolveSameDocumentRef(ref, root, anchors) {
|
|
162
|
+
if (ref === '#') return root;
|
|
163
|
+
if (!ref.startsWith('#')) return undefined;
|
|
164
|
+
if (!ref.startsWith('#/'))
|
|
165
|
+
return anchors === undefined ? undefined : anchors.get(ref.slice(1));
|
|
166
|
+
let node = root;
|
|
167
|
+
let tokens;
|
|
168
|
+
try {
|
|
169
|
+
tokens = parseJSONPointer(ref.slice(1));
|
|
170
|
+
}
|
|
171
|
+
catch (_e) {
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
175
|
+
if (!isJsonContainer(node)) return undefined;
|
|
176
|
+
node = node[tokens[i]];
|
|
177
|
+
}
|
|
178
|
+
return node;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Resolve a per-node normalization switch at COMPILE time. Exported because
|
|
183
|
+
* `@jarenjs/emit` has to answer the same question when it derives the accepted
|
|
184
|
+
* and normalized type variants: two implementations of this rule would drift,
|
|
185
|
+
* and a type that disagrees with the normalizer is worse than no type. `true` turns the
|
|
186
|
+
* behavior on everywhere, `false` nowhere, and a predicate decides per schema
|
|
187
|
+
* node — which is how a consumer expresses "trim these 34 string fields, not
|
|
188
|
+
* the other 185" without the option becoming a whole-schema blunt instrument.
|
|
189
|
+
* Because it runs during compilation, a predicate costs nothing at runtime.
|
|
190
|
+
* @param {boolean|((node: Record<string, unknown>) => boolean)|undefined} option
|
|
191
|
+
* @param {object} node - The schema node the switch applies to
|
|
192
|
+
* @returns {boolean}
|
|
193
|
+
*/
|
|
194
|
+
export function resolveNormalizeSwitch(option, node) {
|
|
195
|
+
if (option === true) return true;
|
|
196
|
+
if (typeof option === 'function') return option(node) === true;
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Compile a regular expression, tolerating patterns this engine rejects. */
|
|
201
|
+
function compilePattern(source) {
|
|
202
|
+
try {
|
|
203
|
+
return new RegExp(source, 'u');
|
|
204
|
+
}
|
|
205
|
+
catch (_e) {
|
|
206
|
+
try {
|
|
207
|
+
return new RegExp(source);
|
|
208
|
+
}
|
|
209
|
+
catch (_e2) {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Compose a list of node steps into one function, skipping the empty case.
|
|
217
|
+
* @param {Function[]} steps - The steps to run in order
|
|
218
|
+
* @returns {Function|null} The composed step, or null when there is none
|
|
219
|
+
*/
|
|
220
|
+
function composeSteps(steps) {
|
|
221
|
+
if (steps.length === 0) return null;
|
|
222
|
+
if (steps.length === 1) return steps[0];
|
|
223
|
+
return function runSteps(value) {
|
|
224
|
+
let out = value;
|
|
225
|
+
for (let i = 0; i < steps.length; i++)
|
|
226
|
+
out = steps[i](out);
|
|
227
|
+
return out;
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Build the object step: strip unknown members, normalize known ones, then
|
|
233
|
+
* materialize defaults. That order is what makes the result stable - a
|
|
234
|
+
* default is never stripped, and a stripped member never gets normalized.
|
|
235
|
+
* @param {object} node - The schema node
|
|
236
|
+
* @param {object} ctx - The compile context
|
|
237
|
+
* @returns {Function|null} The step, or null when this node needs none
|
|
238
|
+
*/
|
|
239
|
+
function buildObjectStep(node, ctx) {
|
|
240
|
+
const options = ctx.options;
|
|
241
|
+
const properties = isJsonObject(node.properties) ? node.properties : null;
|
|
242
|
+
const patternProperties = isJsonObject(node.patternProperties) ? node.patternProperties : null;
|
|
243
|
+
const additional = node.additionalProperties;
|
|
244
|
+
|
|
245
|
+
// Every declared name is recorded, including one whose subschema needs no
|
|
246
|
+
// work: "declared but unchanged" and "not declared at all" are the same
|
|
247
|
+
// lookup result otherwise, and stripping would delete a known member.
|
|
248
|
+
// A null value means declared, nothing to do.
|
|
249
|
+
const propertySteps = new Map();
|
|
250
|
+
const defaults = [];
|
|
251
|
+
let hasStep = false;
|
|
252
|
+
if (properties !== null) {
|
|
253
|
+
const keys = Object.getOwnPropertyNames(properties);
|
|
254
|
+
for (let i = 0; i < keys.length; i++) {
|
|
255
|
+
const key = keys[i];
|
|
256
|
+
const sub = properties[key];
|
|
257
|
+
const step = compileNode(sub, ctx);
|
|
258
|
+
propertySteps.set(key, step);
|
|
259
|
+
if (step !== null) hasStep = true;
|
|
260
|
+
// The step is stored with the default so a materialized container is
|
|
261
|
+
// normalized by the same schema an explicitly supplied one would be;
|
|
262
|
+
// otherwise a defaulted `{ port: '8080' }` keeps its string where a
|
|
263
|
+
// provided one is coerced.
|
|
264
|
+
if (isJsonObject(sub) && sub.default !== undefined
|
|
265
|
+
&& resolveNormalizeSwitch(options.useDefaults, sub))
|
|
266
|
+
defaults.push(key, sub.default, step);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Same for patterns: a pattern that matches makes a member known, whether
|
|
271
|
+
// or not its subschema normalizes anything.
|
|
272
|
+
const patternSteps = [];
|
|
273
|
+
if (patternProperties !== null) {
|
|
274
|
+
const sources = Object.getOwnPropertyNames(patternProperties);
|
|
275
|
+
for (let i = 0; i < sources.length; i++) {
|
|
276
|
+
const regexp = compilePattern(sources[i]);
|
|
277
|
+
if (regexp === null) continue;
|
|
278
|
+
const step = compileNode(patternProperties[sources[i]], ctx);
|
|
279
|
+
patternSteps.push(regexp, step);
|
|
280
|
+
if (step !== null) hasStep = true;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const additionalStep = additional === undefined || typeof additional === 'boolean'
|
|
285
|
+
? null
|
|
286
|
+
: compileNode(additional, ctx);
|
|
287
|
+
|
|
288
|
+
// `true` strips only where the schema says the members are forbidden;
|
|
289
|
+
// 'all' additionally strips members a declared shape simply does not
|
|
290
|
+
// mention. Neither strips an object whose shape is undeclared - there,
|
|
291
|
+
// every member is legitimately "additional".
|
|
292
|
+
const declaresShape = properties !== null || patternProperties !== null;
|
|
293
|
+
const strip = ctx.allowStrip && options.removeAdditional !== false
|
|
294
|
+
&& (additional === false || (options.removeAdditional === 'all' && declaresShape));
|
|
295
|
+
|
|
296
|
+
const defaultCount = defaults.length;
|
|
297
|
+
const patternCount = patternSteps.length;
|
|
298
|
+
if (!hasStep && additionalStep === null && !strip && defaultCount === 0)
|
|
299
|
+
return null;
|
|
300
|
+
|
|
301
|
+
return function normalizeObject(value) {
|
|
302
|
+
if (!isJsonObject(value)) return value;
|
|
303
|
+
let out = value;
|
|
304
|
+
const keys = Object.keys(value);
|
|
305
|
+
for (let i = 0; i < keys.length; i++) {
|
|
306
|
+
const key = keys[i];
|
|
307
|
+
const current = value[key];
|
|
308
|
+
let next = current;
|
|
309
|
+
// JSON Schema applies EVERY applicable subschema to a member, so a
|
|
310
|
+
// member covered by `properties` and by one or more `patternProperties`
|
|
311
|
+
// is normalized by all of them in turn. `additionalProperties` applies
|
|
312
|
+
// only when nothing else did.
|
|
313
|
+
// undefined = not declared; null = declared with nothing to do.
|
|
314
|
+
const named = propertySteps.get(key);
|
|
315
|
+
let covered = named !== undefined;
|
|
316
|
+
if (named != null) next = named(next);
|
|
317
|
+
for (let p = 0; p < patternCount; p += 2) {
|
|
318
|
+
if (!patternSteps[p].test(key)) continue;
|
|
319
|
+
covered = true;
|
|
320
|
+
const patternStep = patternSteps[p + 1];
|
|
321
|
+
if (patternStep !== null) next = patternStep(next);
|
|
322
|
+
}
|
|
323
|
+
if (!covered) {
|
|
324
|
+
if (strip) {
|
|
325
|
+
if (out === value) out = { ...value };
|
|
326
|
+
delete out[key];
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (additionalStep !== null) next = additionalStep(next);
|
|
330
|
+
}
|
|
331
|
+
if (next !== current) {
|
|
332
|
+
if (out === value) out = { ...value };
|
|
333
|
+
setObjectMember(out, key, next);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
for (let i = 0; i < defaultCount; i += 3) {
|
|
337
|
+
const key = defaults[i];
|
|
338
|
+
if (hasOwn(value, key)) continue;
|
|
339
|
+
if (out === value) out = { ...value };
|
|
340
|
+
// Each instance gets its own copy: a container default shared across
|
|
341
|
+
// normalized documents would let a mutation of one leak into all. The
|
|
342
|
+
// copy then runs through the property's own step, so a materialized
|
|
343
|
+
// default is shaped exactly like a supplied value.
|
|
344
|
+
const step = defaults[i + 2];
|
|
345
|
+
const materialized = cloneJson(defaults[i + 1]);
|
|
346
|
+
setObjectMember(out, key, step === null ? materialized : step(materialized));
|
|
347
|
+
}
|
|
348
|
+
return out;
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Build the array step. Both tuple spellings are handled: draft 2020-12's
|
|
354
|
+
* `prefixItems` + `items`, and draft-07's array-valued `items` +
|
|
355
|
+
* `additionalItems`.
|
|
356
|
+
* @param {object} node - The schema node
|
|
357
|
+
* @param {object} ctx - The compile context
|
|
358
|
+
* @returns {Function|null} The step, or null when this node needs none
|
|
359
|
+
*/
|
|
360
|
+
function buildArrayStep(node, ctx) {
|
|
361
|
+
const itemsIsTuple = Array.isArray(node.items);
|
|
362
|
+
const prefixSource = itemsIsTuple ? node.items : node.prefixItems;
|
|
363
|
+
const restSource = itemsIsTuple ? node.additionalItems : node.items;
|
|
364
|
+
|
|
365
|
+
let prefixSteps = null;
|
|
366
|
+
if (Array.isArray(prefixSource)) {
|
|
367
|
+
prefixSteps = new Array(prefixSource.length);
|
|
368
|
+
let used = false;
|
|
369
|
+
for (let i = 0; i < prefixSource.length; i++) {
|
|
370
|
+
prefixSteps[i] = compileNode(prefixSource[i], ctx);
|
|
371
|
+
if (prefixSteps[i] !== null) used = true;
|
|
372
|
+
}
|
|
373
|
+
if (!used) prefixSteps = null;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const restStep = restSource === undefined || typeof restSource === 'boolean'
|
|
377
|
+
? null
|
|
378
|
+
: compileNode(restSource, ctx);
|
|
379
|
+
|
|
380
|
+
if (prefixSteps === null && restStep === null) return null;
|
|
381
|
+
const prefixLength = prefixSteps === null ? 0 : prefixSteps.length;
|
|
382
|
+
|
|
383
|
+
return function normalizeArray(value) {
|
|
384
|
+
if (!Array.isArray(value)) return value;
|
|
385
|
+
let out = value;
|
|
386
|
+
for (let i = 0; i < value.length; i++) {
|
|
387
|
+
const step = i < prefixLength ? prefixSteps[i] : restStep;
|
|
388
|
+
if (step === null) continue;
|
|
389
|
+
const current = value[i];
|
|
390
|
+
const next = step(current);
|
|
391
|
+
if (next !== current) {
|
|
392
|
+
if (out === value) out = value.slice();
|
|
393
|
+
out[i] = next;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return out;
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Build the scalar step: trim, then coerce. Trimming runs first so that a
|
|
402
|
+
* padded transport value (`' 42 '` for a numeric field) reaches coercion in
|
|
403
|
+
* the shape the grammar accepts.
|
|
404
|
+
*
|
|
405
|
+
* Trimming applies to every string the walk reaches, not only to nodes typed
|
|
406
|
+
* `string`, because the value that needs trimming is frequently the one
|
|
407
|
+
* declared as a number.
|
|
408
|
+
* @param {object} node - The schema node
|
|
409
|
+
* @param {object} ctx - The compile context
|
|
410
|
+
* @returns {Function|null} The step, or null when this node needs none
|
|
411
|
+
*/
|
|
412
|
+
function buildScalarStep(node, ctx) {
|
|
413
|
+
const options = ctx.options;
|
|
414
|
+
const trim = resolveNormalizeSwitch(options.trimStrings, node);
|
|
415
|
+
// A union `type` gives no single conversion target, so coercion is skipped
|
|
416
|
+
// rather than guessed.
|
|
417
|
+
const coerceTo = resolveNormalizeSwitch(options.coerceTypes, node) && typeof node.type === 'string'
|
|
418
|
+
? node.type
|
|
419
|
+
: null;
|
|
420
|
+
if (!trim && coerceTo === null) return null;
|
|
421
|
+
|
|
422
|
+
if (coerceTo === null) {
|
|
423
|
+
return function normalizeTrim(value) {
|
|
424
|
+
return typeof value === 'string' ? value.trim() : value;
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
if (!trim) {
|
|
428
|
+
return function normalizeCoerce(value) {
|
|
429
|
+
return coerceToType(value, coerceTo);
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
return function normalizeTrimCoerce(value) {
|
|
433
|
+
return coerceToType(typeof value === 'string' ? value.trim() : value, coerceTo);
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Compile one schema node into a normalizer step, or null when the node
|
|
439
|
+
* cannot change anything. Recursive schemas are handled by publishing a
|
|
440
|
+
* deferring placeholder into the memo before the node is built, so a `$ref`
|
|
441
|
+
* back to an ancestor resolves to a function that is complete by the time it
|
|
442
|
+
* is called.
|
|
443
|
+
* @param {object|boolean} node - The schema node
|
|
444
|
+
* @param {object} ctx - The compile context
|
|
445
|
+
* @returns {Function|null} The step, or null
|
|
446
|
+
*/
|
|
447
|
+
function compileNode(node, ctx) {
|
|
448
|
+
if (!isJsonObject(node)) return null;
|
|
449
|
+
|
|
450
|
+
const memo = ctx.allowStrip ? ctx.memoStrip : ctx.memoNoStrip;
|
|
451
|
+
const cached = memo.get(node);
|
|
452
|
+
if (cached !== undefined) return cached;
|
|
453
|
+
|
|
454
|
+
let built = null;
|
|
455
|
+
const deferred = function normalizeDeferred(value) {
|
|
456
|
+
return built === null ? value : built(value);
|
|
457
|
+
};
|
|
458
|
+
memo.set(node, deferred);
|
|
459
|
+
|
|
460
|
+
const steps = [];
|
|
461
|
+
|
|
462
|
+
if (typeof node.$ref === 'string') {
|
|
463
|
+
const target = resolveSameDocumentRef(node.$ref, ctx.root, ctx.anchors);
|
|
464
|
+
// A ref this module cannot follow is left alone rather than guessed at;
|
|
465
|
+
// validation still resolves it through the full ref machinery.
|
|
466
|
+
if (target !== undefined && target !== node) {
|
|
467
|
+
const step = compileNode(target, ctx);
|
|
468
|
+
if (step !== null) steps.push(step);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// `allOf` branches all apply, so they compose. Stripping is disabled for
|
|
473
|
+
// the whole subtree underneath: a branch declares only its own share of
|
|
474
|
+
// the object, so what looks "additional" to it may be exactly what a
|
|
475
|
+
// sibling branch declares. Composition-shaped schemas therefore keep their
|
|
476
|
+
// members; only defaults, coercion and trimming flow through.
|
|
477
|
+
if (Array.isArray(node.allOf)) {
|
|
478
|
+
const branchCtx = ctx.allowStrip ? { ...ctx, allowStrip: false } : ctx;
|
|
479
|
+
for (let i = 0; i < node.allOf.length; i++) {
|
|
480
|
+
const step = compileNode(node.allOf[i], branchCtx);
|
|
481
|
+
if (step !== null) steps.push(step);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const objectStep = buildObjectStep(node, ctx);
|
|
486
|
+
if (objectStep !== null) steps.push(objectStep);
|
|
487
|
+
|
|
488
|
+
const arrayStep = buildArrayStep(node, ctx);
|
|
489
|
+
if (arrayStep !== null) steps.push(arrayStep);
|
|
490
|
+
|
|
491
|
+
const scalarStep = buildScalarStep(node, ctx);
|
|
492
|
+
if (scalarStep !== null) steps.push(scalarStep);
|
|
493
|
+
|
|
494
|
+
built = composeSteps(steps);
|
|
495
|
+
memo.set(node, built);
|
|
496
|
+
// Callers that resolved this node while it was being built hold `deferred`;
|
|
497
|
+
// everyone else gets the direct function.
|
|
498
|
+
return built === null ? null : deferred;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Compile a JSON Schema into a normalizer: a function that returns a
|
|
503
|
+
* normalized copy of its input, leaving the input untouched.
|
|
504
|
+
*
|
|
505
|
+
* Validation is unaffected and unchanged - normalize first, then hand the
|
|
506
|
+
* result to a compiled validator:
|
|
507
|
+
*
|
|
508
|
+
* ```javascript
|
|
509
|
+
* const normalize = compileNormalizer(schema, { useDefaults: true, trimStrings: true });
|
|
510
|
+
* const validate = new JarenValidator({ collectErrors: true }).compile(schema);
|
|
511
|
+
* const shaped = normalize(input);
|
|
512
|
+
* const result = validate(shaped);
|
|
513
|
+
* ```
|
|
514
|
+
*
|
|
515
|
+
* **What is normalized.** `properties`, `patternProperties`,
|
|
516
|
+
* `additionalProperties`, `items`/`prefixItems`/`additionalItems`, same-document
|
|
517
|
+
* `$ref` (`#`, `#/pointer` and plain `#anchor` forms), and `allOf` (composed,
|
|
518
|
+
* with stripping disabled inside it).
|
|
519
|
+
*
|
|
520
|
+
* **What is not, and why.** `anyOf`, `oneOf`, `if`/`then`/`else` and `not`
|
|
521
|
+
* are not descended: which branch applies is only known after validating,
|
|
522
|
+
* and normalizing under a branch can change which branch validates. Nothing
|
|
523
|
+
* arbitrary runs either - there is no transform hook, because an arbitrary
|
|
524
|
+
* transform is application code, not schema semantics, and belongs on the
|
|
525
|
+
* caller's side of the boundary.
|
|
526
|
+
* @template [In=unknown]
|
|
527
|
+
* @template [Out=In]
|
|
528
|
+
* @param {object|boolean} schema - The schema to compile
|
|
529
|
+
* @param {NormalizeOptions} [options] - Which normalizations to apply
|
|
530
|
+
* @returns {Normalizer<In, Out>} The compiled normalizer
|
|
531
|
+
* @example
|
|
532
|
+
* const normalize = compileNormalizer({
|
|
533
|
+
* type: 'object',
|
|
534
|
+
* properties: {
|
|
535
|
+
* name: { type: 'string' },
|
|
536
|
+
* port: { type: 'integer', default: 8080 },
|
|
537
|
+
* },
|
|
538
|
+
* additionalProperties: false,
|
|
539
|
+
* }, { useDefaults: true, removeAdditional: true, coerceTypes: true, trimStrings: true });
|
|
540
|
+
*
|
|
541
|
+
* const input = { name: ' jaren ', port: '9000', stray: 1 };
|
|
542
|
+
* normalize(input); // { name: 'jaren', port: 9000 }
|
|
543
|
+
* input; // { name: ' jaren ', port: '9000', stray: 1 } - untouched
|
|
544
|
+
*/
|
|
545
|
+
export function compileNormalizer(schema, options = {}) {
|
|
546
|
+
const resolved = {
|
|
547
|
+
useDefaults: options.useDefaults,
|
|
548
|
+
removeAdditional: options.removeAdditional === 'all'
|
|
549
|
+
? 'all'
|
|
550
|
+
: options.removeAdditional === true,
|
|
551
|
+
coerceTypes: options.coerceTypes,
|
|
552
|
+
trimStrings: options.trimStrings,
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
const ctx = {
|
|
556
|
+
options: resolved,
|
|
557
|
+
root: schema,
|
|
558
|
+
anchors: collectSameDocumentAnchors(schema),
|
|
559
|
+
memoStrip: new Map(),
|
|
560
|
+
memoNoStrip: new Map(),
|
|
561
|
+
allowStrip: true,
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
const step = compileNode(schema, ctx);
|
|
565
|
+
|
|
566
|
+
// A root `default` answers the "the whole document was absent" case, which
|
|
567
|
+
// no member walk can reach.
|
|
568
|
+
const rootDefault = isJsonObject(schema) && schema.default !== undefined
|
|
569
|
+
&& resolveNormalizeSwitch(resolved.useDefaults, schema)
|
|
570
|
+
? schema.default
|
|
571
|
+
: undefined;
|
|
572
|
+
|
|
573
|
+
if (step === null && rootDefault === undefined)
|
|
574
|
+
return function normalizeIdentity(data) { return data; };
|
|
575
|
+
|
|
576
|
+
if (rootDefault === undefined) {
|
|
577
|
+
return function normalize(data) { return step(data); };
|
|
578
|
+
}
|
|
579
|
+
return function normalizeWithRootDefault(data) {
|
|
580
|
+
if (data === undefined) return cloneJson(rootDefault);
|
|
581
|
+
return step === null ? data : step(data);
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
//#endregion
|