mirror of
https://gitlab.com/openstapps/openstapps.git
synced 2026-01-21 00:52:55 +00:00
feat: improve monorepo dev experience
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
26
packages/core-tools/src/better-ajv-errors.d.ts
vendored
26
packages/core-tools/src/better-ajv-errors.d.ts
vendored
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()}`;
|
||||
}
|
||||
|
||||
@@ -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}.`);
|
||||
}
|
||||
Reference in New Issue
Block a user