API Service
A provider-agnostic, production-ready HTTP API client with built-in support for authentication, token management, error handling, and Redux Toolkit Query integration.
Overview
The API Service provides:
- Provider-Agnostic Architecture: Works with Firebase, custom auth, or any provider via the
IAuthProviderinterface - Automatic Token Management: Handles token refresh, expiration, and retry logic
- Configurable Retry Logic: Exponential backoff with customizable retry strategies
- Error Handling: Comprehensive error handling with custom error types
- Request Interceptors: Automatic auth header injection and request transformation
- Response Caching: Built-in response caching for GET requests
- RTK Query Integration: Pre-built integration with Redux Toolkit Query
- TypeScript Support: Fully typed API responses and configuration
- Observables Support: RxJS-based API calls with automatic cleanup
Architecture
Core Components
ApiService
Main HTTP client service that handles all API operations:
interface ApiConfig {
baseUrl: string;
defaultHeaders?: ApiHeaders;
timeout?: number;
withCredentials?: boolean;
refreshTokenRoute?: string;
cacheDuration?: number;
}
interface ApiRequestConfig {
headers?: ApiHeaders;
params?: Record<string, any>;
skipTokenRefresh?: boolean;
skipErrorHandling?: boolean;
retryConfig?: Partial<RetryConfig>;
}
interface RetryConfig {
maxRetries: number;
initialDelay: number;
maxDelay: number;
retryableStatuses: number[];
}
TokenManager
Manages authentication tokens with lifecycle management:
interface TokenState {
accessToken: string | null;
refreshToken: string | null;
expiresAt: number | null;
}
ApiErrorHandler
Comprehensive error handling with custom error types:
interface ApiError {
message: string;
status: number;
code?: string;
details?: Record<string, any>;
}
Auth Providers
Pluggable authentication providers:
interface IAuthProvider {
getIdToken(): Promise<string | null>;
isAuthenticated(): boolean;
refreshToken(): Promise<string | null>;
}
Installation
npm install @codella-software/utils
Setup
Quick Setup with Factory Function
import { createApiClient } from '@codella-software/utils';
import { firebaseAuthProvider } from './auth-provider';
const apiClient = createApiClient({
baseUrl: 'https://api.example.com',
authProvider: firebaseAuthProvider,
withCredentials: true,
timeout: 30000
});
const { apiService, tokenManager } = apiClient;
Manual Setup
1. Create Auth Provider
import type { IAuthProvider } from '@codella-software/utils';
export class FirebaseAuthProvider implements IAuthProvider {
constructor(private auth: Auth) {}
async getIdToken(): Promise<string | null> {
const user = this.auth.currentUser;
if (!user) return null;
return user.getIdToken();
}
isAuthenticated(): boolean {
return !!this.auth.currentUser;
}
async refreshToken(): Promise<string | null> {
const user = this.auth.currentUser;
if (!user) return null;
await user.getIdToken(true); // Force refresh
return user.getIdToken();
}
}
2. Create Token Manager
import { TokenManager } from '@codella-software/utils';
import { firebaseAuthProvider } from './auth-provider';
const tokenManager = new TokenManager(firebaseAuthProvider, {
headerName: 'Authorization',
headerPrefix: 'Bearer',
storageKey: 'api_token_state',
});
3. Create API Service
import { ApiService } from '@codella-software/utils';
const apiService = new ApiService(
tokenManager,
{
baseUrl: 'https://api.example.com',
timeout: 30000,
withCredentials: true,
},
undefined, // Use default error handler
{
maxRetries: 3,
initialDelay: 300,
maxDelay: 5000,
retryableStatuses: [401, 403, 429, 503],
}
);
Usage
GET Request
// Observable-based (RxJS)
apiService.get<User>('/api/users/123')
.subscribe({
next: (response) => {
console.log('User:', response.data);
},
error: (error) => {
console.error('Error:', error.message);
}
});
// Promise-based
const response = await firstValueFrom(
apiService.get<User>('/api/users/123')
);
console.log(response.data);
POST Request
apiService.post<CreateUserResponse>(
'/api/users',
{
name: 'John Doe',
email: 'john@example.com'
},
{
headers: { 'X-Custom-Header': 'value' }
}
)
.subscribe(response => {
console.log('Created user:', response.data);
});
PUT Request
apiService.put<User>(
'/api/users/123',
{
name: 'Jane Doe'
}
)
.subscribe(response => {
console.log('Updated user:', response.data);
});
DELETE Request
apiService.delete('/api/users/123')
.subscribe(() => {
console.log('User deleted');
});
PATCH Request
apiService.patch<Partial<User>>(
'/api/users/123',
{
email: 'newemail@example.com'
}
)
.subscribe(response => {
console.log('Patched user:', response.data);
});
Request with Query Parameters
apiService.get<User[]>(
'/api/users',
{
params: {
page: 1,
limit: 10,
sort: 'name'
}
}
)
.subscribe(response => {
console.log('Users:', response.data);
});
Request with Custom Headers
apiService.post(
'/api/upload',
file,
{
headers: {
'Content-Type': 'multipart/form-data',
'X-File-Name': file.name
}
}
)
.subscribe(response => {
console.log('Upload successful');
});
React Integration
API Hooks
Create custom hooks for your API endpoints:
import { useEffect, useState } from 'react';
import { ApiResponse, ApiError } from '@codella-software/utils';
export function useUser(userId: string) {
const [data, setData] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<ApiError | null>(null);
useEffect(() => {
setLoading(true);
const subscription = apiService.get<User>(`/api/users/${userId}`)
.subscribe({
next: (response) => {
setData(response.data);
setError(null);
},
error: (err: ApiError) => {
setError(err);
setData(null);
},
complete: () => setLoading(false)
});
return () => subscription.unsubscribe();
}, [userId]);
return { data, loading, error };
}
// Usage
function UserProfile({ userId }: { userId: string }) {
const { data: user, loading, error } = useUser(userId);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>{user?.name}</div>;
}
Redux Toolkit Query Integration
import { createApi } from '@reduxjs/toolkit/query/react';
import { createCustomQuery } from '@codella-software/utils';
import { apiService } from './api-service';
export const usersApi = createApi({
reducerPath: 'usersApi',
baseQuery: createCustomQuery(apiService),
endpoints: (builder) => ({
getUser: builder.query<User, string>({
query: (userId) => `/api/users/${userId}`
}),
updateUser: builder.mutation<User, { id: string; data: Partial<User> }>({
query: ({ id, data }) => ({
url: `/api/users/${id}`,
method: 'PUT',
body: data
})
}),
deleteUser: builder.mutation<void, string>({
query: (userId) => ({
url: `/api/users/${userId}`,
method: 'DELETE'
})
})
})
});
export const { useGetUserQuery, useUpdateUserMutation, useDeleteUserMutation } = usersApi;
Using RTK Query in Components
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading, error } = useGetUserQuery(userId);
const [updateUser, { isLoading: isUpdating }] = useUpdateUserMutation();
const handleUpdate = async (newData: Partial<User>) => {
try {
await updateUser({ id: userId, data: newData }).unwrap();
} catch (error) {
console.error('Update failed:', error);
}
};
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error loading user</div>;
return (
<div>
<h2>{user?.name}</h2>
<button onClick={() => handleUpdate({ name: 'New Name' })}>
{isUpdating ? 'Updating...' : 'Update'}
</button>
</div>
);
}
Authentication Providers
Firebase Authentication
import { createFirebaseAuthProvider } from '@codella-software/utils';
import { getAuth } from 'firebase/auth';
const authProvider = createFirebaseAuthProvider({
auth: getAuth(),
headerName: 'Authorization',
headerPrefix: 'Bearer'
});
Firebase App Check
import { createFirebaseAppCheckProvider } from '@codella-software/utils';
import { getAppCheck, initializeAppCheck, ReCaptchaV3Provider } from 'firebase/app-check';
const appCheckProvider = createFirebaseAppCheckProvider({
appCheck: initializeAppCheck(app, {
provider: new ReCaptchaV3Provider('YOUR_RECAPTCHA_KEY'),
isTokenAutoRefreshEnabled: true
})
});
// Use in API Service
const apiClient = createApiClient({
baseUrl: 'https://api.example.com',
authProvider,
appCheckProvider
});
Custom Authentication
import { createCustomAuthProvider } from '@codella-software/utils';
const customAuthProvider = createCustomAuthProvider({
getToken: async () => {
const response = await fetch('/api/auth/token');
const { token } = await response.json();
return token;
},
isAuthenticated: () => {
return !!localStorage.getItem('auth_token');
},
refreshToken: async () => {
const response = await fetch('/api/auth/refresh', {
method: 'POST'
});
const { token } = await response.json();
return token;
}
});
Request/Response Interceptors
Interceptors allow you to transform requests and responses, handle cross-cutting concerns like logging, authentication, and error transformation.
Request Interceptors
Transform or validate requests before they are sent:
// Add auth transformation
apiService.interceptors.request.use((config) => {
return {
...config,
headers: {
...config.headers,
'X-Request-ID': crypto.randomUUID()
}
};
});
// Add logging
apiService.interceptors.request.use((config) => {
console.log('🚀 Request:', config.method?.toUpperCase(), config.url);
return config;
});
// Add API versioning
apiService.interceptors.request.use((config) => {
return {
...config,
url: `/api/v2${config.url}`
};
});
Response Interceptors
Transform or validate responses after they are received:
// Extract data from envelope
apiService.interceptors.response.use((response) => {
// If your API wraps responses in an envelope
if (response.data?.envelope) {
return {
...response,
data: response.data.envelope.data
};
}
return response;
});
// Add response logging
apiService.interceptors.response.use((response) => {
console.log('✅ Response:', response.status, response.config.url);
return response;
});
// Validate response structure
apiService.interceptors.response.use((response) => {
if (!response.data) {
throw new Error('Invalid response: missing data');
}
return response;
});
Error Interceptors
Handle and recover from errors:
// Retry with exponential backoff on 429 (rate limit)
apiService.interceptors.error.use((error) => {
if (error.status === 429) {
const retryAfter = parseInt(error.headers['retry-after'] || '1') * 1000;
console.log(`Rate limited. Retrying after ${retryAfter}ms`);
return new Promise(resolve => {
setTimeout(() => {
// Return a recovery response
resolve({ data: { cached: true }, status: 200 });
}, retryAfter);
});
}
throw error;
});
// Transform specific errors
apiService.interceptors.error.use((error) => {
if (error.status === 401) {
// Clear auth state
localStorage.removeItem('token');
// Redirect to login
window.location.href = '/login';
}
throw error;
});
Removing Interceptors
const id = apiService.interceptors.request.use((config) => config);
// Later, remove the interceptor
apiService.interceptors.request.eject(id);
// Clear all interceptors
apiService.interceptors.request.clear();
Middleware Hooks
Hooks allow you to observe the request lifecycle for logging, analytics, and monitoring without modifying requests/responses.
onBeforeRequest Hook
Called before the request is sent:
const apiService = new ApiService(tokenManager, {
baseUrl: 'https://api.example.com',
hooks: {
onBeforeRequest: async (context) => {
console.log('📤 Sending request:', {
method: context.config.method,
url: context.config.url,
timestamp: context.timestamp
});
// Send to analytics
analytics.trackEvent('api_request', {
endpoint: context.config.url,
method: context.config.method
});
}
}
});
onAfterResponse Hook
Called after a successful response:
const apiService = new ApiService(tokenManager, {
baseUrl: 'https://api.example.com',
hooks: {
onAfterResponse: async (context) => {
console.log('📥 Response received:', {
status: context.response.status,
duration: context.duration,
url: context.config.url
});
// Log performance metrics
if (context.duration > 1000) {
console.warn('⚠️ Slow API response:', context.duration + 'ms');
}
// Track success metrics
metrics.recordApiCall({
endpoint: context.config.url,
status: context.response.status,
duration: context.duration
});
}
}
});
onError Hook
Called when an error occurs:
const apiService = new ApiService(tokenManager, {
baseUrl: 'https://api.example.com',
hooks: {
onError: async (context) => {
console.error('❌ Request error:', {
endpoint: context.config.url,
error: context.error.message,
attempt: context.attempt
});
// Send error to tracking service
errorTracking.captureException(context.error, {
endpoint: context.config.url,
attempt: context.attempt,
isRetrying: context.isRetrying
});
}
}
});
onRetry Hook
Called when a request is retried:
const apiService = new ApiService(tokenManager, {
baseUrl: 'https://api.example.com',
hooks: {
onRetry: async (context) => {
console.log('🔄 Retrying request:', {
endpoint: context.config.url,
attempt: context.attempt,
delay: context.delay + 'ms'
});
// Track retry attempts
analytics.trackEvent('api_retry', {
endpoint: context.config.url,
attempt: context.attempt,
error: context.error.message
});
}
}
});
Complete Monitoring Setup
const apiService = new ApiService(tokenManager, {
baseUrl: 'https://api.example.com',
hooks: {
onBeforeRequest: async ({ config }) => {
// Set request ID for tracing
config.headers = {
...config.headers,
'X-Request-ID': crypto.randomUUID()
};
},
onAfterResponse: async ({ response, config, duration }) => {
// Log successful requests
logger.info('api.success', {
endpoint: config.url,
status: response.status,
duration
});
},
onError: async ({ error, config, attempt }) => {
// Log errors with context
logger.error('api.error', {
endpoint: config.url,
error: error.message,
status: error.status,
attempt
});
// Send to error tracking
if (attempt > 1) {
errorTracking.captureException(error, {
tags: { retried: true },
extra: { endpoint: config.url }
});
}
},
onRetry: async ({ error, attempt, delay }) => {
// Log retry attempts
logger.warn('api.retry', {
error: error.message,
attempt,
delayMs: delay
});
}
}
});
Error Handling
Error Types
interface ApiError extends Error {
message: string;
status: number;
code?: string;
details?: Record<string, any>;
}
Error Handler
import { ApiErrorHandler } from '@codella-software/utils';
const errorHandler = new ApiErrorHandler();
apiService.get('/api/users/123').subscribe({
error: (error: ApiError) => {
console.error('Status:', error.status);
console.error('Message:', error.message);
console.error('Code:', error.code);
// Handle specific status codes
if (error.status === 401) {
// Handle unauthorized
redirectToLogin();
} else if (error.status === 403) {
// Handle forbidden
showPermissionError();
} else if (error.status === 404) {
// Handle not found
showNotFoundError();
}
}
});
Custom Error Handling
class CustomErrorHandler extends ApiErrorHandler {
override handleError(error: AxiosError): ApiError {
const baseError = super.handleError(error);
// Add custom handling
if (error.response?.status === 429) {
return {
...baseError,
message: 'Too many requests. Please try again later.'
};
}
return baseError;
}
}
const apiService = new ApiService(
tokenManager,
config,
new CustomErrorHandler()
);
Configuration Options
ApiConfig
- baseUrl (required): Base URL for all API requests
- defaultHeaders (optional): Default headers for all requests
- timeout (optional): Request timeout in milliseconds (default: 30000)
- withCredentials (optional): Include credentials in requests (default: true)
- refreshTokenRoute (optional): Route to refresh token (default: '/api/v1/users/me')
- cacheDuration (optional): Cache duration in milliseconds (default: 0)
RetryConfig
- maxRetries (default: 2): Maximum number of retry attempts
- initialDelay (default: 300ms): Initial delay before first retry
- maxDelay (default: 2000ms): Maximum delay between retries
- retryableStatuses (default: [401, 403]): HTTP status codes to retry
Best Practices
- Singleton Pattern: Create one API Service instance per application
- Token Lifecycle: Let TokenManager handle token refresh automatically
- Error Handling: Always handle errors in subscriptions or catches
- Memory Management: Unsubscribe from observables to prevent memory leaks
- Request Headers: Use request interceptors for common headers
- Response Caching: Configure cache duration based on data freshness needs
- Auth Provider: Choose the right provider for your authentication backend
- RTK Query: Use RTK Query for advanced caching and state management
Common Patterns
Polling Pattern
import { interval } from 'rxjs';
import { switchMap } from 'rxjs/operators';
interval(5000) // Poll every 5 seconds
.pipe(
switchMap(() => apiService.get<User>('/api/users/123'))
)
.subscribe(response => {
console.log('Updated data:', response.data);
});
Request Debouncing
import { Subject, debounceTime } from 'rxjs';
import { switchMap } from 'rxjs/operators';
const search$ = new Subject<string>();
search$
.pipe(
debounceTime(300),
switchMap(query =>
apiService.get<User[]>('/api/search', {
params: { q: query }
})
)
)
.subscribe(response => {
console.log('Search results:', response.data);
});
// Usage
search$.next('john');
Concurrent Requests
import { combineLatest } from 'rxjs';
combineLatest([
apiService.get<User>('/api/users/123'),
apiService.get<Posts>('/api/posts/123'),
apiService.get<Comments>('/api/comments/123')
]).subscribe(([userRes, postsRes, commentsRes]) => {
console.log('User:', userRes.data);
console.log('Posts:', postsRes.data);
console.log('Comments:', commentsRes.data);
});
Request Cancellation
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
const destroy$ = new Subject<void>();
apiService.get<User>('/api/users/123')
.pipe(
takeUntil(destroy$)
)
.subscribe(response => {
console.log('User:', response.data);
});
// Cancel request
destroy$.next();
destroy$.complete();
Troubleshooting
Token Not Refreshing
- Verify auth provider implements
refreshToken()method - Check
refreshTokenRouteconfiguration matches backend endpoint - Ensure auth token is valid before calling API
CORS Errors
- Enable
withCredentialsif needed - Check backend CORS configuration
- Verify allowed headers in server configuration
Request Timeout
- Increase
timeoutconfiguration value - Check network connectivity
- Review backend performance issues
Memory Leaks
- Always unsubscribe from observables
- Use
takeUntil()pattern for automatic cleanup - Clean up subscriptions in React component unmount
Type Safety
Typed API Responses
interface User {
id: string;
name: string;
email: string;
}
interface ApiResponse<T> {
status: number;
data: T;
headers: Record<string, any>;
}
// Usage
apiService.get<User>('/api/users/123')
.subscribe(response => {
const user: User = response.data; // Type-safe
console.log(user.name); // ✓ Correct
console.log(user.invalid); // ✗ TypeScript error
});
Typed Errors
import type { ApiError } from '@codella-software/utils';
apiService.get('/api/users/123')
.subscribe({
error: (error: ApiError) => {
const message: string = error.message;
const status: number = error.status;
const code: string | undefined = error.code;
}
});
Migration Guide
From Fetch API
// Before
const response = await fetch('/api/users/123', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
const data = await response.json();
// After
const response = await firstValueFrom(
apiService.get<User>('/api/users/123')
);
const data = response.data; // Token handled automatically
From Axios
// Before
const { data } = await axios.get('/api/users/123');
// After
const response = await firstValueFrom(
apiService.get<User>('/api/users/123')
);
const data = response.data; // With built-in retry, caching, auth