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.