wip league admin tools

This commit is contained in:
2025-12-28 12:04:12 +01:00
parent 5dc8c2399c
commit 6edf12fda8
401 changed files with 15365 additions and 6047 deletions

View File

@@ -12,17 +12,21 @@
* Or use: npm run api:sync-types (runs both)
*/
import { execSync } from 'child_process';
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');
const outputFile = path.join(outputDir, 'api.ts');
console.log('🔄 Generating TypeScript types from OpenAPI spec...');
@@ -31,7 +35,7 @@ async function generateTypes() {
await fs.access(openapiPath);
} catch {
console.error(`❌ OpenAPI spec not found at: ${openapiPath}`);
console.error('Run "npm run api:generate-spec" first to generate the OpenAPI spec from NestJS');
console.error('Restore the committed OpenAPI contract or run "npm run api:generate-spec" to regenerate it.');
process.exit(1);
}
@@ -39,33 +43,24 @@ async function generateTypes() {
await fs.mkdir(outputDir, { recursive: true });
try {
// Skip generating monolithic api.ts file
// Use openapi-typescript to generate types
// console.log('📝 Running openapi-typescript...');
// execSync(`npx openapi-typescript "${openapiPath}" -o "${outputFile}"`, {
// stdio: 'inherit',
// cwd: path.join(__dirname, '..')
// });
// console.log(`✅ TypeScript types generated at: ${outputFile}`);
// Generate individual DTO files
await generateIndividualDtoFiles(openapiPath, outputDir);
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) {
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);
const schemaNames = Object.keys(schemas).sort((a: string, b: string) => a.localeCompare(b));
// Get existing files in output directory
let existingFiles: string[] = [];
@@ -80,17 +75,24 @@ async function generateIndividualDtoFiles(openapiPath: string, outputDir: string
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);
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) {
@@ -105,26 +107,44 @@ async function generateIndividualDtoFiles(openapiPath: string, outputDir: string
}
}
function generateDtoFileContent(schemaName: string, schema: any, allSchemas: Record<string, any>): string {
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:sync-types
* Do not edit manually - regenerate using: npm run api:generate-types
*/
`;
// Add imports for dependencies
for (const dep of dependencies) {
// Add imports for dependencies (sorted for deterministic output)
for (const dep of sortedDependencies) {
content += `import type { ${dep} } from './${dep}';\n`;
}
if (dependencies.size > 0) {
if (sortedDependencies.length > 0) {
content += '\n';
}
@@ -137,7 +157,7 @@ function generateDtoFileContent(schemaName: string, schema: any, allSchemas: Rec
for (const [propName, propSchema] of Object.entries(properties)) {
const isRequired = required.has(propName);
const optionalMark = isRequired ? '' : '?';
const typeStr = schemaToTypeString(propSchema as any);
const typeStr = schemaToTypeString(propSchema as any, allSchemas);
// Add JSDoc comment for format
if ((propSchema as any).format) {
@@ -197,42 +217,44 @@ function collectDependencies(schema: any, deps: Set<string>, allSchemas: Record<
}
}
function schemaToTypeString(schema: any): string {
function schemaToTypeString(schema: any, allSchemas: Record<string, any>): string {
if (!schema) return 'unknown';
if (schema.$ref) {
return schema.$ref.split('/').pop() || 'unknown';
const refName = schema.$ref.split('/').pop();
if (!refName) return 'unknown';
return allSchemas[refName] ? refName : 'unknown';
}
if (schema.type === 'array') {
const itemType = schemaToTypeString(schema.items);
const itemType = schemaToTypeString(schema.items, allSchemas);
return `${itemType}[]`;
}
if (schema.type === 'object') {
if (schema.additionalProperties) {
const valueType = schemaToTypeString(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)}`)
.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)).join(' | ');
return schema.oneOf.map((s: any) => schemaToTypeString(s, allSchemas)).join(' | ');
}
if (schema.anyOf) {
return schema.anyOf.map((s: any) => schemaToTypeString(s)).join(' | ');
return schema.anyOf.map((s: any) => schemaToTypeString(s, allSchemas)).join(' | ');
}
if (schema.enum) {
return schema.enum.map((v: any) => JSON.stringify(v)).join(' | ');
}

View File

@@ -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);

View File

@@ -5,9 +5,9 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { execSync } from 'child_process';
import { createHash } from 'crypto';
import * as fs from 'fs/promises';
import * as path from 'path';
import { glob } from 'glob';
describe('Type Generation Script', () => {
const apiRoot = path.join(__dirname, '../../apps/api');
@@ -16,6 +16,11 @@ describe('Type Generation Script', () => {
const generatedTypesDir = path.join(websiteRoot, 'lib/types/generated');
const backupDir = path.join(__dirname, '../../.backup/type-gen-test');
async function sha256OfFile(filePath: string): Promise<string> {
const buffer = await fs.readFile(filePath);
return createHash('sha256').update(buffer).digest('hex');
}
beforeAll(async () => {
// Backup existing generated types
await fs.mkdir(backupDir, { recursive: true });
@@ -50,14 +55,7 @@ describe('Type Generation Script', () => {
});
describe('OpenAPI Spec Generation', () => {
it('should generate valid OpenAPI spec', async () => {
// Run the spec generation
execSync('npm run api:generate-spec', {
cwd: path.join(__dirname, '../..'),
stdio: 'pipe'
});
// Check that spec exists and is valid JSON
it('should have a valid committed OpenAPI spec', async () => {
const specContent = await fs.readFile(openapiPath, 'utf-8');
expect(() => JSON.parse(specContent)).not.toThrow();
@@ -67,6 +65,32 @@ describe('Type Generation Script', () => {
expect(spec.components.schemas).toBeDefined();
});
it('should include league schedule route and schema', async () => {
const specContent = await fs.readFile(openapiPath, 'utf-8');
const spec = JSON.parse(specContent);
// Route should exist (controller route extraction)
expect(spec.paths?.['/leagues/{leagueId}/schedule']).toBeDefined();
// Schema should exist (DTO scanning)
const scheduleSchema = spec.components?.schemas?.['LeagueScheduleDTO'];
expect(scheduleSchema).toBeDefined();
// Contract requirements: season-aware schedule DTO
expect(scheduleSchema.required ?? []).toContain('seasonId');
expect(scheduleSchema.properties?.seasonId).toEqual({ type: 'string' });
// Races must be typed and use RaceDTO items
expect(scheduleSchema.required ?? []).toContain('races');
expect(scheduleSchema.properties?.races?.type).toBe('array');
expect(scheduleSchema.properties?.races?.items).toEqual({ $ref: '#/components/schemas/RaceDTO' });
// RaceDTO.date must be ISO-safe string (OpenAPI generator maps Date->date-time, but DTO uses string)
const raceSchema = spec.components?.schemas?.['RaceDTO'];
expect(raceSchema).toBeDefined();
expect(raceSchema.properties?.date).toEqual({ type: 'string' });
});
it('should not have duplicate schema names with different casing', async () => {
const specContent = await fs.readFile(openapiPath, 'utf-8');
const spec = JSON.parse(specContent);
@@ -100,6 +124,26 @@ describe('Type Generation Script', () => {
});
describe('Type Generation', () => {
it('should stamp generated output with the committed OpenAPI SHA256', async () => {
execSync('npm run api:generate-types', {
cwd: path.join(__dirname, '../..'),
stdio: 'pipe',
});
const expectedHash = await sha256OfFile(openapiPath);
const barrelPath = path.join(generatedTypesDir, 'index.ts');
const barrelContent = await fs.readFile(barrelPath, 'utf-8');
expect(barrelContent).toContain(`Spec SHA256: ${expectedHash}`);
expect(barrelContent).toContain(`export type { RaceDTO } from './RaceDTO';`);
expect(barrelContent).toContain(`export type { DriverDTO } from './DriverDTO';`);
const sampleDtoPath = path.join(generatedTypesDir, 'RaceDTO.ts');
const sampleDtoContent = await fs.readFile(sampleDtoPath, 'utf-8');
expect(sampleDtoContent).toContain(`Spec SHA256: ${expectedHash}`);
});
it('should generate TypeScript files for all schemas', async () => {
// Generate types
execSync('npm run api:generate-types', {
@@ -121,7 +165,7 @@ describe('Type Generation Script', () => {
// Most schemas should have corresponding generated files
// (allowing for some duplicates/conflicts that are intentionally skipped)
const missingFiles = schemas.filter(schema => !generatedDTOs.includes(schema));
// Should have at least 95% coverage
const coverage = (schemas.length - missingFiles.length) / schemas.length;
expect(coverage).toBeGreaterThan(0.95);
@@ -129,15 +173,14 @@ describe('Type Generation Script', () => {
it('should generate files with correct interface names', async () => {
const files = await fs.readdir(generatedTypesDir);
const dtos = files.filter(f => f.endsWith('.ts'));
const dtos = files.filter(f => f.endsWith('.ts') && f !== 'index.ts');
for (const file of dtos) {
const content = await fs.readFile(path.join(generatedTypesDir, file), 'utf-8');
const interfaceName = file.replace('.ts', '');
// File should contain an interface (name might be normalized)
expect(content).toMatch(/export interface \w+\s*{/);
// Should not have duplicate interface names in the same file
const interfaceMatches = content.match(/export interface (\w+)/g);
expect(interfaceMatches?.length).toBe(1);
@@ -146,17 +189,23 @@ describe('Type Generation Script', () => {
it('should generate valid TypeScript syntax', async () => {
const files = await fs.readdir(generatedTypesDir);
const dtos = files.filter(f => f.endsWith('.ts'));
const tsFiles = files.filter(f => f.endsWith('.ts'));
for (const file of dtos) {
for (const file of tsFiles) {
const content = await fs.readFile(path.join(generatedTypesDir, file), 'utf-8');
if (file === 'index.ts') {
expect(content).toContain('Auto-generated barrel');
expect(content).toContain('export type { RaceDTO } from');
continue;
}
// Basic syntax checks
expect(content).toContain('export interface');
expect(content).toContain('{');
expect(content).toContain('}');
expect(content).toContain('Auto-generated DTO');
// Should not have syntax errors
expect(content).not.toMatch(/interface\s+\w+\s*\{\s*\}/); // Empty interfaces
expect(content).not.toContain('undefined;');