Skip to main content

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 IAuthProvider interface
  • 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

  1. Singleton Pattern: Create one API Service instance per application
  2. Token Lifecycle: Let TokenManager handle token refresh automatically
  3. Error Handling: Always handle errors in subscriptions or catches
  4. Memory Management: Unsubscribe from observables to prevent memory leaks
  5. Request Headers: Use request interceptors for common headers
  6. Response Caching: Configure cache duration based on data freshness needs
  7. Auth Provider: Choose the right provider for your authentication backend
  8. 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 refreshTokenRoute configuration matches backend endpoint
  • Ensure auth token is valid before calling API

CORS Errors

  • Enable withCredentials if needed
  • Check backend CORS configuration
  • Verify allowed headers in server configuration

Request Timeout

  • Increase timeout configuration 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