UNPKG

5.93 kBPlain TextView Raw
1import { ErrorObject } from 'ajv';
2
3import groupby from 'lodash.groupby';
4
5/**
6 * Convert AJV errors to human readable messages
7 * @param allErrors The AJV errors to describe
8 */
9export function describeErrors(allErrors: ErrorObject[]): string[] {
10 const processedErrors = filterRelevantErrors(allErrors);
11 return processedErrors.map(describeError);
12}
13
14/**
15 * Filters the relevant AJV errors for error reporting.
16 * Removes meta schema errors, merges type errors for the same `dataPath` and removes type errors for which another error also exist.
17 * @param allErrors The raw source AJV errors
18 * @example
19 * This:
20 * ```
21 * [
22 * {
23 * keyword: 'type',
24 * dataPath: '.mutator',
25 * params: { type: 'string' },
26 * [...]
27 * },
28 * {
29 * keyword: 'required',
30 * dataPath: '.mutator',
31 * params: { missingProperty: 'name' },
32 * [...]
33 * },
34 * {
35 * keyword: 'oneOf',
36 * dataPath: '.mutator',
37 * params: { passingSchemas: null },
38 * [...]
39 * }
40 * ]
41 * ```
42 *
43 * Becomes:
44 * ```
45 * [
46 * {
47 * keyword: 'required',
48 * dataPath: '.mutator',
49 * params: { missingProperty: 'name' },
50 * [...]
51 * }
52 * ]
53 * ```
54 */
55function filterRelevantErrors(allErrors: ErrorObject[]): ErrorObject[] {
56 // 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
57 const META_SCHEMA_KEYWORDS = Object.freeze(['anyOf', 'allOf', 'oneOf']);
58
59 // Split the meta errors from what I call "single errors" (the real errors)
60 const [metaErrors, singleErrors] = split(allErrors, (error) => META_SCHEMA_KEYWORDS.includes(error.keyword));
61
62 // Filter out the single errors we want to show
63 const nonShadowedSingleErrors = removeShadowingErrors(singleErrors, metaErrors);
64
65 // We're handling type errors differently, split them out
66 const [typeErrors, nonTypeErrors] = split(nonShadowedSingleErrors, (error) => error.keyword === 'type');
67
68 // Filter out the type errors that already have other errors as well.
69 // For example when setting `logLevel: 4`, we don't want to see the error specifying that logLevel should be a string,
70 // if the other error already specified that it should be one of the enum values.
71 const nonShadowingTypeErrors = typeErrors.filter(
72 (typeError) => !nonTypeErrors.some((nonTypeError) => nonTypeError.instancePath === typeError.instancePath)
73 );
74 const typeErrorsMerged = mergeTypeErrorsByPath(nonShadowingTypeErrors);
75 return [...nonTypeErrors, ...typeErrorsMerged];
76}
77
78/**
79 * Remove the single errors that are pointing to the same data path.
80 * This can happen when using meta schemas.
81 * For example, the "mutator" Stryker option can be either a `string` or a `MutatorDescriptor`.
82 * 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.
83 * @param singleErrors The 'real' errors
84 * @param metaErrors The grouping errors
85 */
86function removeShadowingErrors(singleErrors: ErrorObject[], metaErrors: ErrorObject[]): ErrorObject[] {
87 return singleErrors.filter((error) => {
88 if (metaErrors.some((metaError) => error.instancePath.startsWith(metaError.instancePath))) {
89 return !singleErrors.some(
90 (otherError) => otherError.instancePath.startsWith(error.instancePath) && otherError.instancePath.length > error.instancePath.length
91 );
92 } else {
93 return true;
94 }
95 });
96}
97
98function split<T>(items: T[], splitFn: (item: T) => boolean): [T[], T[]] {
99 return [items.filter(splitFn), items.filter((error) => !splitFn(error))];
100}
101
102/**
103 * Merge type errors that have the same path into 1.
104 * @example
105 * The 'plugins' Stryker option can have 2 types, null or an array of strings.
106 * When setting `plugins: 'my-plugin'` we get 2 type errors, because it isn't an array AND it isn't `null`.
107 * @param typeErrors The type errors to merge by path
108 */
109function mergeTypeErrorsByPath(typeErrors: ErrorObject[]): ErrorObject[] {
110 const typeErrorsByPath = groupby(typeErrors, (error) => error.instancePath);
111 return Object.values(typeErrorsByPath).map(mergeTypeErrors);
112
113 function mergeTypeErrors(errors: ErrorObject[]): ErrorObject {
114 const params = {
115 type: errors.map((error) => error.params.type).join(','),
116 };
117 return {
118 ...errors[0],
119 params,
120 };
121 }
122}
123
124/**
125 * Converts the AJV error object to a human readable error.
126 * @param error The error to describe
127 */
128function describeError(error: ErrorObject): string {
129 const errorPrefix = `Config option "${error.instancePath.substr(1)}"`;
130
131 switch (error.keyword) {
132 case 'type':
133 const expectedTypeDescription = error.params.type.split(',').join(' or ');
134 return `${errorPrefix} has the wrong type. It should be a ${expectedTypeDescription}, but was a ${jsonSchemaType(error.data)}.`;
135 case 'enum':
136 return `${errorPrefix} should be one of the allowed values (${error.params.allowedValues.map(stringify).join(', ')}), but was ${stringify(
137 error.data
138 )}.`;
139 case 'minimum':
140 case 'maximum':
141 return `${errorPrefix} ${error.message}, was ${error.data}.`;
142 default:
143 return `${errorPrefix} ${error.message!.replace(/'/g, '"')}`;
144 }
145}
146
147/**
148 * Returns the JSON schema name of the type. JSON schema types are slightly different from actual JS types.
149 * @see https://json-schema.org/understanding-json-schema/reference/type.html
150 * @param value The value of which it's type should be known
151 */
152function jsonSchemaType(value: unknown): string {
153 if (value === null) {
154 return 'null';
155 }
156 if (value === undefined) {
157 return 'undefined';
158 }
159 if (Array.isArray(value)) {
160 return 'array';
161 }
162 return typeof value;
163}
164
165function stringify(value: unknown): string {
166 if (typeof value === 'number' && isNaN(value)) {
167 return 'NaN';
168 } else {
169 return JSON.stringify(value);
170 }
171}