/* * Copyright (C) 2018-2019 StApps * This program is free software: you can redistribute it and/or modify it * under the terms of the GNU General Public License as published by the Free * Software Foundation, version 3. * * This program is distributed in the hope that it will be useful, but WITHOUT * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for * more details. * * You should have received a copy of the GNU General Public License along with * this program. If not, see . */ import {asyncPool} from '@krlwlfrt/async-pool'; import {Logger} from '@openstapps/logger'; import Ajv from 'ajv'; import betterAjvErrors from 'better-ajv-errors'; import {PathLike} from 'fs'; import {JSONSchema7} from 'json-schema'; import * as mustache from 'mustache'; import {basename, join, resolve} from 'path'; import {Schema} from 'ts-json-schema-generator'; import { ExpectedValidationErrors, globPromisified, isThingWithType, readFilePromisified, ValidationError, ValidationResult, writeFilePromisified, } from './common'; /** * StAppsCore validator */ export class Validator { /** * JSON Schema Validator */ private readonly ajv = Ajv({verbose: true, jsonPointers: true, extendRefs: true}); /** * Map of schema names to schemas */ private readonly schemas: { [type: string]: Schema; } = {}; /** * A wrapper function for Ajv that transforms the error into the compatible old error * * @param schema the schema that will be validated against * @param instance the instance that will be validated */ private ajvValidateWrapper(schema: JSONSchema7, instance: unknown): ValidationResult { return fromAjvResult(this.ajv.validate(schema, instance), schema, instance, this.ajv); } /** * Feed the schema files to the validator * * @param schemaDir Path to directory that contains schema files */ public async addSchemas(schemaDir: PathLike): Promise { const schemaFiles = await globPromisified(join(schemaDir.toString(), '*.json')); if (schemaFiles.length === 0) { throw new Error(`No schema files in ${schemaDir.toString()}!`); } Logger.log(`Adding schemas from ${schemaDir} to validator.`); // tslint:disable-next-line:no-magic-numbers - iterate over schema files await asyncPool(2, schemaFiles, async (file: string) => { // read schema file const buffer = await readFilePromisified(file); // add schema to map this.schemas[basename(file, '.json')] = JSON.parse(buffer.toString()); Logger.info(`Added ${file} to validator.`); }); return schemaFiles; } /** * Validates anything against a given schema name or infers schema name from object * * @param instance Instance to validate * @param schema Name of schema to validate instance against or the schema itself */ public validate(instance: unknown, schema?: string | Schema): ValidationResult { if (typeof schema === 'undefined') { if (isThingWithType(instance)) { // schema name can be inferred from type string // tslint:disable-next-line: completed-docs const schemaSuffix = (instance as { type: string; }).type.split(' ') .map((part: string) => { return part.substr(0, 1) .toUpperCase() + part.substr(1); }) .join(''); const schemaName = `SC${schemaSuffix}`; return this.validate(instance, schemaName); } throw new Error('Instance.type does not exist.'); } if (typeof schema === 'string') { // if you want to access a schema that is contained in the validator object if (typeof this.schemas[schema] !== 'object') { throw new Error(`No schema available for ${schema}.`); } // schema will be cached return this.ajvValidateWrapper(this.schemas[schema], instance); } return this.ajvValidateWrapper(schema, instance); } } /** * Creates a ValidationResult from ajv * * Implemented for compatibility purposes * * @param result the result, now a ValidationResult * @param schema the schema that has been validated against * @param instance the data that has been validated * @param ajvInstance the ajv instance with which the validation was done */ function fromAjvResult( result: boolean | PromiseLike, schema: JSONSchema7, instance: unknown, ajvInstance: Ajv.Ajv, ): ValidationResult { // tslint:disable-next-line // @ts-ignore function can return void, which at runtime will be undefined. TS doesn't allow to assign void to undefined const betterErrorObject: betterAjvErrors.IOutputError[] | undefined = betterAjvErrors(schema, instance, ajvInstance.errors, {format: 'js', indent: null}); return { errors: ajvInstance.errors?.map((ajvError, index) => { const error: ValidationError = { dataPath: ajvError.dataPath, instance: instance, message: betterErrorObject?.[index].error!, name: ajvError.keyword, schemaPath: ajvError.schemaPath, suggestion: betterErrorObject?.[index].suggestion, }; // (validationError as ValidationError).humanReadableError = betterErrorCLI?.[index] as unknown as string; return error; }) ?? [], valid: typeof result === 'boolean' ? result : false, }; } /** * Validate all test files in the given resources directory against schema files in the given (schema) directory * * @param schemaDir The directory where the JSON schema files are * @param resourcesDir The directory where the test files are */ export async function validateFiles(schemaDir: string, resourcesDir: string): Promise { // instantiate new validator const v = new Validator(); await v.addSchemas(schemaDir); // get list of files to test const testFiles = await globPromisified(join(resourcesDir, '*.json')); if (testFiles.length === 0) { throw new Error(`No test files in ${resourcesDir}!`); } Logger.log(`Found ${testFiles.length} file(s) to test.`); // map of errors per file const errors: ExpectedValidationErrors = {}; // tslint:disable-next-line:no-magic-numbers - iterate over files to test await asyncPool(2, testFiles, async (testFile: string) => { const testFileName = basename(testFile); const buffer = await readFilePromisified(join(resourcesDir, testFileName)); // read test description from file const testDescription = JSON.parse(buffer.toString()); // validate instance from test description const result = v.validate(testDescription.instance, testDescription.schema); // list of expected errors for this test description const expectedErrors: string[] = []; expectedErrors.push.apply(expectedErrors, testDescription.errorNames); // number of unexpected errors let unexpectedErrors = 0; if (result.errors.length > 0) { errors[testFileName] = []; // iterate over errors for (const error of result.errors) { const errorIndex = expectedErrors.indexOf(error.name); let expected = false; if (errorIndex >= 0) { expectedErrors.splice(errorIndex, 1); expected = true; } else { unexpectedErrors++; await Logger.error(`Unexpected error ${error.name} in ${testFile}`); } // add error to list of errors errors[testFileName].push({ ...error, expected, }); } } if (expectedErrors.length > 0) { for (const error of expectedErrors) { await Logger.error(`Extraneous expected error '${error}' in ${testFile}.`); errors[testFileName].push({ dataPath: 'undefined', expected: false, instance: undefined, // instance: testDescription.instance, message: 'undefined', name: `expected ${error}`, schemaPath: 'undefined', suggestion: 'undefined', }); } } else if (unexpectedErrors === 0) { Logger.info(`Successfully validated ${testFile}.`); } }); return errors; } /** * Write a report for errors that occurred in validation * * @param reportPath Path to write report to * @param errors Errors that occurred in validation */ export async function writeReport(reportPath: PathLike, errors: ExpectedValidationErrors): Promise { let buffer = await readFilePromisified(resolve(__dirname, '..', 'resources', 'file.html.mustache')); const fileTemplate = buffer.toString(); buffer = await readFilePromisified(resolve(__dirname, '..', 'resources', 'error.html.mustache')); const errorTemplate = buffer.toString(); let output = ''; for (const fileName in errors) { if (!errors.hasOwnProperty(fileName)) { continue; } let fileOutput = ''; errors[fileName].forEach((error, idx) => { fileOutput += mustache.render(errorTemplate, { idx: idx + 1, // tslint:disable-next-line:no-magic-numbers instance: JSON.stringify(error.instance, null, 2), message: error.message, name: error.name, schemaPath: error.schemaPath, status: (error.expected) ? 'alert-success' : 'alert-danger', suggestion: error.suggestion, }); }); output += mustache.render(fileTemplate, { errors: fileOutput, testFile: fileName, }); } buffer = await readFilePromisified(resolve(__dirname, '..', 'resources', 'report.html.mustache')); const reportTemplate = buffer.toString(); await writeFilePromisified(reportPath, mustache.render(reportTemplate, { report: output, timestamp: (new Date()).toISOString(), })); Logger.ok(`Wrote report to ${reportPath}.`); }