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,6 +1,6 @@
{
"name": "@openstapps/openapi-generator",
"description": "Validator for @openstapps/core",
"description": "OpenAPI generator for @openstapps/core",
"version": "3.0.0",
"type": "module",
"license": "GPL-3.0-only",
@@ -13,20 +13,19 @@
"core",
"validator"
],
"main": "lib/index.js",
"types": "lib/index.d.ts",
"main": "src/index.js",
"types": "src/types.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/",
"lint": "tsc --noEmit && eslint --ext .js src/",
"lint:fix": "eslint --fix --ext .js src/",
"test": "c8 mocha"
},
"dependencies": {
@@ -50,21 +49,9 @@
"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": [

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';

View File

@@ -13,24 +13,24 @@
"core",
"validator"
],
"main": "lib/index.js",
"types": "lib/index.d.ts",
"types": "src/types.d.ts",
"main": "src/index.js",
"files": [
"lib",
"README.md",
"CHANGELOG.md"
],
"scripts": {
"build": "tsup-node --dts",
"docs": "typedoc --json ./docs/docs.json --options ../../typedoc.base.json src/index.ts",
"docs": "typedoc --json ./docs/docs.json --options ../../typedoc.base.json src/index.js",
"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/",
"lint": "tsc --noEmit && eslint --ext .js src/",
"lint:fix": "eslint --fix --ext .js src/",
"test": "c8 mocha"
},
"dependencies": {
"colorette": "2.0.20"
"colorette": "2.0.20",
"tsup": "7.2.0"
},
"devDependencies": {
"@openstapps/eslint-config": "workspace:*",
@@ -49,21 +49,9 @@
"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": [

View File

@@ -1,22 +1,17 @@
import {Options} from 'tsup';
import {writeFile, stat} from 'fs/promises';
import {bold, green} from 'colorette';
type ArrayElement<ArrayType extends readonly unknown[]> = ArrayType extends readonly (infer ElementType)[]
? ElementType
: never;
export type Plugin = ArrayElement<Required<Options>['plugins']>;
export type PluginContext = ThisParameterType<Required<Plugin>['buildEnd']>;
/**
* Utility function for generating files in a TSUp plugin
* @typedef {import('./types.js').PluginContext} PluginContext
* @typedef {import('./types.js').Plugin} Plugin
*
* @param label {string}
* @param build {(this: PluginContext) => Promise<Record<string, string | Buffer>>}
* @returns {(this: PluginContext) => Promise<void>}
*/
export function generateFiles(
label: string,
build: (this: PluginContext) => Promise<Record<string, string | Buffer>>,
) {
return async function (this: PluginContext) {
export function generateFiles(label, build) {
return async function () {
this.logger.info(label, 'Build start');
const time = performance.now();
const result = await build.call(this);

10
packages/tsup-plugin/src/types.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
import {Options} from 'tsup';
type ArrayElement<ArrayType extends readonly unknown[]> = ArrayType extends readonly (infer ElementType)[]
? ElementType
: never;
export type Plugin = ArrayElement<Required<Options>['plugins']>;
export type PluginContext = ThisParameterType<Required<Plugin>['buildEnd']>;
export {generateFiles} from './index.js';