feat: generator updates

This commit is contained in:
2023-11-07 15:23:38 +01:00
parent 4e181f881b
commit 9ef77ab3ed
14 changed files with 471 additions and 249 deletions

View File

@@ -1,17 +1,14 @@
import {getRoutes} from './routes.js';
import {openapi3Template} from './resources/template.js';
import {openapi3Template} from './template.js';
import {capitalize, generateOpenAPIForRoute} from './openapi.js';
import ts from 'typescript';
/**
* Generate OpenAPI definitions
* @param schemaName {string}
* @param program {import('typescript').Program}
* @param instances {Record<string, any>}
*/
export async function generateOpenAPI(
schemaName: string,
program: ts.Program,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
instances: Record<string, any>,
) {
export async function generateOpenAPI(schemaName, program, instances) {
const routes = await getRoutes(program, instances);
routes.sort((a, b) => a.route.instance.urlPath.localeCompare(b.route.instance.urlPath));
@@ -20,7 +17,7 @@ export async function generateOpenAPI(
routeWithMetaInformation.route.instance.urlPath =
routeWithMetaInformation.route.instance.urlPath.replaceAll(
/:\w+/g,
(match: string) => `{${match.replace(':', '')}}`,
match => `{${match.replace(':', '')}}`,
);
}

View File

@@ -0,0 +1,89 @@
/**
* Creates sentence cased string
* @param string {string | undefined}
* @returns {string}
*/
export function capitalize(string) {
return `${string?.charAt(0).toUpperCase()}${string?.slice(1).toLowerCase()}`;
}
/**
* Generate documentation snippet for one route
* @typedef {import('../types.js').RouteMeta} RouteMeta
*
* @param routeMeta {RouteMeta} A route instance with its meta information
* @param schemaName {string} Path to directory that will contain relevant schemas for the route
* @param tagsToKeep {string[]} Tags / keywords that can be used for grouping routes
* @returns {import('openapi-types').OpenAPIV3.PathItemObject}
*/
export function generateOpenAPIForRoute({route, description, tags}, schemaName, tagsToKeep) {
/** @type {(name: string) => ({$ref: string})} */
const schema = name => ({$ref: `./${schemaName}#/definitions/${name}`});
/** @type {import('openapi-types').OpenAPIV3.ResponsesObject} */
const responses = Object.fromEntries(
route.instance.errorNames
.map(RouteError => new RouteError())
.map(error => [
error.statusCode,
{
description:
error.message ?? capitalize(error.name.replaceAll(/([A-Z][a-z])/g, ' $1').replace('SC ', '')),
content: {
'application/json': {
schema: schema(error.name),
},
},
},
]),
);
/** @type {import('openapi-types').OpenAPIV3.ParameterObject[]} */
const parameters = Object.entries(route.instance.obligatoryParameters ?? {}).map(
([parameter, schemaDefinition]) => ({
in: 'path',
name: parameter,
required: true,
schema: schema(schemaDefinition),
}),
);
/** @type {import('openapi-types').OpenAPIV3.OperationObject} */
const operation = {
summary: capitalize(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,
},
...parameters,
],
responses: {
200: {
description: route.responseBodyDescription,
content: {
'application/json': {
schema: schema(route.instance.responseBodyName),
},
},
},
...responses,
},
tags: tags?.filter(value => tagsToKeep.includes(value)),
};
return {[route.instance.method.toLowerCase()]: operation};
}

View File

@@ -1,91 +0,0 @@
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

@@ -12,19 +12,25 @@
* 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
* @typedef {import('../types.js').RouteMeta} RouteMeta
* @typedef {import('../types.js').SCAbstractRoute} SCAbstractRoute
*
* @param program {ts.Program}
* @param instances {Record<string, any>}
* @returns {Promise<RouteMeta[]>}
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function getRoutes(program: ts.Program, instances: Record<string, any>) {
export async function getRoutes(program, instances) {
const checker = program.getTypeChecker();
const routes: RouteMeta[] = [];
const interfaces = new Map<string, ts.InterfaceDeclaration>();
const routeClasses: ts.ClassDeclaration[] = [];
/** @type {RouteMeta[]} */
const routes = [];
/** @type {Map<string, ts.InterfaceDeclaration | ts.TypeAliasDeclaration>} */
const interfaces = new Map();
/** @type {ts.ClassDeclaration[]} */
const routeClasses = [];
for (const sourceFile of program.getSourceFiles()) {
const sourceFileSymbol = checker.getSymbolAtLocation(sourceFile);
@@ -33,23 +39,31 @@ export async function getRoutes(program: ts.Program, instances: Record<string, a
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);
} else if (ts.isInterfaceDeclaration(declaration) || ts.isTypeAliasDeclaration(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]();
const name = ts.getNameOfDeclaration(route)?.getText() ?? '';
/** @type {SCAbstractRoute} */
const instance = new instances[name]();
const requestBodyDeclaration = interfaces.get(instance.requestBodyName);
const responseBodyDeclaration = interfaces.get(instance.responseBodyName);
if (!requestBodyDeclaration)
throw new Error(`Could not resolve ${instance.requestBodyName}. Does the type exist?`);
if (!responseBodyDeclaration)
throw new Error(`Could not resolve ${instance.responseBodyName}. Does the type exist?`);
routes.push({
name,
description: getDescription(route),
route: {
instance,
requestBodyDescription: getDescription(interfaces.get(instance.requestBodyName)!),
responseBodyDescription: getDescription(interfaces.get(instance.responseBodyName)!),
requestBodyDescription: getDescription(requestBodyDeclaration),
responseBodyDescription: getDescription(responseBodyDeclaration),
},
});
}
@@ -59,8 +73,10 @@ export async function getRoutes(program: ts.Program, instances: Record<string, a
/**
* If a declaration extends `SCAbstractRoute`
* @param declaration {ts.ClassDeclaration}
* @returns {boolean}
*/
function extendsAbstractRoute(declaration: ts.ClassDeclaration): boolean {
function extendsAbstractRoute(declaration) {
return (
declaration.heritageClauses?.some(clause =>
clause.types.some(it => it.getText() === 'SCAbstractRoute'),
@@ -70,8 +86,10 @@ function extendsAbstractRoute(declaration: ts.ClassDeclaration): boolean {
/**
* Gets the description of a declaration
* @param declaration {ts.Declaration}
* @returns {string}
*/
function getDescription(declaration: ts.Declaration) {
function getDescription(declaration) {
return ts
.getJSDocCommentsAndTags(declaration)
.filter(ts.isJSDoc)

View File

@@ -12,9 +12,8 @@
* 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 = {
/** @type {import('openapi-types').OpenAPIV3.Document} */
export const openapi3Template = {
openapi: '3.0.3',
info: {
title: 'Openstapps Backend',

View File

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

@@ -1,25 +1,27 @@
import {generateOpenAPI} from './generator/index.js';
import path from 'path';
import {generateFiles, Plugin} from '@openstapps/tsup-plugin';
import {generateFiles} 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
* @param filename {string} the name of the generated OpenAPI definitions
* @param schemaName {string} the name of the generated JSON Schema for reference in the OpenAPI file
* @returns {import('@openstapps/tsup-plugin').Plugin}
*/
export function openapiPlugin(filename: string, schemaName: string): Plugin {
export function openapiPlugin(filename, schemaName) {
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}`);
if (!this.options.tsconfig) throw new Error('No TSConfig path specified');
const rootNames = /** @type {string[]} */ (this.options.entry);
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)),
await readFile(this.options.tsconfig, 'utf8').then(it => JSON.parse(it)),
ts.sys,
projectDirectory,
);

View File

@@ -12,7 +12,10 @@
* 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';
// sneaky import here
// noinspection ES6PreferShortImport
import {SCAbstractRoute} from '../../core/src/protocol/route.js';
export {SCAbstractRoute} from '../../core/src/protocol/route.js';
export interface RouteInstanceMeta {
instance: SCAbstractRoute;
@@ -26,3 +29,5 @@ export interface RouteMeta {
route: RouteInstanceMeta;
tags?: string[];
}
export {openapiPlugin} from './index.js';