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

278 lines
8.3 KiB
TypeScript

#!/usr/bin/env tsx
/**
* Generate TypeScript types from OpenAPI spec
*
* This script uses openapi-typescript to generate proper TypeScript types
* from the OpenAPI spec that NestJS/Swagger generates.
*
* Usage:
* 1. First generate the OpenAPI spec: npm run api:generate-spec
* 2. Then generate types: npm run api:generate-types
*
* Or use: npm run api:sync-types (runs both)
*/
import { createHash } from 'crypto';
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
async function sha256OfFile(filePath: string): Promise<string> {
const buffer = await fs.readFile(filePath);
return createHash('sha256').update(buffer).digest('hex');
}
async function generateTypes() {
const openapiPath = path.join(__dirname, '../apps/api/openapi.json');
const outputDir = path.join(__dirname, '../apps/website/lib/types/generated');
console.log('🔄 Generating TypeScript types from OpenAPI spec...');
// Check if OpenAPI spec exists
try {
await fs.access(openapiPath);
} catch {
console.error(`❌ OpenAPI spec not found at: ${openapiPath}`);
console.error('Restore the committed OpenAPI contract or run "npm run api:generate-spec" to regenerate it.');
process.exit(1);
}
// Ensure output directory exists
await fs.mkdir(outputDir, { recursive: true });
try {
const specSha256 = await sha256OfFile(openapiPath);
// Generate individual DTO files + barrel index for deterministic imports
await generateIndividualDtoFiles(openapiPath, outputDir, specSha256);
} catch (error) {
console.error('❌ Failed to generate types:', error);
process.exit(1);
}
}
async function generateIndividualDtoFiles(openapiPath: string, outputDir: string, specSha256: string) {
console.log('📝 Generating individual DTO files...');
const specContent = await fs.readFile(openapiPath, 'utf-8');
const spec = JSON.parse(specContent);
const schemas = spec.components?.schemas || {};
const schemaNames = Object.keys(schemas).sort((a: string, b: string) => a.localeCompare(b));
// Get existing files in output directory
let existingFiles: string[] = [];
try {
existingFiles = await fs.readdir(outputDir);
existingFiles = existingFiles.filter(f => f.endsWith('.ts'));
} catch (error) {
// Directory doesn't exist yet
}
// Generate individual files for each schema
const generatedFileNames: string[] = [];
for (const schemaName of schemaNames) {
const schema = schemas[schemaName];
// File name should match the schema name exactly
const fileName = `${schemaName}.ts`;
const filePath = path.join(outputDir, fileName);
const fileContent = generateDtoFileContent(schemaName, schema, schemas, specSha256);
await fs.writeFile(filePath, fileContent);
console.log(` ✅ Generated ${fileName}`);
generatedFileNames.push(fileName);
}
const indexFileName = 'index.ts';
const indexFilePath = path.join(outputDir, indexFileName);
const indexFileContent = generateIndexFileContent(schemaNames, specSha256);
await fs.writeFile(indexFilePath, indexFileContent);
console.log(` ✅ Generated ${indexFileName}`);
generatedFileNames.push(indexFileName);
// Clean up files that are no longer in the spec
const filesToRemove = existingFiles.filter(f => !generatedFileNames.includes(f));
for (const file of filesToRemove) {
const filePath = path.join(outputDir, file);
await fs.unlink(filePath);
console.log(` 🗑️ Removed obsolete file: ${file}`);
}
console.log(`✅ Generated ${schemaNames.length} individual DTO files at: ${outputDir}`);
if (filesToRemove.length > 0) {
console.log(`🧹 Cleaned up ${filesToRemove.length} obsolete files`);
}
}
function generateIndexFileContent(schemaNames: string[], specSha256: string): string {
let content = `/**
* Auto-generated barrel for API DTO types.
* Spec SHA256: ${specSha256}
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types
*/
`;
for (const schemaName of schemaNames) {
content += `\nexport type { ${schemaName} } from './${schemaName}';`;
}
content += '\n';
return content;
}
function generateDtoFileContent(schemaName: string, schema: any, allSchemas: Record<string, any>, specSha256: string): string {
// Collect dependencies (referenced DTOs)
const dependencies = new Set<string>();
collectDependencies(schema, dependencies, allSchemas);
dependencies.delete(schemaName); // Remove self-reference
const sortedDependencies = Array.from(dependencies).sort((a, b) => a.localeCompare(b));
let content = `/**
* Auto-generated DTO from OpenAPI spec
* Spec SHA256: ${specSha256}
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:generate-types
*/
`;
// Add imports for dependencies (sorted for deterministic output)
for (const dep of sortedDependencies) {
content += `import type { ${dep} } from './${dep}';\n`;
}
if (sortedDependencies.length > 0) {
content += '\n';
}
// Generate interface - use the schema name directly
content += `export interface ${schemaName} {\n`;
const properties = schema.properties || {};
const required = new Set(schema.required || []);
for (const [propName, propSchema] of Object.entries(properties)) {
const isRequired = required.has(propName);
const optionalMark = isRequired ? '' : '?';
const typeStr = schemaToTypeString(propSchema as any, allSchemas);
// Add JSDoc comment for format
if ((propSchema as any).format) {
content += ` /** Format: ${(propSchema as any).format} */\n`;
}
content += ` ${propName}${optionalMark}: ${typeStr};\n`;
}
content += '}\n';
return content;
}
function collectDependencies(schema: any, deps: Set<string>, allSchemas: Record<string, any>): void {
if (!schema) return;
if (schema.$ref) {
const refName = schema.$ref.split('/').pop();
if (refName && allSchemas[refName]) {
deps.add(refName);
}
return;
}
if (schema.type === 'array' && schema.items) {
collectDependencies(schema.items, deps, allSchemas);
return;
}
if (schema.properties) {
for (const propSchema of Object.values(schema.properties)) {
collectDependencies(propSchema, deps, allSchemas);
}
}
if (schema.additionalProperties) {
collectDependencies(schema.additionalProperties, deps, allSchemas);
}
if (schema.oneOf) {
for (const subSchema of schema.oneOf) {
collectDependencies(subSchema, deps, allSchemas);
}
}
if (schema.anyOf) {
for (const subSchema of schema.anyOf) {
collectDependencies(subSchema, deps, allSchemas);
}
}
if (schema.allOf) {
for (const subSchema of schema.allOf) {
collectDependencies(subSchema, deps, allSchemas);
}
}
}
function schemaToTypeString(schema: any, allSchemas: Record<string, any>): string {
if (!schema) return 'unknown';
if (schema.$ref) {
const refName = schema.$ref.split('/').pop();
if (!refName) return 'unknown';
return allSchemas[refName] ? refName : 'unknown';
}
if (schema.type === 'array') {
const itemType = schemaToTypeString(schema.items, allSchemas);
return `${itemType}[]`;
}
if (schema.type === 'object') {
if (schema.additionalProperties) {
const valueType = schemaToTypeString(schema.additionalProperties, allSchemas);
return `Record<string, ${valueType}>`;
}
if (schema.properties) {
// Inline object type
const props = Object.entries(schema.properties)
.map(([key, val]) => `${key}: ${schemaToTypeString(val as any, allSchemas)}`)
.join('; ');
return `{ ${props} }`;
}
return 'Record<string, unknown>';
}
if (schema.oneOf) {
return schema.oneOf.map((s: any) => schemaToTypeString(s, allSchemas)).join(' | ');
}
if (schema.anyOf) {
return schema.anyOf.map((s: any) => schemaToTypeString(s, allSchemas)).join(' | ');
}
if (schema.enum) {
return schema.enum.map((v: any) => JSON.stringify(v)).join(' | ');
}
switch (schema.type) {
case 'string':
return 'string';
case 'number':
case 'integer':
return 'number';
case 'boolean':
return 'boolean';
case 'null':
return 'null';
default:
return 'unknown';
}
}
generateTypes().catch(console.error);