feat: improve monorepo dev experience

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

View File

@@ -0,0 +1,77 @@
{
"name": "@openstapps/openapi-generator",
"description": "Validator for @openstapps/core",
"version": "3.0.0",
"type": "module",
"license": "GPL-3.0-only",
"repository": "git@gitlab.com:openstapps/openstapps.git",
"author": "Thea Schöbl <dev@theaninova.de>",
"keywords": [
"StApps",
"StAppsCore",
"converter",
"core",
"validator"
],
"main": "lib/index.js",
"types": "lib/index.d.ts",
"files": [
"lib",
"README.md",
"CHANGELOG.md"
],
"scripts": {
"build": "tsup-node --dts",
"docs": "typedoc --json ./docs/docs.json --options ../../typedoc.base.json src/generator/index.ts",
"format": "prettier . -c --ignore-path ../../.gitignore",
"format:fix": "prettier --write . --ignore-path ../../.gitignore",
"lint": "eslint --ext .ts src/",
"lint:fix": "eslint --fix --ext .ts src/",
"test": "c8 mocha"
},
"dependencies": {
"@openstapps/tsup-plugin": "workspace:*",
"openapi-types": "12.1.3"
},
"devDependencies": {
"@openstapps/eslint-config": "workspace:*",
"@openstapps/prettier-config": "workspace:*",
"@openstapps/tsconfig": "workspace:*",
"@types/chai": "4.3.5",
"@types/fs-extra": "9.0.13",
"@types/glob": "8.0.1",
"@types/json-schema": "7.0.14",
"@types/mocha": "10.0.1",
"@types/mustache": "4.2.2",
"@types/node": "18.15.3",
"c8": "7.14.0",
"chai": "4.3.7",
"esbuild": "0.17.19",
"mocha": "10.2.0",
"mocha-junit-reporter": "2.2.0",
"nock": "13.3.1",
"ts-node": "10.9.1",
"tsup": "6.7.0",
"typedoc": "0.24.8",
"typescript": "5.1.6"
},
"tsup": {
"entry": [
"src/app.ts",
"src/index.ts"
],
"sourcemap": true,
"clean": true,
"format": "esm",
"outDir": "lib"
},
"prettier": "@openstapps/prettier-config",
"eslintConfig": {
"extends": [
"@openstapps"
]
},
"eslintIgnore": [
"resources"
]
}

View File

@@ -0,0 +1,49 @@
import {getRoutes} from './routes.js';
import {openapi3Template} from './resources/template.js';
import {capitalize, generateOpenAPIForRoute} from './openapi.js';
import ts from 'typescript';
/**
* Generate OpenAPI definitions
*/
export async function generateOpenAPI(
schemaName: string,
program: ts.Program,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
instances: Record<string, any>,
) {
const routes = await getRoutes(program, instances);
routes.sort((a, b) => a.route.instance.urlPath.localeCompare(b.route.instance.urlPath));
// change url path parameters to openapi notation
for (const routeWithMetaInformation of routes) {
routeWithMetaInformation.route.instance.urlPath =
routeWithMetaInformation.route.instance.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.instance.urlPath.split('/')[1]),
);
tagsToKeep = tagsToKeep.filter(
(element, i, array) => array.indexOf(element) === i && array.lastIndexOf(element) !== i,
);
const output = openapi3Template;
for (const routeWithMetaInformation of routes) {
routeWithMetaInformation.tags = [
capitalize(routeWithMetaInformation.route.instance.urlPath.split('/')[1]),
];
output.paths[routeWithMetaInformation.route.instance.urlPath] = generateOpenAPIForRoute(
routeWithMetaInformation,
schemaName,
tagsToKeep,
);
}
return output;
}

View File

@@ -0,0 +1,91 @@
import {RouteMeta} from './types/route-meta.js';
import {OpenAPIV3} from 'openapi-types';
/**
* Creates sentence cased string
*/
export function capitalize(string?: string): string {
return `${string?.charAt(0).toUpperCase()}${string?.slice(1).toLowerCase()}`;
}
/**
* Generate documentation snippet for one route
* @param routeMeta A route instance with its meta information
* @param schemaName 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(
routeMeta: RouteMeta,
schemaName: string,
tagsToKeep: string[],
): OpenAPIV3.PathItemObject {
const route = routeMeta.route;
const openapiPath: OpenAPIV3.PathItemObject = {};
const schema = (name: string) => ({$ref: `./${schemaName}#/definitions/${name}`});
openapiPath[route.instance.method.toLowerCase() as OpenAPIV3.HttpMethods] = {
summary: capitalize(routeMeta.description?.replace(/(Route to |Route for )/gim, '')),
requestBody: {
description: route.responseBodyDescription ?? undefined,
content: {
'application/json': {
schema: schema(route.instance.requestBodyName),
},
},
},
parameters: [
{
name: 'X-StApps-Version',
in: 'header',
schema: {
type: 'string',
example: '2.0.0',
},
required: true,
},
],
responses: {},
tags: routeMeta.tags?.filter(value => tagsToKeep.includes(value)),
};
openapiPath[route.instance.method.toLowerCase() as OpenAPIV3.HttpMethods]!.responses![
route.instance.statusCodeSuccess
] = {
description: route.responseBodyDescription,
content: {
'application/json': {
schema: schema(route.instance.responseBodyName),
},
},
};
for (const RouteError of route.instance.errorNames) {
const error = new RouteError();
openapiPath[route.instance.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: schema(RouteError.name),
},
},
};
}
if (typeof route.instance.obligatoryParameters === 'object') {
for (const [parameter, schemaDefinition] of Object.entries(route.instance.obligatoryParameters)) {
const openapiParameter: OpenAPIV3.ParameterObject = {
in: 'path',
name: parameter,
required: true,
schema: schema(schemaDefinition),
};
openapiPath[route.instance.method.toLowerCase() as OpenAPIV3.HttpMethods]?.parameters?.push(
openapiParameter,
);
}
}
return openapiPath;
}

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,80 @@
/*
* Copyright (C) 2018-2023 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 {RouteMeta} from './types/route-meta.js';
import ts from 'typescript';
import {SCAbstractRoute} from '../../../core/lib/index.js';
/**
* Get all routes
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function getRoutes(program: ts.Program, instances: Record<string, any>) {
const checker = program.getTypeChecker();
const routes: RouteMeta[] = [];
const interfaces = new Map<string, ts.InterfaceDeclaration>();
const routeClasses: ts.ClassDeclaration[] = [];
for (const sourceFile of program.getSourceFiles()) {
const sourceFileSymbol = checker.getSymbolAtLocation(sourceFile);
if (!sourceFileSymbol) continue;
for (const exportSymbol of checker.getExportsOfModule(sourceFileSymbol)) {
for (const declaration of exportSymbol.getDeclarations() ?? []) {
if (ts.isClassDeclaration(declaration) && extendsAbstractRoute(declaration)) {
routeClasses.push(declaration);
} else if (ts.isInterfaceDeclaration(declaration)) {
interfaces.set(ts.getNameOfDeclaration(declaration)!.getText(), declaration);
}
}
}
}
for (const route of routeClasses) {
const name = ts.getNameOfDeclaration(route)!.getText();
const instance: SCAbstractRoute = new instances[name]();
routes.push({
name,
description: getDescription(route),
route: {
instance,
requestBodyDescription: getDescription(interfaces.get(instance.requestBodyName)!),
responseBodyDescription: getDescription(interfaces.get(instance.responseBodyName)!),
},
});
}
return routes;
}
/**
* If a declaration extends `SCAbstractRoute`
*/
function extendsAbstractRoute(declaration: ts.ClassDeclaration): boolean {
return (
declaration.heritageClauses?.some(clause =>
clause.types.some(it => it.getText() === 'SCAbstractRoute'),
) === true
);
}
/**
* Gets the description of a declaration
*/
function getDescription(declaration: ts.Declaration) {
return ts
.getJSDocCommentsAndTags(declaration)
.filter(ts.isJSDoc)
.map(it => ts.getTextOfJSDocComment(it.comment))
.join('');
}

View File

@@ -0,0 +1,17 @@
/*
* Copyright (C) 2018-2023 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/>.
*/
// sneaky import here
// noinspection ES6PreferShortImport
export type {SCAbstractRoute} from '../../../../core/src/protocol/route.js';

View File

@@ -0,0 +1,28 @@
/*
* Copyright (C) 2018-2023 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 {SCAbstractRoute} from '../../../../core/src/index.js';
export interface RouteInstanceMeta {
instance: SCAbstractRoute;
requestBodyDescription: string;
responseBodyDescription: string;
}
export interface RouteMeta {
name: string;
description?: string;
route: RouteInstanceMeta;
tags?: string[];
}

View File

@@ -0,0 +1,42 @@
import {generateOpenAPI} from './generator/index.js';
import path from 'path';
import {generateFiles, Plugin} from '@openstapps/tsup-plugin';
import ts from 'typescript';
import {readFile} from 'fs/promises';
/**
* TSUp plugin for generating OpenAPI files
* @param filename the name of the generated OpenAPI definitions
* @param schemaName the name of the generated JSON Schema for reference in the OpenAPI file
*/
export function openapiPlugin(filename: string, schemaName: string): Plugin {
return {
name: 'openapi-generator',
async buildEnd({writtenFiles}) {
await generateFiles('OpenAPI', async function () {
const rootNames = this.options.entry as string[];
const projectDirectory = path.dirname(this.options.tsconfig!);
const generatedFile = writtenFiles.find(it => it.name.endsWith('.js'))!;
const instances = await import(`${projectDirectory}/${generatedFile.name}`);
const tsconfig = ts.parseJsonConfigFileContent(
await readFile(this.options.tsconfig!, 'utf8').then(it => JSON.parse(it)),
ts.sys,
projectDirectory,
);
const program = ts.createProgram({
options: tsconfig.options,
rootNames: rootNames.map(it => `${projectDirectory}/${it}`),
});
const file = await generateOpenAPI(schemaName, program, instances);
if (file.paths['/search'] === undefined) {
this.logger.log('OpenAPI', 'error', 'OpenAPI /search route missing, this is likely an error');
throw new Error('Missing /search route');
}
return {
[filename]: JSON.stringify(file),
};
}).call(this);
},
};
}

View File

@@ -0,0 +1,3 @@
{
"extends": "@openstapps/tsconfig"
}