#!/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'); } const methodSignature = `async ${methodName}(${params.join(', ')}): Promise`; // 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(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() { // Group endpoints by namespace based on path analysis const namespaceGroups = this.groupEndpointsByNamespace(); // Generate convenience methods for each namespace const namespaceDefinitions = Object.entries(namespaceGroups).map(([namespace, endpoints]) => { // Track used method names to avoid duplicate object keys const usedNames = new Set(); const methods = endpoints.map(endpoint => { // Compute desired method name let candidate = this.generateConvenienceMethodName(endpoint); // If name already used, try to disambiguate with contextual suffixes if (usedNames.has(candidate)) { if (endpoint.path.includes('/instance/')) { candidate = candidate + 'ByInstance'; } else if (endpoint.path.match(/\/config\/schema\/\{?bot_name\}?/)) { candidate = candidate + 'ByName'; } else { // Fallback: append a numeric suffix to ensure uniqueness let i = 2; while (usedNames.has(candidate + i)) i++; candidate = candidate + i; } } usedNames.add(candidate); return this.generateConvenienceMethod(endpoint, candidate); }).join(',\n '); return `export const ${namespace}Api = {\n ${methods}\n};`; }).join('\n\n'); return `// Convenience API namespaces for easy usage\n${namespaceDefinitions}`; } /** * Group endpoints into logical namespaces based on path patterns * Dynamically creates namespaces based on the top-level API path segments */ groupEndpointsByNamespace() { const groups = {}; this.endpoints.forEach(endpoint => { const path = endpoint.path; // Extract the namespace from the path: /ai-voicebot/api/{namespace}/... const pathMatch = path.match(/^\/ai-voicebot\/api\/([^\/]+)/); if (!pathMatch) { // Fallback for unexpected path formats console.warn(`Unexpected path format: ${path}`); return; } let namespace = pathMatch[1]; // Apply special routing rules for better organization if (namespace === 'lobby' && path.includes('/{session_id}') && endpoint.method.toLowerCase() === 'post') { // Lobby creation belongs to sessions namespace for better UX namespace = 'session'; } else if (namespace === 'session' && (path.includes('/cleanup') || path.includes('/metrics') || path.includes('/validate'))) { // Admin session operations belong to admin namespace namespace = 'admin'; } // Initialize namespace group if it doesn't exist if (!groups[namespace]) { groups[namespace] = []; } groups[namespace].push(endpoint); }); // Remove empty groups and return return Object.fromEntries( Object.entries(groups).filter(([_, endpoints]) => endpoints.length > 0) ); } /** * Generate a convenience method for an endpoint with intuitive naming */ generateConvenienceMethod(endpoint, overrideName) { const methodName = overrideName || this.generateConvenienceMethodName(endpoint); const clientMethodName = this.generateMethodName(endpoint); const params = this.extractMethodParameters(endpoint); // Generate parameter list for the convenience method const paramList = params.length > 0 ? params.join(', ') : ''; // Generate argument list for the client method call const argList = this.generateArgumentList(endpoint, params); return `${methodName}: (${paramList}) => apiClient.${clientMethodName}(${argList})`; } /** * Generate an intuitive convenience method name based on the endpoint */ generateConvenienceMethodName(endpoint) { const path = endpoint.path; const method = endpoint.method.toLowerCase(); // Special naming patterns for better developer experience if (path.includes('/lobby') && path.includes('/chat') && method === 'get') { return 'getChatMessages'; } if (path.includes('/lobby') && method === 'get' && !path.includes('/chat')) { return 'getAll'; } if (path.includes('/session') && method === 'get') { return 'getCurrent'; } if (path.includes('/health') && method === 'get' && !path.includes('/live') && !path.includes('/ready')) { return 'check'; } if (path.includes('/health/ready')) { return 'ready'; } if (path.includes('/health/live')) { return 'live'; } if (path.includes('/providers') && method === 'get') { return 'getProviders'; } if (path.includes('/bots') && method === 'get' && !path.includes('/providers') && !path.includes('/instances') && !path.includes('/config')) { return 'getAvailable'; } if (path.includes('/join')) { return 'requestJoinLobby'; } if (path.includes('/leave')) { return 'requestLeaveLobby'; } if (path.includes('/instances/') && method === 'get') { return 'getInstance'; } if (path.includes('/providers/register')) { return 'registerProvider'; } if (path.includes('/lobby/{session_id}') && method === 'post') { return 'createLobby'; } if (path.includes('/cleanup_sessions')) { return 'cleanupSessions'; } if (path.includes('/cleanup_lobbies')) { return 'cleanupLobbies'; } if (path.includes('/session_metrics')) { return 'sessionMetrics'; } if (path.includes('/validate_sessions')) { return 'validateSessions'; } if (path.includes('/set_password')) { return 'setPassword'; } if (path.includes('/clear_password')) { return 'clearPassword'; } if (path.includes('/names')) { return 'listNames'; } // Generic patterns based on method and path const pathSegments = path.split('/').filter(segment => segment && !segment.startsWith('{')); const lastSegment = pathSegments[pathSegments.length - 1]; // Convert common method patterns to intuitive names const methodPrefixes = { 'get': 'get', 'post': 'create', 'put': 'replace', 'patch': 'update', 'delete': 'delete' }; const prefix = methodPrefixes[method] || method; const resource = lastSegment.charAt(0).toUpperCase() + lastSegment.slice(1); return prefix + resource; } /** * Extract parameters needed for the convenience method */ extractMethodParameters(endpoint) { const params = []; // Extract path parameters const pathParams = endpoint.path.match(/\{([^}]+)\}/g); if (pathParams) { pathParams.forEach(param => { const paramName = param.slice(1, -1); // Remove { and } const tsType = this.inferParameterType(paramName); params.push(`${paramName}: ${tsType}`); }); } // Add request body parameter if needed if (endpoint.requestBody) { params.push('data: any'); } // Add query parameters parameter for GET requests if (endpoint.method === 'GET' && this.hasQueryParameters(endpoint)) { params.push('params?: Record'); } return params; } /** * Generate argument list for calling the client method */ generateArgumentList(endpoint, params) { const args = []; // Extract parameter names (without types) - exclude optional params indicator params.forEach(param => { const paramName = param.split(':')[0].trim().replace('?', ''); args.push(paramName); }); return args.join(', '); } /** * Infer TypeScript type for a parameter based on its name */ inferParameterType(paramName) { if (paramName.includes('id')) { return 'string'; } if (paramName.includes('limit') || paramName.includes('count')) { return 'number'; } return 'string'; // Default to string } /** * Check if endpoint has query parameters */ hasQueryParameters(endpoint) { return endpoint.parameters && endpoint.parameters.some(p => p.in === 'query'); } /** * Generate an intuitive convenience method name based on the endpoint */ generateConvenienceMethodName(endpoint) { const path = endpoint.path; const method = endpoint.method.toLowerCase(); // Special naming patterns for better developer experience (order matters - most specific first) // Lobby-related endpoints if (path.includes('/lobby') && path.includes('/chat') && method === 'get') { return 'getChatMessages'; } if (path === '/ai-voicebot/api/lobby' && method === 'get') { return 'getAll'; } if (path.includes('/lobby/{session_id}') && method === 'post') { return 'createLobby'; } // Bot config endpoints (need specific handling to avoid duplicates) if (path.includes('/bots/config/lobby') && path.includes('/bot/') && method === 'get') { return 'getBotConfig'; } if (path.includes('/bots/config/lobby') && path.includes('/bot/') && method === 'delete') { return 'deleteBotConfig'; } if (path.includes('/bots/config/lobby') && method === 'get' && !path.includes('/bot/')) { return 'getBotConfigs'; } if (path.includes('/bots/config/lobby') && method === 'delete' && !path.includes('/bot/')) { return 'deleteBotConfigs'; } if (path.includes('/config/update')) { return 'updateConfig'; } if (path.includes('/config/statistics')) { return 'getConfigStatistics'; } if (path.includes('/config/refresh-schemas')) { return 'refreshAllSchemas'; } if (path.includes('/config/schema/') && path.includes('/refresh')) { return 'refreshSchema'; } if (path.includes('/config/schema/') && path.includes('/cache') && method === 'delete') { return 'clearSchemaCache'; } if (path.includes('/config/schema/') && method === 'get') { return 'getSchema'; } // Bot general endpoints if (path.includes('/providers/register')) { return 'registerProvider'; } if (path.includes('/providers') && method === 'get') { return 'getProviders'; } if (path.includes('/bots') && method === 'get' && !path.includes('/providers') && !path.includes('/instances') && !path.includes('/config')) { return 'getAvailable'; } if (path.includes('/join')) { return 'requestJoinLobby'; } if (path.includes('/leave')) { return 'requestLeaveLobby'; } if (path.includes('/instances/') && method === 'get') { return 'getInstance'; } // Session endpoints if (path.includes('/session') && method === 'get') { return 'getCurrent'; } // Health endpoints (avoid duplicates by being specific) if (path.includes('/system/health')) { return 'getSystemHealth'; } if (path === '/ai-voicebot/api/health' && method === 'get') { return 'check'; } if (path.includes('/health/ready')) { return 'ready'; } if (path.includes('/health/live')) { return 'live'; } // Admin endpoints if (path.includes('/cleanup_sessions')) { return 'cleanupSessions'; } if (path.includes('/cleanup_lobbies')) { return 'cleanupLobbies'; } if (path.includes('/session_metrics')) { return 'getMetrics'; } if (path.includes('/validate_sessions')) { return 'validateSessions'; } if (path.includes('/set_password')) { return 'setPassword'; } if (path.includes('/clear_password')) { return 'clearPassword'; } if (path.includes('/admin/names')) { return 'getNames'; } // Metrics endpoints if (path.includes('/metrics/history')) { return 'getHistory'; } if (path.includes('/metrics/export')) { return 'exportPrometheus'; } if (path === '/ai-voicebot/api/metrics' && method === 'get') { return 'getCurrent'; } // Cache endpoints if (path.includes('/cache/stats')) { return 'getStats'; } if (path.includes('/cache/clear')) { return 'clear'; } // System endpoints if (path.includes('/system/info')) { return 'getInfo'; } // Generic patterns based on method and path segments (fallback) const pathSegments = path.split('/').filter(segment => segment && !segment.startsWith('{')); const lastSegment = pathSegments[pathSegments.length - 1]; // Handle special characters in path segments let resource = lastSegment; if (resource.includes('-')) { // Convert kebab-case to camelCase resource = resource.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); } // Convert common method patterns to intuitive names const methodPrefixes = { 'get': 'get', 'post': 'create', 'put': 'replace', 'patch': 'update', 'delete': 'delete' }; const prefix = methodPrefixes[method] || method; const resourceName = resource.charAt(0).toUpperCase() + resource.slice(1); return prefix + resourceName; } /** * Infer TypeScript type for a parameter based on its name */ inferParameterType(paramName) { if (paramName.includes('id')) { return 'string'; } if (paramName.includes('limit') || paramName.includes('count')) { return 'number'; } return 'string'; // Default to string } /** * Check if endpoint has query parameters */ hasQueryParameters(endpoint) { return endpoint.parameters && endpoint.parameters.some(p => p.in === 'query'); } /** * 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; 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( path: string, options: { method: string; body?: any; params?: Record; } ): Promise { 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); }