912 lines
28 KiB
JavaScript
912 lines
28 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() {
|
|
// 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<string, string>');
|
|
}
|
|
|
|
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<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);
|
|
}
|