#!/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; additionalProperties?: OpenAPISchema; required?: string[]; enum?: string[]; nullable?: boolean; description?: string; } interface OpenAPISpec { openapi: string; info: { title: string; description: string; version: string; }; paths: Record; components: { schemas: Record; }; } async function generateSpec() { console.log('🔄 Generating OpenAPI spec from DTO files...'); const schemas: Record = {}; // Find all DTO files const dtoFiles = await glob('apps/api/src/domain/*/dtos/**/*.ts', { cwd: process.cwd() }); 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 spec: OpenAPISpec = { openapi: '3.0.0', info: { title: 'GridPilot API', description: 'GridPilot API documentation', version: '1.0.0' }, paths: {}, components: { schemas } }; const outputPath = path.join(process.cwd(), 'apps/api/openapi.json'); await fs.writeFile(outputPath, JSON.stringify(spec, null, 2)); console.log(`✅ OpenAPI spec generated with ${Object.keys(schemas).length} schemas at: ${outputPath}`); } async function processDTOFile(filePath: string, schemas: Record) { 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 = {}; 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 if (line.startsWith('@')) { currentDecorators.push(line); 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 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')); 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 // 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 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);