refactor: move core-tools to monorepo

This commit is contained in:
2023-03-14 17:08:48 +01:00
parent 0ebfc57fd6
commit 721ea0fe67
73 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,248 @@
/*
* 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 {Command} from 'commander';
import {existsSync, readFileSync, writeFileSync} from 'fs';
import {copy} from 'fs-extra';
import path from 'path';
import {mkdirPromisified, readFilePromisified} from './common';
import {lightweightDefinitionsFromPath, lightweightProjectFromPath} from './easy-ast/easy-ast';
import {pack} from './pack';
import {openapi3Template} from './resources/openapi-303-template';
import {gatherRouteInformation, generateOpenAPIForRoute} from './routes';
import {Converter, getValidatableTypesInPath} from './schema';
import {createDiagram, createDiagramFromString} from './uml/create-diagram';
import {UMLConfig} from './uml/uml-config';
import {capitalize} from './util/string';
import {validateFiles, writeReport} from './validate';
// handle unhandled promise rejections
process.on('unhandledRejection', async (reason: unknown) => {
if (reason instanceof Error) {
await Logger.error(reason.message);
Logger.info(reason.stack);
}
process.exit(1);
});
const commander = new Command('openstapps-core-tools');
// eslint-disable-next-line unicorn/prefer-module
commander.version(JSON.parse(readFileSync(path.resolve(__dirname, '..', 'package.json')).toString()).version);
commander.command('prototype <srcBundle> <out>').action(async (sourcePath, out) => {
const files = lightweightProjectFromPath(sourcePath);
writeFileSync(path.resolve(out), JSON.stringify(files, undefined, 2));
});
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, 'schemas');
// 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.replace(
/:\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;
// names of the schemas to copy
const schemasToCopy: string[] = [];
// 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),
schemasToCopy,
tagsToKeep,
);
}
// copy schema json schema files
try {
if (!existsSync(outDirectorySchemasPath)) {
await mkdirPromisified(outDirectorySchemasPath, {
recursive: true,
});
}
for (const fileName of schemasToCopy) {
await copy(
path.join(sourcePath, 'schema', `${fileName}.json`),
path.join(outDirectorySchemasPath, `${fileName}.json`),
);
}
} catch (error) {
await Logger.error(error);
process.exit(-2);
}
// 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
const absoluteSourcePath = path.resolve(relativeSourcePath);
const schemaPath = path.resolve(relativeSchemaPath);
// initialize new core converter
const coreConverter = new Converter(absoluteSourcePath);
// get validatable types
const validatableTypes = getValidatableTypesInPath(absoluteSourcePath);
Logger.info(`Found ${validatableTypes.length} type(s) to generate schemas for.`);
await mkdirPromisified(schemaPath, {
recursive: true,
});
Logger.info(`Trying to find a package.json for ${absoluteSourcePath}.`);
let packagePath = absoluteSourcePath;
// TODO: this check should be less ugly! --- What is this doing anyway?
while (!existsSync(path.join(packagePath, 'package.json')) && packagePath.length > 5) {
packagePath = path.resolve(packagePath, '..');
}
const corePackageJsonPath = path.join(packagePath, 'package.json');
Logger.info(`Using ${corePackageJsonPath} to determine version for schemas.`);
const buffer = await readFilePromisified(corePackageJsonPath);
const corePackageJson = JSON.parse(buffer.toString());
const coreVersion = corePackageJson.version;
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}.`);
}
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('pack').action(async () => {
await pack();
});
commander
.command('plantuml <srcPath> <plantumlserver>')
.option('--definitions <definitions>', 'Shows these specific definitions (class, interface or enum)', it =>
it.split(','),
)
.option('--showAssociations', 'Shows associations of definitions')
.option('--showInheritance', 'Shows extensions and implementations of definitions')
.option('--showEnumValues', 'Show enum values')
.option('--showProperties', 'Show attributes')
.option('--showInheritedProperties', 'Shows inherited attributes, needs --showProperties')
.option('--showOptionalProperties', 'Shows optional attributes and relations, needs --showProperties')
.option('--excludeExternals', 'Exclude external definitions')
.option('--outputFileName <fileName>', 'Defines the filename of the output')
.action(async (relativeSourcePath, plantumlServer, options) => {
const plantUmlConfig: UMLConfig = {
definitions: options.definitions === undefined ? [] : options.definitions,
showAssociations: options.showAssociations === undefined ? false : options.showAssociations,
showEnumValues: options.showEnumValues === undefined ? false : options.showEnumValues,
showInheritance: options.showInheritance === undefined ? false : options.showInheritance,
showInheritedProperties:
options.showInheritedProperties === undefined ? false : options.showInheritedProperties,
showOptionalProperties:
options.showOptionalProperties === undefined ? false : options.showOptionalProperties,
showProperties: options.showProperties === undefined ? false : options.showProperties,
};
if (options.outputFileName !== undefined) {
plantUmlConfig.outputFileName = options.outputFileName;
}
Logger.log(`PlantUML options: ${JSON.stringify(plantUmlConfig)}`);
await createDiagram(lightweightDefinitionsFromPath(relativeSourcePath), plantUmlConfig, plantumlServer);
});
commander
.command('plantuml-file <inputFile> <plantumlserver> [outputFile]')
.action(async (file: string, plantumlServer: string, outputFile: string) => {
const fileContent = readFileSync(path.resolve(file)).toString();
await createDiagramFromString(fileContent, plantumlServer, outputFile);
});
commander.parse(process.argv);

View File

@@ -0,0 +1,56 @@
/*
* 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 {existsSync, mkdir, readFile, unlink, writeFile} from 'fs';
import {Glob} from 'glob';
import {platform} from 'os';
import {promisify} from 'util';
import path from 'path';
export const globPromisified = promisify(Glob);
export const mkdirPromisified = promisify(mkdir);
export const readFilePromisified = promisify(readFile);
export const writeFilePromisified = promisify(writeFile);
export const unlinkPromisified = promisify(unlink);
/**
* Get path that contains a tsconfig.json
*
* @param startPath Path from where to start searching "upwards"
*/
export function getTsconfigPath(startPath: string): string {
let tsconfigPath = startPath;
// see https://stackoverflow.com/questions/9652043/identifying-the-file-system-root-with-node-js
const root = platform() === 'win32' ? process.cwd().split(path.sep)[0] : '/';
// repeat until a tsconfig.json is found
while (!existsSync(path.join(tsconfigPath, 'tsconfig.json'))) {
if (tsconfigPath === root) {
throw new Error(
`Reached file system root ${root} while searching for 'tsconfig.json' in ${startPath}!`,
);
}
// pop last directory
const tsconfigPathParts = tsconfigPath.split(path.sep);
tsconfigPathParts.pop();
tsconfigPath = tsconfigPathParts.join(path.sep);
}
Logger.info(`Using 'tsconfig.json' from ${tsconfigPath}.`);
return tsconfigPath;
}

View File

@@ -0,0 +1,143 @@
/*
* 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 {first, last, tail, filter} from 'lodash';
import {
ArrayTypeNode,
ClassDeclaration,
ClassElement,
EnumDeclaration,
Identifier,
InterfaceDeclaration,
isArrayTypeNode,
isClassDeclaration,
isComputedPropertyName,
isEnumDeclaration,
isInterfaceDeclaration,
isPropertyDeclaration,
isPropertySignature,
isTypeAliasDeclaration,
isTypeReferenceNode,
NodeArray,
PropertyDeclaration,
PropertyName,
PropertySignature,
TypeAliasDeclaration,
TypeElement,
TypeNode,
TypeReferenceNode,
} from 'typescript';
import * as ts from 'typescript';
import {cleanupEmpty} from '../util/collections';
import {LightweightComment} from './types/lightweight-comment';
/** @internal */
export function extractComment(node: ts.Node): LightweightComment | undefined {
const jsDocument = last(
// @ts-expect-error jsDoc exists in reality
node.jsDoc as
| Array<{
comment?: string;
tags?: Array<{comment?: string; tagName?: {escapedText?: string}}>;
}>
| undefined,
);
const comment = jsDocument?.comment?.split('\n\n');
return jsDocument === undefined
? undefined
: cleanupEmpty({
shortSummary: first(comment),
description: tail(comment)?.join('\n\n'),
tags: jsDocument?.tags?.map(tag =>
cleanupEmpty({
name: tag.tagName?.escapedText ?? 'UNRESOLVED_NAME',
parameters: tag.comment?.split(' '),
}),
),
});
}
/** @internal */
export function isProperty(
node: ClassElement | TypeElement,
): node is PropertyDeclaration | PropertySignature {
return isPropertyDeclaration(node) || isPropertySignature(node);
}
/** @internal */
export function filterNodeTo<T extends ts.Node, S extends T>(
node: NodeArray<T>,
check: (node: T) => node is S,
): S[] {
return filter(node, check);
}
/** @internal */
export function filterChildrenTo<T extends ts.Node>(node: ts.Node, check: (node: ts.Node) => node is T): T[] {
const out: T[] = [];
node.forEachChild(child => {
if (check(child)) {
out.push(child);
}
});
return out;
}
/** @internal */
export function getModifiers(text: string, kind: string): string[] {
return [
...text
.split(kind)[0]
.split(/\s+/)
.filter(it => it !== ''),
kind,
];
}
/** @internal */
export function resolvePropertyName(name?: PropertyName): string | undefined {
return name === undefined
? undefined
: isComputedPropertyName(name)
? 'UNSUPPORTED_IDENTIFIER_TYPE'
: name.getText();
}
/** @internal */
export function resolveTypeName(type?: TypeNode): string | undefined {
// @ts-expect-error typeName exists in reality
return type?.typeName?.escapedText ?? type?.typeName?.right?.escapedText;
}
/** @internal */
export function isArrayLikeType(typeNode?: TypeNode): typeNode is ArrayTypeNode | TypeReferenceNode {
return typeNode !== undefined && (isArrayTypeNode(typeNode) || isArrayReference(typeNode));
}
/** @internal */
export function isArrayReference(typeNode: TypeNode): boolean {
return isTypeReferenceNode(typeNode) && (typeNode.typeName as Identifier).escapedText === 'Array';
}
/** @internal */
export function isClassLikeNode(node: ts.Node): node is ClassDeclaration | InterfaceDeclaration {
return isClassDeclaration(node) || isInterfaceDeclaration(node);
}
/** @internal */
export function isEnumLikeNode(node: ts.Node): node is EnumDeclaration | TypeAliasDeclaration {
return isEnumDeclaration(node) || isTypeAliasDeclaration(node);
}

View File

@@ -0,0 +1,83 @@
/* eslint-disable jsdoc/require-jsdoc */
/*
* 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 {flatMap, keyBy, isEmpty} from 'lodash';
import {TypeFlags} from 'typescript';
import {LightweightAliasDefinition} from './types/lightweight-alias-definition';
import {LightweightClassDefinition} from './types/lightweight-class-definition';
import {LightweightDefinition} from './types/lightweight-definition';
import {LightweightDefinitionKind} from './types/lightweight-definition-kind';
import {LightweightProject} from './types/lightweight-project';
import {LightweightType} from './types/lightweight-type';
/**
* Creates a printable name of a type
*/
export function expandTypeValue(type: LightweightType): string | undefined {
if (type.isArray) {
return `${type.value}[]`;
}
if (isStringLiteralType(type)) {
return `'${type.value}'`;
}
if (isUnionOrIntersectionType(type)) {
return type.specificationTypes?.map(expandTypeValue).join(isUnionType(type) ? ' | ' : ' & ');
}
if (isEmpty(type.genericsTypes)) {
return `${type.value}<${type.genericsTypes?.map(expandTypeValue).join(', ')}>`;
}
return type.value?.toString();
}
export function definitionsOf(project: LightweightProject): Record<string, LightweightDefinition> {
return keyBy(flatMap(project, Object.values), 'name');
}
export function isPrimitiveType(type: {flags: TypeFlags}): boolean {
return (type.flags & TypeFlags.NonPrimitive) === 0;
}
export function isLiteralType(type: {flags: TypeFlags}): boolean {
return (type.flags & TypeFlags.Literal) !== 0;
}
export function isEnumLiteralType(type: {flags: TypeFlags}): boolean {
return (type.flags & TypeFlags.EnumLiteral) !== 0;
}
export function isStringLiteralType(type: {flags: TypeFlags}): boolean {
return (type.flags & TypeFlags.StringLiteral) !== 0;
}
export function isUnionOrIntersectionType(type: {flags: TypeFlags}): boolean {
return (type.flags & TypeFlags.UnionOrIntersection) !== 0;
}
export function isUnionType(type: {flags: TypeFlags}): boolean {
return (type.flags & TypeFlags.Union) !== 0;
}
export function isLightweightClass(node?: LightweightDefinition): node is LightweightClassDefinition {
return node?.kind === LightweightDefinitionKind.CLASS_LIKE;
}
export function isLightweightEnum(node?: LightweightDefinition): node is LightweightAliasDefinition {
return node?.kind === LightweightDefinitionKind.ALIAS_LIKE;
}
export function isTypeVariable(type: {flags: TypeFlags}): boolean {
return (type.flags & TypeFlags.TypeVariable) !== 0;
}

View File

@@ -0,0 +1,273 @@
/* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */
/*
* 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 {flatMap, groupBy, keyBy, mapValues} from 'lodash';
import * as ts from 'typescript';
import {
ClassDeclaration,
ClassElement,
EnumDeclaration,
InterfaceDeclaration,
isArrayTypeNode,
isClassDeclaration,
isEnumDeclaration,
isIndexSignatureDeclaration,
isPropertyDeclaration,
isTypeLiteralNode,
isTypeReferenceNode,
NodeArray,
Program,
SourceFile,
SyntaxKind,
Type,
TypeAliasDeclaration,
TypeChecker,
TypeElement,
TypeFlags,
TypeLiteralNode,
TypeNode,
} from 'typescript';
import {cleanupEmpty, mapNotNil, rejectNil} from '../util/collections';
import {expandPathToFilesSync} from '../util/io';
import {
extractComment,
filterChildrenTo,
filterNodeTo,
getModifiers,
isArrayLikeType,
isClassLikeNode,
isEnumLikeNode,
isProperty,
resolvePropertyName,
resolveTypeName,
} from './ast-internal-util';
import {isEnumLiteralType, isTypeVariable} from './ast-util';
import {LightweightAliasDefinition} from './types/lightweight-alias-definition';
import {LightweightClassDefinition} from './types/lightweight-class-definition';
import {LightweightDefinition} from './types/lightweight-definition';
import {LightweightDefinitionKind} from './types/lightweight-definition-kind';
import {LightweightProject} from './types/lightweight-project';
import {LightweightType} from './types/lightweight-type';
import path from 'path';
import {LightweightProperty} from './types/lightweight-property';
/**
* Convert a TypeScript project to a lightweight Type-AST representation of the project
*
* @param sourcePath either a directory or a set of input files
* @param includeComments if comments should be included (default true)
*/
export function lightweightProjectFromPath(
sourcePath: string | string[],
includeComments = true,
): LightweightProject {
return new LightweightDefinitionBuilder(sourcePath, includeComments).convert();
}
/**
* Convert a TypeScript project to a set of lightweight definition ASTs
*
* @param sourcePath either a directory or a set of input files
* @param includeComments if comments should be included (default true)
*/
export function lightweightDefinitionsFromPath(
sourcePath: string | string[],
includeComments = true,
): LightweightDefinition[] {
return rejectNil(new LightweightDefinitionBuilder(sourcePath, includeComments).convertToList());
}
/**
* Reads the reflection model and converts it into a flatter, easier to handle model
*/
class LightweightDefinitionBuilder {
readonly program: Program;
readonly sourceFiles: readonly SourceFile[];
readonly typeChecker: TypeChecker;
constructor(sourcePath: string | string[], readonly includeComments: boolean) {
const rootNames = Array.isArray(sourcePath)
? sourcePath
: expandPathToFilesSync(path.resolve(sourcePath), file => file.endsWith('ts'));
this.program = ts.createProgram({
rootNames: rootNames,
options: {
alwaysStrict: true,
charset: 'utf8',
declaration: true,
esModuleInterop: true,
experimentalDecorators: true,
inlineSourceMap: true,
module: ts.ModuleKind.CommonJS,
strict: true,
target: ts.ScriptTarget.ES2015,
},
});
this.typeChecker = this.program.getTypeChecker();
this.sourceFiles = mapNotNil(this.program.getRootFileNames(), it => this.program.getSourceFile(it));
}
private convertAliasLike(enumLike: EnumDeclaration | TypeAliasDeclaration): LightweightAliasDefinition {
return cleanupEmpty({
comment: this.includeComments ? extractComment(enumLike) : undefined,
name: enumLike.name.getText() ?? 'ERROR',
kind: LightweightDefinitionKind.ALIAS_LIKE,
modifiers: getModifiers(enumLike.getText(), isEnumDeclaration(enumLike) ? 'enum' : 'type'),
type: isEnumDeclaration(enumLike)
? enumLike.members.length > 0
? {
flags: 1_048_576,
specificationTypes: enumLike.members.map(it => this.lightweightTypeAtNode(it)),
}
: undefined
: this.lightweightTypeFromType(this.typeChecker.getTypeFromTypeNode(enumLike.type), enumLike.type),
});
}
private convertClassLike(classLike: ClassDeclaration | InterfaceDeclaration): LightweightClassDefinition {
const heritages = mapValues(
groupBy(classLike.heritageClauses, it => it.token),
heritages => flatMap(heritages, it => it.types),
);
return cleanupEmpty({
comment: this.includeComments ? extractComment(classLike) : undefined,
name: classLike.name?.escapedText ?? 'ERROR',
kind: LightweightDefinitionKind.CLASS_LIKE,
modifiers: getModifiers(classLike.getText(), isClassDeclaration(classLike) ? 'class' : 'interface'),
extendedDefinitions: heritages[ts.SyntaxKind.ExtendsKeyword]?.map(it => this.lightweightTypeAtNode(it)),
implementedDefinitions: heritages[ts.SyntaxKind.ImplementsKeyword]?.map(it =>
this.lightweightTypeAtNode(it),
),
indexSignatures: keyBy(
filterNodeTo(
classLike.members as NodeArray<ClassElement | TypeElement>,
isIndexSignatureDeclaration,
).map(indexSignature =>
cleanupEmpty({
name:
this.typeChecker.getSignatureFromDeclaration(indexSignature)?.parameters?.[0]?.escapedName ??
'UNRESOLVED_INDEX_SIGNATURE',
type: this.lightweightTypeFromType(
this.typeChecker.getTypeFromTypeNode(indexSignature.type),
indexSignature.type,
),
indexSignatureType: this.lightweightTypeFromType(
this.typeChecker.getTypeFromTypeNode(indexSignature.parameters[0].type!),
indexSignature.parameters[0].type!,
),
}),
),
it => it.name,
),
typeParameters: classLike.typeParameters?.map(it => it.name.getText()),
properties: this.collectProperties(classLike.members),
});
}
collectProperties(members: NodeArray<ClassElement | TypeElement>): Record<string, LightweightProperty> {
return keyBy(
filterNodeTo(members as NodeArray<ClassElement | TypeElement>, isProperty).map(property =>
cleanupEmpty({
comment: this.includeComments ? extractComment(property) : undefined,
name: resolvePropertyName(property.name) ?? property.getText(),
type: this.lightweightTypeAtNode(property),
properties: this.collectProperties((property.type as TypeLiteralNode)?.members),
optional: isPropertyDeclaration(property)
? property.questionToken === undefined
? undefined
: true
: undefined,
}),
),
it => it.name,
);
}
private lightweightTypeAtNode(node: ts.Node): LightweightType {
const type = this.typeChecker.getTypeAtLocation(node);
return this.lightweightTypeFromType(type, this.typeChecker.typeToTypeNode(type, node, undefined));
}
private lightweightTypeFromType(type: ts.Type, typeNode?: TypeNode): LightweightType {
if (typeNode?.kind === SyntaxKind.ConditionalType) {
return {value: 'UNSUPPORTED_CONDITIONAL_TYPE', flags: TypeFlags.Unknown};
}
if (isArrayLikeType(typeNode)) {
const elementType = isArrayTypeNode(typeNode) ? typeNode.elementType : typeNode.typeArguments?.[0]!;
const out = this.lightweightTypeFromType(
this.typeChecker.getTypeFromTypeNode(elementType),
elementType,
);
out.isArray = true;
return out;
}
const isReference = typeNode !== undefined && isTypeReferenceNode(typeNode) && !isEnumLiteralType(type);
const isTypeLiteral = typeNode !== undefined && isTypeLiteralNode(typeNode);
// @ts-expect-error intrinsic name & value exist
const intrinsicName = (type.intrinsicName ?? type.value) as string | undefined;
return cleanupEmpty({
value: intrinsicName,
referenceName: isTypeLiteral
? undefined
: resolveTypeName(typeNode) ?? (type.symbol?.escapedName as string | undefined),
flags: type.flags,
genericsTypes: isTypeVariable(type)
? undefined
: this.typeChecker
.getApparentType(type)
// @ts-expect-error resolvedTypeArguments exits
?.resolvedTypeArguments?.filter(it => !it.isThisType)
?.map((it: Type) => this.lightweightTypeFromType(it)),
specificationTypes:
type.isUnionOrIntersection() && !isReference
? type.types.map(it =>
this.lightweightTypeFromType(it, this.typeChecker.typeToTypeNode(it, undefined, undefined)),
)
: undefined,
});
}
/**
* Start the conversion process
*/
convert(): LightweightProject {
return mapValues(
keyBy(this.sourceFiles, it => it.fileName),
file =>
keyBy(
[
...filterChildrenTo(file, isClassLikeNode).map(it => this.convertClassLike(it)),
...filterChildrenTo(file, isEnumLikeNode).map(it => this.convertAliasLike(it)),
],
it => it.name,
),
);
}
/**
* Same as conversion, but generates a simple list of all definitions.
*/
convertToList(): LightweightDefinition[] {
return flatMap(this.convert(), it => it.values);
}
}

View File

@@ -0,0 +1,32 @@
/*
* 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 {LightweightDefinitionBase} from './lightweight-definition';
import {LightweightDefinitionKind} from './lightweight-definition-kind';
import {LightweightType} from './lightweight-type';
/**
* Represents an enum definition
*/
export interface LightweightAliasDefinition extends LightweightDefinitionBase {
/**
* Kind
*/
kind: LightweightDefinitionKind.ALIAS_LIKE;
/**
* Enumeration or union values
*/
type?: LightweightType;
}

View File

@@ -0,0 +1,54 @@
/*
* 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 {LightweightDefinitionBase} from './lightweight-definition';
import {LightweightDefinitionKind} from './lightweight-definition-kind';
import {LightweightIndexSignature, LightweightProperty} from './lightweight-property';
import {LightweightType} from './lightweight-type';
/**
* Represents a class definition
*/
export interface LightweightClassDefinition extends LightweightDefinitionBase {
/**
* String values of the extended definitions
*/
extendedDefinitions?: LightweightType[];
/**
* String values of the implemented definitions
*/
implementedDefinitions?: LightweightType[];
/**
* Index signatures
*/
indexSignatures?: Record<string, LightweightIndexSignature>;
/**
* Kind
*/
kind: LightweightDefinitionKind.CLASS_LIKE;
/**
* Properties of the definition
*/
properties?: Record<string, LightweightProperty>;
/**
* Generic type parameters of this class
*/
typeParameters?: string[];
}

View File

@@ -0,0 +1,48 @@
/*
* 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/>.
*/
/**
* Represents a Comment
*/
export interface LightweightComment {
/**
* Description of the comment
*/
description?: string;
/**
* Short summary of the comment
*/
shortSummary?: string;
/**
* Tags of the comment
*/
tags?: LightweightCommentTag[];
}
/**
* Lightweight comment tag
*/
export interface LightweightCommentTag {
/**
* The name of the tag
*/
name: string;
/**
* The parameters of the tag
*/
parameters?: string[];
}

View File

@@ -0,0 +1,19 @@
/*
* 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/>.
*/
export enum LightweightDefinitionKind {
CLASS_LIKE = 'class-like',
ALIAS_LIKE = 'alias-like',
}

View File

@@ -0,0 +1,46 @@
/*
* 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 {LightweightDefinitionKind} from './lightweight-definition-kind';
import {LightweightComment} from './lightweight-comment';
import {LightweightClassDefinition} from './lightweight-class-definition';
import {LightweightAliasDefinition} from './lightweight-alias-definition';
export type LightweightDefinition = LightweightClassDefinition | LightweightAliasDefinition;
/**
* Represents any definition without specifics
*/
export interface LightweightDefinitionBase {
/**
* The comment of the definition
*/
comment?: LightweightComment;
/**
* Kind of the definition
*/
kind: LightweightDefinitionKind;
/**
* The definition type
* e.g. [`abstract`, `class`] or [`enum`] or [`export`, `type`]
*/
modifiers?: string[];
/**
* Name of the definition
*/
name: string;
}

View File

@@ -0,0 +1,100 @@
/*
* 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 {assign, cloneDeep, flatMap, fromPairs, trimEnd} from 'lodash';
import {mapNotNil} from '../../util/collections';
import {definitionsOf, isLightweightClass} from '../ast-util';
import {lightweightProjectFromPath} from '../easy-ast';
import {LightweightClassDefinition} from './lightweight-class-definition';
import {LightweightDefinition} from './lightweight-definition';
/**
* Build an index for a lightweight project
*/
function buildIndex(project: LightweightProject): Record<string, string> {
return fromPairs(
flatMap(project, (definitions, file) => Object.keys(definitions).map(definition => [definition, file])),
);
}
/**
* A lightweight definition class for more advanced use cases
*/
export class LightweightProjectWithIndex {
/**
* All definitions
*/
readonly definitions: Record<string, LightweightDefinition>;
/**
* Project
*/
readonly files: LightweightProject;
/**
* Index of all definitions to their respective files
*/
readonly index: {
[definitionName: string]: string;
};
constructor(project: LightweightProject | string) {
this.files = typeof project === 'string' ? lightweightProjectFromPath(project) : project;
this.index = buildIndex(this.files);
this.definitions = definitionsOf(this.files);
}
/**
* Apply inherited classes; default deeply
*/
applyInheritance(classLike: LightweightClassDefinition, deep?: boolean): LightweightDefinition {
return assign(
mapNotNil(
[...(classLike.implementedDefinitions ?? []), ...(classLike.extendedDefinitions ?? [])],
extension => {
const object = this.definitions[extension.referenceName ?? ''];
return (deep ?? true) && isLightweightClass(object)
? this.applyInheritance(object)
: cloneDeep(object);
},
),
cloneDeep(classLike),
);
}
/**
* Instantiate a definition
*
* Requires the program to be run with `--require ts-node/register`
*/
async instantiateDefinitionByName<T>(name: string, findCompiledModule = true): Promise<T | undefined> {
const fsPath = this.index[name];
if (fsPath === undefined) {
return undefined;
}
const module = await import(findCompiledModule ? `${trimEnd(fsPath, 'd.ts')}.js` : fsPath);
return new module[name]() as T;
}
}
export interface LightweightFile {
[definitionName: string]: LightweightDefinition;
}
export interface LightweightProject {
[sourcePath: string]: LightweightFile;
}

View File

@@ -0,0 +1,53 @@
/*
* 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 {LightweightComment} from './lightweight-comment';
import {LightweightType} from './lightweight-type';
/**
* Represents a property definition
*/
export interface LightweightProperty {
/**
* The comment of the property
*/
comment?: LightweightComment;
/**
* Name of the property
*/
name: string;
/**
* Is the property marked as optional
*/
optional?: true;
/**
* A record of properties if the property happens to be a type literal
*/
properties?: Record<string, LightweightProperty>;
/**
* Type of the property
*/
type: LightweightType;
}
export interface LightweightIndexSignature extends LightweightProperty {
/**
* Type of the index signature, if it is an index signature
*/
indexSignatureType: LightweightType;
}

View File

@@ -0,0 +1,58 @@
/*
* 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 {TypeFlags} from 'typescript';
/**
* Describes an easy to use type definition.
*/
export interface LightweightType {
/**
* Type Flags
*/
flags: TypeFlags;
/**
* Contains all types inside of <> brackets
*/
genericsTypes?: LightweightType[];
/**
* If it is an array(-like) type
*/
isArray?: true;
/**
* If it is a type parameter
*/
isTypeParameter?: true;
/**
* The name of the type that is referenced. Enum members have reference names that lead no where.
*/
referenceName?: string;
/**
* Type specifications, if the type is combined by either an array, union or a typeOperator
*/
specificationTypes?: LightweightType[];
/**
* Value of the type
*
* Literal types have their value here, non-literals their type name (for example 'string')
*/
value?: string | number | boolean;
}

View File

@@ -0,0 +1,464 @@
/* eslint-disable unicorn/error-message */
/*
* 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 {Logger} from '@openstapps/logger';
import del from 'del';
import {existsSync} from 'fs';
import {cwd} from 'process';
import {globPromisified, readFilePromisified, unlinkPromisified, writeFilePromisified} from './common';
import {JavaScriptModule} from './types/pack';
import path from 'path';
const PACK_IDENTIFIER = '/* PACKED BY @openstapps/pack */';
/**
* Pack cli.js
*
* This finds all internal requires and replaces the paths with `./index` or internal requires if it has been
* required already.
*
* Furthermore it checks that no shebang line is present and that it does not export anything.
*/
async function packCliJs(): Promise<void> {
const cliPath = path.join(cwd(), 'lib', 'cli.js');
if (!existsSync(cliPath)) {
return;
}
Logger.info('Adjusting JavaScript CLI...');
const buffer = await readFilePromisified(cliPath);
const content = buffer.toString();
if (content.indexOf('#!/') === 0) {
throw new Error('`cli.js` must not contain a shebang line! It is added by this script.');
}
let internalRequire: string | undefined;
const adjustedContent = content
.split('\n')
.map((line, lineNumber) => {
// check for exports (cli.js is not allowed to export anything)
if (Array.isArray(line.match(/^\s*((exports)|(module\.exports))/))) {
throw new TypeError(
`Line '${lineNumber}' in 'cli.js' exports something. cli.js is not for exporting. Line was:\n${line}`,
);
}
// replace lines with internal requires
// extract module name from line
const match = line.match(/^(\s*)(const|var) ([a-z0-9_]*) = require\(("[^"]+"|'[^']+')\);$/i);
if (match !== null) {
const importedName = match[3];
// eslint-disable-next-line unicorn/prefer-string-slice
const moduleName = match[4].substring(1, match[4].length - 1);
// if it begins with '.' and not ends with json
if (/^[.]{1,2}\/(?!.*\.json$).*$/i.test(moduleName)) {
// is the first internal require
if (internalRequire) {
return `const ${importedName} = ${internalRequire};`;
}
// only the first import needs a require
internalRequire = importedName;
return `const ${importedName} = require("./index");`;
}
}
return line;
})
.join('\n');
return writeFilePromisified(cliPath, `#!/usr/bin/env node\n\n${adjustedContent}`);
}
/**
* Get a list containing the contents of all type definition files
*/
async function getAllTypeDefinitions(): Promise<string[]> {
const fileNames = await globPromisified(path.join(cwd(), '*(lib|src)', '**', '*.d.ts'), {
ignore: [
path.join(cwd(), 'lib', 'doc', '**', '*.d.ts'),
path.join(cwd(), 'lib', 'test', '**', '*.d.ts'),
path.join(cwd(), 'lib', 'cli.d.ts'),
],
});
const promises = fileNames.map(async (fileName: string) => {
return readFilePromisified(fileName, 'utf8');
});
return Promise.all(promises);
}
/**
* Pack a list of type definitions into one file
*/
async function packTypeDefinitions(): Promise<void> {
Logger.info('Packing TypeScript definition files...');
const indexPath = path.join(cwd(), 'lib', 'index.d.ts');
await deleteFileIfExistingAndPacked(indexPath);
const typeDefinitions = await getAllTypeDefinitions();
// pack TypeScript definition files
const imports: {[k: string]: string[]} = {};
const referenceLines: string[] = [];
let allDefinitions = typeDefinitions
// concat them separated by new lines
.join('\n\n\n\n\n')
// split all lines
.split('\n')
.map(line => {
if (line.includes('export =')) {
throw new Error('`export =` is not allowed by pack. Use named imports instead.');
}
if (line.indexOf('/// <reference') === 0) {
referenceLines.push(line);
return '// moved referenced line';
}
// match import lines
const match = line.match(/^import {([^}].*)} from '([^'].*)';$/);
if (match !== null) {
const module = match[2];
// extract imported objects
const importedObjects = match[1].split(',').map(object => {
return object.trim();
});
// add list of already imported objects for module
if (imports[module] === undefined) {
imports[module] = [];
}
// count already imported objects and objects to import now
const objectsToImport: string[] = [];
for (const object of importedObjects) {
if (!imports[module].includes(object)) {
imports[module].push(object);
objectsToImport.push(object);
}
}
// replace import line
if (objectsToImport.length === 0) {
return '// extraneous removed import';
}
return `import {${objectsToImport.join(', ')}} from '${module}';`;
}
return line;
})
// filter lines which contain "local" imports
.filter(line => {
return line.match(/^import .* from '\./) === null;
})
// concat all lines separated by new lines
.join('\n');
if (allDefinitions.length > 0) {
if (referenceLines.length > 0) {
allDefinitions = `${referenceLines.join('\n')}
${allDefinitions}`;
}
// write packed TypeScript definition files
return writeFilePromisified(
indexPath,
`${PACK_IDENTIFIER}
${allDefinitions}`,
);
}
}
/**
* Get all JavaScript modules
*/
async function getAllJavaScriptModules(): Promise<JavaScriptModule[]> {
const fileNames = await globPromisified(path.join(cwd(), 'lib', '**', '*.js'), {
ignore: [
path.join(cwd(), 'lib', 'doc', '**', '*.js'),
path.join(cwd(), 'lib', 'test', '*.js'),
path.join(cwd(), 'lib', 'cli.js'),
],
});
const promises = fileNames.map(async (fileName: string) => {
const fileContent = await readFilePromisified(fileName, 'utf8');
const directory = path.dirname(fileName).replace(new RegExp(`^${path.join(cwd(), 'lib')}`), '');
return {
content: `(function() {
${fileContent}
})();
`,
dependencies: getAllInternalDependencies(fileContent),
directory: directory,
name: path.basename(fileName, '.js'),
};
});
return Promise.all(promises);
}
/**
* Pack all javascript files
*/
async function packJavaScriptFiles(): Promise<void> {
const indexPath = path.join(cwd(), 'lib', 'index.js');
Logger.info('Packing JavaScript files...');
await deleteFileIfExistingAndPacked(indexPath);
// topologically sort the modules (sort by dependencies)
const jsModules = topologicalSort(await getAllJavaScriptModules());
let wholeCode = jsModules
// convert modules to strings
.map(module => {
module.content = module.content
.split('\n')
.map(line => {
const match = line.match(
/^(\s*)(const|var) ([a-z0-9_]*) = ((require\("([^"]+)"\))|(require\('([^']+)'\)));$/i,
);
// replace lines with internal requires
if (match !== null) {
if (match[6] === undefined) {
match[6] = match[8];
}
const whiteSpace = typeof match[1] === 'string' && match[1].length > 0 ? match[1] : '';
const importedName = match[3];
const modulePath = match[6];
// leave line unchanged if it is a "global" import
if (modulePath.match(/^[.]{1,2}\//) === null) {
return line;
}
// replace internal requires with `module.exports`
if (existsSync(path.join(cwd(), 'lib', module.directory, `${modulePath}.js`))) {
return `${whiteSpace}const ${importedName} = module.exports;`;
}
if (existsSync(path.join(cwd(), 'src', module.directory, modulePath))) {
return `${whiteSpace} const ${importedName} = require(../src/${modulePath});`;
}
Logger.warn(`Import ${importedName} could not be found in module.directory ${modulePath}.`);
}
return line;
})
.join('\n');
return `// Module: ${module.name}
${module.content}`;
})
// concat them separated by new lines
.join('\n\n\n\n\n')
// split all lines
.split('\n')
// filter lines
.filter(line => {
// remove strict usage
if (line === '"use strict";') {
return false;
}
// remove esModule property
if (line === 'Object.defineProperty(exports, "__esModule", { value: true });') {
return false;
}
// remove source map references
if (line.indexOf('//# sourceMappingURL=') === 0) {
return false;
}
// keep all other lines
return true;
})
// concat all lines separated by new lines
.join('\n');
if (wholeCode.length > 0) {
// add meta lines to the file
wholeCode = `"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
${wholeCode}`;
// write packed JavaScript files
return writeFilePromisified(
indexPath,
`${PACK_IDENTIFIER}
${wholeCode}`,
);
}
}
/**
* Delete file if it exists and is packed by this script
*
* @param path Path to file to check/delete
*/
async function deleteFileIfExistingAndPacked(path: string): Promise<void> {
try {
const buffer = await readFilePromisified(path);
const content = buffer.toString();
// check if packed by this script
if (content.indexOf(PACK_IDENTIFIER) === 0) {
Logger.log(`Found '${path}' which is packed by this script. Deleting it...`);
return unlinkPromisified(path);
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return;
}
}
}
/**
* Get all internal dependencies from the content of a module
*
* @param moduleContent Module content to analyze
*/
function getAllInternalDependencies(moduleContent: string): string[] {
// match all const <name> = require(<moduleName>);
const requireLines = moduleContent.match(
/^\s*(const|var) [a-z0-9_]* = require\("([^"]+)"\)|require\('([^']+)'\);$/gim,
);
if (Array.isArray(requireLines)) {
return requireLines
.map(requireLine => {
const matches = requireLine.match(/require\("([^"]+)"\)|require\('([^']+)'\);$/i);
// previously matched require line does not contain a require?!
if (matches === null) {
throw new Error();
}
// return only the moduleName
return matches[1];
})
.filter(moduleName => {
// filter out internal modules beginning with './' and not ending with '.json'
return /^[.]{1,2}\/(?!.*\.json$).*$/i.test(moduleName);
})
.map(internalModuleName => {
// cut './' from the name
return internalModuleName.slice('./'.length);
});
}
return [];
}
/**
* Sort modules by their dependencies
*
* @param modules Modules to sort
*/
function topologicalSort(modules: JavaScriptModule[]): JavaScriptModule[] {
// eslint-disable-next-line unicorn/prefer-module,@typescript-eslint/no-var-requires
const topoSort = require('toposort');
// vertices are modules, an edge from a to b means that b depends on a
const edges: string[][] = [];
const nodes: string[] = [];
// add all edges
for (const module of modules) {
for (const dependencyPath of module.dependencies) {
// add edge from dependency to our module
edges.push([path.basename(dependencyPath), module.name]);
}
nodes.push(module.name);
}
// sort graph and return as an array of sorted modules
return topoSort.array(nodes, edges).map((moduleName: string) => {
return modules.find(module => {
return module.name === moduleName;
});
});
}
/**
* Pack
*/
export async function pack() {
Logger.log(`Packing project in ${process.cwd()}...`);
// run all tasks in parallel
const promises: Array<Promise<void>> = [packCliJs(), packTypeDefinitions(), packJavaScriptFiles()];
await Promise.all(promises);
// clean up afterwards
Logger.info('Deleting extraneous files...');
await del([
// delete all transpiled files
'lib/*',
// keep packed files
'!lib/index.d.ts',
'!lib/index.js',
// keep converted schema files
'!lib/schema',
'!lib/schema/*.json',
// keep documentation
'!lib/doc',
'!lib/doc/*',
'!lib/doc/**/*',
// keep cli
'!lib/cli.js',
// keep tests
'!lib/test',
'!lib/test/*',
'!lib/test/**/*',
]);
}

View File

@@ -0,0 +1,38 @@
/*
* 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/>.
*/
/**
* This is a simple interface declaration for
* testing the schema generation and validation.
*
* @validatable
*/
export interface Foo {
/**
* Dummy parameter
*/
lorem: 'lorem' | 'ipsum';
/**
* String literal type property
*/
type: FooType;
}
/**
* This is a simple type declaration for
* usage in the Foo interface.
*/
export type FooType = 'Foo';

View File

@@ -0,0 +1,41 @@
/*
* 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

@@ -0,0 +1,161 @@
/*
* 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 {assign, filter, map} from 'lodash';
import {OpenAPIV3} from 'openapi-types';
import {isLightweightClass} from './easy-ast/ast-util';
import {LightweightProjectWithIndex} from './easy-ast/types/lightweight-project';
import {RouteInstanceWithMeta, RouteWithMetaInformation} from './types/routes';
import {rejectNil} from './util/collections';
import {capitalize} from './util/string';
import path from 'path';
import {lightweightProjectFromPath} from './easy-ast/easy-ast';
/**
* 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(
map(filter(project.definitions, isLightweightClass), 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
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 schemasToCopy Schemas identified as relevant for this route
* @param tagsToKeep Tags / keywords that can be used for grouping routes
*/
export function generateOpenAPIForRoute(
routeWithInfo: RouteWithMetaInformation,
outDirectorySchemasPath: string,
schemasToCopy: string[],
tagsToKeep: string[],
): OpenAPIV3.PathItemObject {
const route = routeWithInfo.route;
const openapiPath: OpenAPIV3.PathItemObject = {};
schemasToCopy.push(route.requestBodyName, route.responseBodyName);
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) {
schemasToCopy.push(error.name);
openapiPath[route.method.toLowerCase() as OpenAPIV3.HttpMethods]!.responses![error.statusCode] = {
description: error.message ?? capitalize(error.name.replace(/([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: `schemas/SCSearchResponse.json#/definitions/${schemaDefinition}`,
},
};
openapiPath[route.method.toLowerCase() as OpenAPIV3.HttpMethods]?.parameters?.push(openapiParameter);
}
}
return openapiPath;
}

View File

@@ -0,0 +1,119 @@
/*
* 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 Ajv from 'ajv';
import {JSONSchema7 as JSONSchema} from 'json-schema';
import {chain} from 'lodash';
import {Config, DEFAULT_CONFIG, Definition, SchemaGenerator} from 'ts-json-schema-generator';
import {createFormatter} from 'ts-json-schema-generator/dist/factory/formatter';
import {createParser} from 'ts-json-schema-generator/dist/factory/parser';
import {createProgram} from 'ts-json-schema-generator/dist/factory/program';
import {getTsconfigPath} from './common';
import {definitionsOf} from './easy-ast/ast-util';
import {lightweightProjectFromPath} from './easy-ast/easy-ast';
import {isSchemaWithDefinitions} from './util/guards';
import path from 'path';
import re2 from './types/re2';
/**
* StAppsCore converter
*
* Converts TypeScript source files to JSON schema files
*/
export class Converter {
/**
* Generator instance
*/
private readonly generator: SchemaGenerator;
/**
* Schema validator instance
*/
private readonly schemaValidator: Ajv;
/**
* 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({code: {regExp: re2}});
// eslint-disable-next-line @typescript-eslint/no-var-requires,unicorn/prefer-module
this.schemaValidator.addMetaSchema(require('ajv/lib/refs/json-schema-draft-06.json'));
}
/**
* Get schema for specific StAppsCore type
*
* @param type Type to get the schema for
* @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!`);
}
return schema;
}
}
/**
* Get a list of validatable types from an API extractor file
*/
export function getValidatableTypesInPath(path: string): string[] {
return chain(definitionsOf(lightweightProjectFromPath(path)))
.filter(type => !!type.comment?.tags?.find(it => it.name === 'validatable'))
.map(type => type.name)
.value();
}

View File

@@ -0,0 +1,39 @@
/*
* 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/>.
*/
/**
* A JavaScript module representation to sort a list of them by dependencies
*/
export interface JavaScriptModule {
/**
* Content of the module
*/
content: string;
/**
* List of names of dependencies
*/
dependencies: string[];
/**
* Directory the module is in
*/
directory: string;
/**
* The name of the module
*/
name: string;
}

View File

@@ -0,0 +1,6 @@
import re2 from 're2';
type Re2 = typeof re2 & {code: string};
(re2 as Re2).code = 'require("lib/types/re2").default';
export default re2 as Re2;

View File

@@ -0,0 +1,97 @@
/*
* 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

@@ -0,0 +1,26 @@
/*
* 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 {JSONSchema7 as JSONSchema} from 'json-schema';
import {Definition} from 'ts-json-schema-generator';
/**
* A schema with definitions
*/
export interface SchemaWithDefinitions extends JSONSchema {
/**
* Definitions of the schema
*/
definitions?: {[name: string]: Definition};
}

View File

@@ -0,0 +1,87 @@
/*
* 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/>.
*/
/**
* The validation result
*/
export interface ValidationResult {
/**
* A list of errors that occurred
*/
errors: ValidationError[];
/**
* whether the validation was successful
*/
valid: boolean;
}
/**
* An error that occurred while validating
*
* This is a duplicate of the ValidationError in core/protocol/errors/validation because of incompatibilities
* between TypeDoc and TypeScript
*/
export interface ValidationError {
/**
* JSON schema path
*/
dataPath: string;
/**
* The instance
*/
instance: unknown;
/**
* The message
*
* Provided by https://www.npmjs.com/package/better-ajv-errors
*/
message: string;
/**
* Name of the error
*/
name: string;
/**
* Path within the Schema
*/
schemaPath: string;
/**
* Suggestion to fix the occurring error
*
* Provided by https://www.npmjs.com/package/better-ajv-errors
*/
suggestion?: string;
}
/**
* An expected error
*/
export interface ExpectedValidationError extends ValidationError {
/**
* Whether or not the error is expected
*/
expected: boolean;
}
/**
* A map of files and their expected validation errors
*/
export interface ExpectedValidationErrors {
[fileName: string]: ExpectedValidationError[];
}

View File

@@ -0,0 +1,257 @@
/*
* 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 {Logger} from '@openstapps/logger';
import {createWriteStream} from 'fs';
import * as request from 'got';
import {forEach, map, isEmpty} from 'lodash';
import {expandTypeValue, isLightweightClass, isUnionOrIntersectionType} from '../easy-ast/ast-util';
import {LightweightAliasDefinition} from '../easy-ast/types/lightweight-alias-definition';
import {LightweightClassDefinition} from '../easy-ast/types/lightweight-class-definition';
import {LightweightDefinition} from '../easy-ast/types/lightweight-definition';
import {LightweightProperty} from '../easy-ast/types/lightweight-property';
import {LightweightType} from '../easy-ast/types/lightweight-type';
import {UMLConfig} from './uml-config';
/**
* Converts the lightweight class/enum definitions according to the configuration,
* to valid PlantUML Code, which will then be encoded, converted by the plantuml server
* and saved as a .svg file in directory, in which this method was called
*
* @param definitions all type definitions of the project
* @param config contains information on how the PlantUML should be generated
* @param plantUmlBaseURL Hostname of the PlantUML-Server
*/
export async function createDiagram(
definitions: LightweightDefinition[],
config: UMLConfig,
plantUmlBaseURL: string,
): Promise<string> {
// when non definitions were specified use all
config.definitions = map(definitions, 'name');
// when providing definitions and either showing associations or inheritance the
// inherited definitions will be added automatically
if (config.showInheritance) {
// TODO: showInheritance
/*const inheritedDefinitions = gatherTypeAssociations(
definitions,
config.definitions,
);*/
// config.definitions = config.definitions.concat(inheritedDefinitions);
}
// creates a UML definition for every specified definition name
// however if no definitions were provided all definitions will be transformed
const modelPlantUMLCode = map(
definitions.filter(it => !config.definitions.includes(it.name)),
definition =>
isLightweightClass(definition)
? createPlantUMLCodeForClass(config, definition)
: createPlantUMLCodeForEnum(config, definition),
).join('');
return createDiagramFromString(modelPlantUMLCode, plantUmlBaseURL, config.outputFileName);
}
/**
* This will encode the plantuml code and post the code to the plantuml server
* The server will then parse the code and create a corresponding diagram
*
* @param modelPlantUMLCode raw PlantUML code
* @param plantUmlBaseURL PlantUML server address that shall be used
* @param outputFile filename of the output file without file extension
*/
export async function createDiagramFromString(
modelPlantUMLCode: string,
plantUmlBaseURL: string,
outputFile = `Diagram-${new Date().toISOString()}`,
) {
// eslint-disable-next-line @typescript-eslint/no-var-requires,unicorn/prefer-module
const plantumlEncoder = require('plantuml-encoder');
const plantUMLCode = plantumlEncoder.encode(`@startuml\n${modelPlantUMLCode}\n@enduml`);
const url = `${plantUmlBaseURL}/svg/${plantUMLCode}`;
let response;
try {
response = await request.default.get(url);
const httpOK = 200;
if (response.statusCode !== httpOK) {
await Logger.error(`Plantuml Server responded with an error.\n${response.statusMessage}`);
throw new Error('Response not okay');
}
} catch (error) {
Logger.log(
`Please try using the public plantuml server:\nhttp://www.plantuml.com/plantuml/svg/${plantUMLCode}`,
);
throw error;
}
// attach file extension
const fileName = `${outputFile}.svg`;
try {
createWriteStream(fileName).write(response.body);
Logger.log(`Writen data to file: ${fileName}`);
} catch {
throw new Error('Could not write file. Are you missing permissions?');
}
return fileName;
}
/**
* Recursively iterates over all types, to find implemented generic types and parents
*
* @param definitions all type definitions of the project
* @param abstractionNames currently known string values of inherited classes
*/
/*function gatherTypeAssociations(
definitions: LightweightDefinition[],
abstractionNames: string[],
): string[] {
let abstractions: string[] = [];
for (const name of abstractionNames) {
const declaration = definitions.find(
(definition) => definition.name === name,
);
if (isLightweightClass(declaration)) {
const currentAbstractions: string[] = declaration.extendedDefinitions.concat(
declaration.implementedDefinitions,
);
abstractions = abstractions.concat(currentAbstractions);
abstractions = abstractions.concat(
gatherTypeAssociations(definitions, currentAbstractions),
);
}
}
return abstractions;
}*/
/**
* Collects all reference information of this type.
*
* Reference information is everything that is indirectly referencing a type or class by name.
*
* @param type Type with references to other types
*/
function getReferenceTypes(type: LightweightType): string[] {
const types: string[] = [];
if (type.referenceName !== undefined) {
types.push(type.referenceName);
}
forEach(type.genericsTypes, specificType => {
for (const value of getReferenceTypes(specificType)) {
types.push(value);
}
});
if ((isUnionOrIntersectionType(type) && isEmpty(type.specificationTypes)) || type.isArray) {
forEach(type.specificationTypes, specificType => {
for (const value of getReferenceTypes(specificType)) {
types.push(value);
}
});
}
return types;
}
/**
* Creates Plant UML code according to the config for the provided class
*
* @param config Configuration for how the UML should be tweaked
* @param readerClass Class or interface representation
*/
function createPlantUMLCodeForClass(config: UMLConfig, readerClass: LightweightClassDefinition): string {
// create the definition header, what type the definition is, it's name and it's inheritance
let model = `${readerClass.modifiers} ${readerClass.name}`;
if (readerClass.typeParameters?.length ?? 0 > 0) {
model += `<${readerClass.typeParameters!.join(', ')}>`;
}
if (config.showInheritance && (readerClass.extendedDefinitions?.length ?? 0 > 0)) {
// PlantUML will automatically create links, when using extends
model += ` extends ${readerClass.extendedDefinitions!.join(', ')}`;
}
if (config.showInheritance && (readerClass.implementedDefinitions?.length ?? 0 > 0)) {
// PlantUML will automatically create links, when using implements
model += ` implements ${readerClass.implementedDefinitions!.join(', ')}`;
}
model += '{';
// add the properties to the definition body
if (config.showProperties) {
forEach(readerClass.properties, property => {
if (property.optional && !config.showOptionalProperties) {
// don't show optional attributes
return;
}
/*if (property.inherited && !config.showInheritedProperties) {
// don't show inherited properties
continue;
}*/
model += `\n\t${createPropertyLine(property)}`;
});
}
// close the definition body
model += '\n}\n';
// add associations from properties with references
forEach(readerClass.properties, property => {
const types: string[] = getReferenceTypes(property.type);
for (const type of types) {
if (config.showAssociations) {
/*if (property.inherited && !config.showInheritedProperties) {
continue;
}*/
model += `${readerClass.name} -up-> ${type} : ${property.name} >\n`;
}
}
});
return model;
}
/**
* Creates PlantUML code according to the config for the provided enum/-like definition
*
* @param config Configuration for how the UML should be tweaked
* @param readerEnum Enum/-like representation
*/
function createPlantUMLCodeForEnum(config: UMLConfig, readerEnum: LightweightAliasDefinition): string {
// create enum header
let model = `enum ${readerEnum.name} {`;
// add values
if (config.showEnumValues) {
forEach(readerEnum.type?.specificationTypes, value => {
model += `\n\t${value.toString()}`;
});
}
model += '\n}\n';
return model;
}
/**
* Creates a property PlantUML Line
*/
function createPropertyLine(property: LightweightProperty): string {
const prefix = `${/*(property.inherited ? '/ ' : */ ''}${property.optional ? '? ' : ''}`;
return `${prefix}${property.name} : ${expandTypeValue(property.type)}`;
}

View File

@@ -0,0 +1,59 @@
/*
* 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/>.
*/
/**
* Holds configuration information of how the UML code should be build
*/
export interface UMLConfig {
/**
* Defines which definitions are shown
*/
definitions: string[];
/**
* Defines the output file name without file extension
*/
outputFileName?: string;
/**
* Should the associations between definitions be shown
*/
showAssociations: boolean;
/**
* Should enum/-like values be shown
*/
showEnumValues: boolean;
/**
* Should the inheritance be shown
*/
showInheritance: boolean;
/**
* Should the inherited properties be shown
*/
showInheritedProperties: boolean;
/**
* Should optional properties be shown
*/
showOptionalProperties: boolean;
/**
* Should properties be shown
*/
showProperties: boolean;
}

View File

@@ -0,0 +1,37 @@
/*
* 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 {omitBy, isNil, reject, isEmpty, isArray, isObject} from 'lodash';
/**
* Filters only defined elements
*/
export function rejectNil<T>(array: Array<T | undefined | null>): T[] {
return reject(array, isNil) as T[];
}
/**
* Map elements that are not null
*/
export function mapNotNil<T, S>(array: readonly T[], transform: (element: T) => S | undefined | null): S[] {
return rejectNil(array.map(transform));
}
/**
* Deletes all properties with the value 'undefined', [] or {}
*/
// eslint-disable-next-line @typescript-eslint/ban-types
export function cleanupEmpty<T extends object>(object: T): T {
return omitBy(object, it => isNil(it) || ((isObject(it) || isArray(it)) && isEmpty(it))) as T;
}

View File

@@ -0,0 +1,35 @@
/*
* 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 {JSONSchema7 as JSONSchema} from 'json-schema';
import {SchemaWithDefinitions} from '../types/schema';
/**
* Guard for if a JSON schema is in fact a schema with definitions
*/
export function isSchemaWithDefinitions(schema: JSONSchema): schema is SchemaWithDefinitions {
return schema.definitions !== undefined;
}
/**
* Guard method for determining if an object (a thing) has a type property with a type of string
*/
export function isThingWithType(thing: unknown): thing is {type: string} {
return (
typeof thing === 'object' &&
thing !== null &&
'type' in thing &&
typeof (thing as {type: unknown}).type === 'string'
);
}

View File

@@ -0,0 +1,38 @@
/*
* 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 {readdirSync, statSync} from 'fs';
import {flatMap} from 'lodash';
import path from 'path';
/**
* Expand a path to a list of all files deeply contained in it
*/
export function expandPathToFilesSync(sourcePath: string, accept: (fileName: string) => boolean): string[] {
const fullPath = path.resolve(sourcePath);
const directory = statSync(fullPath);
return directory.isDirectory()
? flatMap(readdirSync(fullPath), fragment =>
expandPathToFilesSync(path.resolve(sourcePath, fragment), accept),
)
: [fullPath].filter(accept);
}
/**
* Take a Windows path and make a Unix path out of it
*/
export function toUnixPath(pathString: string): string {
return pathString.replace(/\\/g, '/');
}

View File

@@ -0,0 +1,30 @@
/*
* 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 path from 'path';
import {PathLike} from 'fs';
/**
* Convert a path to a POSIX path
*
* Replaces the system separator with posix separators
* Replaces Windows drive letters with a root '/'
*/
export function toPosixPath(pathLike: PathLike, separator = path.sep): string {
return pathLike
.toString()
.split(separator)
.join(path.posix.sep)
.replace(/^[A-z]:\//, '/');
}

View File

@@ -0,0 +1,20 @@
/*
* 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/>.
*/
/**
* Creates sentence cased string
*/
export function capitalize(string?: string): string {
return `${string?.charAt(0).toUpperCase()}${string?.slice(1).toLowerCase()}`;
}

View File

@@ -0,0 +1,311 @@
/*
* 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 {PathLike} from 'fs';
import {JSONSchema7} from 'json-schema';
import * as mustache from 'mustache';
import {Schema} from 'ts-json-schema-generator';
import {globPromisified, readFilePromisified, writeFilePromisified} from './common';
import {ExpectedValidationErrors, ValidationError, ValidationResult} from './types/validator';
import {isThingWithType} from './util/guards';
import path from 'path';
import re2 from './types/re2';
import {toPosixPath} from './util/posix-path';
/**
* StAppsCore validator
*/
export class Validator {
/**
* JSON Schema Validator
*/
private readonly ajv = new Ajv({
verbose: true,
code: {regExp: re2},
});
/**
* 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: PathLike): Promise<string[]> {
const schemaFiles = await globPromisified(path.posix.join(toPosixPath(schemaDirectory), '*.json'));
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 readFilePromisified(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,
): 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 list of files to test
const testFiles = await globPromisified(path.join(resourcesDirectory, '*.json'));
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 readFilePromisified(path.join(resourcesDirectory, 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[] = [...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> {
// eslint-disable-next-line unicorn/prefer-module
let buffer = await readFilePromisified(path.resolve(__dirname, '..', 'resources', 'file.html.mustache'));
const fileTemplate = buffer.toString();
// eslint-disable-next-line unicorn/prefer-module
buffer = await readFilePromisified(path.resolve(__dirname, '..', '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,
});
}
// eslint-disable-next-line unicorn/prefer-module
buffer = await readFilePromisified(path.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}.`);
}