mirror of
https://gitlab.com/openstapps/openstapps.git
synced 2026-01-19 16:13:06 +00:00
feat: generator updates
This commit is contained in:
@@ -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(':', '')}}`,
|
||||
);
|
||||
}
|
||||
|
||||
89
packages/openapi-generator/src/generator/openapi.js
Normal file
89
packages/openapi-generator/src/generator/openapi.js
Normal 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};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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',
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user