forked from rjsf-team/react-jsonschema-form
/
validator.ts
431 lines (402 loc) · 13.8 KB
/
validator.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
import Ajv, { ErrorObject, ValidateFunction } from "ajv8";
import toPath from "lodash/toPath";
import isObject from "lodash/isObject";
import clone from "lodash/clone";
import {
CustomValidator,
ERRORS_KEY,
ErrorSchema,
ErrorTransformer,
FieldValidation,
FormValidation,
GenericObjectType,
getDefaultFormState,
mergeValidationData,
REF_KEY,
RJSFSchema,
RJSFValidationError,
StrictRJSFSchema,
ValidationData,
ValidatorType,
} from "@rjsf/utils";
import { CustomValidatorOptionsType, Localizer } from "./types";
import createAjvInstance from "./createAjvInstance";
const ROOT_SCHEMA_PREFIX = "__rjsf_rootSchema";
/** `ValidatorType` implementation that uses the AJV 8 validation mechanism.
*/
export default class AJV8Validator<
T = any,
S extends StrictRJSFSchema = RJSFSchema
> implements ValidatorType<T>
{
/** The AJV instance to use for all validations
*
* @private
*/
private ajv: Ajv;
/** The Localizer function to use for localizing Ajv errors
*
* @private
*/
readonly localizer?: Localizer;
/** Constructs an `AJV8Validator` instance using the `options`
*
* @param options - The `CustomValidatorOptionsType` options that are used to create the AJV instance
* @param [localizer] - If provided, is used to localize a list of Ajv `ErrorObject`s
*/
constructor(options: CustomValidatorOptionsType, localizer?: Localizer) {
const {
additionalMetaSchemas,
customFormats,
ajvOptionsOverrides,
ajvFormatOptions,
AjvClass,
} = options;
this.ajv = createAjvInstance(
additionalMetaSchemas,
customFormats,
ajvOptionsOverrides,
ajvFormatOptions,
AjvClass
);
this.localizer = localizer;
}
/** Transforms a ajv validation errors list:
* [
* {property: '.level1.level2[2].level3', message: 'err a'},
* {property: '.level1.level2[2].level3', message: 'err b'},
* {property: '.level1.level2[4].level3', message: 'err b'},
* ]
* Into an error tree:
* {
* level1: {
* level2: {
* 2: {level3: {errors: ['err a', 'err b']}},
* 4: {level3: {errors: ['err b']}},
* }
* }
* };
*
* @param errors - The list of RJSFValidationError objects
* @private
*/
private toErrorSchema(errors: RJSFValidationError[]): ErrorSchema<T> {
if (!errors.length) {
return {} as ErrorSchema<T>;
}
return errors.reduce(
(errorSchema: ErrorSchema<T>, error): ErrorSchema<T> => {
const { property, message } = error;
const path = toPath(property);
let parent: GenericObjectType = errorSchema;
// If the property is at the root (.level1) then toPath creates
// an empty array element at the first index. Remove it.
if (path.length > 0 && path[0] === "") {
path.splice(0, 1);
}
for (const segment of path.slice(0)) {
if (!(segment in parent)) {
parent[segment] = {};
}
parent = parent[segment];
}
if (Array.isArray(parent.__errors)) {
// We store the list of errors for this node in a property named __errors
// to avoid name collision with a possible sub schema field named
// 'errors' (see `validate.createErrorHandler`).
parent.__errors = parent.__errors.concat(message!);
} else {
if (message) {
parent.__errors = [message];
}
}
return errorSchema;
},
{} as ErrorSchema<T>
);
}
/** Converts an `errorSchema` into a list of `RJSFValidationErrors`
*
* @param errorSchema - The `ErrorSchema` instance to convert
* @param [fieldPath=[]] - The current field path, defaults to [] if not specified
*/
toErrorList(errorSchema?: ErrorSchema<T>, fieldPath: string[] = []) {
if (!errorSchema) {
return [];
}
let errorList: RJSFValidationError[] = [];
if (ERRORS_KEY in errorSchema) {
errorList = errorList.concat(
errorSchema.__errors!.map((message: string) => {
const property = `.${fieldPath.join(".")}`;
return {
property,
message,
stack: `${property} ${message}`,
};
})
);
}
return Object.keys(errorSchema).reduce((acc, key) => {
if (key !== ERRORS_KEY) {
acc = acc.concat(
this.toErrorList((errorSchema as GenericObjectType)[key], [
...fieldPath,
key,
])
);
}
return acc;
}, errorList);
}
/** Given a `formData` object, recursively creates a `FormValidation` error handling structure around it
*
* @param formData - The form data around which the error handler is created
* @private
*/
private createErrorHandler(formData: T): FormValidation<T> {
const handler: FieldValidation = {
// We store the list of errors for this node in a property named __errors
// to avoid name collision with a possible sub schema field named
// 'errors' (see `utils.toErrorSchema`).
__errors: [],
addError(message: string) {
this.__errors!.push(message);
},
};
if (Array.isArray(formData)) {
return formData.reduce((acc, value, key) => {
return { ...acc, [key]: this.createErrorHandler(value) };
}, handler);
}
if (isObject(formData)) {
const formObject: GenericObjectType = formData as GenericObjectType;
return Object.keys(formObject).reduce((acc, key) => {
return { ...acc, [key]: this.createErrorHandler(formObject[key]) };
}, handler as FormValidation<T>);
}
return handler as FormValidation<T>;
}
/** Unwraps the `errorHandler` structure into the associated `ErrorSchema`, stripping the `addError` functions from it
*
* @param errorHandler - The `FormValidation` error handling structure
* @private
*/
private unwrapErrorHandler(errorHandler: FormValidation<T>): ErrorSchema<T> {
return Object.keys(errorHandler).reduce((acc, key) => {
if (key === "addError") {
return acc;
} else if (key === ERRORS_KEY) {
return { ...acc, [key]: (errorHandler as GenericObjectType)[key] };
}
return {
...acc,
[key]: this.unwrapErrorHandler(
(errorHandler as GenericObjectType)[key]
),
};
}, {} as ErrorSchema<T>);
}
/** Transforming the error output from ajv to format used by @rjsf/utils.
* At some point, components should be updated to support ajv.
*
* @param errors - The list of AJV errors to convert to `RJSFValidationErrors`
* @private
*/
private transformRJSFValidationErrors(
errors: ErrorObject[] = []
): RJSFValidationError[] {
return errors.map((e: ErrorObject) => {
const { instancePath, keyword, message, params, schemaPath } = e;
const property = instancePath.replace(/\//g, ".");
// put data in expected format
return {
name: keyword,
property,
message,
params, // specific to ajv
stack: `${property} ${message}`.trim(),
schemaPath,
};
});
}
/** Runs the pure validation of the `schema` and `formData` without any of the RJSF functionality. Provided for use
* by the playground. Returns the `errors` from the validation
*
* @param schema - The schema against which to validate the form data * @param schema
* @param formData - The form data to validate
*/
rawValidation<Result = any>(
schema: RJSFSchema,
formData?: T
): { errors?: Result[]; validationError?: Error } {
let compilationError: Error | undefined = undefined;
let compiledValidator: ValidateFunction | undefined;
if (schema["$id"]) {
compiledValidator = this.ajv.getSchema(schema["$id"]);
}
try {
if (compiledValidator === undefined) {
compiledValidator = this.ajv.compile(schema);
}
compiledValidator(formData);
} catch (err) {
compilationError = err as Error;
}
let errors;
if (compiledValidator) {
if (typeof this.localizer === "function") {
this.localizer(compiledValidator.errors);
}
errors = compiledValidator.errors || undefined;
// Clear errors to prevent persistent errors, see #1104
compiledValidator.errors = null;
}
return {
errors: errors as unknown as Result[],
validationError: compilationError,
};
}
/** This function processes the `formData` with an optional user contributed `customValidate` function, which receives
* the form data and a `errorHandler` function that will be used to add custom validation errors for each field. Also
* supports a `transformErrors` function that will take the raw AJV validation errors, prior to custom validation and
* transform them in what ever way it chooses.
*
* @param formData - The form data to validate
* @param schema - The schema against which to validate the form data
* @param [customValidate] - An optional function that is used to perform custom validation
* @param [transformErrors] - An optional function that is used to transform errors after AJV validation
*/
validateFormData(
formData: T | undefined,
schema: S,
customValidate?: CustomValidator<T>,
transformErrors?: ErrorTransformer
): ValidationData<T> {
// Include form data with undefined values, which is required for validation.
const newFormData = getDefaultFormState<T>(
this,
schema,
formData,
schema,
true
) as T;
const rawErrors = this.rawValidation<ErrorObject>(schema, newFormData);
const { validationError: invalidSchemaError } = rawErrors;
let errors = this.transformRJSFValidationErrors(rawErrors.errors);
if (invalidSchemaError) {
errors = [...errors, { stack: invalidSchemaError!.message }];
}
if (typeof transformErrors === "function") {
errors = transformErrors(errors);
}
let errorSchema = this.toErrorSchema(errors);
if (invalidSchemaError) {
errorSchema = {
...errorSchema,
$schema: {
__errors: [invalidSchemaError!.message],
},
};
}
if (typeof customValidate !== "function") {
return { errors, errorSchema };
}
const errorHandler = customValidate(
newFormData,
this.createErrorHandler(newFormData)
);
const userErrorSchema = this.unwrapErrorHandler(errorHandler);
return mergeValidationData<T>(
this,
{ errors, errorSchema },
userErrorSchema
);
}
/** Takes a `node` object and transforms any contained `$ref` node variables with a prefix, recursively calling
* `withIdRefPrefix` for any other elements.
*
* @param node - The object node to which a ROOT_SCHEMA_PREFIX is added when a REF_KEY is part of it
* @private
*/
private withIdRefPrefixObject(node: S) {
for (const key in node) {
const realObj: GenericObjectType = node;
const value = realObj[key];
if (
key === REF_KEY &&
typeof value === "string" &&
value.startsWith("#")
) {
realObj[key] = ROOT_SCHEMA_PREFIX + value;
} else {
realObj[key] = this.withIdRefPrefix(value);
}
}
return node;
}
/** Takes a `node` object list and transforms any contained `$ref` node variables with a prefix, recursively calling
* `withIdRefPrefix` for any other elements.
*
* @param node - The list of object nodes to which a ROOT_SCHEMA_PREFIX is added when a REF_KEY is part of it
* @private
*/
private withIdRefPrefixArray(node: S[]): S[] {
for (let i = 0; i < node.length; i++) {
node[i] = this.withIdRefPrefix(node[i]) as S;
}
return node;
}
/** Validates data against a schema, returning true if the data is valid, or
* false otherwise. If the schema is invalid, then this function will return
* false.
*
* @param schema - The schema against which to validate the form data
* @param formData - The form data to validate
* @param rootSchema - The root schema used to provide $ref resolutions
*/
isValid(schema: S, formData: T, rootSchema: S) {
const rootSchemaId = rootSchema["$id"] ?? ROOT_SCHEMA_PREFIX;
try {
// add the rootSchema ROOT_SCHEMA_PREFIX as id.
// then rewrite the schema ref's to point to the rootSchema
// this accounts for the case where schema have references to models
// that lives in the rootSchema but not in the schema in question.
if (this.ajv.getSchema(rootSchemaId) === undefined) {
this.ajv.addSchema(rootSchema, rootSchemaId);
}
const schemaWithIdRefPrefix = this.withIdRefPrefix(schema) as S;
let compiledValidator: ValidateFunction | undefined;
if (schemaWithIdRefPrefix["$id"]) {
compiledValidator = this.ajv.getSchema(schemaWithIdRefPrefix["$id"]);
}
if (compiledValidator === undefined) {
compiledValidator = this.ajv.compile(schemaWithIdRefPrefix);
}
const result = compiledValidator(formData);
return result as boolean;
} catch (e) {
console.warn("Error encountered compiling schema:", e);
return false;
} finally {
// TODO: A function should be called if the root schema changes so we don't have to remove and recompile the schema every run.
// make sure we remove the rootSchema from the global ajv instance
this.ajv.removeSchema(rootSchemaId);
}
}
/** Recursively prefixes all $ref's in a schema with `ROOT_SCHEMA_PREFIX`
* This is used in isValid to make references to the rootSchema
*
* @param schemaNode - The object node to which a ROOT_SCHEMA_PREFIX is added when a REF_KEY is part of it
* @protected
*/
protected withIdRefPrefix(schemaNode: S | S[]): S | S[] {
if (Array.isArray(schemaNode)) {
return this.withIdRefPrefixArray([...schemaNode]);
}
if (isObject(schemaNode)) {
return this.withIdRefPrefixObject(clone<S>(schemaNode));
}
return schemaNode;
}
}