import ThrowableDiagnostic, { generateJSONCodeHighlights, escapeMarkdown, encodeJSONKeyComponent, } from '@atlaspack/diagnostic'; import type {Mapping} from '@mischnic/json-sourcemap'; import nullthrows from 'nullthrows'; import * as levenshtein from 'fastest-levenshtein'; export type SchemaEntity = | SchemaObject | SchemaArray | SchemaBoolean | SchemaString | SchemaNumber | SchemaEnum | SchemaOneOf | SchemaAllOf | SchemaNot | SchemaAny; export type SchemaArray = { type: 'array'; items?: SchemaEntity; __type?: string; }; export type SchemaBoolean = { type: 'boolean'; __type?: string; }; export type SchemaOneOf = { oneOf: Array; }; export type SchemaAllOf = { allOf: Array; }; export type SchemaNot = { not: SchemaEntity; __message: string; }; export type SchemaString = { type: 'string'; enum?: Array; __validate?: (val: string) => string | null | undefined; __type?: string; }; export type SchemaNumber = { type: 'number'; enum?: Array; __type?: string; }; export type SchemaEnum = { enum: Array; }; export type SchemaObject = { type: 'object'; properties: { [key: string]: SchemaEntity; }; additionalProperties?: boolean | SchemaEntity; required?: Array; __forbiddenProperties?: Array; __type?: string; }; export type SchemaAny = Record; export type SchemaError = | { type: 'type'; expectedTypes: Array; dataType: 'key' | null | undefined | 'value'; dataPath: string; ancestors: Array; prettyType?: string; } | { type: 'enum'; expectedValues: Array; dataType: 'key' | 'value'; actualValue: unknown; dataPath: string; ancestors: Array; prettyType?: string; } | { type: 'forbidden-prop'; prop: string; expectedProps: Array; actualProps: Array; dataType: 'key'; dataPath: string; ancestors: Array; prettyType?: string; } | { type: 'missing-prop'; prop: string; expectedProps: Array; actualProps: Array; dataType: 'key' | 'value'; dataPath: string; ancestors: Array; prettyType?: string; } | { type: 'other'; actualValue: unknown; dataType: 'key' | null | undefined | 'value'; message?: string; dataPath: string; ancestors: Array; }; function validateSchema( schema: SchemaEntity, data: unknown, ): Array { function walk( schemaAncestors: Array, dataNode: unknown, dataPath: string, ): SchemaError | null | undefined | Array { let [schemaNode] = schemaAncestors; if ('type' in schemaNode && schemaNode.type) { let type = Array.isArray(dataNode) ? 'array' : typeof dataNode; if (schemaNode.type !== type) { return { type: 'type', dataType: 'value', dataPath, expectedTypes: [schemaNode.type], ancestors: schemaAncestors, prettyType: '__type' in schemaNode ? schemaNode.__type : undefined, }; } else { switch (schemaNode.type) { case 'array': { if ( 'items' in schemaNode && schemaNode.items && Array.isArray(dataNode) ) { let results: Array> = []; for (let i = 0; i < dataNode.length; i++) { let result = walk( [schemaNode.items].concat(schemaAncestors), dataNode[i], dataPath + '/' + i, ); if (result) results.push(result); } if (results.length) return results.reduce>( (acc, v) => acc.concat(v), [], ); } break; } case 'string': { if (typeof dataNode === 'string') { let value: string = dataNode; if ('enum' in schemaNode && schemaNode.enum) { if (!schemaNode.enum.includes(value)) { return { type: 'enum', dataType: 'value', dataPath, expectedValues: schemaNode.enum, actualValue: value, ancestors: schemaAncestors, }; } } else if ('__validate' in schemaNode && schemaNode.__validate) { let validationError = schemaNode.__validate(value); if (typeof validationError == 'string') { return { type: 'other', dataType: 'value', dataPath, message: validationError, actualValue: value, ancestors: schemaAncestors, }; } } } break; } case 'number': { if (typeof dataNode === 'number') { let value: number = dataNode; if ('enum' in schemaNode && schemaNode.enum) { if (!schemaNode.enum.includes(value)) { return { type: 'enum', dataType: 'value', dataPath, expectedValues: schemaNode.enum, actualValue: value, ancestors: schemaAncestors, }; } } } break; } case 'object': { if ( typeof dataNode === 'object' && dataNode !== null && !Array.isArray(dataNode) ) { let results: Array | SchemaError> = []; let invalidProps; if ( '__forbiddenProperties' in schemaNode && schemaNode.__forbiddenProperties ) { let keys = Object.keys(dataNode); invalidProps = schemaNode.__forbiddenProperties.filter( (val: string) => keys.includes(val), ); results.push( ...invalidProps.map( (k: string) => ({ type: 'forbidden-prop', dataPath: dataPath + '/' + encodeJSONKeyComponent(k), dataType: 'key', prop: k, expectedProps: Object.keys(schemaNode.properties), actualProps: keys, ancestors: schemaAncestors, }) as SchemaError, ), ); } if ('required' in schemaNode && schemaNode.required) { let keys = Object.keys(dataNode); let missingKeys = schemaNode.required.filter( (val: string) => !keys.includes(val), ); results.push( ...missingKeys.map( (k: string) => ({ type: 'missing-prop', dataPath, dataType: 'value', prop: k, expectedProps: schemaNode.required, actualProps: keys, ancestors: schemaAncestors, }) as SchemaError, ), ); } if ('properties' in schemaNode && schemaNode.properties) { let {additionalProperties = true} = schemaNode; for (let k in dataNode) { if (invalidProps && invalidProps.includes(k)) { // Don't check type on forbidden props continue; } else if (k in schemaNode.properties) { let result = walk( [schemaNode.properties[k]].concat(schemaAncestors), (dataNode as Record)[k], dataPath + '/' + encodeJSONKeyComponent(k), ); if (result) results.push(result); } else { if (typeof additionalProperties === 'boolean') { if (!additionalProperties) { results.push({ type: 'enum', dataType: 'key', dataPath: dataPath + '/' + encodeJSONKeyComponent(k), expectedValues: Object.keys( schemaNode.properties, ).filter((p) => !(p in dataNode)), actualValue: k, ancestors: schemaAncestors, prettyType: schemaNode.__type, }); } } else { let result = walk( [additionalProperties].concat(schemaAncestors), (dataNode as Record)[k], dataPath + '/' + encodeJSONKeyComponent(k), ); if (result) results.push(result); } } } } if (results.length) return results.reduce>( (acc, v) => acc.concat(v), [], ); } break; } case 'boolean': // NOOP, type was checked already break; default: throw new Error(`Unimplemented schema type ${type}?`); } } } else { if ( 'enum' in schemaNode && schemaNode.enum && !schemaNode.enum.includes(dataNode) ) { return { type: 'enum', dataType: 'value', dataPath: dataPath, expectedValues: schemaNode.enum, actualValue: schemaNode, ancestors: schemaAncestors, }; } if ('oneOf' in schemaNode || 'allOf' in schemaNode) { let list = 'oneOf' in schemaNode ? schemaNode.oneOf : 'allOf' in schemaNode ? schemaNode.allOf : []; let results: Array> = []; for (let f of list) { let result = walk([f].concat(schemaAncestors), dataNode, dataPath); if (result) results.push(result); } if ( 'oneOf' in schemaNode ? results.length == schemaNode.oneOf.length : results.length > 0 ) { // return the result with more values / longer key results.sort((a, b) => Array.isArray(a) || Array.isArray(b) ? Array.isArray(a) && !Array.isArray(b) ? -1 : !Array.isArray(a) && Array.isArray(b) ? 1 : Array.isArray(a) && Array.isArray(b) ? b.length - a.length : 0 : b.dataPath.length - a.dataPath.length, ); return results[0]; } } else if ('not' in schemaNode && schemaNode.not) { let result = walk( [schemaNode.not].concat(schemaAncestors), dataNode, dataPath, ); if (!result || (Array.isArray(result) && result.length == 0)) { return { type: 'other', dataPath, dataType: null, message: schemaNode.__message, actualValue: dataNode, ancestors: schemaAncestors, }; } } } return undefined; } let result = walk([schema], data, ''); return Array.isArray(result) ? result : result ? [result] : []; } export default validateSchema; export function fuzzySearch( expectedValues: Array, actualValue: string, ): Array { let result = expectedValues .map( (exp) => [exp, levenshtein.distance(exp, actualValue)] as [string, number], ) .filter( // Remove if more than half of the string would need to be changed ([, d]: [string, number]) => d * 2 < actualValue.length, ); result.sort(([, a]: [string, number], [, b]: [string, number]) => a - b); return result.map(([v]: [string, number]) => v); } validateSchema.diagnostic = function ( schema: SchemaEntity, data: ( | { source?: (() => string) | string | null | undefined; data?: unknown; } | { source: string | (() => string); map: { data: unknown; pointers: { [key: string]: Mapping; }; }; } ) & { filePath?: string | null | undefined; prependKey?: string | null | undefined; }, origin: string, message: string, ): undefined { if (!('map' in data) && !('source' in data || 'data' in data)) { throw new Error( 'At least one of data.source, data.data, or data.map must be defined!', ); } let loadedSource: string | null | undefined; function loadSource( loader: string | (() => string) | null | undefined, ): string | null | undefined { if (loadedSource !== undefined) { return loadedSource; } else if (typeof loader === 'function') { loadedSource = loader(); return loadedSource; } else if (typeof loader === 'string') { loadedSource = loader; return loadedSource; } return loadedSource; } let object: unknown; if ('map' in data && data.map) { object = data.map.data; } else if ('data' in data && data.data !== undefined) { object = data.data; } else if ('source' in data && data.source) { object = JSON.parse(loadSource(data.source) || ''); } else { throw new Error('Unable to get object from data'); } let errors = validateSchema(schema, object); if (errors.length) { let keys = errors.map((e) => { let message; if (e.type === 'enum') { let {actualValue} = e; let expectedValues = e.expectedValues.map(String); let likely = actualValue != null ? fuzzySearch(expectedValues, String(actualValue)) : []; if (likely.length > 0) { message = `Did you mean ${likely .map((v) => JSON.stringify(v)) .join(', ')}?`; } else if (expectedValues.length > 0) { message = `Possible values: ${expectedValues .map((v) => JSON.stringify(v)) .join(', ')}`; } else { message = 'Unexpected value'; } } else if (e.type === 'forbidden-prop') { let {prop, expectedProps, actualProps} = e; let likely = fuzzySearch(expectedProps, prop).filter( (v) => !actualProps.includes(v), ); if (likely.length > 0) { message = `Did you mean ${likely .map((v) => JSON.stringify(v)) .join(', ')}?`; } else { message = 'Unexpected property'; } } else if (e.type === 'missing-prop') { let {prop, actualProps} = e; let likely = fuzzySearch(actualProps, prop); if (likely.length > 0) { message = `Did you mean ${JSON.stringify(prop)}?`; e.dataPath += '/' + likely[0]; e.dataType = 'key'; } else { message = `Missing property ${prop}`; } } else if (e.type === 'type') { if (e.prettyType != null) { message = `Expected ${e.prettyType}`; } else { message = `Expected type ${e.expectedTypes.join(', ')}`; } } else { message = e.message; } return {key: e.dataPath, type: e.dataType, message}; }); let map, code; if ('map' in data && data.map) { map = data.map; code = loadSource(data.source) ?? ''; } else { if ('source' in data && data.source) { map = loadSource(data.source) ?? ''; } else if ('data' in data && data.data !== undefined) { map = JSON.stringify(nullthrows(data.data), null, '\t'); } else { map = ''; } code = map; } let codeFrames = [ { filePath: data.filePath ?? undefined, language: 'json' as const, code: code ?? '', codeHighlights: generateJSONCodeHighlights( map, keys.map(({key, type, message}) => ({ key: (data.prependKey ?? '') + key, type: type, message: message != null ? escapeMarkdown(message) : message, })), ), }, ]; throw new ThrowableDiagnostic({ diagnostic: { message: message, origin, codeFrames, }, }); } };