-
Notifications
You must be signed in to change notification settings - Fork 250
/
Copy pathvalidationErrors.ts
167 lines (152 loc) · 5.81 KB
/
validationErrors.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
import { ErrorObject, TypeParams, EnumParams } from 'ajv';
import groupby = require('lodash.groupby');
/**
* Convert AJV errors to human readable messages
* @param allErrors The AJV errors to describe
*/
export function describeErrors(allErrors: ErrorObject[]): string[] {
const processedErrors = filterRelevantErrors(allErrors);
return processedErrors.map(describeError);
}
/**
* Filters the relevant AJV errors for error reporting.
* Removes meta schema errors, merges type errors for the same `dataPath` and removes type errors for which another error also exist.
* @param allErrors The raw source AJV errors
* @example
* This:
* ```
* [
* {
* keyword: 'type',
* dataPath: '.mutator',
* params: { type: 'string' },
* [...]
* },
* {
* keyword: 'required',
* dataPath: '.mutator',
* params: { missingProperty: 'name' },
* [...]
* },
* {
* keyword: 'oneOf',
* dataPath: '.mutator',
* params: { passingSchemas: null },
* [...]
* }
* ]
* ```
*
* Becomes:
* ```
* [
* {
* keyword: 'required',
* dataPath: '.mutator',
* params: { missingProperty: 'name' },
* [...]
* }
* ]
* ```
*/
function filterRelevantErrors(allErrors: ErrorObject[]): ErrorObject[] {
// These are the "meta schema" keywords. A Meta schema is a schema consisting of other schemas. See https://json-schema.org/understanding-json-schema/structuring.html
const META_SCHEMA_KEYWORDS = Object.freeze(['anyOf', 'allOf', 'oneOf']);
// Split the meta errors from what I call "single errors" (the real errors)
const [metaErrors, singleErrors] = split(allErrors, error => META_SCHEMA_KEYWORDS.includes(error.keyword));
// Filter out the single errors we want to show
const nonShadowedSingleErrors = removeShadowingErrors(singleErrors, metaErrors);
// We're handling type errors differently, split them out
const [typeErrors, nonTypeErrors] = split(nonShadowedSingleErrors, error => error.keyword === 'type');
// Filter out the type errors that already have other errors as well.
// For example when setting `logLevel: 4`, we don't want to see the error specifying that logLevel should be a string,
// if the other error already specified that it should be one of the enum values.
const nonShadowingTypeErrors = typeErrors.filter(typeError => !nonTypeErrors.some(nonTypeError => nonTypeError.dataPath === typeError.dataPath));
const typeErrorsMerged = mergeTypeErrorsByPath(nonShadowingTypeErrors);
return [...nonTypeErrors, ...typeErrorsMerged];
}
/**
* Remove the single errors that are pointing to the same data path.
* This can happen when using meta schemas.
* For example, the "mutator" Stryker option can be either a `string` or a `MutatorDescriptor`.
* A data object of `{ "foo": "bar" }` would result in 2 errors. One of a missing property "name" missing, and one that mutator itself should be a string.
* @param singleErrors The 'real' errors
* @param metaErrors The grouping errors
*/
function removeShadowingErrors(singleErrors: ErrorObject[], metaErrors: ErrorObject[]): ErrorObject[] {
return singleErrors.filter(error => {
if (metaErrors.some(metaError => error.dataPath.startsWith(metaError.dataPath))) {
return !singleErrors.some(otherError => otherError.dataPath.startsWith(error.dataPath) && otherError.dataPath.length > error.dataPath.length);
} else {
return true;
}
});
}
function split<T>(items: T[], splitFn: (item: T) => boolean): [T[], T[]] {
return [items.filter(splitFn), items.filter(error => !splitFn(error))];
}
/**
* Merge type errors that have the same path into 1.
* @example
* The 'plugins' Stryker option can have 2 types, null or an array of strings.
* When setting `plugins: 'my-plugin'` we get 2 type errors, because it isn't an array AND it isn't `null`.
* @param typeErrors The type errors to merge by path
*/
function mergeTypeErrorsByPath(typeErrors: ErrorObject[]): ErrorObject[] {
const typeErrorsByPath = groupby(typeErrors, error => error.dataPath);
return Object.values(typeErrorsByPath).map(mergeTypeErrors);
function mergeTypeErrors(typeErrors: ErrorObject[]): ErrorObject {
const params: TypeParams = {
type: typeErrors.map(error => (error.params as TypeParams).type).join(',')
};
return {
...typeErrors[0],
params
};
}
}
/**
* Converts the AJV error object to a human readable error.
* @param error The error to describe
*/
function describeError(error: ErrorObject): string {
const errorPrefix = `Config option "${error.dataPath.substr(1)}"`;
switch (error.keyword) {
case 'type':
const expectedTypeDescription = (error.params as TypeParams).type.split(',').join(' or ');
return `${errorPrefix} has the wrong type. It should be a ${expectedTypeDescription}, but was a ${jsonSchemaType(error.data)}.`;
case 'enum':
return `${errorPrefix} should be one of the allowed values (${(error.params as EnumParams).allowedValues
.map(stringify)
.join(', ')}), but was ${stringify(error.data)}.`;
case 'minimum':
case 'maximum':
return `${errorPrefix} ${error.message}, was ${error.data}.`;
default:
return `${errorPrefix} ${error.message!.replace(/'/g, '"')}`;
}
}
/**
* Returns the JSON schema name of the type. JSON schema types are slightly different from actual JS types.
* @see https://json-schema.org/understanding-json-schema/reference/type.html
* @param value The value of which it's type should be known
*/
function jsonSchemaType(value: unknown): string {
if (value === null) {
return 'null';
}
if (value === undefined) {
return 'undefined';
}
if (Array.isArray(value)) {
return 'array';
}
return typeof value;
}
function stringify(value: unknown): string {
if (typeof value === 'number' && isNaN(value)) {
return 'NaN';
} else {
return JSON.stringify(value);
}
}