ai-voicebot/client/update-api-client.js

516 lines
16 KiB
JavaScript

#!/usr/bin/env node
/**
* Automated API Client Generator
*
* This script analyzes the generated OpenAPI schema and automatically generates:
* 1. api-client.ts - Complete TypeScript API client with types and methods
* 2. api-evolution-checker.ts - Updates known endpoints list
*
* The api-client.ts file contains only minimal imports at the top, with everything
* else being auto-generated after a single marker.
*/
const fs = require('fs');
const path = require('path');
// File paths
const OPENAPI_SCHEMA_PATH = path.join(__dirname, 'openapi-schema.json');
const API_TYPES_PATH = path.join(__dirname, 'src', 'api-types.ts');
const API_CLIENT_PATH = path.join(__dirname, 'src', 'api-client.ts');
const API_EVOLUTION_CHECKER_PATH = path.join(__dirname, 'src', 'api-evolution-checker.ts');
class ApiClientGenerator {
constructor() {
this.schema = null;
this.endpoints = [];
}
/**
* Load and parse the OpenAPI schema
*/
loadSchema() {
try {
const schemaContent = fs.readFileSync(OPENAPI_SCHEMA_PATH, 'utf8');
this.schema = JSON.parse(schemaContent);
console.log('✅ Loaded OpenAPI schema');
return true;
} catch (error) {
console.error('❌ Failed to load OpenAPI schema:', error.message);
return false;
}
}
/**
* Extract all endpoints from the OpenAPI schema
*/
extractEndpoints() {
if (!this.schema || !this.schema.paths) {
console.error('❌ No paths found in schema');
return;
}
this.endpoints = [];
Object.entries(this.schema.paths).forEach(([path, pathItem]) => {
Object.entries(pathItem).forEach(([method, operation]) => {
if (method === 'parameters') return; // Skip path-level parameters
const endpoint = {
path,
method: method.toUpperCase(),
operationId: operation.operationId,
summary: operation.summary,
requestBody: operation.requestBody,
parameters: operation.parameters || [],
responses: operation.responses || {}
};
this.endpoints.push(endpoint);
});
});
console.log(`✅ Extracted ${this.endpoints.length} endpoints from schema`);
}
/**
* Generate method name from operation ID or path with HTTP method prefix
*/
generateMethodName(endpoint) {
let baseName;
if (endpoint.operationId) {
// Convert snake_case operation ID to camelCase
baseName = endpoint.operationId
.replace(/_ai_voicebot_.*$/, '') // Remove the long suffix
.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())
.replace(/^([a-z])/, (_, letter) => letter.toLowerCase());
} else {
// Fallback: generate from path
const pathParts = endpoint.path.split('/').filter(part => part && !part.startsWith('{'));
const lastPart = pathParts[pathParts.length - 1] || 'resource';
baseName = lastPart.charAt(0).toLowerCase() + lastPart.slice(1);
}
// Add method prefix to avoid duplicates
const method = endpoint.method.toLowerCase();
const methodPrefixes = {
'get': 'get',
'post': 'create',
'put': 'replace', // Use 'replace' for PUT to distinguish from PATCH
'patch': 'update', // Use 'update' for PATCH
'delete': 'delete',
'head': 'head',
'options': 'options'
};
const prefix = methodPrefixes[method] || method;
// If baseName already starts with the prefix, don't duplicate
if (baseName.toLowerCase().startsWith(prefix.toLowerCase())) {
return baseName;
}
// Capitalize first letter of baseName and add prefix
const capitalizedBaseName = baseName.charAt(0).toUpperCase() + baseName.slice(1);
return prefix + capitalizedBaseName;
}
/**
* Generate method implementation
*/
generateMethodImplementation(endpoint) {
const methodName = this.generateMethodName(endpoint);
let params = [];
let pathParams = [];
// Extract path parameters
const pathParamMatches = endpoint.path.match(/{([^}]+)}/g);
if (pathParamMatches) {
pathParamMatches.forEach(param => {
const paramName = param.slice(1, -1); // Remove { }
pathParams.push(paramName);
params.push(`${paramName}: string`);
});
}
// Check for request body
if (endpoint.requestBody) {
params.push('data: any');
}
// Check for query parameters
const hasQueryParams = endpoint.parameters && endpoint.parameters.some(p => p.in === 'query');
if (hasQueryParams) {
params.push('params?: Record<string, string>');
}
const methodSignature = `async ${methodName}(${params.join(', ')}): Promise<any>`;
// Build the path with parameter substitution
let apiPath = endpoint.path;
pathParams.forEach(param => {
apiPath = apiPath.replace(`{${param}}`, `\${${param}}`);
});
// Build the request options
let requestOptions = `{ method: "${endpoint.method}"`;
if (endpoint.requestBody && endpoint.method !== 'GET') {
requestOptions += ', body: data';
}
if (hasQueryParams) {
requestOptions += ', params';
}
requestOptions += ' }';
return ` ${methodSignature} {
return this.request<any>(this.getApiPath(\`${apiPath}\`), ${requestOptions});
}`;
}
/**
* Extract all types from schema for re-export
*/
extractAllTypes() {
if (!this.schema || !this.schema.components || !this.schema.components.schemas) {
return '// No schema types available for export';
}
const schemas = this.schema.components.schemas;
const allTypeNames = Object.keys(schemas).sort();
if (allTypeNames.length === 0) {
return '// No types found in schema';
}
const typeExports = allTypeNames.map(typeName =>
`export type ${typeName} = components["schemas"]["${typeName}"];`
).join('\n');
return `// Re-export all types from the generated schema for convenience\n${typeExports}`;
}
/**
* Generate convenience API namespaces
*/
generateConvenienceApis() {
return `// Convenience API namespaces for easy usage
export const adminApi = {
listNames: () => apiClient.getListNames(),
setPassword: (data: any) => apiClient.createSetPassword(data),
clearPassword: (data: any) => apiClient.createClearPassword(data),
cleanupSessions: () => apiClient.createCleanupSessions(),
sessionMetrics: () => apiClient.getSessionMetrics(),
validateSessions: () => apiClient.getValidateSessions(),
cleanupLobbies: () => apiClient.createCleanupLobbies(),
};
export const healthApi = {
check: () => apiClient.getHealthSummary(),
ready: () => apiClient.getReadinessProbe(),
live: () => apiClient.getLivenessProbe(),
};
export const lobbiesApi = {
getAll: () => apiClient.getListLobbies(),
getChatMessages: (lobbyId: string, params?: Record<string, string>) => apiClient.getChatMessages(lobbyId, params),
};
export const sessionsApi = {
getCurrent: () => apiClient.getSession(),
createLobby: (sessionId: string, data: any) => apiClient.createLobby(sessionId, data),
};
export const botsApi = {
getProviders: () => apiClient.getListBotProviders(),
getAvailable: () => apiClient.getListAvailableBots(),
requestJoinLobby: (botName: string, request: any) => apiClient.createRequestBotJoinLobby(botName, request),
requestLeaveLobby: (botInstanceId: string) => apiClient.createRequestBotLeaveLobby(botInstanceId),
getInstance: (botInstanceId: string) => apiClient.getBotInstance(botInstanceId),
registerProvider: (data: any) => apiClient.createRegisterBotProvider(data),
};`;
}
/**
* Create minimal template with just imports
*/
createMinimalTemplate() {
const template = `// TypeScript API client for AI Voicebot server
import { components } from "./api-types";
import { base } from "./Common";
// DO NOT MANUALLY EDIT BELOW THIS LINE - All content below is auto-generated
// To modify auto-generated content, edit the template in client/update-api-client.js
`;
fs.writeFileSync(API_CLIENT_PATH, template, 'utf8');
console.log('✅ Created minimal API client template');
}
/**
* Generate complete API client from scratch
*/
generateFullApiClient() {
try {
// Generate all components
const typeExports = this.extractAllTypes();
const allMethods = this.endpoints.map(endpoint => {
console.log(`${endpoint.method} ${endpoint.path} (${this.generateMethodName(endpoint)})`);
return this.generateMethodImplementation(endpoint);
}).join('\n\n');
const convenienceApis = this.generateConvenienceApis();
const autoGeneratedContent = `
// Re-export all types from the generated schema
export type { components } from "./api-types";
${typeExports}
export class ApiError extends Error {
constructor(public status: number, public statusText: string, public data?: any) {
super(\`HTTP \${status}: \${statusText}\`);
this.name = "ApiError";
}
}
export class ApiClient {
private baseURL: string;
private defaultHeaders: Record<string, string>;
constructor(baseURL?: string) {
// Use the current window location instead of localhost, just like WebSocket connections
const defaultBaseURL =
typeof window !== "undefined" ? \`\${window.location.protocol}//\${window.location.host}\` : "http://localhost:8001";
this.baseURL = baseURL || process.env.REACT_APP_API_URL || defaultBaseURL;
this.defaultHeaders = {};
}
/**
* Construct API path using PUBLIC_URL environment variable
* Replaces hardcoded /ai-voicebot prefix with dynamic base from environment
*/
private getApiPath(schemaPath: string): string {
// Replace the hardcoded /ai-voicebot prefix with the dynamic base
return schemaPath.replace("/ai-voicebot", base);
}
private async request<T>(
path: string,
options: {
method: string;
body?: any;
params?: Record<string, string>;
}
): Promise<T> {
const url = new URL(path, this.baseURL);
if (options.params) {
Object.entries(options.params).forEach(([key, value]) => {
url.searchParams.append(key, value);
});
}
const requestInit: RequestInit = {
method: options.method,
headers: {
"Content-Type": "application/json",
...this.defaultHeaders,
},
};
if (options.body && options.method !== "GET") {
requestInit.body = JSON.stringify(options.body);
}
const response = await fetch(url.toString(), requestInit);
if (!response.ok) {
let errorData;
// Clone the response before trying to read it, in case JSON parsing fails
const responseClone = response.clone();
try {
errorData = await response.json();
} catch {
try {
errorData = await responseClone.text();
} catch {
errorData = \`HTTP \${response.status}: \${response.statusText}\`;
}
}
throw new ApiError(response.status, response.statusText, errorData);
}
const contentType = response.headers.get("content-type");
if (contentType && contentType.includes("application/json")) {
return response.json();
}
return response.text() as unknown as T;
}
// Auto-generated endpoint methods
${allMethods}
}
// Default client instance
export const apiClient = new ApiClient();
${convenienceApis}
`;
// Read the current file and find the marker
const currentContent = fs.readFileSync(API_CLIENT_PATH, 'utf8');
const marker = '// DO NOT MANUALLY EDIT BELOW THIS LINE';
const markerIndex = currentContent.indexOf(marker);
if (markerIndex === -1) {
console.error('❌ Could not find auto-generation marker');
return;
}
// Find the end of the marker line (including the guidance comment)
const markerEndIndex = currentContent.indexOf('\n', markerIndex);
const guidanceEndIndex = currentContent.indexOf('\n', markerEndIndex + 1);
// Keep everything up to and including the guidance comment, replace everything after
const templatePart = currentContent.slice(0, guidanceEndIndex + 1);
const fullContent = templatePart + autoGeneratedContent;
fs.writeFileSync(API_CLIENT_PATH, fullContent, 'utf8');
console.log(`✅ Generated complete API client with ${this.endpoints.length} endpoints`);
} catch (error) {
console.error('❌ Failed to generate API client:', error.message);
}
}
/**
* Update existing API client
*/
updateApiClient() {
try {
// Check if file exists
if (!fs.existsSync(API_CLIENT_PATH)) {
console.log('📝 API client file not found, creating minimal template...');
this.createMinimalTemplate();
}
const clientContent = fs.readFileSync(API_CLIENT_PATH, 'utf8');
// Check if this is a minimal template (only has imports and marker)
const marker = '// DO NOT MANUALLY EDIT BELOW THIS LINE';
const markerIndex = clientContent.indexOf(marker);
if (markerIndex === -1) {
console.log('⚠️ Auto-generation marker not found, creating new template...');
this.createMinimalTemplate();
}
// Always regenerate everything after the marker
console.log('🔄 Regenerating complete API client...');
this.generateFullApiClient();
} catch (error) {
console.error('❌ Failed to update API client:', error.message);
}
}
/**
* Update API evolution checker
*/
updateApiEvolutionChecker() {
try {
// Generate list of all current endpoints for evolution tracking
// Convert paths to use dynamic base instead of hardcoded prefix
const currentEndpoints = this.endpoints.map(ep => {
const dynamicPath = ep.path.replace('/ai-voicebot', '${base}');
return `${ep.method}:${dynamicPath}`;
}).sort();
const evolutionContent = `// Auto-generated API evolution checker
// This file tracks known API endpoints to detect changes
import { base } from './Common';
export const knownEndpoints = new Set([
${currentEndpoints.map(ep => ` \`${ep}\``).join(',\n')}
]);
// Schema path for dynamic usage
export const schemaPath = \`\${base}/openapi-schema.json\`;
// Proxy path pattern for matching
export const proxyPathPattern = \`\${base}/{path}\`;
export function checkApiEvolution(discoveredEndpoints: string[]): {
newEndpoints: string[];
removedEndpoints: string[];
totalEndpoints: number;
} {
const discoveredSet = new Set(discoveredEndpoints);
const newEndpoints = discoveredEndpoints.filter(ep => !knownEndpoints.has(ep));
const removedEndpoints = Array.from(knownEndpoints).filter(ep => !discoveredSet.has(ep));
return {
newEndpoints,
removedEndpoints,
totalEndpoints: discoveredEndpoints.length
};
}
`;
fs.writeFileSync(API_EVOLUTION_CHECKER_PATH, evolutionContent, 'utf8');
console.log('✅ Updated API evolution checker with current endpoints');
} catch (error) {
console.error('❌ Failed to update API evolution checker:', error.message);
}
}
/**
* Main execution method
*/
async run() {
console.log('🚀 Starting automated API client generation...');
// Load and validate schema
if (!this.loadSchema()) {
process.exit(1);
}
// Extract endpoints
this.extractEndpoints();
if (this.endpoints.length === 0) {
console.error('❌ No endpoints found in schema');
process.exit(1);
}
console.log(`\n📊 Analysis Results:`);
console.log(` • Schema endpoints: ${this.endpoints.length}`);
// Update/generate API client
this.updateApiClient();
// Update evolution checker
this.updateApiEvolutionChecker();
console.log('\n✅ API client generation complete!');
console.log('📄 Updated files:');
console.log(' - api-client.ts (regenerated)');
console.log(' - api-evolution-checker.ts (updated known endpoints)');
}
}
// Run if called directly
if (require.main === module) {
const generator = new ApiClientGenerator();
generator.run().catch(console.error);
}