From d940c2cbef7db59814568aea33824c1c1c3deaf3 Mon Sep 17 00:00:00 2001 From: James Ketrenos Date: Wed, 3 Sep 2025 13:54:29 -0700 Subject: [PATCH] Type generation --- AUTOMATED_API_CLIENT.md | 238 ++++++++++++++ client/demo-api-evolution.js | 2 +- client/package.json | 4 +- client/src/api-client.ts | 100 +++--- client/src/api-evolution-checker.ts | 37 ++- client/test-dynamic-paths.js | 152 +++++++++ client/update-api-client.js | 469 ++++++++++++++++++++++++++++ generate-ts-types.sh | 17 +- 8 files changed, 960 insertions(+), 59 deletions(-) create mode 100644 AUTOMATED_API_CLIENT.md create mode 100644 client/test-dynamic-paths.js create mode 100644 client/update-api-client.js diff --git a/AUTOMATED_API_CLIENT.md b/AUTOMATED_API_CLIENT.md new file mode 100644 index 0000000..0c9f110 --- /dev/null +++ b/AUTOMATED_API_CLIENT.md @@ -0,0 +1,238 @@ +# Automated API Client Generation System + +This document explains the automated TypeScript API client generation and update system for the AI Voicebot project. + +## Overview + +The system automatically: +1. **Generates OpenAPI schema** from FastAPI server +2. **Creates TypeScript types** from the schema +3. **Updates API client** with missing endpoint implementations using dynamic paths +4. **Updates evolution checker** with current endpoint lists +5. **Validates TypeScript** compilation +6. **Runs evolution checks** to ensure completeness + +All generated API calls use the `PUBLIC_URL` environment variable to dynamically construct paths, making the system deployable to any base path without hardcoded `/ai-voicebot` prefixes. + +## Files in the System + +### Generated Files (Auto-updated) +- `client/openapi-schema.json` - OpenAPI schema from server +- `client/src/api-types.ts` - TypeScript type definitions +- `client/src/api-client.ts` - API client (auto-sections updated) +- `client/src/api-evolution-checker.ts` - Evolution checker (lists updated) + +### Manual Files +- `generate-ts-types.sh` - Main orchestration script +- `client/update-api-client.js` - API client updater utility +- `client/src/api-usage-examples.ts` - Usage examples and patterns + +## Configuration + +### Environment Variables + +The system uses environment variables for dynamic path configuration: + +- **`PUBLIC_URL`** - Base path for the application (e.g., `/ai-voicebot`, `/my-app`, etc.) + - Used in: API paths, schema loading, asset paths + - Default: `""` (empty string for root deployment) + - Set in: Docker environment, build process, or runtime + +### Dynamic Path Handling + +All API endpoints use dynamic path construction: + +```typescript +// Instead of hardcoded paths: +// "/ai-voicebot/api/health" + +// The system uses: +this.getApiPath("/ai-voicebot/api/health") +// Which becomes: `${PUBLIC_URL}/api/health` +``` + +This allows deployment to different base paths without code changes. + +## Usage + +### Full Generation (Recommended) +```bash +./generate-ts-types.sh +``` +This runs the complete pipeline and is the primary way to use the system. + +### Individual Steps +```bash +# Inside client container +npm run generate-schema # Generate OpenAPI schema +npm run generate-types # Generate TypeScript types +npm run update-api-client # Update API client +npm run check-api-evolution # Check for missing endpoints +``` + +## How Auto-Updates Work + +### API Client Updates + +The `update-api-client.js` script: + +1. **Parses OpenAPI schema** to find all available endpoints +2. **Scans existing API client** to detect implemented methods +3. **Identifies missing endpoints** by comparing the two +4. **Generates method implementations** for missing endpoints +5. **Updates the client class** by inserting new methods in designated section +6. **Updates endpoint lists** used by evolution checking + +#### Auto-Generated Section +```typescript +export class ApiClient { + // ... manual methods ... + + /** + * Construct API path using PUBLIC_URL environment variable + * Replaces hardcoded /ai-voicebot prefix with dynamic base from environment + */ + private getApiPath(schemaPath: string): string { + return schemaPath.replace('/ai-voicebot', base); + } + + // Auto-generated endpoints will be added here by update-api-client.js + // DO NOT MANUALLY EDIT BELOW THIS LINE + + // New endpoints automatically appear here using this.getApiPath() +} +``` + +#### Method Generation +- **Method names** derived from `operationId` or path/method combination +- **Parameters** inferred from path parameters and request body +- **Return types** use generic `Promise` (can be enhanced) +- **Path handling** supports both static and parameterized paths using `PUBLIC_URL` +- **Dynamic paths** automatically replace hardcoded prefixes with environment-based values + +### Evolution Checker Updates + +The evolution checker tracks: +- **Known schema endpoints** - updated from current OpenAPI schema +- **Implemented endpoints** - updated from actual API client code +- **Missing endpoints** - calculated difference for warnings + +## Customization + +### Adding Manual Endpoints + +For endpoints not in OpenAPI schema (e.g., external services), add them manually before the auto-generated section: + +```typescript +// Manual endpoints (these won't be auto-generated) +async getCustomData(): Promise { + return this.request("/custom/endpoint", { method: "GET" }); +} + +// Auto-generated endpoints will be added here by update-api-client.js +// DO NOT MANUALLY EDIT BELOW THIS LINE +``` + +### Improving Generated Methods + +To enhance auto-generated methods: + +1. **Better Type Inference**: Modify `generateMethodSignature()` in `update-api-client.js` to use specific types from schema +2. **Parameter Validation**: Add validation logic in method generation +3. **Error Handling**: Customize error handling patterns +4. **Documentation**: Add JSDoc generation from OpenAPI descriptions + +### Schema Evolution Detection + +The system detects: +- **New endpoints** added to OpenAPI schema +- **Changed endpoints** (parameter or response changes) +- **Deprecated endpoints** (with proper OpenAPI marking) + +## Development Workflow + +1. **Develop API endpoints** in FastAPI server with proper typing +2. **Run generation script** to update client: `./generate-ts-types.sh` +3. **Use generated types** in React components +4. **Manual customization** for complex endpoints if needed +5. **Commit all changes** including generated and updated files + +## Best Practices + +### Server Development +- Use **Pydantic models** for all request/response types +- Add **proper OpenAPI metadata** (summary, description, tags) +- Use **consistent naming** for operation IDs +- **Version your API** to handle breaking changes + +### Client Development +- **Import from api-client.ts** rather than making raw fetch calls +- **Use generated types** for type safety +- **Avoid editing auto-generated sections** - they will be overwritten +- **Add custom endpoints manually** when needed + +### Type Safety +```typescript +// Good: Using generated types and client +import { apiClient, type LobbyModel, type LobbyCreateRequest } from './api-client'; + +const createLobby = async (data: LobbyCreateRequest): Promise => { + const response = await apiClient.createLobby(sessionId, data); + return response.data; // Fully typed +}; + +// Avoid: Direct fetch calls +const createLobbyRaw = async () => { + const response = await fetch('/api/lobby', { /* ... */ }); + return response.json(); // No type safety +}; +``` + +## Troubleshooting + +### Common Issues + +**"Could not find insertion marker"** +- The API client file was manually edited and the auto-generation markers were removed +- Restore the markers or regenerate the client file from template + +**"Missing endpoints detected"** +- New endpoints were added to the server but the generation script wasn't run +- Run `./generate-ts-types.sh` to update the client + +**"Type errors after generation"** +- Schema changes may have affected existing manual code +- Check the TypeScript compiler output and update affected code + +**"Duplicate method names"** +- Manual methods conflict with auto-generated ones +- Rename manual methods or adjust the operation ID generation logic + +### Debug Mode + +Add debug logging by modifying `update-api-client.js`: + +```javascript +// Add after parsing +console.log('Schema endpoints:', this.endpoints.map(e => `${e.method}:${e.path}`)); +console.log('Implemented endpoints:', Array.from(this.implementedEndpoints)); +``` + +## Future Enhancements + +- **Stronger type inference** from OpenAPI schema components +- **Request/response validation** using schema definitions +- **Mock data generation** for testing +- **API versioning support** with backward compatibility +- **Performance optimization** with request caching +- **OpenAPI spec validation** before generation + +## Integration with Build Process + +The system integrates with: +- **Docker Compose** for cross-container coordination +- **npm scripts** for frontend build pipeline +- **TypeScript compilation** for type checking +- **CI/CD workflows** for automated updates + +This ensures that API changes are automatically reflected in the frontend without manual intervention, reducing development friction and preventing API/client drift. diff --git a/client/demo-api-evolution.js b/client/demo-api-evolution.js index a2bcfe6..03325e5 100644 --- a/client/demo-api-evolution.js +++ b/client/demo-api-evolution.js @@ -1,7 +1,7 @@ #!/usr/bin/env node /** * Demo script to show API evolution detection in action - * This simulates having a missing endpoint to demonstrate the output + * This simulates having missing endpoints and shows the automated update process */ const fs = require('fs'); diff --git a/client/package.json b/client/package.json index 19663c4..db73454 100644 --- a/client/package.json +++ b/client/package.json @@ -37,8 +37,10 @@ "type-check": "tsc --noEmit", "generate-schema": "cd ../server && python3 generate_schema_simple.py", "generate-types": "npx openapi-typescript openapi-schema.json -o src/api-types.ts", - "generate-api-types": "npm run generate-schema && npm run generate-types", + "update-api-client": "node update-api-client.js", + "generate-api-types": "npm run generate-schema && npm run generate-types && npm run update-api-client", "check-api-evolution": "node check-api-evolution.js", + "test-dynamic-paths": "node test-dynamic-paths.js", "prebuild": "npm run generate-api-types" }, "npmConfig": { diff --git a/client/src/api-client.ts b/client/src/api-client.ts index cd3155c..5b36857 100644 --- a/client/src/api-client.ts +++ b/client/src/api-client.ts @@ -1,22 +1,23 @@ // TypeScript API client for AI Voicebot server import { components, paths } from "./api-types"; +import { base } from "./Common"; // Re-export commonly used types from the generated schema -export type LobbyModel = components['schemas']['LobbyModel']; -export type LobbyListItem = components['schemas']['LobbyListItem']; -export type LobbyCreateData = components['schemas']['LobbyCreateData']; -export type NamePasswordRecord = components['schemas']['NamePasswordRecord']; +export type LobbyModel = components["schemas"]["LobbyModel"]; +export type LobbyListItem = components["schemas"]["LobbyListItem"]; +export type LobbyCreateData = components["schemas"]["LobbyCreateData"]; +export type NamePasswordRecord = components["schemas"]["NamePasswordRecord"]; // Type aliases for API methods -export type AdminNamesResponse = components['schemas']['AdminNamesResponse']; -export type AdminActionResponse = components['schemas']['AdminActionResponse']; -export type AdminSetPassword = components['schemas']['AdminSetPassword']; -export type AdminClearPassword = components['schemas']['AdminClearPassword']; -export type HealthResponse = components['schemas']['HealthResponse']; -export type LobbiesResponse = components['schemas']['LobbiesResponse']; -export type SessionResponse = components['schemas']['SessionResponse']; -export type LobbyCreateRequest = components['schemas']['LobbyCreateRequest']; -export type LobbyCreateResponse = components['schemas']['LobbyCreateResponse']; +export type AdminNamesResponse = components["schemas"]["AdminNamesResponse"]; +export type AdminActionResponse = components["schemas"]["AdminActionResponse"]; +export type AdminSetPassword = components["schemas"]["AdminSetPassword"]; +export type AdminClearPassword = components["schemas"]["AdminClearPassword"]; +export type HealthResponse = components["schemas"]["HealthResponse"]; +export type LobbiesResponse = components["schemas"]["LobbiesResponse"]; +export type SessionResponse = components["schemas"]["SessionResponse"]; +export type LobbyCreateRequest = components["schemas"]["LobbyCreateRequest"]; +export type LobbyCreateResponse = components["schemas"]["LobbyCreateResponse"]; // Bot Provider Types (manually defined until API types are regenerated) export interface BotInfoModel { @@ -57,13 +58,9 @@ export interface BotJoinLobbyResponse { } export class ApiError extends Error { - constructor( - public status: number, - public statusText: string, - public data?: any - ) { + constructor(public status: number, public statusText: string, public data?: any) { super(`HTTP ${status}: ${statusText}`); - this.name = 'ApiError'; + this.name = "ApiError"; } } @@ -76,6 +73,15 @@ export class ApiClient { 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: { @@ -126,50 +132,70 @@ export class ApiClient { // Admin API methods async adminListNames(): Promise { - return this.request("/ai-voicebot/api/admin/names", { method: "GET" }); + return this.request(this.getApiPath("/ai-voicebot/api/admin/names"), { method: "GET" }); } async adminSetPassword(data: AdminSetPassword): Promise { - return this.request("/ai-voicebot/api/admin/set_password", { method: "POST", body: data }); + return this.request(this.getApiPath("/ai-voicebot/api/admin/set_password"), { + method: "POST", + body: data, + }); } async adminClearPassword(data: AdminClearPassword): Promise { - return this.request("/ai-voicebot/api/admin/clear_password", { method: "POST", body: data }); + return this.request(this.getApiPath("/ai-voicebot/api/admin/clear_password"), { + method: "POST", + body: data, + }); } // Health check async healthCheck(): Promise { - return this.request("/ai-voicebot/api/health", { method: "GET" }); + return this.request(this.getApiPath("/ai-voicebot/api/health"), { method: "GET" }); } // Session methods async getSession(): Promise { - return this.request("/ai-voicebot/api/session", { method: "GET" }); + return this.request(this.getApiPath("/ai-voicebot/api/session"), { method: "GET" }); } // Lobby methods async getLobbies(): Promise { - return this.request("/ai-voicebot/api/lobby", { method: "GET" }); + return this.request(this.getApiPath("/ai-voicebot/api/lobby"), { method: "GET" }); } async createLobby(sessionId: string, data: LobbyCreateRequest): Promise { - return this.request(`/ai-voicebot/api/lobby/${sessionId}`, { method: "POST", body: data }); + return this.request(this.getApiPath(`/ai-voicebot/api/lobby/${sessionId}`), { + method: "POST", + body: data, + }); } // Bot Provider methods async getBotProviders(): Promise { - return this.request("/ai-voicebot/api/bots/providers", { method: "GET" }); + return this.request(this.getApiPath("/ai-voicebot/api/bots/providers"), { method: "GET" }); } async getAvailableBots(): Promise { - return this.request("/ai-voicebot/api/bots", { method: "GET" }); + return this.request(this.getApiPath("/ai-voicebot/api/bots"), { method: "GET" }); } async requestBotJoinLobby(botName: string, request: BotJoinLobbyRequest): Promise { - return this.request(`/ai-voicebot/api/bots/${encodeURIComponent(botName)}/join`, { - method: "POST", - body: request, - }); + return this.request( + this.getApiPath(`/ai-voicebot/api/bots/${encodeURIComponent(botName)}/join`), + { + method: "POST", + body: request, + } + ); + } + + // Auto-generated endpoints will be added here by update-api-client.js + // DO NOT MANUALLY EDIT BELOW THIS LINE + + // Auto-generated endpoints + async lobbyCreate(session_id: string, data: any): Promise { + return this.request(this.getApiPath(`/ai-voicebot/api/lobby/${session_id}`), { method: "POST", body: data }); } } @@ -193,13 +219,17 @@ class ApiEvolutionChecker { private getImplementedEndpoints(): Set { // Define all endpoints that are currently implemented in ApiClient + // This list is automatically updated by update-api-client.js return new Set([ 'GET:/ai-voicebot/api/admin/names', - 'POST:/ai-voicebot/api/admin/set_password', - 'POST:/ai-voicebot/api/admin/clear_password', + 'GET:/ai-voicebot/api/bots', + 'GET:/ai-voicebot/api/bots/providers', 'GET:/ai-voicebot/api/health', - 'GET:/ai-voicebot/api/session', 'GET:/ai-voicebot/api/lobby', + 'GET:/ai-voicebot/api/session', + 'POST:/ai-voicebot/api/admin/clear_password', + 'POST:/ai-voicebot/api/admin/set_password', + 'POST:/ai-voicebot/api/lobby/{sessionId}', 'POST:/ai-voicebot/api/lobby/{session_id}' ]); } diff --git a/client/src/api-evolution-checker.ts b/client/src/api-evolution-checker.ts index fb387bc..8fa1ec2 100644 --- a/client/src/api-evolution-checker.ts +++ b/client/src/api-evolution-checker.ts @@ -2,6 +2,7 @@ // This module provides runtime introspection of OpenAPI schema changes import { paths } from './api-types'; +import { base } from "./Common"; export interface EndpointInfo { path: string; @@ -27,10 +28,11 @@ export class AdvancedApiEvolutionChecker { */ private extractEndpointsFromSchema(): EndpointInfo[] { const endpoints: EndpointInfo[] = []; - + // In a real implementation, we would need to parse the actual OpenAPI JSON // since TypeScript types are erased at runtime. For now, we'll maintain // a list that should be updated when new endpoints are added to the schema. + // This list is automatically updated by the update-api-client.js script const knownSchemaEndpoints = [ { path: '/ai-voicebot/api/admin/names', method: 'GET', operationId: 'admin_list_names_ai_voicebot_api_admin_names_get' }, { path: '/ai-voicebot/api/admin/set_password', method: 'POST', operationId: 'admin_set_password_ai_voicebot_api_admin_set_password_post' }, @@ -38,17 +40,17 @@ export class AdvancedApiEvolutionChecker { { path: '/ai-voicebot/api/health', method: 'GET', operationId: 'health_ai_voicebot_api_health_get' }, { path: '/ai-voicebot/api/session', method: 'GET', operationId: 'session_ai_voicebot_api_session_get' }, { path: '/ai-voicebot/api/lobby', method: 'GET', operationId: 'get_lobbies_ai_voicebot_api_lobby_get' }, - { path: '/ai-voicebot/api/lobby/{session_id}', method: 'POST', operationId: 'lobby_create_ai_voicebot_api_lobby__session_id__post' }, + { path: '/ai-voicebot/api/lobby/{session_id}', method: 'POST', operationId: 'lobby_create_ai_voicebot_api_lobby__session_id__post' } ]; // Get implemented endpoints from ApiClient const implementedEndpoints = this.getImplementedEndpoints(); - knownSchemaEndpoints.forEach(endpoint => { + knownSchemaEndpoints.forEach((endpoint) => { const key = `${endpoint.method}:${endpoint.path}`; endpoints.push({ ...endpoint, - implemented: implementedEndpoints.has(key) + implemented: implementedEndpoints.has(key), }); }); @@ -58,11 +60,14 @@ export class AdvancedApiEvolutionChecker { private getImplementedEndpoints(): Set { return new Set([ 'GET:/ai-voicebot/api/admin/names', - 'POST:/ai-voicebot/api/admin/set_password', + 'POST:/ai-voicebot/api/admin/set_password', 'POST:/ai-voicebot/api/admin/clear_password', 'GET:/ai-voicebot/api/health', 'GET:/ai-voicebot/api/session', 'GET:/ai-voicebot/api/lobby', + 'POST:/ai-voicebot/api/lobby/{sessionId}', + 'GET:/ai-voicebot/api/bots/providers', + 'GET:/ai-voicebot/api/bots', 'POST:/ai-voicebot/api/lobby/{session_id}' ]); } @@ -72,8 +77,8 @@ export class AdvancedApiEvolutionChecker { */ async loadSchemaFromJson(): Promise { try { - // In a real implementation, you might fetch this from a URL or import it - const response = await fetch("/ai-voicebot/openapi-schema.json"); + // Use dynamic base path from environment + const response = await fetch(`${base}/openapi-schema.json`); if (response.ok) { return await response.json(); } @@ -118,26 +123,24 @@ export class AdvancedApiEvolutionChecker { // Extract endpoints from the actual schema if (schema.paths) { Object.keys(schema.paths).forEach(path => { - if (path === '/ai-voicebot/{path}') return; // Skip generic proxy - + if (path === `${base}/{path}`) return; // Skip generic proxy + const pathObj = schema.paths[path]; - Object.keys(pathObj).forEach(method => { + Object.keys(pathObj).forEach((method) => { const endpoint = `${method.toUpperCase()}:${path}`; const implementedEndpoints = this.getImplementedEndpoints(); - + if (!implementedEndpoints.has(endpoint)) { const endpointInfo: EndpointInfo = { path, method: method.toUpperCase(), operationId: pathObj[method].operationId, - implemented: false + implemented: false, }; - + // Check if this is a new endpoint (not in our known list) - const isKnown = currentEndpoints.some(ep => - ep.path === path && ep.method === method.toUpperCase() - ); - + const isKnown = currentEndpoints.some((ep) => ep.path === path && ep.method === method.toUpperCase()); + if (!isKnown) { hasNewEndpoints = true; newEndpoints.push(endpointInfo); diff --git a/client/test-dynamic-paths.js b/client/test-dynamic-paths.js new file mode 100644 index 0000000..d25e64e --- /dev/null +++ b/client/test-dynamic-paths.js @@ -0,0 +1,152 @@ +#!/usr/bin/env node +/** + * Test script to verify dynamic path handling in API client + * This script tests that the API client correctly uses PUBLIC_URL for dynamic paths + */ + +const fs = require('fs'); +const path = require('path'); + +class ApiClientPathTester { + constructor() { + this.clientPath = path.join(__dirname, 'src', 'api-client.ts'); + } + + /** + * Test that all API methods use getApiPath for dynamic paths + */ + testDynamicPaths() { + console.log('๐Ÿงช Testing API Client Dynamic Path Usage'); + console.log('=========================================\n'); + + try { + const clientContent = fs.readFileSync(this.clientPath, 'utf8'); + + // Find all API method calls + const apiCallRegex = /this\.request<[^>]*>\(([^,]+),/g; + const hardcodedPathRegex = /this\.request<[^>]*>\("\/ai-voicebot/g; + const dynamicPathRegex = /this\.request<[^>]*>\(this\.getApiPath\(/g; + + let match; + const allApiCalls = []; + const hardcodedCalls = []; + const dynamicCalls = []; + + // Find all API calls + while ((match = apiCallRegex.exec(clientContent)) !== null) { + allApiCalls.push(match[1].trim()); + } + + // Find hardcoded calls (bad) + const hardcodedMatches = [...clientContent.matchAll(hardcodedPathRegex)]; + + // Find dynamic calls (good) + const dynamicMatches = [...clientContent.matchAll(dynamicPathRegex)]; + + console.log('๐Ÿ“Š Results:'); + console.log(` โ€ข Total API method calls: ${allApiCalls.length}`); + console.log(` โ€ข Using this.getApiPath(): ${dynamicMatches.length}`); + console.log(` โ€ข Using hardcoded paths: ${hardcodedMatches.length}`); + + if (hardcodedMatches.length === 0) { + console.log('\nโœ… PASS: No hardcoded /ai-voicebot paths found!'); + console.log(' All API calls use dynamic path construction.'); + } else { + console.log('\nโŒ FAIL: Found hardcoded paths that should use getApiPath():'); + // Show some context for each hardcoded path + hardcodedMatches.forEach((match, index) => { + const start = Math.max(0, match.index - 50); + const end = Math.min(clientContent.length, match.index + 100); + const context = clientContent.slice(start, end).replace(/\n/g, '\\n'); + console.log(` ${index + 1}. ...${context}...`); + }); + } + + // Check that imports are correct + const hasCommonImport = clientContent.includes('import { base } from "./Common"'); + const hasGetApiPathMethod = clientContent.includes('getApiPath(schemaPath: string)'); + + console.log('\n๐Ÿ” Implementation Details:'); + console.log(` โ€ข Imports base from Common.ts: ${hasCommonImport ? 'โœ…' : 'โŒ'}`); + console.log(` โ€ข Has getApiPath() method: ${hasGetApiPathMethod ? 'โœ…' : 'โŒ'}`); + + if (hasCommonImport && hasGetApiPathMethod && hardcodedMatches.length === 0) { + console.log('\n๐ŸŽ‰ All tests passed! API client correctly uses dynamic paths.'); + return true; + } else { + console.log('\nโš ๏ธ Some issues found. See details above.'); + return false; + } + + } catch (error) { + console.error('โŒ Test failed:', error.message); + return false; + } + } + + /** + * Test that the evolution checker also uses dynamic paths + */ + testEvolutionChecker() { + console.log('\n๐Ÿงช Testing Evolution Checker Dynamic Path Usage'); + console.log('===============================================\n'); + + try { + const checkerPath = path.join(__dirname, 'src', 'api-evolution-checker.ts'); + const checkerContent = fs.readFileSync(checkerPath, 'utf8'); + + const hasCommonImport = checkerContent.includes('import { base } from \'./Common\'') || + checkerContent.includes('import { base } from "./Common"'); + const usesDynamicSchema = checkerContent.includes('`${base}/openapi-schema.json`'); + const usesDynamicProxy = checkerContent.includes('`${base}/{path}`'); + + console.log('๐Ÿ“Š Evolution Checker Results:'); + console.log(` โ€ข Imports base from Common.ts: ${hasCommonImport ? 'โœ…' : 'โŒ'}`); + console.log(` โ€ข Uses dynamic schema path: ${usesDynamicSchema ? 'โœ…' : 'โŒ'}`); + console.log(` โ€ข Uses dynamic proxy check: ${usesDynamicProxy ? 'โœ…' : 'โŒ'}`); + + if (hasCommonImport && usesDynamicSchema && usesDynamicProxy) { + console.log('\nโœ… Evolution checker correctly uses dynamic paths!'); + return true; + } else { + console.log('\nโš ๏ธ Evolution checker has some hardcoded paths.'); + return false; + } + + } catch (error) { + console.error('โŒ Evolution checker test failed:', error.message); + return false; + } + } + + /** + * Run all tests + */ + run() { + const apiClientTest = this.testDynamicPaths(); + const evolutionTest = this.testEvolutionChecker(); + + console.log('\n๐Ÿ“‹ Summary'); + console.log('==========='); + console.log(`API Client: ${apiClientTest ? 'โœ… PASS' : 'โŒ FAIL'}`); + console.log(`Evolution Checker: ${evolutionTest ? 'โœ… PASS' : 'โŒ FAIL'}`); + + if (apiClientTest && evolutionTest) { + console.log('\n๐ŸŽ‰ All dynamic path tests passed!'); + console.log('The system correctly uses PUBLIC_URL for dynamic path construction.'); + return 0; + } else { + console.log('\nโš ๏ธ Some tests failed. Check the details above.'); + return 1; + } + } +} + +// Run the test +if (require.main === module) { + const tester = new ApiClientPathTester(); + const exitCode = tester.run(); + process.exit(exitCode); +} + +module.exports = ApiClientPathTester; diff --git a/client/update-api-client.js b/client/update-api-client.js new file mode 100644 index 0000000..eaff765 --- /dev/null +++ b/client/update-api-client.js @@ -0,0 +1,469 @@ +#!/usr/bin/env node +/** + * Automated API Client and Evolution Checker Updater + * + * This script analyzes the generated OpenAPI schema and automatically updates: + * 1. api-client.ts - Adds missing endpoint implementations + * 2. api-evolution-checker.ts - Updates known endpoints list + * + * Run this script after generating TypeScript types from OpenAPI schema. + */ + +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 ApiClientUpdater { + constructor() { + this.schema = null; + this.endpoints = []; + this.implementedEndpoints = new Set(); + } + + /** + * 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, pathObj]) => { + // Skip the generic proxy endpoint + if (path === '/ai-voicebot/{path}') { + return; + } + + Object.entries(pathObj).forEach(([method, methodObj]) => { + const endpoint = { + path, + method: method.toUpperCase(), + operationId: methodObj.operationId, + summary: methodObj.summary, + parameters: methodObj.parameters || [], + requestBody: methodObj.requestBody, + responses: methodObj.responses + }; + + this.endpoints.push(endpoint); + }); + }); + + console.log(`โœ… Extracted ${this.endpoints.length} endpoints from schema`); + } + + /** + * Parse the current API client to find implemented endpoints + */ + parseCurrentApiClient() { + try { + const clientContent = fs.readFileSync(API_CLIENT_PATH, 'utf8'); + + // Find implemented endpoints by looking for method patterns and path literals + // Now handles both old hardcoded paths and new this.getApiPath() calls + const methodRegex = /async\s+(\w+)\([^)]*\):[^{]*{\s*return\s+this\.request<[^>]*>\(([^,]+),\s*{\s*method:\s*"([^"]+)"/g; + + let match; + while ((match = methodRegex.exec(clientContent)) !== null) { + const [, methodName, pathExpression, httpMethod] = match; + + // Handle both string literals, template literals, and this.getApiPath() calls + let actualPath; + + if (pathExpression.includes('this.getApiPath(')) { + // Extract path from this.getApiPath("path") or this.getApiPath(`path`) + const getApiPathMatch = pathExpression.match(/this\.getApiPath\(([^)]+)\)/); + if (getApiPathMatch) { + let innerPath = getApiPathMatch[1]; + + // Remove quotes or backticks + if ((innerPath.startsWith('"') && innerPath.endsWith('"')) || + (innerPath.startsWith("'") && innerPath.endsWith("'"))) { + actualPath = innerPath.slice(1, -1); + } else if (innerPath.startsWith('`') && innerPath.endsWith('`')) { + // Template literal - convert ${param} back to {param} + actualPath = innerPath.slice(1, -1).replace(/\$\{([^}]+)\}/g, '{$1}'); + } + } + } else if (pathExpression.startsWith('"') && pathExpression.endsWith('"')) { + // String literal + actualPath = pathExpression.slice(1, -1); + } else if (pathExpression.startsWith('`') && pathExpression.endsWith('`')) { + // Template literal - convert ${param} back to {param} + actualPath = pathExpression.slice(1, -1).replace(/\$\{([^}]+)\}/g, '{$1}'); + } else { + // Skip complex expressions we can't easily parse + continue; + } + + if (actualPath) { + this.implementedEndpoints.add(`${httpMethod}:${actualPath}`); + } + } + + console.log(`โœ… Found ${this.implementedEndpoints.size} implemented endpoints in API client`); + } catch (error) { + console.error('โŒ Failed to parse current API client:', error.message); + } + } + + /** + * Generate method name from operation ID or path + */ + generateMethodName(endpoint) { + if (endpoint.operationId) { + // Convert snake_case operation ID to camelCase + return endpoint.operationId + .replace(/_ai_voicebot_.*$/, '') // Remove the long suffix + .replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()) + .replace(/^([a-z])/, (_, letter) => letter.toLowerCase()); + } + + // Fallback: generate from path and method + const pathParts = endpoint.path.split('/').filter(part => part && !part.startsWith('{')); + const lastPart = pathParts[pathParts.length - 1] || 'resource'; + const method = endpoint.method.toLowerCase(); + + if (method === 'get') { + return `get${lastPart.charAt(0).toUpperCase() + lastPart.slice(1)}`; + } else { + return `${method}${lastPart.charAt(0).toUpperCase() + lastPart.slice(1)}`; + } + } + + /** + * Generate parameter types and method signature + */ + generateMethodSignature(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'); // Could be more specific with schema analysis + } + + // Check for query parameters + if (endpoint.parameters && endpoint.parameters.length > 0) { + const queryParams = endpoint.parameters.filter(p => p.in === 'query'); + if (queryParams.length > 0) { + params.push('params?: Record'); + } + } + + const paramString = params.join(', '); + const returnType = 'Promise'; // Could be more specific + + return { + methodName, + paramString, + returnType, + pathParams + }; + } + + /** + * Generate method implementation + */ + generateMethodImplementation(endpoint) { + const { methodName, paramString, returnType, pathParams } = this.generateMethodSignature(endpoint); + + // Use this.getApiPath() to make paths dynamic based on PUBLIC_URL + let pathExpression; + if (pathParams.length > 0) { + // For parameterized paths, we need to use template literals + pathExpression = 'this.getApiPath(`' + endpoint.path.replace(/{([^}]+)}/g, '${$1}') + '`)'; + } else { + // For static paths, use string literal + pathExpression = `this.getApiPath("${endpoint.path}")`; + } + + let requestOptions = `{ method: "${endpoint.method}"`; + + if (endpoint.requestBody) { + requestOptions += ', body: data'; + } + + if (endpoint.parameters && endpoint.parameters.some(p => p.in === 'query')) { + requestOptions += ', params'; + } + + requestOptions += ' }'; + + return ` async ${methodName}(${paramString}): ${returnType} { + return this.request(${pathExpression}, ${requestOptions}); + }`; + } + + /** + * Update the API client with missing endpoints + */ + updateApiClient() { + try { + const clientContent = fs.readFileSync(API_CLIENT_PATH, 'utf8'); + + // Find unimplemented endpoints + const unimplementedEndpoints = this.endpoints.filter(endpoint => { + const key = `${endpoint.method}:${endpoint.path}`; + return !this.implementedEndpoints.has(key); + }); + + if (unimplementedEndpoints.length === 0) { + console.log('โœ… All endpoints are already implemented in API client'); + return; + } + + console.log(`๐Ÿ”ง Adding ${unimplementedEndpoints.length} missing endpoints to API client`); + + // Generate new method implementations + const newMethods = unimplementedEndpoints.map(endpoint => { + console.log(` โ€ข ${endpoint.method} ${endpoint.path} (${this.generateMethodName(endpoint)})`); + return this.generateMethodImplementation(endpoint); + }).join('\n\n'); + + // Find the insertion point (before the auto-generated comment and closing brace) + const insertionMarker = ' // Auto-generated endpoints will be added here by update-api-client.js'; + const insertionIndex = clientContent.indexOf(insertionMarker); + + if (insertionIndex === -1) { + throw new Error('Could not find auto-generated endpoints insertion marker in ApiClient class'); + } + + // Find the end of the marker line + const markerEndIndex = clientContent.indexOf('\n', insertionIndex); + const doNotEditLine = clientContent.indexOf('\n', markerEndIndex + 1); + + // Insert the new methods after the "DO NOT MANUALLY EDIT BELOW THIS LINE" comment + const updatedContent = + clientContent.slice(0, doNotEditLine + 1) + + '\n // Auto-generated endpoints\n' + + newMethods + + '\n' + + clientContent.slice(doNotEditLine + 1); + + // Write the updated content + fs.writeFileSync(API_CLIENT_PATH, updatedContent, 'utf8'); + console.log('โœ… Updated API client with new endpoints'); + + // Also update the implemented endpoints list + this.updateApiClientImplementedEndpointsList(unimplementedEndpoints); + + } catch (error) { + console.error('โŒ Failed to update API client:', error.message); + } + } + + /** + * Update the implemented endpoints list in ApiClient + */ + updateApiClientImplementedEndpointsList(newEndpoints) { + try { + const clientContent = fs.readFileSync(API_CLIENT_PATH, 'utf8'); + + // Add new endpoints to the implemented list + const newEndpointKeys = newEndpoints.map(ep => `${ep.method}:${ep.path}`); + const allImplementedEndpoints = Array.from(this.implementedEndpoints) + .concat(newEndpointKeys) + .sort(); + + const implementedArray = allImplementedEndpoints + .map(endpoint => ` '${endpoint}'`) + .join(',\n'); + + const newImplementedEndpoints = `return new Set([ +${implementedArray} + ]);`; + + // Replace the existing implemented endpoints set in ApiClient + const implementedRegex = /private getImplementedEndpoints\(\): Set \{[\s\S]*?return new Set\(\[[^\]]*\]\);[\s\S]*?\}/; + + const replacement = `private getImplementedEndpoints(): Set { + // Define all endpoints that are currently implemented in ApiClient + // This list is automatically updated by update-api-client.js + ${newImplementedEndpoints} + }`; + + if (implementedRegex.test(clientContent)) { + const updatedContent = clientContent.replace(implementedRegex, replacement); + fs.writeFileSync(API_CLIENT_PATH, updatedContent, 'utf8'); + console.log('โœ… Updated implemented endpoints list in API client'); + } else { + console.warn('โš ๏ธ Could not find getImplementedEndpoints method in API client'); + } + + } catch (error) { + console.error('โŒ Failed to update implemented endpoints list in API client:', error.message); + } + } + + /** + * Update the API evolution checker with current endpoints + */ + updateEvolutionChecker() { + try { + const checkerContent = fs.readFileSync(API_EVOLUTION_CHECKER_PATH, 'utf8'); + + // Generate the updated known endpoints list + const knownEndpointsArray = this.endpoints.map(endpoint => { + return ` { path: '${endpoint.path}', method: '${endpoint.method}', operationId: '${endpoint.operationId || ''}' }`; + }).join(',\n'); + + const newKnownEndpoints = `const knownSchemaEndpoints = [ +${knownEndpointsArray} + ];`; + + // Replace the existing knownSchemaEndpoints array + const knownEndpointsRegex = /const knownSchemaEndpoints = \[[^\]]+\];/s; + + if (knownEndpointsRegex.test(checkerContent)) { + const updatedContent = checkerContent.replace(knownEndpointsRegex, newKnownEndpoints); + fs.writeFileSync(API_EVOLUTION_CHECKER_PATH, updatedContent, 'utf8'); + console.log('โœ… Updated API evolution checker with current endpoints'); + } else { + console.warn('โš ๏ธ Could not find knownSchemaEndpoints array in evolution checker'); + } + + } catch (error) { + console.error('โŒ Failed to update evolution checker:', error.message); + } + } + + /** + * Update the implemented endpoints list in the evolution checker + */ + updateImplementedEndpointsList() { + try { + const checkerContent = fs.readFileSync(API_EVOLUTION_CHECKER_PATH, 'utf8'); + + // Generate the updated implemented endpoints list + const implementedArray = Array.from(this.implementedEndpoints) + .concat(this.endpoints.map(ep => `${ep.method}:${ep.path}`)) + .filter((value, index, self) => self.indexOf(value) === index) // Remove duplicates + .map(endpoint => ` '${endpoint}'`) + .join(',\n'); + + const newImplementedEndpoints = `return new Set([ +${implementedArray} + ]);`; + + // Replace the existing implemented endpoints set + const implementedRegex = /return new Set\(\[[^\]]+\]\);/s; + + if (implementedRegex.test(checkerContent)) { + const updatedContent = checkerContent.replace(implementedRegex, newImplementedEndpoints); + fs.writeFileSync(API_EVOLUTION_CHECKER_PATH, updatedContent, 'utf8'); + console.log('โœ… Updated implemented endpoints list in evolution checker'); + } else { + console.warn('โš ๏ธ Could not find implemented endpoints set in evolution checker'); + } + + } catch (error) { + console.error('โŒ Failed to update implemented endpoints list:', error.message); + } + } + + /** + * Generate updated type exports for the API client + */ + updateTypeExports() { + try { + const clientContent = fs.readFileSync(API_CLIENT_PATH, 'utf8'); + + // Extract all schema types from the generated api-types.ts + const typesContent = fs.readFileSync(API_TYPES_PATH, 'utf8'); + const schemaRegex = /export interface paths \{[\s\S]*?\n\}/; + const componentsRegex = /export interface components \{[\s\S]*schemas: \{[\s\S]*?\n \};\n\}/; + + // Find all schema names in components.schemas + const schemaNames = []; + const schemaMatches = typesContent.match(/"([^"]+)":\s*\{/g); + if (schemaMatches) { + schemaMatches.forEach(match => { + const name = match.match(/"([^"]+)":/)[1]; + if (name && !name.includes('ValidationError')) { + schemaNames.push(name); + } + }); + } + + console.log(`โœ… Found ${schemaNames.length} schema types for potential export`); + + } catch (error) { + console.error('โŒ Failed to update type exports:', error.message); + } + } + + /** + * Run the complete update process + */ + async run() { + console.log('๐Ÿš€ Starting automated API client update...\n'); + + if (!this.loadSchema()) { + process.exit(1); + } + + this.extractEndpoints(); + this.parseCurrentApiClient(); + + console.log('\n๐Ÿ“Š Analysis Results:'); + console.log(` โ€ข Schema endpoints: ${this.endpoints.length}`); + console.log(` โ€ข Implemented endpoints: ${this.implementedEndpoints.size}`); + console.log(` โ€ข Missing endpoints: ${this.endpoints.length - this.implementedEndpoints.size}\n`); + + // Update files + this.updateApiClient(); + this.updateEvolutionChecker(); + this.updateImplementedEndpointsList(); + + console.log('\nโœ… API client update complete!'); + console.log('๐Ÿ“„ Updated files:'); + console.log(' - api-client.ts (added missing endpoints)'); + console.log(' - api-evolution-checker.ts (updated known endpoints)'); + } +} + +// Run the updater +if (require.main === module) { + const updater = new ApiClientUpdater(); + updater.run().catch(error => { + console.error('โŒ Update failed:', error); + process.exit(1); + }); +} + +module.exports = ApiClientUpdater; diff --git a/generate-ts-types.sh b/generate-ts-types.sh index c090dab..8935821 100755 --- a/generate-ts-types.sh +++ b/generate-ts-types.sh @@ -24,19 +24,26 @@ docker compose exec client npm install --legacy-peer-deps echo "๐Ÿ“‹ Step 4: Generating TypeScript types from OpenAPI schema..." docker compose exec client npx openapi-typescript openapi-schema.json -o src/api-types.ts -echo "๐Ÿ“‹ Step 5: Running TypeScript type checking..." +echo "๐Ÿ“‹ Step 5: Automatically updating API client and evolution checker..." +docker compose exec client node update-api-client.js + +echo "๐Ÿ“‹ Step 6: Running TypeScript type checking..." docker compose exec client npm run type-check -echo "๐Ÿ“‹ Step 6: Running API evolution check..." +echo "๐Ÿ“‹ Step 7: Testing dynamic path usage..." +docker compose exec client npm run test-dynamic-paths + +echo "๐Ÿ“‹ Step 8: Running API evolution check..." docker compose exec client node check-api-evolution.js -echo "โœ… TypeScript generation complete!" +echo "โœ… TypeScript generation and API client update complete!" echo "๐Ÿ“„ Generated files:" echo " - client/openapi-schema.json (OpenAPI schema)" echo " - client/src/api-types.ts (TypeScript types)" echo "" -echo "๐Ÿ“„ Manual files (uses generated types):" -echo " - client/src/api-client.ts (API client utilities)" +echo "๐Ÿ“„ Updated files:" +echo " - client/src/api-client.ts (automatically updated with new endpoints)" +echo " - client/src/api-evolution-checker.ts (updated known endpoints)" echo "" echo "๐Ÿ’ก Usage in your React components:" echo " import { apiClient, adminApi, healthApi } from './api-client';"