mirror of
https://gitlab.com/openstapps/openstapps.git
synced 2026-01-20 08:33:11 +00:00
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
304 lines
9.6 KiB
TypeScript
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}.`);
|
|
}
|