Files
openstapps/src/validate.ts
Wieland Schöbl 5330255b7e refactor: update dependencies and fix resulting errors
Upgraded JSON Schema from version 6 to version 7
Upgraded TypeDoc version to latest
Replaced 'jsonschema' with 'json-schema' package to better comply with 'ts-json-schema-generator'
Replace JSON Schema validation with AJV in areas where it wasn't used previously
Removed commander help output as it causes strange issues
2020-10-28 16:56:23 +01:00

304 lines
9.6 KiB
TypeScript

/*
* 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 <https://www.gnu.org/licenses/>.
*/
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<string[]> {
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<unknown>,
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<ExpectedValidationErrors> {
// 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<void> {
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}.`);
}