UNPKG

13.8 kBJavaScriptView Raw
1// @flow strict-local
2import ThrowableDiagnostic, {
3 generateJSONCodeHighlights,
4} from '@parcel/diagnostic';
5// flowlint-next-line untyped-import:off
6import levenshteinDistance from 'js-levenshtein';
7
8export type SchemaEntity =
9 | SchemaObject
10 | SchemaArray
11 | SchemaBoolean
12 | SchemaString
13 | SchemaEnum
14 | SchemaOneOf
15 | SchemaAllOf
16 | SchemaNot
17 | SchemaAny;
18export type SchemaArray = {|
19 type: 'array',
20 items?: SchemaEntity,
21 __type?: string,
22|};
23export type SchemaBoolean = {|
24 type: 'boolean',
25 __type?: string,
26|};
27export type SchemaOneOf = {|
28 oneOf: Array<SchemaEntity>,
29|};
30export type SchemaAllOf = {|
31 allOf: Array<SchemaEntity>,
32|};
33export type SchemaNot = {|
34 not: SchemaEntity,
35 __message: string,
36|};
37export type SchemaString = {|
38 type: 'string',
39 enum?: Array<string>,
40 __validate?: (val: string) => ?string,
41 __type?: string,
42|};
43export type SchemaEnum = {|
44 enum: Array<mixed>,
45|};
46export type SchemaObject = {|
47 type: 'object',
48 properties: {[string]: SchemaEntity, ...},
49 additionalProperties?: boolean | SchemaEntity,
50 required?: Array<string>,
51 __forbiddenProperties?: Array<string>,
52 __type?: string,
53|};
54export type SchemaAny = {||};
55export type SchemaError =
56 | {|
57 type: 'type',
58 expectedTypes: Array<string>,
59 dataType: ?'key' | 'value',
60
61 dataPath: string,
62 ancestors: Array<SchemaEntity>,
63 prettyType?: string,
64 |}
65 | {|
66 type: 'enum',
67 expectedValues: Array<mixed>,
68 dataType: 'key' | 'value',
69 actualValue: mixed,
70
71 dataPath: string,
72 ancestors: Array<SchemaEntity>,
73 prettyType?: string,
74 |}
75 | {|
76 type: 'forbidden-prop',
77 prop: string,
78 expectedProps: Array<string>,
79 actualProps: Array<string>,
80 dataType: 'key',
81
82 dataPath: string,
83 ancestors: Array<SchemaEntity>,
84 prettyType?: string,
85 |}
86 | {|
87 type: 'missing-prop',
88 prop: string,
89 expectedProps: Array<string>,
90 actualProps: Array<string>,
91 dataType: null | 'key',
92
93 dataPath: string,
94 ancestors: Array<SchemaEntity>,
95 prettyType?: string,
96 |}
97 | {|
98 type: 'other',
99 actualValue: mixed,
100 dataType: ?'key' | 'value',
101 message?: string,
102
103 dataPath: string,
104 ancestors: Array<SchemaEntity>,
105 |};
106
107function validateSchema(schema: SchemaEntity, data: mixed): Array<SchemaError> {
108 function walk(
109 schemaAncestors,
110 dataNode,
111 dataPath,
112 ): ?SchemaError | Array<SchemaError> {
113 let [schemaNode] = schemaAncestors;
114
115 if (schemaNode.type) {
116 let type = Array.isArray(dataNode) ? 'array' : typeof dataNode;
117 if (schemaNode.type !== type) {
118 return {
119 type: 'type',
120 dataType: 'value',
121 dataPath,
122 expectedTypes: [schemaNode.type],
123 ancestors: schemaAncestors,
124 prettyType: schemaNode.__type,
125 };
126 } else {
127 switch (schemaNode.type) {
128 case 'array': {
129 if (schemaNode.items) {
130 let results: Array<SchemaError | Array<SchemaError>> = [];
131 // $FlowFixMe type was already checked
132 for (let i = 0; i < dataNode.length; i++) {
133 let result = walk(
134 [schemaNode.items].concat(schemaAncestors),
135 // $FlowFixMe type was already checked
136 dataNode[i],
137 dataPath + '/' + i,
138 );
139 if (result) results.push(result);
140 }
141 if (results.length)
142 return results.reduce((acc, v) => acc.concat(v), []);
143 }
144 break;
145 }
146 case 'string': {
147 // $FlowFixMe type was already checked
148 let value: string = dataNode;
149 if (schemaNode.enum) {
150 if (!schemaNode.enum.includes(value)) {
151 return {
152 type: 'enum',
153 dataType: 'value',
154 dataPath,
155 expectedValues: schemaNode.enum,
156 actualValue: value,
157 ancestors: schemaAncestors,
158 };
159 }
160 } else if (schemaNode.__validate) {
161 let validationError = schemaNode.__validate(value);
162 // $FlowFixMe
163 if (validationError) {
164 return {
165 type: 'other',
166 dataType: 'value',
167 dataPath,
168 message: validationError,
169 actualValue: value,
170 ancestors: schemaAncestors,
171 };
172 }
173 }
174 break;
175 }
176 case 'object': {
177 let results: Array<Array<SchemaError> | SchemaError> = [];
178 let invalidProps;
179 if (schemaNode.__forbiddenProperties) {
180 // $FlowFixMe type was already checked
181 let keys = Object.keys(dataNode);
182 invalidProps = schemaNode.__forbiddenProperties.filter(val =>
183 keys.includes(val),
184 );
185 results.push(
186 ...invalidProps.map(
187 k =>
188 ({
189 type: 'forbidden-prop',
190 dataPath: dataPath + '/' + k,
191 dataType: 'key',
192 prop: k,
193 expectedProps: Object.keys(schemaNode.properties),
194 actualProps: keys,
195 ancestors: schemaAncestors,
196 }: SchemaError),
197 ),
198 );
199 }
200 if (schemaNode.required) {
201 // $FlowFixMe type was already checked
202 let keys = Object.keys(dataNode);
203 let missingKeys = schemaNode.required.filter(
204 val => !keys.includes(val),
205 );
206 results.push(
207 ...missingKeys.map(
208 k =>
209 ({
210 type: 'missing-prop',
211 dataPath,
212 dataType: null,
213 prop: k,
214 expectedProps: schemaNode.required,
215 actualProps: keys,
216 ancestors: schemaAncestors,
217 }: SchemaError),
218 ),
219 );
220 }
221 if (schemaNode.properties) {
222 let {additionalProperties = true} = schemaNode;
223 // $FlowFixMe type was already checked
224 for (let k in dataNode) {
225 if (invalidProps && invalidProps.includes(k)) {
226 // Don't check type on forbidden props
227 continue;
228 } else if (k in schemaNode.properties) {
229 let result = walk(
230 [schemaNode.properties[k]].concat(schemaAncestors),
231 // $FlowFixMe type was already checked
232 dataNode[k],
233 dataPath + '/' + k,
234 );
235 if (result) results.push(result);
236 } else {
237 if (typeof additionalProperties === 'boolean') {
238 if (!additionalProperties) {
239 results.push({
240 type: 'enum',
241 dataType: 'key',
242 dataPath: dataPath + '/' + k,
243 expectedValues: Object.keys(
244 schemaNode.properties,
245 ).filter(
246 // $FlowFixMe type was already checked
247 p => !(p in dataNode),
248 ),
249 actualValue: k,
250 ancestors: schemaAncestors,
251 prettyType: schemaNode.__type,
252 });
253 }
254 } else {
255 let result = walk(
256 [additionalProperties].concat(schemaAncestors),
257 // $FlowFixMe type was already checked
258 dataNode[k],
259 dataPath + '/' + k,
260 );
261 if (result) results.push(result);
262 }
263 }
264 }
265 }
266 if (results.length)
267 return results.reduce((acc, v) => acc.concat(v), []);
268 break;
269 }
270 case 'boolean':
271 // NOOP, type was checked already
272 break;
273 default:
274 throw new Error(`Unimplemented schema type ${type}?`);
275 }
276 }
277 } else {
278 if (schemaNode.enum && !schemaNode.enum.includes(dataNode)) {
279 return {
280 type: 'enum',
281 dataType: 'value',
282 dataPath: dataPath,
283 expectedValues: schemaNode.enum,
284 actualValue: schemaNode,
285 ancestors: schemaAncestors,
286 };
287 }
288
289 if (schemaNode.oneOf || schemaNode.allOf) {
290 let list = schemaNode.oneOf || schemaNode.allOf;
291 let results: Array<SchemaError | Array<SchemaError>> = [];
292 for (let f of list) {
293 let result = walk([f].concat(schemaAncestors), dataNode, dataPath);
294 if (result) results.push(result);
295 }
296 if (
297 schemaNode.oneOf
298 ? results.length == schemaNode.oneOf.length
299 : results.length > 0
300 ) {
301 // return the result with more values / longer key
302 results.sort((a, b) =>
303 Array.isArray(a) || Array.isArray(b)
304 ? Array.isArray(a) && !Array.isArray(b)
305 ? -1
306 : !Array.isArray(a) && Array.isArray(b)
307 ? 1
308 : Array.isArray(a) && Array.isArray(b)
309 ? b.length - a.length
310 : 0
311 : b.dataPath.length - a.dataPath.length,
312 );
313 return results[0];
314 }
315 } else if (schemaNode.not) {
316 let result = walk(
317 [schemaNode.not].concat(schemaAncestors),
318 dataNode,
319 dataPath,
320 );
321 if (!result || result.length == 0) {
322 return {
323 type: 'other',
324 dataPath,
325 dataType: null,
326 message: schemaNode.__message,
327 actualValue: dataNode,
328 ancestors: schemaAncestors,
329 };
330 }
331 }
332 }
333
334 return undefined;
335 }
336
337 let result = walk([schema], data, '');
338 return Array.isArray(result) ? result : result ? [result] : [];
339}
340export default validateSchema;
341
342export function fuzzySearch(
343 expectedValues: Array<string>,
344 actualValue: string,
345): Array<string> {
346 let result = expectedValues
347 .map(exp => [exp, levenshteinDistance(exp, actualValue)])
348 .filter(
349 // Remove if more than half of the string would need to be changed
350 ([, d]) => d * 2 < actualValue.length,
351 );
352 result.sort(([, a], [, b]) => a - b);
353 return result.map(([v]) => v);
354}
355
356validateSchema.diagnostic = function(
357 schema: SchemaEntity,
358 data: mixed,
359 dataContentsPath?: ?string,
360 dataContents: string | mixed,
361 origin: string,
362 prependKey: string,
363 message: string,
364): void {
365 let errors = validateSchema(schema, data);
366 if (errors.length) {
367 let dataContentsString: string =
368 typeof dataContents === 'string'
369 ? dataContents
370 : // $FlowFixMe
371 JSON.stringify(dataContents, null, '\t');
372 let keys = errors.map(e => {
373 let message;
374 if (e.type === 'enum') {
375 let {actualValue} = e;
376 let expectedValues = e.expectedValues.map(String);
377 let likely =
378 actualValue != null
379 ? fuzzySearch(expectedValues, String(actualValue))
380 : [];
381
382 if (likely.length > 0) {
383 message = `Did you mean ${likely
384 .map(v => JSON.stringify(v))
385 .join(', ')}?`;
386 } else if (expectedValues.length > 0) {
387 message = `Possible values: ${expectedValues
388 .map(v => JSON.stringify(v))
389 .join(', ')}`;
390 } else {
391 message = 'Unexpected value';
392 }
393 } else if (e.type === 'forbidden-prop') {
394 let {prop, expectedProps, actualProps} = e;
395 let likely = fuzzySearch(expectedProps, prop).filter(
396 v => !actualProps.includes(v),
397 );
398 if (likely.length > 0) {
399 message = `Did you mean ${likely
400 .map(v => JSON.stringify(v))
401 .join(', ')}?`;
402 } else {
403 message = 'Unexpected property';
404 }
405 } else if (e.type === 'missing-prop') {
406 let {prop, actualProps} = e;
407 let likely = fuzzySearch(actualProps, prop);
408 if (likely.length > 0) {
409 message = `Did you mean ${likely
410 .map(v => JSON.stringify(v))
411 .join(', ')}?`;
412 e.dataPath += '/' + prop;
413 e.dataType = 'key';
414 } else {
415 message = `Missing property ${prop}`;
416 }
417 } else if (e.type === 'type') {
418 if (e.prettyType != null) {
419 message = `Expected ${e.prettyType}`;
420 } else {
421 message = `Expected type ${e.expectedTypes.join(', ')}`;
422 }
423 } else {
424 message = e.message;
425 }
426 return {key: e.dataPath, type: e.dataType, message};
427 });
428 let codeFrame = {
429 code: dataContentsString,
430 codeHighlights: generateJSONCodeHighlights(
431 dataContentsString,
432 keys.map(({key, type, message}) => ({
433 key: prependKey + key,
434 type: type,
435 message,
436 })),
437 ),
438 };
439
440 throw new ThrowableDiagnostic({
441 diagnostic: {
442 message,
443 origin,
444 // $FlowFixMe should be a sketchy string check
445 filePath: dataContentsPath || undefined,
446 language: 'json',
447 codeFrame,
448 },
449 });
450 }
451};