UTA DevHub

API Client Architecture

Production-ready API client with authentication, interceptors, and type-safe error handling

API Client Architecture

Centralized HTTP client with authentication, error handling, and request/response interceptors

Overview

This module provides a robust, type-safe API client built on top of the Fetch API with automatic token management, request/response interceptors, and comprehensive error handling.

Implementation Files

Base API Client

// core/shared/api/ApiClient.ts
import { SecureStorage } from '../storage/SecureStorage';
import { ApiError, NetworkError, AuthenticationError } from './errors';
import type { ApiResponse, RequestConfig, AuthTokens } from './types';
 
export class ApiClient {
  private baseURL: string;
  private storage: SecureStorage;
  private defaultHeaders: Record<string, string>;
  private requestInterceptors: Array<(config: RequestConfig) => RequestConfig | Promise<RequestConfig>> = [];
  private responseInterceptors: Array<(response: Response) => Response | Promise<Response>> = [];
 
  constructor(baseURL?: string) {
    this.baseURL = baseURL || process.env.EXPO_PUBLIC_API_URL || 'https://api.example.com';
    this.storage = new SecureStorage();
    this.defaultHeaders = {
      'Content-Type': 'application/json',
      'Accept': 'application/json',
    };
 
    // Add default interceptors
    this.addRequestInterceptor(this.addAuthHeader.bind(this));
    this.addResponseInterceptor(this.handleAuthErrors.bind(this));
  }
 
  // Request interceptors
  addRequestInterceptor(interceptor: (config: RequestConfig) => RequestConfig | Promise<RequestConfig>) {
    this.requestInterceptors.push(interceptor);
  }
 
  // Response interceptors  
  addResponseInterceptor(interceptor: (response: Response) => Response | Promise<Response>) {
    this.responseInterceptors.push(interceptor);
  }
 
  private async addAuthHeader(config: RequestConfig): Promise<RequestConfig> {
    const tokens = await this.storage.getTokens();
    
    if (tokens?.accessToken) {
      config.headers = {
        ...config.headers,
        'Authorization': `Bearer ${tokens.accessToken}`,
      };
    }
    
    return config;
  }
 
  private async handleAuthErrors(response: Response): Promise<Response> {
    if (response.status === 401) {
      // Try to refresh token
      try {
        await this.refreshTokens();
        // Retry the original request
        // Note: In a full implementation, you'd need to store the original request
        return response;
      } catch (error) {
        // Clear tokens and redirect to login
        await this.storage.clearTokens();
        throw new AuthenticationError('Session expired');
      }
    }
    
    return response;
  }
 
  private async refreshTokens(): Promise<AuthTokens> {
    const currentTokens = await this.storage.getTokens();
    
    if (!currentTokens?.refreshToken) {
      throw new Error('No refresh token available');
    }
 
    const response = await this.makeRequest('/auth/refresh', {
      method: 'POST',
      body: JSON.stringify({ refreshToken: currentTokens.refreshToken }),
    });
 
    const data = await response.json();
    await this.storage.setTokens(data.tokens);
    
    return data.tokens;
  }
 
  private async processRequest(config: RequestConfig): Promise<RequestConfig> {
    let processedConfig = { ...config };
    
    for (const interceptor of this.requestInterceptors) {
      processedConfig = await interceptor(processedConfig);
    }
    
    return processedConfig;
  }
 
  private async processResponse(response: Response): Promise<Response> {
    let processedResponse = response;
    
    for (const interceptor of this.responseInterceptors) {
      processedResponse = await interceptor(processedResponse);
    }
    
    return processedResponse;
  }
 
  private async makeRequest(
    endpoint: string,
    config: RequestConfig = {}
  ): Promise<Response> {
    const url = endpoint.startsWith('http') ? endpoint : `${this.baseURL}${endpoint}`;
    
    const requestConfig: RequestConfig = {
      method: 'GET',
      headers: { ...this.defaultHeaders },
      ...config,
      headers: { ...this.defaultHeaders, ...config.headers },
    };
 
    // Process request through interceptors
    const processedConfig = await this.processRequest(requestConfig);
 
    try {
      const response = await fetch(url, processedConfig);
      
      // Process response through interceptors
      const processedResponse = await this.processResponse(response);
      
      if (!processedResponse.ok) {
        await this.handleErrorResponse(processedResponse);
      }
      
      return processedResponse;
    } catch (error) {
      if (error instanceof TypeError && error.message.includes('fetch')) {
        throw new NetworkError('Network connection failed');
      }
      throw error;
    }
  }
 
  private async handleErrorResponse(response: Response): Promise<never> {
    let errorData: any = {};
    
    try {
      errorData = await response.json();
    } catch {
      // Response doesn't contain JSON
    }
 
    const error = new ApiError(
      errorData.message || `HTTP ${response.status}`,
      response.status,
      errorData
    );
 
    throw error;
  }
 
  // Public HTTP methods
  async get<T = any>(endpoint: string, config?: Omit<RequestConfig, 'method' | 'body'>): Promise<T> {
    const response = await this.makeRequest(endpoint, {
      ...config,
      method: 'GET',
    });
    
    return response.json();
  }
 
  async post<T = any>(
    endpoint: string,
    data?: any,
    config?: Omit<RequestConfig, 'method' | 'body'>
  ): Promise<T> {
    const response = await this.makeRequest(endpoint, {
      ...config,
      method: 'POST',
      body: data ? JSON.stringify(data) : undefined,
    });
    
    return response.json();
  }
 
  async patch<T = any>(
    endpoint: string,
    data?: any,
    config?: Omit<RequestConfig, 'method' | 'body'>
  ): Promise<T> {
    const response = await this.makeRequest(endpoint, {
      ...config,
      method: 'PATCH',
      body: data ? JSON.stringify(data) : undefined,
    });
    
    return response.json();
  }
 
  async put<T = any>(
    endpoint: string,
    data?: any,
    config?: Omit<RequestConfig, 'method' | 'body'>
  ): Promise<T> {
    const response = await this.makeRequest(endpoint, {
      ...config,
      method: 'PUT',
      body: data ? JSON.stringify(data) : undefined,
    });
    
    return response.json();
  }
 
  async delete<T = any>(endpoint: string, config?: Omit<RequestConfig, 'method' | 'body'>): Promise<T> {
    const response = await this.makeRequest(endpoint, {
      ...config,
      method: 'DELETE',
    });
    
    // Handle empty responses
    const contentType = response.headers.get('content-type');
    if (contentType && contentType.includes('application/json')) {
      return response.json();
    }
    
    return {} as T;
  }
 
  // Utility methods
  setBaseURL(url: string): void {
    this.baseURL = url;
  }
 
  setDefaultHeader(key: string, value: string): void {
    this.defaultHeaders[key] = value;
  }
 
  removeDefaultHeader(key: string): void {
    delete this.defaultHeaders[key];
  }
}

Type Definitions

// core/shared/api/types.ts
export interface RequestConfig extends RequestInit {
  headers?: Record<string, string>;
}
 
export interface ApiResponse<T = any> {
  data: T;
  message?: string;
  status: number;
}
 
export interface PaginatedResponse<T = any> {
  data: T[];
  pagination: {
    page: number;
    limit: number;
    total: number;
    hasNext: boolean;
    hasPrevious: boolean;
  };
}
 
export interface AuthTokens {
  accessToken: string;
  refreshToken: string;
  expiresAt: number;
}
 
export interface ApiErrorData {
  message: string;
  code?: string;
  details?: any;
  field?: string;
}

Error Classes

// core/shared/api/errors.ts
export class ApiError extends Error {
  public status: number;
  public data: any;
  public code: string;
 
  constructor(message: string, status: number, data?: any) {
    super(message);
    this.name = 'ApiError';
    this.status = status;
    this.data = data;
    this.code = data?.code || 'API_ERROR';
  }
 
  get isClientError(): boolean {
    return this.status >= 400 && this.status < 500;
  }
 
  get isServerError(): boolean {
    return this.status >= 500;
  }
 
  get isValidationError(): boolean {
    return this.status === 422;
  }
 
  get isNotFoundError(): boolean {
    return this.status === 404;
  }
 
  get isUnauthorizedError(): boolean {
    return this.status === 401;
  }
 
  get isForbiddenError(): boolean {
    return this.status === 403;
  }
}
 
export class NetworkError extends Error {
  constructor(message: string = 'Network connection failed') {
    super(message);
    this.name = 'NetworkError';
  }
}
 
export class AuthenticationError extends Error {
  constructor(message: string = 'Authentication failed') {
    super(message);
    this.name = 'AuthenticationError';
  }
}
 
export class ValidationError extends ApiError {
  public validationErrors: Record<string, string[]>;
 
  constructor(message: string, validationErrors: Record<string, string[]>) {
    super(message, 422, { validationErrors });
    this.name = 'ValidationError';
    this.validationErrors = validationErrors;
  }
}

Secure Storage Implementation

// core/shared/storage/SecureStorage.ts
import * as SecureStore from 'expo-secure-store';
import type { AuthTokens } from '../api/types';
 
export class SecureStorage {
  private readonly TOKEN_KEY = 'auth_tokens';
  private readonly USER_KEY = 'user_data';
 
  async setTokens(tokens: AuthTokens): Promise<void> {
    try {
      await SecureStore.setItemAsync(this.TOKEN_KEY, JSON.stringify(tokens));
    } catch (error) {
      console.error('Failed to store tokens:', error);
      throw error;
    }
  }
 
  async getTokens(): Promise<AuthTokens | null> {
    try {
      const tokensStr = await SecureStore.getItemAsync(this.TOKEN_KEY);
      return tokensStr ? JSON.parse(tokensStr) : null;
    } catch (error) {
      console.error('Failed to retrieve tokens:', error);
      return null;
    }
  }
 
  async clearTokens(): Promise<void> {
    try {
      await SecureStore.deleteItemAsync(this.TOKEN_KEY);
    } catch (error) {
      console.error('Failed to clear tokens:', error);
    }
  }
 
  async setUserData(userData: any): Promise<void> {
    try {
      await SecureStore.setItemAsync(this.USER_KEY, JSON.stringify(userData));
    } catch (error) {
      console.error('Failed to store user data:', error);
      throw error;
    }
  }
 
  async getUserData<T = any>(): Promise<T | null> {
    try {
      const userDataStr = await SecureStore.getItemAsync(this.USER_KEY);
      return userDataStr ? JSON.parse(userDataStr) : null;
    } catch (error) {
      console.error('Failed to retrieve user data:', error);
      return null;
    }
  }
 
  async clearUserData(): Promise<void> {
    try {
      await SecureStore.deleteItemAsync(this.USER_KEY);
    } catch (error) {
      console.error('Failed to clear user data:', error);
    }
  }
 
  async clearAll(): Promise<void> {
    await Promise.all([
      this.clearTokens(),
      this.clearUserData(),
    ]);
  }
}

Usage Examples

Basic API Client Usage

// Example: Using the API client in a service
import { ApiClient } from '@/core/shared/api/ApiClient';
 
const apiClient = new ApiClient();
 
// GET request
const users = await apiClient.get('/users');
 
// POST request
const newUser = await apiClient.post('/users', {
  name: 'John Doe',
  email: 'john@example.com',
});
 
// PATCH request
const updatedUser = await apiClient.patch('/users/123', {
  name: 'Jane Doe',
});
 
// DELETE request
await apiClient.delete('/users/123');

Custom Request Interceptor

// Add logging interceptor
apiClient.addRequestInterceptor((config) => {
  console.log(`Making request to: ${config.url}`);
  return config;
});
 
// Add request ID interceptor
apiClient.addRequestInterceptor((config) => {
  config.headers = {
    ...config.headers,
    'X-Request-ID': generateRequestId(),
  };
  return config;
});

Custom Response Interceptor

// Add response logging
apiClient.addResponseInterceptor((response) => {
  console.log(`Response status: ${response.status}`);
  return response;
});
 
// Add response transformation
apiClient.addResponseInterceptor(async (response) => {
  if (response.headers.get('content-type')?.includes('application/json')) {
    const data = await response.json();
    // Transform response data
    return new Response(JSON.stringify({
      ...data,
      timestamp: new Date().toISOString(),
    }), response);
  }
  return response;
});

Error Handling

// Service with comprehensive error handling
export class UserService {
  private apiClient = new ApiClient();
 
  async getUser(id: string): Promise<User> {
    try {
      return await this.apiClient.get(`/users/${id}`);
    } catch (error) {
      if (error instanceof ApiError) {
        if (error.isNotFoundError) {
          throw new Error('User not found');
        }
        if (error.isUnauthorizedError) {
          throw new Error('Authentication required');
        }
        if (error.isValidationError) {
          throw new ValidationError('Invalid user data', error.data.validationErrors);
        }
      }
      
      if (error instanceof NetworkError) {
        throw new Error('Please check your internet connection');
      }
      
      throw error;
    }
  }
}

Testing Patterns

Mocking API Client

// __tests__/ApiClient.test.ts
import { ApiClient } from '../ApiClient';
import { ApiError, NetworkError } from '../errors';
 
// Mock fetch
global.fetch = jest.fn();
const mockFetch = fetch as jest.MockedFunction<typeof fetch>;
 
describe('ApiClient', () => {
  let apiClient: ApiClient;
 
  beforeEach(() => {
    apiClient = new ApiClient('https://api.test.com');
    jest.clearAllMocks();
  });
 
  it('makes GET requests correctly', async () => {
    const mockData = { id: 1, name: 'Test' };
    mockFetch.mockResolvedValue({
      ok: true,
      json: async () => mockData,
    } as Response);
 
    const result = await apiClient.get('/test');
 
    expect(fetch).toHaveBeenCalledWith('https://api.test.com/test', {
      method: 'GET',
      headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/json',
      },
    });
    expect(result).toEqual(mockData);
  });
 
  it('handles API errors correctly', async () => {
    mockFetch.mockResolvedValue({
      ok: false,
      status: 404,
      json: async () => ({ message: 'Not found' }),
    } as Response);
 
    await expect(apiClient.get('/nonexistent')).rejects.toThrow(ApiError);
  });
 
  it('handles network errors correctly', async () => {
    mockFetch.mockRejectedValue(new TypeError('fetch error'));
 
    await expect(apiClient.get('/test')).rejects.toThrow(NetworkError);
  });
});

Service Testing with Mocked API Client

// __tests__/UserService.test.ts
import { UserService } from '../UserService';
import { ApiClient } from '@/core/shared/api/ApiClient';
 
jest.mock('@/core/shared/api/ApiClient');
const MockedApiClient = ApiClient as jest.MockedClass<typeof ApiClient>;
 
describe('UserService', () => {
  let userService: UserService;
  let mockApiClient: jest.Mocked<ApiClient>;
 
  beforeEach(() => {
    mockApiClient = {
      get: jest.fn(),
      post: jest.fn(),
      patch: jest.fn(),
      delete: jest.fn(),
    } as any;
    
    MockedApiClient.mockImplementation(() => mockApiClient);
    userService = new UserService();
  });
 
  it('fetches user successfully', async () => {
    const mockUser = { id: '1', name: 'John', email: 'john@example.com' };
    mockApiClient.get.mockResolvedValue(mockUser);
 
    const result = await userService.getUser('1');
 
    expect(mockApiClient.get).toHaveBeenCalledWith('/users/1');
    expect(result).toEqual(mockUser);
  });
});

Best Practices

Configuration

  • ✅ Use environment variables for API base URLs
  • ✅ Configure different timeouts for different request types
  • ✅ Implement proper retry logic for network failures
  • ✅ Set up request/response logging for debugging

Security

  • ✅ Store tokens securely using platform-specific secure storage
  • ✅ Implement automatic token refresh
  • ✅ Clear sensitive data on logout
  • ✅ Validate SSL certificates in production

Performance

  • ✅ Implement request deduplication for identical requests
  • ✅ Use appropriate cache headers
  • ✅ Implement request cancellation for navigation changes
  • ✅ Optimize payload sizes with proper data serialization

Error Handling

  • ✅ Create specific error classes for different error types
  • ✅ Provide user-friendly error messages
  • ✅ Implement retry logic for recoverable errors
  • ✅ Log errors for debugging and monitoring

Ready to use? Copy these patterns into your src/core/shared/api/ directory and customize the base URL, authentication flow, and error handling for your specific API requirements.