mirror of
https://gitlab.com/openstapps/openstapps.git
synced 2026-01-20 08:33:11 +00:00
feat: improve monorepo dev experience
This commit is contained in:
49
packages/openapi-generator/src/generator/index.ts
Normal file
49
packages/openapi-generator/src/generator/index.ts
Normal 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;
|
||||
}
|
||||
91
packages/openapi-generator/src/generator/openapi.ts
Normal file
91
packages/openapi-generator/src/generator/openapi.ts
Normal 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;
|
||||
}
|
||||
@@ -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: {},
|
||||
};
|
||||
80
packages/openapi-generator/src/generator/routes.ts
Normal file
80
packages/openapi-generator/src/generator/routes.ts
Normal 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('');
|
||||
}
|
||||
@@ -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';
|
||||
28
packages/openapi-generator/src/generator/types/route-meta.ts
Normal file
28
packages/openapi-generator/src/generator/types/route-meta.ts
Normal 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[];
|
||||
}
|
||||
42
packages/openapi-generator/src/index.ts
Normal file
42
packages/openapi-generator/src/index.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user