Files
gridpilot.gg/scripts/generate-openapi-spec.ts
2025-12-28 12:04:12 +01:00

437 lines
13 KiB
TypeScript

#!/usr/bin/env tsx
/**
* Generate OpenAPI spec from NestJS DTO classes
*
* This script scans all DTO files and extracts OpenAPI schema definitions
* using the @nestjs/swagger metadata.
*/
import 'reflect-metadata';
import * as fs from 'fs/promises';
import * as path from 'path';
import { glob } from 'glob';
// OpenAPI schema types
interface OpenAPISchema {
type?: string;
format?: string;
$ref?: string;
items?: OpenAPISchema;
properties?: Record<string, OpenAPISchema>;
additionalProperties?: OpenAPISchema;
required?: string[];
enum?: string[];
nullable?: boolean;
description?: string;
}
interface OpenAPISpec {
openapi: string;
info: {
title: string;
description: string;
version: string;
};
paths: Record<string, any>;
components: {
schemas: Record<string, OpenAPISchema>;
};
}
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 (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`);
for (const dtoFile of dtoFiles) {
try {
await processDTOFile(path.join(process.cwd(), dtoFile), schemas);
} catch (e: any) {
console.warn(`⚠️ Could not process ${dtoFile}: ${e.message}`);
}
}
const paths = await extractPathsFromControllers();
const spec: OpenAPISpec = {
openapi: '3.0.0',
info: {
title: 'GridPilot API',
description: 'GridPilot API documentation',
version: '1.0.0'
},
paths,
components: {
schemas: sortRecordKeys(schemas)
}
};
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');
// Find exported class definitions with DTO suffix.
// NOTE: We cannot use a naive regex to capture the full class body because
// decorators often contain object literals with braces (e.g. @ApiProperty({ ... })).
// Instead, we locate the opening brace and then parse using a simple brace counter.
const classRegex = /export\s+class\s+(\w+)\b/g;
let classMatch: RegExpExecArray | null;
while ((classMatch = classRegex.exec(content)) !== null) {
const className = classMatch[1];
if (!className.endsWith('DTO') && !className.endsWith('Dto')) {
continue;
}
const declStartIndex = classMatch.index;
const braceOpenIndex = content.indexOf('{', classRegex.lastIndex);
if (braceOpenIndex === -1) {
continue;
}
// Walk forward to find the matching closing brace.
// This is intentionally simple: it counts braces and does not attempt to fully
// understand strings/comments. It's sufficient for our DTO files where braces
// are primarily used for TS blocks and decorator object literals.
let depth = 0;
let i = braceOpenIndex;
let braceCloseIndex = -1;
for (; i < content.length; i++) {
const ch = content[i];
if (ch === '{') depth++;
if (ch === '}') depth--;
if (depth === 0) {
braceCloseIndex = i;
break;
}
}
if (braceCloseIndex === -1) {
console.warn(` ⚠️ Could not find closing brace for ${className} in ${filePath}`);
continue;
}
const classBody = content.slice(braceOpenIndex + 1, braceCloseIndex);
// Normalize class name to always use DTO suffix (not Dto)
const normalizedName = className.endsWith('Dto') ?
className.slice(0, -3) + 'DTO' : className;
console.log(` 📝 Processing ${className} -> ${normalizedName}`);
// Check for conflicts
if (schemas[normalizedName]) {
console.warn(` ⚠️ Conflict: ${normalizedName} already exists. Skipping duplicate from ${filePath}`);
continue;
}
const schema = extractSchemaFromClassBody(classBody, content);
if (schema && Object.keys(schema.properties || {}).length > 0) {
schemas[normalizedName] = schema;
console.log(` ✅ Added ${normalizedName} with ${Object.keys(schema.properties || {}).length} properties`);
}
}
}
function extractSchemaFromClassBody(classBody: string, fullContent: string): OpenAPISchema {
const properties: Record<string, OpenAPISchema> = {};
const required: string[] = [];
// Split by lines and process each property
const lines = classBody.split('\n');
let currentDecorators: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
// Skip empty lines and comments
if (!line || line.startsWith('//') || line.startsWith('/*') || line.startsWith('*')) {
continue;
}
// Collect decorators (support multi-line decorator calls like @ApiProperty({ ... }))
if (line.startsWith('@')) {
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;
}
// Check if this is a property declaration
const propertyMatch = line.match(/^(\w+)(\?)?[!]?\s*:\s*(.+?)\s*;?\s*$/);
if (propertyMatch) {
const [, propName, optional, propType] = propertyMatch;
// Strip any default initializer from the "type" capture.
// Example: "sponsor: SponsorDTO = new SponsorDTO();" -> "SponsorDTO"
// Without this, later mapping may fall back to "string" and corrupt the schema.
const cleanedType = propType.split('=')[0].trim();
// Skip if propName is a TypeScript/decorator keyword
if (['constructor', 'private', 'public', 'protected', 'static', 'readonly'].includes(propName)) {
currentDecorators = [];
continue;
}
// Determine if required
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);
}
// Extract type from @ApiProperty decorator if present
let schema = extractTypeFromDecorators(currentDecorators, cleanedType);
properties[propName] = schema;
currentDecorators = [];
}
}
const result: OpenAPISchema = {
type: 'object',
properties
};
if (required.length > 0) {
result.required = required;
}
return result;
}
function extractTypeFromDecorators(decorators: string[], tsType: string): OpenAPISchema {
// Join all decorators to search across them
const decoratorStr = decorators.join(' ');
// Check for @ApiProperty type specification
// NOTE: Avoid the RegExp dotAll (/s) flag to keep compatibility with older TS targets.
const apiPropertyMatch = decoratorStr.match(/@ApiProperty\s*\(\s*\{([\s\S]*?)\}\s*\)/);
if (apiPropertyMatch) {
const apiPropertyContent = apiPropertyMatch[1];
// Check for array type: type: [SomeDTO]
const arrayTypeMatch = apiPropertyContent.match(/type:\s*\[\s*(\w+)\s*\]/);
if (arrayTypeMatch) {
const itemType = arrayTypeMatch[1];
return {
type: 'array',
items: mapTypeToSchema(itemType)
};
}
// Check for function type: type: () => SomeDTO
const funcTypeMatch = apiPropertyContent.match(/type:\s*\(\)\s*=>\s*(\w+)/);
if (funcTypeMatch) {
return mapTypeToSchema(funcTypeMatch[1]);
}
// Check for direct type reference: type: SomeDTO
const directTypeMatch = apiPropertyContent.match(/type:\s*(\w+)(?:\s*[,}])/);
if (directTypeMatch && !['String', 'Number', 'Boolean', 'string', 'number', 'boolean'].includes(directTypeMatch[1])) {
return mapTypeToSchema(directTypeMatch[1]);
}
// Check for enum
const enumMatch = apiPropertyContent.match(/enum:\s*(\w+)/);
if (enumMatch) {
return { type: 'string' }; // Simplify enum to string
}
// Check for nullable
if (apiPropertyContent.includes('nullable: true')) {
const baseSchema = mapTypeToSchema(tsType);
baseSchema.nullable = true;
return baseSchema;
}
}
// Fall back to TypeScript type
return mapTypeToSchema(tsType);
}
function mapTypeToSchema(type: string): OpenAPISchema {
// Clean up the type
type = type.replace(/[;!]/g, '').trim();
const normalizeDtoName = (name: string) => (name.endsWith('Dto') ? name.slice(0, -3) + 'DTO' : name);
// Handle Record<string, T>
// eslint-disable-next-line no-useless-escape
const recordMatch = type.match(/^Record<\s*string\s*,\s*(.+)\s*>$/);
if (recordMatch) {
return {
type: 'object',
additionalProperties: mapTypeToSchema(recordMatch[1]),
};
}
// Handle index signature object types: { [key: string]: T }
// eslint-disable-next-line no-useless-escape
const indexSigMatch = type.match(/^\{\s*\[\s*\w+\s*:\s*(string|number)\s*\]\s*:\s*(.+)\s*\}$/);
if (indexSigMatch) {
return {
type: 'object',
additionalProperties: mapTypeToSchema(indexSigMatch[2]),
};
}
// Handle union with null
if (type.includes('| null') || type.includes('null |')) {
const baseType = type.replace(/\|\s*null/g, '').replace(/null\s*\|/g, '').trim();
const schema = mapTypeToSchema(baseType);
schema.nullable = true;
return schema;
}
// Handle array types
if (type.endsWith('[]')) {
const itemType = type.slice(0, -2).trim();
return {
type: 'array',
items: mapTypeToSchema(itemType)
};
}
// Handle Array<T> syntax
const arrayGenericMatch = type.match(/^Array<(.+)>$/);
if (arrayGenericMatch) {
return {
type: 'array',
items: mapTypeToSchema(arrayGenericMatch[1])
};
}
// Handle object literal types (inline objects)
if (type.startsWith('{')) {
return { type: 'object' };
}
// Handle primitive types
const lowerType = type.toLowerCase();
switch (lowerType) {
case 'string':
return { type: 'string' };
case 'number':
case 'bigint':
return { type: 'number' };
case 'boolean':
return { type: 'boolean' };
case 'date':
return { type: 'string', format: 'date-time' };
case 'any':
case 'unknown':
case 'object':
return { type: 'object' };
}
// Handle DTO references
if (type.endsWith('DTO') || type.endsWith('Dto')) {
return { $ref: `#/components/schemas/${normalizeDtoName(type)}` };
}
// Default to string for unknown types
return { type: 'string' };
}
generateSpec().catch(console.error);