feat: improve monorepo dev experience

This commit is contained in:
2023-10-27 22:45:44 +02:00
parent f618725598
commit c6ab4ae48b
124 changed files with 2647 additions and 2857 deletions

View File

@@ -49,6 +49,7 @@
"@openstapps/easy-ast": "workspace:*",
"@openstapps/logger": "workspace:*",
"ajv": "8.12.0",
"ajv-formats": "2.1.1",
"better-ajv-errors": "1.2.0",
"commander": "10.0.0",
"deepmerge": "4.3.1",
@@ -60,11 +61,10 @@
"humanize-string": "3.0.0",
"json-schema": "0.4.0",
"mustache": "4.2.0",
"openapi-types": "12.1.0",
"openapi-types": "12.1.3",
"plantuml-encoder": "1.4.0",
"re2": "1.18.2",
"toposort": "2.0.2",
"ts-json-schema-generator": "1.2.0"
"ts-json-schema-generator": "1.4.0"
},
"devDependencies": {
"@openstapps/eslint-config": "workspace:*",
@@ -73,7 +73,7 @@
"@types/chai": "4.3.5",
"@types/fs-extra": "9.0.13",
"@types/glob": "8.0.1",
"@types/json-schema": "7.0.11",
"@types/json-schema": "7.0.14",
"@types/mocha": "10.0.1",
"@types/mustache": "4.2.2",
"@types/node": "18.15.3",

View File

@@ -17,15 +17,17 @@ import {Command} from 'commander';
import {existsSync, readFileSync, writeFileSync} from 'fs';
import path from 'path';
import {lightweightDefinitionsFromPath, lightweightProjectFromPath} from '@openstapps/easy-ast';
import {openapi3Template} from './resources/openapi-303-template.js';
import {gatherRouteInformation, generateOpenAPIForRoute} from './routes.js';
import {Converter, getValidatableTypesInPath} from './schema.js';
import {openapi3Template} from '../../openapi-generator/src/openapi-303-template.js';
import {
gatherRouteInformation,
generateOpenAPIForRoute,
} from '../../openapi-generator/src/generator/routes.js';
import {Converter, getValidatableTypesInPath, mergeSchemas} from './schema.js';
import {createDiagram, createDiagramFromString} from './uml/create-diagram.js';
import {UMLConfig} from './uml/uml-config.js';
import {capitalize} from './util/string.js';
import {validateFiles, writeReport} from './validate.js';
import {fileURLToPath} from 'url';
import {mkdir, readFile} from 'fs/promises';
import {readFile} from 'fs/promises';
// handle unhandled promise rejections
process.on('unhandledRejection', async (reason: unknown) => {
@@ -52,56 +54,10 @@ commander.command('prototype <srcBundle> <out>').action(async (sourcePath, out)
commander
.command('openapi <srcPath> <outDirPath>')
.action(async (relativeSourceBundlePath, relativeOutDirectoryPath) => {
// get absolute paths
const sourcePath = path.resolve(relativeSourceBundlePath);
const outDirectoryPath = path.resolve(relativeOutDirectoryPath);
const outDirectorySchemasPath = path.join(outDirectoryPath, 'schema');
.action(async (relativeSourceBundlePath, relativeOutDirectoryPath) => {});
// get information about routes
const routes = await gatherRouteInformation(sourcePath);
routes.sort((a, b) => a.route.urlPath.localeCompare(b.route.urlPath));
// change url path parameters to openapi notation
for (const routeWithMetaInformation of routes) {
routeWithMetaInformation.route.urlPath = routeWithMetaInformation.route.urlPath.replaceAll(
/:\w+/g,
(match: string) => `{${match.replace(':', '')}}`,
);
}
// keep openapi tags for routes that actually share url fragments
let tagsToKeep = routes.map(routeWithMetaInformation =>
capitalize(routeWithMetaInformation.route.urlPath.split('/')[1]),
);
tagsToKeep = tagsToKeep.filter(
(element, i, array) => array.indexOf(element) === i && array.lastIndexOf(element) !== i,
);
// initialize json output
const output = openapi3Template;
// generate documentation for all routes
for (const routeWithMetaInformation of routes) {
routeWithMetaInformation.tags = [capitalize(routeWithMetaInformation.route.urlPath.split('/')[1])];
output.paths[routeWithMetaInformation.route.urlPath] = generateOpenAPIForRoute(
routeWithMetaInformation,
path.relative(relativeOutDirectoryPath, outDirectorySchemasPath),
tagsToKeep,
);
}
// write openapi object to file (prettified)
writeFileSync(path.join(outDirectoryPath, 'openapi.json'), JSON.stringify(output, undefined, 2));
Logger.ok(`OpenAPI representation resources written to ${outDirectoryPath} .`);
});
commander.command('schema <srcPath> <schemaPath>').action(async (relativeSourcePath, relativeSchemaPath) => {
// get absolute paths
commander.command('schema <srcPath> <schemaPath>').action(async (relativeSourcePath, schemaPath) => {
const absoluteSourcePath = path.resolve(relativeSourcePath);
const schemaPath = path.resolve(relativeSchemaPath);
// initialize new core converter
const coreConverter = new Converter(absoluteSourcePath);
@@ -111,10 +67,6 @@ commander.command('schema <srcPath> <schemaPath>').action(async (relativeSourceP
Logger.info(`Found ${validatableTypes.length} type(s) to generate schemas for.`);
await mkdir(schemaPath, {
recursive: true,
});
Logger.info(`Trying to find a package.json for ${absoluteSourcePath}.`);
let packagePath = absoluteSourcePath;
@@ -134,53 +86,12 @@ commander.command('schema <srcPath> <schemaPath>').action(async (relativeSourceP
Logger.log(`Using ${coreVersion} as version for schemas.`);
// generate and write JSONSchema files for validatable types
for (const type of validatableTypes) {
const schema = coreConverter.getSchema(type, coreVersion);
const stringifiedSchema = JSON.stringify(schema, undefined, 2);
const file = path.join(schemaPath, `${type}.json`);
// write schema to file
writeFileSync(file, stringifiedSchema);
Logger.info(`Generated schema for ${type} and saved to ${file}.`);
}
const schema = mergeSchemas(validatableTypes.map(type => coreConverter.getSchema(type, coreVersion)));
writeFileSync(schemaPath, JSON.stringify(schema, undefined, 2));
Logger.ok(`Generated schemas for ${validatableTypes.length} type(s).`);
});
commander
.command('validate <schemaPath> <testPath> [reportPath]')
.action(async (relativeSchemaPath, relativeTestPath, relativeReportPath) => {
// get absolute paths
const schemaPath = path.resolve(relativeSchemaPath);
const testPath = path.resolve(relativeTestPath);
const errorsPerFile = await validateFiles(schemaPath, testPath);
let unexpected = false;
for (const file in errorsPerFile) {
if (!errorsPerFile.hasOwnProperty(file)) {
continue;
}
unexpected = unexpected || errorsPerFile[file].some(error => !error.expected);
}
if (relativeReportPath !== undefined) {
const reportPath = path.resolve(relativeReportPath);
await writeReport(reportPath, errorsPerFile);
}
if (unexpected) {
await Logger.error('Unexpected errors occurred during validation');
process.exit(1);
} else {
Logger.ok('Successfully finished validation.');
}
});
commander
.command('plantuml <srcPath> <plantumlserver>')
.option('--definitions <definitions>', 'Shows these specific definitions (class, interface or enum)', it =>

View File

@@ -1,26 +0,0 @@
declare module 'better-ajv-errors' {
import type {ErrorObject} from 'ajv';
export interface IOutputError {
start: {line: number; column: number; offset: number};
// Optional for required
end?: {line: number; column: number; offset: number};
error: string;
suggestion?: string;
}
export interface IInputOptions {
format?: 'cli' | 'js';
indent?: number | null;
/** Raw JSON used when highlighting error location */
json?: string | null;
}
export default function <S, T, Options extends IInputOptions>(
schema: S,
data: T,
errors: Array<ErrorObject>,
options?: Options,
): Options extends {format: 'js'} ? Array<IOutputError> : string;
}

View File

@@ -1,11 +1,10 @@
export * from './validate.js';
export * from './types/validator.js';
export * from './uml/uml-config.js';
export * from './uml/create-diagram.js';
export * from './routes.js';
export * from './types/routes.js';
export * from '../../openapi-generator/src/generator/routes.js';
export * from '../../openapi-generator/src/generator/types/routes.js';
export * from './schema.js';
export * from './types/schema.js';

View File

@@ -1,41 +0,0 @@
/*
* Copyright (C) 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 <https://www.gnu.org/licenses/>.
*/
import {OpenAPIV3} from 'openapi-types';
export const openapi3Template: OpenAPIV3.Document = {
openapi: '3.0.3',
info: {
title: 'Openstapps Backend',
description: `# Introduction
This is a human readable documentation of the backend OpenAPI representation.`,
contact: {
name: 'Openstapps Team',
url: 'https://gitlab.com/openstapps/backend',
email: 'app@uni-frankfurt.de',
},
license: {
name: 'AGPL 3.0',
url: 'https://www.gnu.org/licenses/agpl-3.0.en.html',
},
version: '2.0.0',
},
servers: [
{
url: 'https://mobile.server.uni-frankfurt.de:3000',
description: 'Production server',
},
],
paths: {},
};

View File

@@ -1,161 +0,0 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {OpenAPIV3} from 'openapi-types';
import {
isLightweightClass,
lightweightProjectFromPath,
LightweightProjectWithIndex,
} from '@openstapps/easy-ast';
import {RouteInstanceWithMeta, RouteWithMetaInformation} from './types/routes.js';
import {rejectNil} from './util/collections.js';
import {capitalize} from './util/string.js';
import path from 'path';
/**
* Gather relevant information of routes
*
* This gathers the information for all routes that implement the abstract class SCAbstractRoute.
* Furthermore it instantiates every route and adds it to the information.
*/
export async function gatherRouteInformation(path: string): Promise<RouteWithMetaInformation[]> {
const project = new LightweightProjectWithIndex(lightweightProjectFromPath(path));
// find all classes that implement the SCAbstractRoute
return rejectNil(
await Promise.all(
Object.values(project.definitions)
.filter(isLightweightClass)
.map(async node => {
if (!node.extendedDefinitions?.some(it => it.referenceName === 'SCAbstractRoute')) {
return undefined;
}
const instantiatedRoute = (await project.instantiateDefinitionByName(
node.name,
)) as RouteInstanceWithMeta;
// instantiate all errors
instantiatedRoute.errors = await Promise.all(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
instantiatedRoute.errorNames.map(async (error: any) =>
// eslint-disable-next-line @typescript-eslint/ban-types
Object.assign((await project.instantiateDefinitionByName(error.name)) as object, {
name: error.name,
}),
),
);
instantiatedRoute.responseBodyDescription =
// eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain
project.definitions[instantiatedRoute.responseBodyName]?.comment?.shortSummary!;
instantiatedRoute.requestBodyDescription =
// eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain
project.definitions[instantiatedRoute.requestBodyName]?.comment?.shortSummary!;
return {
description: {
shortText: node.comment?.shortSummary,
text: node.comment?.description,
},
name: node.name!,
route: instantiatedRoute,
};
}),
),
);
}
/**
* Generate documentation snippet for one route
* @param routeWithInfo A route instance with its meta information
* @param outDirectorySchemasPath Path to directory that will contain relevant schemas for the route
* @param tagsToKeep Tags / keywords that can be used for grouping routes
*/
export function generateOpenAPIForRoute(
routeWithInfo: RouteWithMetaInformation,
outDirectorySchemasPath: string,
tagsToKeep: string[],
): OpenAPIV3.PathItemObject {
const route = routeWithInfo.route;
const openapiPath: OpenAPIV3.PathItemObject = {};
openapiPath[route.method.toLowerCase() as OpenAPIV3.HttpMethods] = {
summary: capitalize(routeWithInfo.description.shortText?.replace(/(Route to |Route for )/gim, '')),
description: routeWithInfo.description.text,
requestBody: {
description: route.responseBodyDescription ?? undefined,
content: {
'application/json': {
schema: {
$ref: path.join(outDirectorySchemasPath, `${route.requestBodyName}.json`),
},
},
},
},
parameters: [
{
name: 'X-StApps-Version',
in: 'header',
schema: {
type: 'string',
example: '2.0.0',
},
required: true,
},
],
responses: {},
tags: routeWithInfo.tags?.filter(value => tagsToKeep.includes(value)),
};
openapiPath[route.method.toLowerCase() as OpenAPIV3.HttpMethods]!.responses![route.statusCodeSuccess] = {
description: route.responseBodyDescription,
content: {
'application/json': {
schema: {
$ref: path.join(outDirectorySchemasPath, `${route.responseBodyName}.json`),
},
},
},
};
for (const error of route.errors) {
openapiPath[route.method.toLowerCase() as OpenAPIV3.HttpMethods]!.responses![error.statusCode] = {
description:
error.message ?? capitalize(error.name.replaceAll(/([A-Z][a-z])/g, ' $1').replace('SC ', '')),
content: {
'application/json': {
schema: {
$ref: path.join(outDirectorySchemasPath, `${error.name}.json`),
},
},
},
};
}
if (typeof route.obligatoryParameters === 'object') {
for (const [parameter, schemaDefinition] of Object.entries(route.obligatoryParameters)) {
const openapiParameter: OpenAPIV3.ParameterObject = {
in: 'path',
name: parameter,
required: true,
schema: {
// TODO make this less of a hack and search copied schemas for the first occurring definition
$ref: `schema/SCSearchResponse.json#/definitions/${schemaDefinition}`,
},
};
openapiPath[route.method.toLowerCase() as OpenAPIV3.HttpMethods]?.parameters?.push(openapiParameter);
}
}
return openapiPath;
}

View File

@@ -12,17 +12,18 @@
* 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 Ajv from 'ajv';
import {JSONSchema7 as JSONSchema} from 'json-schema';
import {Config, DEFAULT_CONFIG, Definition, SchemaGenerator} from 'ts-json-schema-generator';
import {createFormatter} from 'ts-json-schema-generator';
import {createParser} from 'ts-json-schema-generator';
import {createProgram} from 'ts-json-schema-generator';
import Ajv, {JSONSchemaType} from 'ajv';
import {
Config,
DEFAULT_CONFIG,
SchemaGenerator,
createParser,
createFormatter,
createProgram,
} from 'ts-json-schema-generator';
import {getTsconfigPath} from './common.js';
import {definitionsOf, lightweightProjectFromPath} from '@openstapps/easy-ast';
import {isSchemaWithDefinitions} from './util/guards.js';
import path from 'path';
import re2 from 're2';
/**
* StAppsCore converter
@@ -30,74 +31,35 @@ import re2 from 're2';
* Converts TypeScript source files to JSON schema files
*/
export class Converter {
/**
* Generator instance
*/
private readonly generator: SchemaGenerator;
/**
* Schema validator instance
*/
private readonly schemaValidator: Ajv.default;
/**
* Create a new converter
* @param projectPath Path to the project
* @param sourcePath Path to optionally point to a different directory of / or single source file
*/
constructor(projectPath: string, sourcePath?: string) {
// set config for schema generator
const config: Config = {
...DEFAULT_CONFIG,
path: sourcePath,
sortProps: true,
topRef: false,
tsconfig: path.join(getTsconfigPath(projectPath), 'tsconfig.json'),
type: 'SC',
};
// create TypeScript program from config
const program = createProgram(config);
// create generator
this.generator = new SchemaGenerator(program, createParser(program, config), createFormatter(config));
// create Ajv instance
this.schemaValidator = new Ajv.default({code: {regExp: re2 as never}});
this.schemaValidator = new Ajv.default();
}
/**
* Get schema for specific StAppsCore type
* @param type Type to get the schema for
* @param version Version to set for the schema
* @param _version Version to set for the schema
* @returns Generated schema
*/
getSchema(type: string, version: string): JSONSchema {
// generate schema for this file/type
const schema: JSONSchema = this.generator.createSchema(type);
// set id of schema
schema.$id = `https://core.stapps.tu-berlin.de/v${version}/lib/schema/${type}.json`;
if (isSchemaWithDefinitions(schema)) {
const selfReference = {
...schema,
};
delete selfReference.$schema;
delete selfReference.definitions;
delete selfReference.$id;
// add self reference to definitions
schema.definitions![`SC${type}`] = {
...(selfReference as unknown as Definition),
};
}
if (!this.schemaValidator.validateSchema(schema)) {
throw new Error(`Generated schema for ${type} is invalid!`);
}
getSchema(type: string, _version: string): JSONSchemaType<unknown> {
const schema = this.generator.createSchema(type) as JSONSchemaType<unknown>;
this.schemaValidator.validateSchema(schema, true);
return schema;
}
}
@@ -110,3 +72,14 @@ export function getValidatableTypesInPath(path: string): string[] {
.filter(type => !!type.comment?.tags?.find(it => it.name === 'validatable'))
.map(type => type.name);
}
/**
* Merge multiple schemas
*/
export function mergeSchemas(schemas: JSONSchemaType<unknown>[]): JSONSchemaType<unknown> {
const completeSchema = {definitions: {}} as JSONSchemaType<unknown>;
for (const schema of schemas) {
Object.assign(completeSchema.definitions!, schema.definitions);
}
return completeSchema;
}

View File

@@ -1,97 +0,0 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type SCRoute = any;
export interface RouteInstanceWithMeta extends SCRoute {
/**
* Possible errors on a route
*/
errors: SCErrorResponse[];
/**
* Description of the request body
*/
requestBodyDescription: string;
/**
* Description of the response body
*/
responseBodyDescription: string;
}
/**
* A route instance with its relevant meta information
*/
export interface RouteWithMetaInformation {
/**
* Description of the route
*/
description: {
/**
* Short text of the description - title
*/
shortText?: string;
/**
* Text of the description
*/
text?: string;
};
/**
* Name of the route
*/
name: string;
/**
* Instance of the route
*/
route: RouteInstanceWithMeta;
/**
* Possible tags/keywords the route can be associated with
*/
tags?: [string];
}
/**
* A node with its relevant meta information
*/
export interface NodeWithMetaInformation {
/**
* Module the node belongs to
*/
module: string;
/**
* Type of the node
*/
type: string;
}
/**
* A generic error that can be returned by the backend if somethings fails during the processing of a request
*/
export interface SCErrorResponse extends Error {
/**
* Additional data that describes the error
*/
additionalData?: unknown;
/**
* HTTP status code to return this error with
*/
statusCode: number;
}

View File

@@ -12,9 +12,3 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Creates sentence cased string
*/
export function capitalize(string?: string): string {
return `${string?.charAt(0).toUpperCase()}${string?.slice(1).toLowerCase()}`;
}

View File

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