API Integration
API Integration
Section titled “API Integration”Problem and Rationale
Section titled “Problem and Rationale”Zelta Chat requires consistent API communication to:
- Authenticate all requests with session tokens
- Transform request/response data between camelCase and snake_case
- Handle errors uniformly across the app
- Automatically construct API URLs with account context
- Support self-hosted Zelta Chat installations with dynamic base URLs
Why Axios:
- Interceptor support for request/response transformation
- Promise-based API
- Request/response transformation
- Automatic JSON parsing
- TypeScript support
Design
Section titled “Design”APIService Architecture
Section titled “APIService Architecture”Component/Action ↓APIService.get/post/put/delete ↓Request Interceptor ├─ Inject auth headers from Redux ├─ Set baseURL from settings ├─ Add account ID to URL └─ Transform payload (camelCase → snake_case) ↓Axios HTTP Request → Zelta Chat Server ↓Response Interceptor ├─ Check for 401 (auto logout) ├─ Transform response (snake_case → camelCase) └─ Handle errors ↓Return Data to CallerService Implementation
Section titled “Service Implementation”Singleton Pattern (src/services/APIService.ts):
class APIService { private static instance: APIService; private api = axios.create();
private constructor() { this.setupInterceptors(); }
public static getInstance(): APIService { if (!APIService.instance) { APIService.instance = new APIService(); } return APIService.instance; }
// HTTP methods public async get<T>(url: string, config?: AxiosRequestConfig) { return this.api.get<T>(url, config); }
public async post<T, D = unknown>(url: string, data?: D, config?: AxiosRequestConfig) { return this.api.post<T>(url, data, config); }
public async put<T, D = unknown>(url: string, data?: D, config?: AxiosRequestConfig) { return this.api.put<T>(url, data, config); }
public async delete<T>(url: string, config?: AxiosRequestConfig) { return this.api.delete<T>(url, config); }}
export const apiService = APIService.getInstance();URL Construction
Section titled “URL Construction”Non-Account Routes:
Routes that don’t require account context:
profileprofile/availabilitynotification_subscriptionsprofile/set_active_account
Account-Scoped Routes:
Most API endpoints are automatically scoped to current account:
Original: apiService.get('conversations')Transformed: {baseURL}/api/v1/accounts/{accountId}/conversationsLifecycle and Data Flow
Section titled “Lifecycle and Data Flow”Request Flow
Section titled “Request Flow”Component Dispatches Action ↓Action Calls Service Method ↓Service Calls apiService.get('endpoint') ↓Request Interceptor: 1. Get auth headers from Redux (access-token, uid, client) 2. Get baseURL from settings.installationUrl 3. Get accountId from auth.user.account_id 4. Transform URL: 'conversations' → 'api/v1/accounts/{accountId}/conversations' 5. Add headers to request ↓HTTP Request Sent ↓Server Response ↓Response Interceptor: 1. Check status code 2. If 401 → Dispatch logout action 3. If error → Show toast notification 4. Return response/error ↓Action Processes Response ↓Dispatch Redux Action to Update StateImplementation Notes
Section titled “Implementation Notes”Request Interceptor
Section titled “Request Interceptor”private setupInterceptors() { this.api.interceptors.request.use( async (config: AxiosRequestConfig): Promise<InternalAxiosRequestConfig> => { const headers = this.getHeaders(); const store = getStore(); const state = store.getState();
// Set base URL from user's installation config.baseURL = state.settings?.installationUrl;
// Add account ID to URL const accountId = state.auth.user?.account_id; if (accountId && config.url && !nonAccountRoutes.includes(config.url)) { config.url = `api/v1/accounts/${accountId}/${config.url}`; } else if (nonAccountRoutes.includes(config.url || '')) { config.url = `api/v1/${config.url}`; }
return { ...config, headers: { ...config.headers, ...headers, // Auth headers }, } as InternalAxiosRequestConfig; }, (error: AxiosError) => Promise.reject(error), );}
private getHeaders() { const store = getStore(); const state = store.getState(); const headers = state.auth.headers;
if (!headers) return {};
return { 'access-token': headers['access-token'], uid: headers.uid, client: headers.client, };}Response Interceptor
Section titled “Response Interceptor”this.api.interceptors.response.use( (response: AxiosResponse) => response, async (error: AxiosError) => { if (error.response?.status === 401) { // Automatic logout on unauthorized const store = getStore(); store.dispatch({ type: 'auth/logout' }); } else { // Show generic error toast showToast({ message: I18n.t('ERRORS.COMMON_ERROR') }); } return Promise.reject(error); },);Data Transformation
Section titled “Data Transformation”Request Transformation (camelCase → snake_case):
import snakecaseKeys from 'snakecase-keys';
export const conversationService = { async updateStatus(conversationId: number, status: string) { const payload = { conversationId, statusType: status };
// Transform to snake_case before sending const response = await apiService.post( `conversations/${conversationId}/toggle_status`, snakecaseKeys(payload) );
return response.data; },};Response Transformation (snake_case → camelCase):
import camelcaseKeys from 'camelcase-keys';
export const conversationService = { async fetchConversations(params: FetchParams) { const response = await apiService.get('conversations', { params: snakecaseKeys(params), });
// Transform to camelCase after receiving return camelcaseKeys(response.data, { deep: true }); },};Patterns and Anti-Patterns
Section titled “Patterns and Anti-Patterns”✅ Good Patterns:
// Type-safe service methodsinterface UpdateConversationPayload { conversationId: number; status: string;}
export const conversationService = { async updateStatus(payload: UpdateConversationPayload) { const response = await apiService.post<Conversation>( `conversations/${payload.conversationId}/toggle_status`, snakecaseKeys(payload) ); return camelcaseKeys(response.data, { deep: true }); },};
// Handle errors in thunksexport const fetchConversations = createAsyncThunk( 'conversations/fetch', async (params, { rejectWithValue }) => { try { return await conversationService.fetchConversations(params); } catch (error) { return rejectWithValue(handleApiError(error)); } });❌ Anti-Patterns:
// DON'T: Bypass APIServiceaxios.get('https://api.Zelta Chat.com/conversations'); // ❌ No auth headers
// DON'T: Hardcode base URLsconst response = await fetch('https://app.Zelta Chat.com/api/conversations'); // ❌
// DON'T: Ignore errorstry { await apiService.get('conversations');} catch (error) { console.log(error); // ❌ User sees no feedback}
// DON'T: Store response objects in Reduxdispatch(setConversations(response)); // ❌ Store response.data onlyPerformance Considerations
Section titled “Performance Considerations”Request Caching
Section titled “Request Caching”Implement caching for frequently accessed data:
const cache = new Map<string, { data: any; timestamp: number }>();const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
export const cachedGet = async <T>(url: string): Promise<T> => { const cached = cache.get(url); const now = Date.now();
if (cached && now - cached.timestamp < CACHE_TTL) { return cached.data; }
const response = await apiService.get<T>(url); cache.set(url, { data: response.data, timestamp: now });
return response.data;};Request Deduplication
Section titled “Request Deduplication”Prevent duplicate simultaneous requests:
const pendingRequests = new Map<string, Promise<any>>();
export const deduplicatedGet = async <T>(url: string): Promise<T> => { if (pendingRequests.has(url)) { return pendingRequests.get(url); }
const request = apiService.get<T>(url) .finally(() => pendingRequests.delete(url));
pendingRequests.set(url, request); return (await request).data;};Testing Strategy
Section titled “Testing Strategy”Mock APIService
Section titled “Mock APIService”jest.mock('@/services/APIService', () => ({ apiService: { get: jest.fn(), post: jest.fn(), put: jest.fn(), delete: jest.fn(), },}));Test Service Methods
Section titled “Test Service Methods”describe('conversationService', () => { it('should fetch conversations with transformed params', async () => { const mockData = { conversations: [] }; apiService.get.mockResolvedValue({ data: mockData });
const result = await conversationService.fetchConversations({ assigneeType: 'me' });
expect(apiService.get).toHaveBeenCalledWith('conversations', { params: { assignee_type: 'me' }, // Transformed to snake_case }); });});Extending This Concept
Section titled “Extending This Concept”Adding Request Retry
Section titled “Adding Request Retry”import axiosRetry from 'axios-retry';
class APIService { private constructor() { this.setupInterceptors(); this.setupRetry(); }
private setupRetry() { axiosRetry(this.api, { retries: 3, retryDelay: axiosRetry.exponentialDelay, retryCondition: (error) => { return ( axiosRetry.isNetworkOrIdempotentRequestError(error) || error.response?.status === 429 // Rate limit ); }, }); }}Adding Request Timeout
Section titled “Adding Request Timeout”public async get<T>(url: string, config?: AxiosRequestConfig) { return this.api.get<T>(url, { ...config, timeout: config?.timeout || 30000, // 30s default });}Adding Upload Progress
Section titled “Adding Upload Progress”public async uploadFile( url: string, file: File, onProgress?: (progress: number) => void) { const formData = new FormData(); formData.append('file', file);
return this.api.post(url, formData, { headers: { 'Content-Type': 'multipart/form-data' }, onUploadProgress: (progressEvent) => { const progress = (progressEvent.loaded / progressEvent.total!) * 100; onProgress?.(progress); }, });}Adding Request Cancellation
Section titled “Adding Request Cancellation”import axios, { CancelTokenSource } from 'axios';
const cancelTokens = new Map<string, CancelTokenSource>();
export const cancelableGet = async <T>( url: string, cancelKey: string): Promise<T> => { // Cancel previous request with same key if (cancelTokens.has(cancelKey)) { cancelTokens.get(cancelKey)!.cancel('Request cancelled'); }
// Create new cancel token const source = axios.CancelToken.source(); cancelTokens.set(cancelKey, source);
try { const response = await apiService.get<T>(url, { cancelToken: source.token, }); return response.data; } finally { cancelTokens.delete(cancelKey); }};Further Reading
Section titled “Further Reading”- Authentication Flow - Token management
- State Management - Redux integration
- Conversations Feature - API usage example
- Axios Documentation