288 lines
9.3 KiB
TypeScript
288 lines
9.3 KiB
TypeScript
#!/usr/bin/env tsx
|
||
/**
|
||
* Contract Compatibility Verification Script
|
||
*
|
||
* This script verifies that the API contracts are compatible with the website
|
||
* by running the type generation and then checking for breaking changes.
|
||
*/
|
||
|
||
import { execSync } from 'child_process';
|
||
import * as fs from 'fs/promises';
|
||
import * as path from 'path';
|
||
import { glob } from 'glob';
|
||
|
||
interface OpenAPISchema {
|
||
type?: string;
|
||
format?: string;
|
||
$ref?: string;
|
||
items?: OpenAPISchema;
|
||
properties?: Record<string, 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>;
|
||
};
|
||
}
|
||
|
||
interface ContractChange {
|
||
type: 'added' | 'removed' | 'modified' | 'breaking';
|
||
dto: string;
|
||
property?: string;
|
||
details: string;
|
||
}
|
||
|
||
const colors = {
|
||
reset: '\x1b[0m',
|
||
green: '\x1b[32m',
|
||
red: '\x1b[31m',
|
||
yellow: '\x1b[33m',
|
||
blue: '\x1b[34m',
|
||
dim: '\x1b[2m'
|
||
};
|
||
|
||
async function runContractCompatibilityCheck(): Promise<void> {
|
||
console.log(`${colors.blue}🔍 Running Contract Compatibility Check...${colors.reset}\n`);
|
||
|
||
const apiRoot = path.join(__dirname, '../apps/api');
|
||
const websiteRoot = path.join(__dirname, '../apps/website');
|
||
const openapiPath = path.join(apiRoot, 'openapi.json');
|
||
const generatedTypesDir = path.join(websiteRoot, 'lib/types/generated');
|
||
const backupDir = path.join(__dirname, '../.backup/contract-types');
|
||
|
||
// Step 1: Generate current OpenAPI spec
|
||
console.log(`${colors.yellow}1. Generating OpenAPI spec...${colors.reset}`);
|
||
try {
|
||
execSync('npm run api:generate-spec', { stdio: 'inherit' });
|
||
} catch (error) {
|
||
console.error(`${colors.red}❌ Failed to generate OpenAPI spec${colors.reset}`);
|
||
process.exit(1);
|
||
}
|
||
|
||
// Step 2: Backup current generated types
|
||
console.log(`${colors.yellow}2. Backing up current generated types...${colors.reset}`);
|
||
await fs.mkdir(backupDir, { recursive: true });
|
||
try {
|
||
const files = await fs.readdir(generatedTypesDir);
|
||
for (const file of files) {
|
||
if (file.endsWith('.ts')) {
|
||
const content = await fs.readFile(path.join(generatedTypesDir, file), 'utf-8');
|
||
await fs.writeFile(path.join(backupDir, file), content);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.log(`${colors.yellow}⚠️ No existing types to backup${colors.reset}`);
|
||
}
|
||
|
||
// Step 3: Generate new types
|
||
console.log(`${colors.yellow}3. Generating new types...${colors.reset}`);
|
||
try {
|
||
execSync('npm run api:generate-types', { stdio: 'inherit' });
|
||
} catch (error) {
|
||
console.error(`${colors.red}❌ Failed to generate types${colors.reset}`);
|
||
process.exit(1);
|
||
}
|
||
|
||
// Step 4: Compare and detect changes
|
||
console.log(`${colors.yellow}4. Analyzing contract changes...${colors.reset}`);
|
||
const changes = await detectContractChanges(backupDir, generatedTypesDir, openapiPath);
|
||
|
||
// Step 5: Report results
|
||
console.log(`${colors.yellow}5. Reporting changes...${colors.reset}\n`);
|
||
await reportChanges(changes);
|
||
|
||
// Step 6: Clean up backup
|
||
console.log(`${colors.yellow}6. Cleaning up...${colors.reset}`);
|
||
await fs.rm(backupDir, { recursive: true, force: true });
|
||
|
||
console.log(`\n${colors.green}✅ Contract compatibility check completed!${colors.reset}`);
|
||
}
|
||
|
||
async function detectContractChanges(
|
||
backupDir: string,
|
||
currentDir: string,
|
||
openapiPath: string
|
||
): Promise<ContractChange[]> {
|
||
const changes: ContractChange[] = [];
|
||
|
||
// Read OpenAPI spec
|
||
const specContent = await fs.readFile(openapiPath, 'utf-8');
|
||
const spec: OpenAPISpec = JSON.parse(specContent);
|
||
const schemas = spec.components.schemas;
|
||
|
||
// Get current and backup files
|
||
const currentFiles = await fs.readdir(currentDir);
|
||
const backupFiles = await fs.readdir(backupDir);
|
||
|
||
const currentDTOs = currentFiles.filter(f => f.endsWith('.ts')).map(f => f.replace('.ts', ''));
|
||
const backupDTOs = backupFiles.filter(f => f.endsWith('.ts')).map(f => f.replace('.ts', ''));
|
||
|
||
// Check for removed DTOs
|
||
for (const backupDTO of backupDTOs) {
|
||
if (!currentDTOs.includes(backupDTO)) {
|
||
changes.push({
|
||
type: 'removed',
|
||
dto: backupDTO,
|
||
details: `DTO ${backupDTO} was removed`
|
||
});
|
||
}
|
||
}
|
||
|
||
// Check for added and modified DTOs
|
||
for (const currentDTO of currentDTOs) {
|
||
const currentPath = path.join(currentDir, `${currentDTO}.ts`);
|
||
const backupPath = path.join(backupDir, `${currentDTO}.ts`);
|
||
|
||
const currentContent = await fs.readFile(currentPath, 'utf-8');
|
||
const backupExists = backupDTOs.includes(currentDTO);
|
||
|
||
if (!backupExists) {
|
||
changes.push({
|
||
type: 'added',
|
||
dto: currentDTO,
|
||
details: `New DTO ${currentDTO} was added`
|
||
});
|
||
} else {
|
||
const backupContent = await fs.readFile(backupPath, 'utf-8');
|
||
|
||
// Check for property changes
|
||
const schema = schemas[currentDTO];
|
||
if (schema && schema.properties) {
|
||
const currentProps = extractProperties(currentContent);
|
||
const backupProps = extractProperties(backupContent);
|
||
|
||
// Check for removed properties
|
||
for (const [propName, backupProp] of Object.entries(backupProps)) {
|
||
if (!currentProps[propName]) {
|
||
const isRequired = schema.required?.includes(propName);
|
||
changes.push({
|
||
type: isRequired ? 'breaking' : 'modified',
|
||
dto: currentDTO,
|
||
property: propName,
|
||
details: `Property ${propName} was removed${isRequired ? ' (BREAKING)' : ''}`
|
||
});
|
||
}
|
||
}
|
||
|
||
// Check for added properties
|
||
for (const [propName, currentProp] of Object.entries(currentProps)) {
|
||
if (!backupProps[propName]) {
|
||
const isRequired = schema.required?.includes(propName);
|
||
changes.push({
|
||
type: isRequired ? 'breaking' : 'added',
|
||
dto: currentDTO,
|
||
property: propName,
|
||
details: `Property ${propName} was added${isRequired ? ' (potentially breaking)' : ''}`
|
||
});
|
||
}
|
||
}
|
||
|
||
// Check for type changes
|
||
for (const [propName, currentProp] of Object.entries(currentProps)) {
|
||
if (backupProps[propName]) {
|
||
const backupProp = backupProps[propName];
|
||
if (currentProp.type !== backupProp.type) {
|
||
changes.push({
|
||
type: 'breaking',
|
||
dto: currentDTO,
|
||
property: propName,
|
||
details: `Property ${propName} type changed from ${backupProp.type} to ${currentProp.type} (BREAKING)`
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return changes;
|
||
}
|
||
|
||
function extractProperties(content: string): Record<string, { type: string; optional: boolean }> {
|
||
const properties: Record<string, { type: string; optional: boolean }> = {};
|
||
|
||
// Match property lines: propertyName?: type;
|
||
const propertyRegex = /^\s*(\w+)(\??):\s*([^;]+);/gm;
|
||
let match;
|
||
|
||
while ((match = propertyRegex.exec(content)) !== null) {
|
||
const [, name, optional, type] = match;
|
||
properties[name] = {
|
||
type: type.trim(),
|
||
optional: !!optional
|
||
};
|
||
}
|
||
|
||
return properties;
|
||
}
|
||
|
||
async function reportChanges(changes: ContractChange[]): Promise<void> {
|
||
if (changes.length === 0) {
|
||
console.log(`${colors.green}✅ No changes detected - contracts are stable${colors.reset}`);
|
||
return;
|
||
}
|
||
|
||
const breaking = changes.filter(c => c.type === 'breaking');
|
||
const modified = changes.filter(c => c.type === 'modified');
|
||
const added = changes.filter(c => c.type === 'added');
|
||
const removed = changes.filter(c => c.type === 'removed');
|
||
|
||
if (breaking.length > 0) {
|
||
console.log(`${colors.red}🚨 BREAKING CHANGES DETECTED:${colors.reset}`);
|
||
breaking.forEach(change => {
|
||
console.log(` ${colors.red}• ${change.dto}${change.property ? '.' + change.property : ''}: ${change.details}${colors.reset}`);
|
||
});
|
||
console.log('');
|
||
}
|
||
|
||
if (removed.length > 0) {
|
||
console.log(`${colors.red}❌ REMOVED:${colors.reset}`);
|
||
removed.forEach(change => {
|
||
console.log(` ${colors.red}• ${change.dto}: ${change.details}${colors.reset}`);
|
||
});
|
||
console.log('');
|
||
}
|
||
|
||
if (modified.length > 0) {
|
||
console.log(`${colors.yellow}⚠️ MODIFIED:${colors.reset}`);
|
||
modified.forEach(change => {
|
||
console.log(` ${colors.yellow}• ${change.dto}.${change.property}: ${change.details}${colors.reset}`);
|
||
});
|
||
console.log('');
|
||
}
|
||
|
||
if (added.length > 0) {
|
||
console.log(`${colors.green}➕ ADDED:${colors.reset}`);
|
||
added.forEach(change => {
|
||
console.log(` ${colors.green}• ${change.dto}${change.property ? '.' + change.property : ''}: ${change.details}${colors.reset}`);
|
||
});
|
||
console.log('');
|
||
}
|
||
|
||
const totalChanges = changes.length;
|
||
console.log(`${colors.blue}📊 Summary: ${totalChanges} total changes (${breaking.length} breaking, ${removed.length} removed, ${modified.length} modified, ${added.length} added)${colors.reset}`);
|
||
|
||
if (breaking.length > 0) {
|
||
console.log(`\n${colors.red}❌ Contract compatibility check FAILED due to breaking changes${colors.reset}`);
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
// Run if called directly
|
||
if (require.main === module) {
|
||
runContractCompatibilityCheck().catch(error => {
|
||
console.error(`${colors.red}❌ Error running contract compatibility check:${colors.reset}`, error);
|
||
process.exit(1);
|
||
});
|
||
} |