688 lines
20 KiB
TypeScript
Executable File
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);
|
|
});
|