wip league admin tools
This commit is contained in:
@@ -38,15 +38,34 @@ interface OpenAPISpec {
|
||||
};
|
||||
}
|
||||
|
||||
function getCliArgValue(flag: string): string | undefined {
|
||||
const index = process.argv.indexOf(flag);
|
||||
if (index === -1) return undefined;
|
||||
return process.argv[index + 1];
|
||||
}
|
||||
|
||||
function resolveOutputPath(): string {
|
||||
const cliOutput = getCliArgValue('--output') ?? getCliArgValue('-o');
|
||||
const envOutput = process.env.OPENAPI_OUTPUT_PATH;
|
||||
const configured = cliOutput ?? envOutput;
|
||||
|
||||
if (!configured) {
|
||||
return path.join(process.cwd(), 'apps/api/openapi.json');
|
||||
}
|
||||
|
||||
return path.resolve(process.cwd(), configured);
|
||||
}
|
||||
|
||||
async function generateSpec() {
|
||||
console.log('🔄 Generating OpenAPI spec from DTO files...');
|
||||
|
||||
const schemas: Record<string, OpenAPISchema> = {};
|
||||
|
||||
// Find all DTO files
|
||||
// Find all DTO files (sorted for deterministic output)
|
||||
const dtoFiles = await glob('apps/api/src/domain/*/dtos/**/*.ts', {
|
||||
cwd: process.cwd()
|
||||
});
|
||||
dtoFiles.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
console.log(`📁 Found ${dtoFiles.length} DTO files to process`);
|
||||
|
||||
@@ -58,6 +77,8 @@ async function generateSpec() {
|
||||
}
|
||||
}
|
||||
|
||||
const paths = await extractPathsFromControllers();
|
||||
|
||||
const spec: OpenAPISpec = {
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
@@ -65,17 +86,73 @@ async function generateSpec() {
|
||||
description: 'GridPilot API documentation',
|
||||
version: '1.0.0'
|
||||
},
|
||||
paths: {},
|
||||
paths,
|
||||
components: {
|
||||
schemas
|
||||
schemas: sortRecordKeys(schemas)
|
||||
}
|
||||
};
|
||||
|
||||
const outputPath = path.join(process.cwd(), 'apps/api/openapi.json');
|
||||
const outputPath = resolveOutputPath();
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await fs.writeFile(outputPath, JSON.stringify(spec, null, 2));
|
||||
console.log(`✅ OpenAPI spec generated with ${Object.keys(schemas).length} schemas at: ${outputPath}`);
|
||||
}
|
||||
|
||||
function sortRecordKeys<T>(record: Record<string, T>): Record<string, T> {
|
||||
return Object.fromEntries(Object.entries(record).sort(([a], [b]) => a.localeCompare(b)));
|
||||
}
|
||||
|
||||
function joinRouteParts(controllerPath: string, methodPath: string): string {
|
||||
const base = controllerPath.replace(/^\/+|\/+$/g, '');
|
||||
const sub = methodPath.replace(/^\/+|\/+$/g, '');
|
||||
const joined = [base, sub].filter(Boolean).join('/');
|
||||
return `/${joined}`;
|
||||
}
|
||||
|
||||
function toOpenApiPath(route: string): string {
|
||||
// Convert Nest-style ":param" to OpenAPI "{param}"
|
||||
return route.replace(/(^|\/):([^/]+)/g, '$1{$2}');
|
||||
}
|
||||
|
||||
async function extractPathsFromControllers(): Promise<Record<string, any>> {
|
||||
const controllerFiles = await glob('apps/api/src/domain/**/*Controller.ts', {
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
controllerFiles.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const paths: Record<string, any> = {};
|
||||
|
||||
for (const controllerFile of controllerFiles) {
|
||||
const filePath = path.join(process.cwd(), controllerFile);
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
|
||||
const controllerMatch = content.match(/@Controller\(\s*['"]([^'"]+)['"]\s*\)/);
|
||||
if (!controllerMatch) continue;
|
||||
|
||||
const controllerPath = controllerMatch[1] ?? '';
|
||||
const methodRegex = /@(Get|Post|Put|Patch|Delete)\(\s*(?:['"]([^'"]*)['"])?\s*\)/g;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = methodRegex.exec(content)) !== null) {
|
||||
const httpMethod = match[1]?.toLowerCase();
|
||||
if (!httpMethod) continue;
|
||||
|
||||
const methodPath = match[2] ?? '';
|
||||
const route = joinRouteParts(controllerPath, methodPath);
|
||||
const openapiPath = toOpenApiPath(route);
|
||||
|
||||
paths[openapiPath] ??= {};
|
||||
paths[openapiPath][httpMethod] ??= {
|
||||
responses: {
|
||||
'200': { description: 'OK' },
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return sortRecordKeys(paths);
|
||||
}
|
||||
|
||||
async function processDTOFile(filePath: string, schemas: Record<string, OpenAPISchema>) {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
|
||||
@@ -160,9 +237,17 @@ function extractSchemaFromClassBody(classBody: string, fullContent: string): Ope
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect decorators
|
||||
// Collect decorators (support multi-line decorator calls like @ApiProperty({ ... }))
|
||||
if (line.startsWith('@')) {
|
||||
currentDecorators.push(line);
|
||||
let decorator = line;
|
||||
|
||||
while (!decorator.includes(')') && i + 1 < lines.length) {
|
||||
const nextLine = lines[i + 1]?.trim() ?? '';
|
||||
decorator = `${decorator} ${nextLine}`.trim();
|
||||
i++;
|
||||
}
|
||||
|
||||
currentDecorators.push(decorator);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -183,10 +268,11 @@ function extractSchemaFromClassBody(classBody: string, fullContent: string): Ope
|
||||
}
|
||||
|
||||
// Determine if required
|
||||
const hasApiProperty = currentDecorators.some(d => d.includes('@ApiProperty'));
|
||||
const isOptional = !!optional ||
|
||||
currentDecorators.some(d => d.includes('required: false') || d.includes('@IsOptional'));
|
||||
const isNullable = currentDecorators.some(d => d.includes('nullable: true'));
|
||||
const isOptional =
|
||||
!!optional || currentDecorators.some(d => d.includes('required: false') || d.includes('@IsOptional'));
|
||||
const isNullableFromDecorator = currentDecorators.some(d => d.includes('nullable: true'));
|
||||
const isNullableFromType = cleanedType.includes('| null') || cleanedType.includes('null |');
|
||||
const isNullable = isNullableFromDecorator || isNullableFromType;
|
||||
|
||||
if (!isOptional && !isNullable) {
|
||||
required.push(propName);
|
||||
|
||||
Reference in New Issue
Block a user