/* * Copyright (C) 2018-2021 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 {Logger} from '@openstapps/logger'; import Ajv from 'ajv'; import betterAjvErrors, {IOutputError} from 'better-ajv-errors'; import type {PathLike} from 'fs'; import {readFile, writeFile} from 'fs/promises'; import {JSONSchema7} from 'json-schema'; import mustache from 'mustache'; import {Schema} from 'ts-json-schema-generator'; import {ExpectedValidationErrors, ValidationError, ValidationResult} from './types/validator.js'; import {isThingWithType} from './util/guards.js'; import path from 'path'; import re2 from 're2'; import {glob} from 'glob'; import {fileURLToPath} from 'url'; /** * StAppsCore validator */ export class Validator { /** * JSON Schema Validator */ private readonly ajv = new Ajv.default({ verbose: true, allowUnionTypes: true, code: {regExp: re2 as never}, }); /** * 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 schemaDirectory Path to directory that contains schema files */ public async addSchemas(schemaDirectory: string): Promise { const searchGlob = path.posix.join(schemaDirectory.replaceAll(path.sep, path.posix.sep), '*.json'); const schemaFiles = await glob(searchGlob); if (schemaFiles.length === 0) { throw new Error(`No schema files in ${schemaDirectory.toString()}!`); } Logger.log(`Adding schemas from ${schemaDirectory} to validator.`); await Promise.all( schemaFiles.map(async (file: string) => { // read schema file const buffer = await readFile(file); // add schema to map this.schemas[path.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 (schema === undefined) { if (isThingWithType(instance)) { // schema name can be inferred from type string const schemaSuffix = (instance as {type: string}).type .split(' ') .map((part: string) => { return part.slice(0, 1).toUpperCase() + part.slice(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 TypeError(`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.default, ): ValidationResult { const betterErrorObject: IOutputError[] | undefined = betterAjvErrors( schema, instance, ajvInstance.errors ?? [], // eslint-disable-next-line unicorn/no-null {format: 'js', indent: null}, ); return { errors: ajvInstance.errors?.map((ajvError, index) => { const error: ValidationError = { dataPath: ajvError.instancePath, instance: instance, message: betterErrorObject?.[index]?.error ?? ajvError.message, 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 schemaDirectory The directory where the JSON schema files are * @param resourcesDirectory The directory where the test files are */ export async function validateFiles( schemaDirectory: string, resourcesDirectory: string, ): Promise { // instantiate new validator const v = new Validator(); await v.addSchemas(schemaDirectory); // get a list of files to test const testFiles = await glob( path.posix.join(resourcesDirectory.replaceAll(path.sep, path.posix.sep), '*.json'), {absolute: true}, ); if (testFiles.length === 0) { throw new Error(`No test files in ${resourcesDirectory}!`); } Logger.log(`Found ${testFiles.length} file(s) to test.`); // map of errors per file const errors: ExpectedValidationErrors = {}; await Promise.all( testFiles.map(async (testFile: string) => { const testFileName = path.basename(testFile); const buffer = await readFile(testFile); // 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[] = [...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 readFile( path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'resources', 'file.html.mustache'), ); const fileTemplate = buffer.toString(); buffer = await readFile( path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'resources', 'error.html.mustache'), ); const errorTemplate = buffer.toString(); let output = ''; for (const fileName in errors) { if (!errors.hasOwnProperty(fileName)) { continue; } let fileOutput = ''; for (const [index, error] of errors[fileName].entries()) { fileOutput += mustache.render(errorTemplate, { idx: index + 1, instance: JSON.stringify(error.instance, undefined, 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 readFile( path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'resources', 'report.html.mustache'), ); const reportTemplate = buffer.toString(); await writeFile( reportPath, mustache.render(reportTemplate, { report: output, timestamp: new Date().toISOString(), }), ); Logger.ok(`Wrote report to ${reportPath}.`); }