Files
iddaai-be/src/scripts/export-swagger-endpoints-summary.ts
T
2026-04-16 17:21:48 +03:00

688 lines
20 KiB
TypeScript
Executable File

import "reflect-metadata";
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import * as path from "node:path";
import ts from "typescript";
import { NestFactory } from "@nestjs/core";
import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
import { AppModule } from "../app.module";
type HttpMethod =
| "get"
| "post"
| "put"
| "patch"
| "delete"
| "options"
| "head"
| "all";
interface TsDecoratorMeta {
name: string;
firstArg?: string;
}
interface TsParameterMeta {
name: string;
type: string | null;
decorators: TsDecoratorMeta[];
}
interface TsMethodMeta {
operationId: string;
controller: string;
controllerRoute: string;
methodName: string;
httpMethod: HttpMethod;
routePath: string;
returnType: string | null;
hasPublicDecorator: boolean;
methodDecorators: string[];
params: TsParameterMeta[];
filePath: string;
}
const HTTP_DECORATOR_TO_METHOD: Record<string, HttpMethod> = {
Get: "get",
Post: "post",
Put: "put",
Patch: "patch",
Delete: "delete",
Options: "options",
Head: "head",
All: "all",
};
function getDecorators(node: ts.Node): readonly ts.Decorator[] {
return ts.canHaveDecorators(node) ? (ts.getDecorators(node) ?? []) : [];
}
function parseDecorator(
decorator: ts.Decorator,
sourceFile: ts.SourceFile,
): TsDecoratorMeta | null {
const expression = decorator.expression;
if (ts.isIdentifier(expression)) {
return { name: expression.text };
}
if (ts.isCallExpression(expression)) {
const called = expression.expression;
if (!ts.isIdentifier(called)) {
return null;
}
const firstArg = expression.arguments[0];
let firstArgText: string | undefined;
if (firstArg) {
if (
ts.isStringLiteral(firstArg) ||
ts.isNoSubstitutionTemplateLiteral(firstArg)
) {
firstArgText = firstArg.text;
} else {
firstArgText = firstArg.getText(sourceFile);
}
}
return {
name: called.text,
firstArg: firstArgText,
};
}
return null;
}
function collectControllerFiles(dirPath: string): string[] {
const files: string[] = [];
const entries = readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
const absolutePath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
files.push(...collectControllerFiles(absolutePath));
continue;
}
if (entry.isFile() && entry.name.endsWith(".controller.ts")) {
files.push(absolutePath);
}
}
return files;
}
function normalizeRoutePart(value: string | undefined): string {
if (!value || value === "''" || value === '""') {
return "";
}
return value.trim().replace(/^\/+|\/+$/g, "");
}
function buildSwaggerPath(
globalPrefix: string,
controllerRoute: string,
routePath: string,
): string {
const parts = [
normalizeRoutePart(globalPrefix),
normalizeRoutePart(controllerRoute),
normalizeRoutePart(routePath),
].filter(Boolean);
return `/${parts.join("/")}`;
}
function collectTsEndpointMetadata(
projectRoot: string,
): Map<string, TsMethodMeta> {
const modulesDir = path.join(projectRoot, "src", "modules");
const controllerFiles = collectControllerFiles(modulesDir);
const metadataByOperationId = new Map<string, TsMethodMeta>();
for (const filePath of controllerFiles) {
const sourceText = readFileSync(filePath, "utf8");
const sourceFile = ts.createSourceFile(
filePath,
sourceText,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS,
);
ts.forEachChild(sourceFile, (node) => {
if (!ts.isClassDeclaration(node) || !node.name) {
return;
}
const className = node.name.text;
const classDecorators = getDecorators(node)
.map((decorator) => parseDecorator(decorator, sourceFile))
.filter((decorator): decorator is TsDecoratorMeta =>
Boolean(decorator),
);
const controllerDecorator = classDecorators.find(
(decorator) => decorator.name === "Controller",
);
if (!controllerDecorator) {
return;
}
const controllerRoute = normalizeRoutePart(controllerDecorator.firstArg);
for (const member of node.members) {
if (!ts.isMethodDeclaration(member) || !member.name) {
continue;
}
const methodDecorators = getDecorators(member)
.map((decorator) => parseDecorator(decorator, sourceFile))
.filter((decorator): decorator is TsDecoratorMeta =>
Boolean(decorator),
);
const httpDecorator = methodDecorators.find(
(decorator) => decorator.name in HTTP_DECORATOR_TO_METHOD,
);
if (!httpDecorator) {
continue;
}
const methodName = member.name.getText(sourceFile);
const httpMethod = HTTP_DECORATOR_TO_METHOD[httpDecorator.name];
const routePath = normalizeRoutePart(httpDecorator.firstArg);
const returnType = member.type
? member.type.getText(sourceFile).trim()
: null;
const params: TsParameterMeta[] = member.parameters.map((param) => {
const paramDecorators = getDecorators(param)
.map((decorator) => parseDecorator(decorator, sourceFile))
.filter((decorator): decorator is TsDecoratorMeta =>
Boolean(decorator),
);
return {
name: param.name.getText(sourceFile),
type: param.type ? param.type.getText(sourceFile).trim() : null,
decorators: paramDecorators,
};
});
const operationId = `${className}_${methodName}`;
metadataByOperationId.set(operationId, {
operationId,
controller: className,
controllerRoute,
methodName,
httpMethod,
routePath,
returnType,
hasPublicDecorator: methodDecorators.some(
(decorator) => decorator.name === "Public",
),
methodDecorators: methodDecorators.map((decorator) => decorator.name),
params,
filePath,
});
}
});
}
return metadataByOperationId;
}
function refName(ref?: string): string | null {
if (!ref || typeof ref !== "string") {
return null;
}
const parts = ref.split("/");
return parts[parts.length - 1] ?? null;
}
function collectSchemaRefs(
value: unknown,
refs = new Set<string>(),
): Set<string> {
if (!value || typeof value !== "object") {
return refs;
}
const recordValue = value as Record<string, unknown>;
const maybeRef = recordValue.$ref;
if (typeof maybeRef === "string") {
const name = refName(maybeRef);
if (name) {
refs.add(name);
}
}
for (const nested of Object.values(recordValue)) {
collectSchemaRefs(nested, refs);
}
return refs;
}
function schemaTypeSummary(schema: unknown): string {
if (!schema || typeof schema !== "object") {
return "unknown";
}
const schemaObj = schema as Record<string, unknown>;
if (typeof schemaObj.$ref === "string") {
return refName(schemaObj.$ref) ?? "unknown";
}
const type = typeof schemaObj.type === "string" ? schemaObj.type : "object";
if (type === "array") {
return `array<${schemaTypeSummary(schemaObj.items)}>`;
}
if (Array.isArray(schemaObj.enum) && schemaObj.enum.length > 0) {
return `${type}(${schemaObj.enum.join(" | ")})`;
}
return type;
}
function normalizeParameters(parameters: unknown[] = []) {
const parsed = parameters
.map((parameter) => parameter as Record<string, unknown>)
.filter(Boolean)
.map((parameter) => {
const schema = (parameter.schema ?? {}) as Record<string, unknown>;
return {
name: typeof parameter.name === "string" ? parameter.name : "",
in: typeof parameter.in === "string" ? parameter.in : "",
required: Boolean(parameter.required),
description:
typeof parameter.description === "string"
? parameter.description
: null,
type: schemaTypeSummary(schema),
enum: Array.isArray(schema.enum) ? schema.enum : [],
default: schema.default ?? null,
format: typeof schema.format === "string" ? schema.format : null,
};
});
return {
path: parsed.filter((item) => item.in === "path"),
query: parsed.filter((item) => item.in === "query"),
header: parsed.filter((item) => item.in === "header"),
cookie: parsed.filter((item) => item.in === "cookie"),
};
}
function normalizeRequestBody(requestBody: unknown) {
if (!requestBody || typeof requestBody !== "object") {
return null;
}
const requestBodyObj = requestBody as Record<string, unknown>;
if (typeof requestBodyObj.$ref === "string") {
return {
required: false,
contentTypes: [],
schemaTypes: [],
schemaRefs: [refName(requestBodyObj.$ref)].filter(Boolean),
raw: requestBodyObj,
};
}
const content = (requestBodyObj.content ?? {}) as Record<
string,
Record<string, unknown>
>;
const contentTypes = Object.keys(content);
const schemaTypes: string[] = [];
const refs = new Set<string>();
for (const mediaType of Object.values(content)) {
const schema = mediaType.schema;
schemaTypes.push(schemaTypeSummary(schema));
collectSchemaRefs(schema, refs);
}
return {
required: Boolean(requestBodyObj.required),
contentTypes,
schemaTypes,
schemaRefs: [...refs].sort(),
raw: requestBodyObj,
};
}
function normalizeResponses(responses: Record<string, unknown>) {
return Object.entries(responses)
.sort((a, b) => Number(a[0]) - Number(b[0]))
.map(([statusCode, response]) => {
const responseObj = response as Record<string, unknown>;
const content = (responseObj.content ?? {}) as Record<
string,
Record<string, unknown>
>;
const contentTypes = Object.keys(content);
const refs = new Set<string>();
const schemaTypes: string[] = [];
for (const mediaType of Object.values(content)) {
const schema = mediaType.schema;
schemaTypes.push(schemaTypeSummary(schema));
collectSchemaRefs(schema, refs);
}
return {
status: Number(statusCode),
description:
typeof responseObj.description === "string"
? responseObj.description
: "",
contentTypes,
schemaTypes,
schemaRefs: [...refs].sort(),
hasSchema: contentTypes.length > 0,
raw: responseObj,
};
});
}
async function run() {
const projectRoot = process.cwd();
const outputDir = path.join(projectRoot, "mds");
const outputFile = path.join(
outputDir,
"backend_endpoints_swagger_summary.json",
);
// Predictions module is conditionally loaded with REDIS_ENABLED in AppModule.
// Force-enable here to include all backend endpoints in one Swagger export.
process.env.REDIS_ENABLED = "true";
const tsMetadata = collectTsEndpointMetadata(projectRoot);
const app = await NestFactory.create(AppModule, { logger: false });
app.setGlobalPrefix("api");
const swaggerConfig = new DocumentBuilder()
.setTitle("Suggest Bet Backend API")
.setDescription("Auto-generated endpoint summary from Swagger document")
.setVersion("1.0")
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, swaggerConfig);
const paths = document.paths ?? {};
const endpoints: Array<Record<string, unknown>> = [];
const seenOperationIds = new Set<string>();
const globalPrefix = "api";
const sortedPaths = Object.keys(paths).sort((a, b) => a.localeCompare(b));
for (const endpointPath of sortedPaths) {
const pathItem = paths[endpointPath] as Record<string, unknown>;
const methods = Object.keys(pathItem)
.filter((method) =>
["get", "post", "put", "patch", "delete", "options", "head"].includes(
method,
),
)
.sort((a, b) => a.localeCompare(b));
for (const method of methods) {
const operation = pathItem[method] as Record<string, unknown>;
const operationId =
typeof operation.operationId === "string" ? operation.operationId : "";
if (operationId) {
seenOperationIds.add(operationId);
}
const tsMeta = operationId ? tsMetadata.get(operationId) : undefined;
const tags = Array.isArray(operation.tags)
? operation.tags.map((tag) => String(tag))
: [];
const parameters = normalizeParameters(
Array.isArray(operation.parameters) ? operation.parameters : [],
);
const requestBody = normalizeRequestBody(operation.requestBody);
const responses = normalizeResponses(
(operation.responses ?? {}) as Record<string, unknown>,
);
const security = Array.isArray(operation.security)
? operation.security
: [];
const securitySchemes = security.flatMap((rule) =>
Object.keys((rule ?? {}) as Record<string, unknown>),
);
const tsBodyParams =
tsMeta?.params
.filter((param) =>
param.decorators.some((decorator) => decorator.name === "Body"),
)
.map((param) => ({
name: param.name,
type: param.type,
bodyKey:
param.decorators.find((decorator) => decorator.name === "Body")
?.firstArg ?? null,
})) ?? [];
endpoints.push({
inSwagger: true,
operationId,
method: method.toUpperCase(),
path: endpointPath,
tag: tags[0] ?? null,
tags,
summary:
typeof operation.summary === "string" ? operation.summary : null,
description:
typeof operation.description === "string"
? operation.description
: null,
auth: {
swaggerSecurityRequired: security.length > 0,
swaggerSecuritySchemes: [...new Set(securitySchemes)].sort(),
hasPublicDecorator: tsMeta?.hasPublicDecorator ?? false,
},
request: {
parameters,
body: requestBody,
tsBodyParams,
},
response: {
tsReturnType: tsMeta?.returnType ?? null,
statuses: responses,
},
source: tsMeta
? {
controller: tsMeta.controller,
methodName: tsMeta.methodName,
filePath: path.relative(projectRoot, tsMeta.filePath),
}
: null,
});
}
}
// Add controller methods that are not present in Swagger document.
for (const [operationId, tsMeta] of tsMetadata.entries()) {
if (seenOperationIds.has(operationId)) {
continue;
}
endpoints.push({
inSwagger: false,
operationId,
method: tsMeta.httpMethod.toUpperCase(),
path: buildSwaggerPath(
globalPrefix,
tsMeta.controllerRoute,
tsMeta.routePath,
),
tag: tsMeta.controller.replace(/Controller$/, ""),
tags: [tsMeta.controller.replace(/Controller$/, "")],
summary: null,
description: "Not present in generated Swagger document",
auth: {
swaggerSecurityRequired: null,
swaggerSecuritySchemes: [],
hasPublicDecorator: tsMeta.hasPublicDecorator,
},
request: {
parameters: {
path: [],
query: [],
header: [],
cookie: [],
},
body: null,
tsBodyParams: tsMeta.params
.filter((param) =>
param.decorators.some((decorator) => decorator.name === "Body"),
)
.map((param) => ({
name: param.name,
type: param.type,
bodyKey:
param.decorators.find((decorator) => decorator.name === "Body")
?.firstArg ?? null,
})),
},
response: {
tsReturnType: tsMeta.returnType,
statuses: [],
},
source: {
controller: tsMeta.controller,
methodName: tsMeta.methodName,
filePath: path.relative(projectRoot, tsMeta.filePath),
},
});
}
endpoints.sort((a, b) => {
const pathA = typeof a.path === "string" ? a.path : "";
const pathB = typeof b.path === "string" ? b.path : "";
if (pathA !== pathB) {
return pathA.localeCompare(pathB);
}
return (typeof a.method === "string" ? a.method : "").localeCompare(
typeof b.method === "string" ? b.method : "",
);
});
const tagStats = new Map<string, number>();
for (const endpoint of endpoints) {
const tag = typeof endpoint.tag === "string" ? endpoint.tag : "Unknown";
tagStats.set(tag, (tagStats.get(tag) ?? 0) + 1);
}
const referencedSchemas = new Set<string>();
for (const endpoint of endpoints) {
const requestBody = (endpoint.request as Record<string, unknown>)
.body as Record<string, unknown> | null;
if (requestBody && Array.isArray(requestBody.schemaRefs)) {
for (const schemaName of requestBody.schemaRefs) {
if (typeof schemaName === "string") {
referencedSchemas.add(schemaName);
}
}
}
const statuses = (endpoint.response as Record<string, unknown>)
.statuses as Array<Record<string, unknown>>;
for (const status of statuses ?? []) {
if (!Array.isArray(status.schemaRefs)) {
continue;
}
for (const schemaName of status.schemaRefs) {
if (typeof schemaName === "string") {
referencedSchemas.add(schemaName);
}
}
}
}
const allSchemas = (document.components?.schemas ?? {}) as Record<
string,
unknown
>;
const schemaSnapshots: Record<string, unknown> = {};
for (const schemaName of [...referencedSchemas].sort((a, b) =>
a.localeCompare(b),
)) {
if (allSchemas[schemaName]) {
schemaSnapshots[schemaName] = allSchemas[schemaName];
}
}
const summary = {
generatedAt: new Date().toISOString(),
generatedBy: "src/scripts/export-swagger-endpoints-summary.ts",
project: "Suggest-Bet-BE",
swagger: {
docsPath: "/api/docs",
globalPrefix: "/api",
endpointCountInSwagger: endpoints.filter((item) => item.inSwagger).length,
endpointCountTotal: endpoints.length,
warnings: [
"Swagger output reflects loaded modules for current environment.",
"This export forces REDIS_ENABLED=true to include conditional Prediction endpoints.",
],
},
stats: {
byTag: [...tagStats.entries()]
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([tag, count]) => ({ tag, count })),
endpointsWithoutSummary: endpoints
.filter((endpoint) => !endpoint.summary)
.map((endpoint) => ({
operationId: endpoint.operationId,
method: endpoint.method,
path: endpoint.path,
})),
endpointsWithoutResponseSchema: endpoints
.filter((endpoint) =>
(
(endpoint.response as Record<string, unknown>).statuses as Array<
Record<string, unknown>
>
).some((status) => status.hasSchema === false),
)
.map((endpoint) => ({
operationId: endpoint.operationId,
method: endpoint.method,
path: endpoint.path,
})),
},
endpoints,
referencedSchemas: schemaSnapshots,
};
mkdirSync(outputDir, { recursive: true });
writeFileSync(outputFile, JSON.stringify(summary, null, 2), "utf8");
await app.close();
console.log(`✅ Swagger endpoint summary exported: ${outputFile}`);
console.log(
` Endpoints in swagger: ${summary.swagger.endpointCountInSwagger}, total (with TS scan): ${summary.swagger.endpointCountTotal}`,
);
}
void run().catch((error: unknown) => {
console.error("❌ Failed to export Swagger endpoint summary");
console.error(error);
process.exit(1);
});