1 |
|
2 | import ThrowableDiagnostic, {
|
3 | generateJSONCodeHighlights,
|
4 | } from '@parcel/diagnostic';
|
5 |
|
6 | import levenshteinDistance from 'js-levenshtein';
|
7 |
|
8 | export type SchemaEntity =
|
9 | | SchemaObject
|
10 | | SchemaArray
|
11 | | SchemaBoolean
|
12 | | SchemaString
|
13 | | SchemaEnum
|
14 | | SchemaOneOf
|
15 | | SchemaAllOf
|
16 | | SchemaNot
|
17 | | SchemaAny;
|
18 | export type SchemaArray = {|
|
19 | type: 'array',
|
20 | items?: SchemaEntity,
|
21 | __type?: string,
|
22 | |};
|
23 | export type SchemaBoolean = {|
|
24 | type: 'boolean',
|
25 | __type?: string,
|
26 | |};
|
27 | export type SchemaOneOf = {|
|
28 | oneOf: Array<SchemaEntity>,
|
29 | |};
|
30 | export type SchemaAllOf = {|
|
31 | allOf: Array<SchemaEntity>,
|
32 | |};
|
33 | export type SchemaNot = {|
|
34 | not: SchemaEntity,
|
35 | __message: string,
|
36 | |};
|
37 | export type SchemaString = {|
|
38 | type: 'string',
|
39 | enum?: Array<string>,
|
40 | __validate?: (val: string) => ?string,
|
41 | __type?: string,
|
42 | |};
|
43 | export type SchemaEnum = {|
|
44 | enum: Array<mixed>,
|
45 | |};
|
46 | export 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 | |};
|
54 | export type SchemaAny = {||};
|
55 | export 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 |
|
107 | function 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 |
|
132 | for (let i = 0; i < dataNode.length; i++) {
|
133 | let result = walk(
|
134 | [schemaNode.items].concat(schemaAncestors),
|
135 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
224 | for (let k in dataNode) {
|
225 | if (invalidProps && invalidProps.includes(k)) {
|
226 |
|
227 | continue;
|
228 | } else if (k in schemaNode.properties) {
|
229 | let result = walk(
|
230 | [schemaNode.properties[k]].concat(schemaAncestors),
|
231 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 | }
|
340 | export default validateSchema;
|
341 |
|
342 | export 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 |
|
350 | ([, d]) => d * 2 < actualValue.length,
|
351 | );
|
352 | result.sort(([, a], [, b]) => a - b);
|
353 | return result.map(([v]) => v);
|
354 | }
|
355 |
|
356 | validateSchema.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 | :
|
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 |
|
445 | filePath: dataContentsPath || undefined,
|
446 | language: 'json',
|
447 | codeFrame,
|
448 | },
|
449 | });
|
450 | }
|
451 | };
|