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 = { 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 { const modulesDir = path.join(projectRoot, "src", "modules"); const controllerFiles = collectControllerFiles(modulesDir); const metadataByOperationId = new Map(); 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(), ): Set { if (!value || typeof value !== "object") { return refs; } const recordValue = value as Record; 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; 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) .filter(Boolean) .map((parameter) => { const schema = (parameter.schema ?? {}) as Record; 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; 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 >; const contentTypes = Object.keys(content); const schemaTypes: string[] = []; const refs = new Set(); 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) { return Object.entries(responses) .sort((a, b) => Number(a[0]) - Number(b[0])) .map(([statusCode, response]) => { const responseObj = response as Record; const content = (responseObj.content ?? {}) as Record< string, Record >; const contentTypes = Object.keys(content); const refs = new Set(); 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> = []; const seenOperationIds = new Set(); 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; 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; 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, ); const security = Array.isArray(operation.security) ? operation.security : []; const securitySchemes = security.flatMap((rule) => Object.keys((rule ?? {}) as Record), ); 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(); 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(); for (const endpoint of endpoints) { const requestBody = (endpoint.request as Record) .body as Record | 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) .statuses as Array>; 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 = {}; 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).statuses as Array< Record > ).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); });