Type generation

This commit is contained in:
James Ketr 2025-09-03 13:54:29 -07:00
parent 3a72f6097e
commit d940c2cbef
8 changed files with 960 additions and 59 deletions

238
AUTOMATED_API_CLIENT.md Normal file
View File

@ -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<any>` (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<CustomResponse> {
return this.request<CustomResponse>("/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<LobbyModel> => {
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.

View File

@ -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');

View File

@ -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": {

View File

@ -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<T>(
path: string,
options: {
@ -126,50 +132,70 @@ export class ApiClient {
// Admin API methods
async adminListNames(): Promise<AdminNamesResponse> {
return this.request<AdminNamesResponse>("/ai-voicebot/api/admin/names", { method: "GET" });
return this.request<AdminNamesResponse>(this.getApiPath("/ai-voicebot/api/admin/names"), { method: "GET" });
}
async adminSetPassword(data: AdminSetPassword): Promise<AdminActionResponse> {
return this.request<AdminActionResponse>("/ai-voicebot/api/admin/set_password", { method: "POST", body: data });
return this.request<AdminActionResponse>(this.getApiPath("/ai-voicebot/api/admin/set_password"), {
method: "POST",
body: data,
});
}
async adminClearPassword(data: AdminClearPassword): Promise<AdminActionResponse> {
return this.request<AdminActionResponse>("/ai-voicebot/api/admin/clear_password", { method: "POST", body: data });
return this.request<AdminActionResponse>(this.getApiPath("/ai-voicebot/api/admin/clear_password"), {
method: "POST",
body: data,
});
}
// Health check
async healthCheck(): Promise<HealthResponse> {
return this.request<HealthResponse>("/ai-voicebot/api/health", { method: "GET" });
return this.request<HealthResponse>(this.getApiPath("/ai-voicebot/api/health"), { method: "GET" });
}
// Session methods
async getSession(): Promise<SessionResponse> {
return this.request<SessionResponse>("/ai-voicebot/api/session", { method: "GET" });
return this.request<SessionResponse>(this.getApiPath("/ai-voicebot/api/session"), { method: "GET" });
}
// Lobby methods
async getLobbies(): Promise<LobbiesResponse> {
return this.request<LobbiesResponse>("/ai-voicebot/api/lobby", { method: "GET" });
return this.request<LobbiesResponse>(this.getApiPath("/ai-voicebot/api/lobby"), { method: "GET" });
}
async createLobby(sessionId: string, data: LobbyCreateRequest): Promise<LobbyCreateResponse> {
return this.request<LobbyCreateResponse>(`/ai-voicebot/api/lobby/${sessionId}`, { method: "POST", body: data });
return this.request<LobbyCreateResponse>(this.getApiPath(`/ai-voicebot/api/lobby/${sessionId}`), {
method: "POST",
body: data,
});
}
// Bot Provider methods
async getBotProviders(): Promise<BotProviderListResponse> {
return this.request<BotProviderListResponse>("/ai-voicebot/api/bots/providers", { method: "GET" });
return this.request<BotProviderListResponse>(this.getApiPath("/ai-voicebot/api/bots/providers"), { method: "GET" });
}
async getAvailableBots(): Promise<BotListResponse> {
return this.request<BotListResponse>("/ai-voicebot/api/bots", { method: "GET" });
return this.request<BotListResponse>(this.getApiPath("/ai-voicebot/api/bots"), { method: "GET" });
}
async requestBotJoinLobby(botName: string, request: BotJoinLobbyRequest): Promise<BotJoinLobbyResponse> {
return this.request<BotJoinLobbyResponse>(`/ai-voicebot/api/bots/${encodeURIComponent(botName)}/join`, {
method: "POST",
body: request,
});
return this.request<BotJoinLobbyResponse>(
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<any> {
return this.request<any>(this.getApiPath(`/ai-voicebot/api/lobby/${session_id}`), { method: "POST", body: data });
}
}
@ -193,13 +219,17 @@ class ApiEvolutionChecker {
private getImplementedEndpoints(): Set<string> {
// 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}'
]);
}

View File

@ -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;
@ -31,6 +32,7 @@ export class AdvancedApiEvolutionChecker {
// 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),
});
});
@ -63,6 +65,9 @@ export class AdvancedApiEvolutionChecker {
'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<any> {
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,10 +123,10 @@ 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();
@ -130,13 +135,11 @@ export class AdvancedApiEvolutionChecker {
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;

View File

@ -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;

469
client/update-api-client.js Normal file
View File

@ -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<string, string>');
}
}
const paramString = params.join(', ');
const returnType = 'Promise<any>'; // 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<any>(${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<string> \{[\s\S]*?return new Set\(\[[^\]]*\]\);[\s\S]*?\}/;
const replacement = `private getImplementedEndpoints(): Set<string> {
// 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;

View File

@ -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';"