351 lines
10 KiB
TypeScript
351 lines
10 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>;
|
|
};
|
|
}
|
|
|
|
async function generateSpec() {
|
|
console.log('🔄 Generating OpenAPI spec from DTO files...');
|
|
|
|
const schemas: Record<string, OpenAPISchema> = {};
|
|
|
|
// 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<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
|
|
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<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);
|