Skip to content

Commit

Permalink
Add swagger decorator solution
Browse files Browse the repository at this point in the history
  • Loading branch information
brunotot committed Apr 17, 2024
1 parent 9df0fd0 commit 4c583cb
Show file tree
Hide file tree
Showing 19 changed files with 508 additions and 67 deletions.
10 changes: 4 additions & 6 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,12 @@
"outputCapture": "std",
"console": "integratedTerminal",
"stopOnEntry": false,
"autoAttachChildProcesses": true,
//"internalConsoleOptions": "openOnSessionStart",

"runtimeVersion": "21.7.0",
"runtimeArgs": [
"--no-warnings",
"--loader",
"ts-node/esm",
"--experimental-specifier-resolution=node"
],
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "backend:start"],
"presentation": { "group": "1" }
},
{
Expand Down
3 changes: 2 additions & 1 deletion packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"scripts": {
"build": "rm -rf dist && npm run compile:ts && cp package.json dist/backend",
"compile:ts": "tsc && npm run tsc-alias",
"start": "node --no-warnings --loader ts-node/esm --experimental-specifier-resolution=node ./dist/backend/src/main.js",
"start": "export PACKAGE_JSON_VERSION=$(grep -o '\"version\": *\"[^\"]*\"' package.json | awk -F'\"' '{print $4}') && node --no-warnings --loader ts-node/esm --experimental-specifier-resolution=node ./dist/backend/src/main.js",
"tsc-alias": "tsc --project tsconfig.json && tsc-alias -p tsconfig.json"
},
"keywords": [],
Expand All @@ -20,6 +20,7 @@
"bcrypt": "^5.1.1",
"bottlejs": "^2.0.1",
"express": "^4.18.2",
"http-status": "^1.7.4",
"jsonwebtoken": "^9.0.2",
"mongoose": "^8.2.0",
"swagger-jsdoc": "^6.2.8",
Expand Down
31 changes: 21 additions & 10 deletions packages/backend/src/App.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,8 @@ import hpp from "hpp";
import { connect, set } from "mongoose";
import morgan from "morgan";
import swaggerUi from "swagger-ui-express";
import { $BackendAppConfig } from "./config/BackendAppConfig";
import { logger, stream } from "./config/logger/logger";
import { swaggerSpec } from "./config/swagger";
import { $BackendAppConfig, startupLog, stream } from "./config";
import { $SwaggerManager } from "./config/swagger";
import { getInjectionClasses } from "./decorators/Injectable";
import { RoutesMetaService } from "./meta/RoutesMetaService";
import { withCredentials } from "./middleware/withCredentials";
Expand All @@ -19,11 +18,15 @@ export class App {
public app: express.Application;
public env: string;
public port: string;
public swaggerPath: string;
public url: string;

constructor() {
this.app = express();
this.env = $BackendAppConfig.env.NODE_ENV;
this.port = $BackendAppConfig.env.PORT;
this.swaggerPath = "api-docs";
this.url = `http://localhost:${this.port}`;

this.databaseConnect();
this.initializeMiddlewares();
Expand All @@ -34,10 +37,14 @@ export class App {

public listen() {
this.app.listen(this.port, () => {
logger.info(`=================================`);
logger.info(`======= ENV: ${this.env} =======`);
logger.info(`🚀 App listening on the port ${this.port}`);
logger.info(`=================================`);
startupLog({
title: "Express app started!",
data: {
"🏠 Env": this.env,
"🚀 App": this.url,
"📝 Swagger": `${this.url}/${this.swaggerPath}`,
},
});
});
}

Expand Down Expand Up @@ -89,12 +96,16 @@ export class App {
}

private initializeSwagger() {
this.app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec));
this.app.get("/api-docs.json", (req, res) => {
const swaggerSpec = $SwaggerManager.buildSpec();
this.app.use(
`/${this.swaggerPath}`,
swaggerUi.serve,
swaggerUi.setup(swaggerSpec)
);
this.app.get(`/${this.swaggerPath}.json`, (_req, res) => {
res.setHeader("Content-Type", "application/json");
res.send(swaggerSpec);
});
logger.info(`Docs available at http://localhost:${this.port}/api-docs`);
}

/*private initializeErrorHandling() {
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/config/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
// BackendAppConfig must be imported first in order to be used in other files.
export * from "./BackendAppConfig";
export * from "./swagger";

// Variables used in BackendAppConfig come second.
export * from "./vars";

export * from "./ioc";
export * from "./logger";
export * from "./swagger";
52 changes: 51 additions & 1 deletion packages/backend/src/config/logger/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,54 @@ const stream = {
},
};

export { logger, stream };
type StartupLogProps = {
title: string;
data: Record<string, string>;
padding?: number;
kvSeparator?: string;
};

function startupLog({
title,
data,
kvSeparator = " : ",
padding = 2,
}: StartupLogProps) {
const center = (text: string, length: number) => {
const remainingSpace = length - text.length;
const leftBorderCount = Math.floor(remainingSpace / 2);
const rightBorderCount = remainingSpace - leftBorderCount;
const left = " ".repeat(leftBorderCount);
const right = " ".repeat(rightBorderCount);
return `${left}${text}${right}`;
};

const spacer = " ".repeat(padding);
const hrY = kvSeparator;

const keyValueLengths = Object.entries(data).map(
([key, value]) => key.length + hrY.length + value.length
);
const containerWidth =
Math.max(title.length, ...keyValueLengths) + padding * 2;
const maxKeyLength = Math.max(...Object.keys(data).map((key) => key.length));

const hrX = `${"-".repeat(containerWidth)}`;

const content = Object.entries(data).map(([key, value]) => {
const keyPadding = " ".repeat(maxKeyLength - key.length);
const text = `${key}${keyPadding}${hrY}${value}`;
const remainder = " ".repeat(
containerWidth - text.length - spacer.length * 2
);
return `|${spacer}${text}${remainder}${spacer}|`;
});

logger.info(`┌${hrX}┐`);
logger.info(`|${center(title, containerWidth)}|`);
logger.info(`├${hrX}┤`);
content.forEach((text) => logger.info(text));
logger.info(`└${hrX}┘`);
}

export { StartupLogProps, logger, startupLog, stream };
1 change: 1 addition & 0 deletions packages/backend/src/config/swagger/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./swagger";
export * from "./types";
110 changes: 91 additions & 19 deletions packages/backend/src/config/swagger/swagger.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,99 @@
import { Class } from "@org/shared";
import swaggerJsdoc from "swagger-jsdoc";
//import PackageJson from "./../../../package.json";
import { $BackendAppConfig } from "../BackendAppConfig";
import { RoutesMetaService } from "./../../meta";
import {
SwaggerDefinition,
SwaggerPath,
SwaggerRequestMapping,
SwaggerTag,
} from "./types";

const packageRoot = (path: string) => `packages/backend/${path}`;

const options: swaggerJsdoc.Options = {
definition: {
openapi: "3.0.0",
info: {
title: "REST API",
version: "0.0.1",
description: "This is a dynamically generated Swagger API documentation",
const DEFAULT_SWAGGER_DEFINITION: SwaggerDefinition = {
openapi: "3.0.0",
info: {
title: "REST API",
license: {
name: "MIT",
url: "https://spdx.org/licenses/MIT.html",
},
termsOfService: "http://swagger.io/terms/",
contact: {
email: "",
name: "",
url: "",
},
components: {
securitySchemas: {
bearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
},
version: $BackendAppConfig.env.PACKAGE_JSON_VERSION,
description: "This is a dynamically generated Swagger API documentation",
},
components: {
securitySchemas: {
bearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
},
},
security: [{ bearerAuth: [] }],
},
apis: [packageRoot("src/controllers/*.ts")],
security: [{ bearerAuth: [] }],
tags: [],
paths: {},
};

export const swaggerSpec = swaggerJsdoc(options);
export class SwaggerManager {
#definition: SwaggerDefinition;
#controllerClasses: Class[];

get definition() {
return this.#definition;
}

get controllerClasses() {
return this.#controllerClasses;
}

get tags() {
return this.#definition.tags;
}

constructor() {
this.#definition = DEFAULT_SWAGGER_DEFINITION;
this.#controllerClasses = [];
}

registerTag(tagData: SwaggerTag & { constructor: Class }) {
if (this.tags.some((t) => t.name === tagData.name)) return;
const { constructor, ...tag } = tagData;
this.controllerClasses.push(constructor);
this.tags.push(tag);
}

registerPath(
path: string,
requestMapping: SwaggerRequestMapping,
data: SwaggerPath
) {
if (!this.definition.paths[path]) this.definition.paths[path] = {};
this.definition.paths[path][requestMapping] = data;
}

#registerPaths() {
$SwaggerManager.controllerClasses.forEach((controllerClass) => {
const meta = RoutesMetaService.from(controllerClass).value;
meta.routes.forEach((route) => {
const fullPath = `${meta.basePath}${route.path}`;
const swagger = route.swagger ?? {};
swagger.tags = [String(controllerClass.name)];
$SwaggerManager.registerPath(fullPath, route.method, swagger);
});
});
}

buildSpec(): object {
this.#registerPaths();
return swaggerJsdoc({ definition: this.definition, apis: [] });
}
}

export const $SwaggerManager = new SwaggerManager();
100 changes: 100 additions & 0 deletions packages/backend/src/config/swagger/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import HttpStatus from "http-status";
import swaggerJsdoc from "swagger-jsdoc";

type HttpStatusConverter<T> = {
[K in keyof T]: T[K] extends number ? T[K] : never;
};

type HttpStatusMain = Purify<HttpStatusConverter<typeof HttpStatus>>;

type HttpStatusNumeric = Values<HttpStatusMain>;

/**
* A type that extracts the values from the properties of an object type `T`.
* @typeParam T - An object type.
*/
export type Values<T> = T[keyof T];

/**
* A type that excludes properties with values of type `TExclude` from `TParent`.
* @typeParam TParent - The parent type.
* @typeParam TExclude - The type to exclude from `TParent`.
*/
export type Exclude<TParent, TExclude> = Pick<
TParent,
Values<{
[Prop in keyof TParent]: [TParent[Prop]] extends [TExclude] ? never : Prop;
}>
>;

/**
* A type that removes properties with values of type `never` from `T`.
* @typeParam T - The type to purify.
*/
export type Purify<T> = Exclude<T, never>;

export type SwaggerExternalDocs = {
description: string;
url: string;
};

export type SwaggerTag = {
name: string;
description?: string;
externalDocs?: SwaggerExternalDocs;
};

export type SwaggerPath = {
tags?: string[];
summary?: string;
description?: string;
operationId?: string;
requestBody?: SwaggerRequestBody;
responses?: SwaggerResponse;
};

export type SwaggerPaths = Record<
string,
Partial<Record<SwaggerRequestMapping, SwaggerPath>>
>;

export type SwaggerRequestMapping =
| "put"
| "get"
| "post"
| "delete"
| "patch"
| "options"
| "head";

export type SwaggerResponse = Partial<
Record<
HttpStatusNumeric,
{
description?: string;
content?: SwaggerRequestContent;
}
>
>;

export type SwaggerEntitySchema = {
$ref?: string;
type?: string;
format?: string;
};

export type SwaggerRequestContent = Record<
string,
{ schema: SwaggerEntitySchema }
>;

export type SwaggerRequestBody = {
description?: string;
content?: SwaggerRequestContent;
required?: boolean;
};

export type SwaggerDefinition = swaggerJsdoc.SwaggerDefinition & {
tags: SwaggerTag[];
paths: SwaggerPaths;
};
Loading

0 comments on commit 4c583cb

Please sign in to comment.