Skip to content

add-api-client

Create a type-safe API client for backend communication.

Overview

The add-api-client command generates a fully typed API client for your React Native app, with features like request/response interceptors, automatic retry logic, offline queue, and type-safe endpoints.

Usage

bash
/template add-api-client [options]

Options

  • --base-url - API base URL (default: from env)
  • --auth - Authentication type: jwt, bearer, api-key
  • --retry - Enable automatic retry logic
  • --cache - Add response caching
  • --mock - Generate mock API for development

Examples

Basic API Client

bash
/template add-api-client --base-url https://api.myapp.com

With Authentication

bash
/template add-api-client --auth jwt --retry
bash
/template add-api-client --auth bearer --retry --cache --mock

What It Creates

API Client Structure

src/
├── api/
│   ├── client.ts          # API client core
│   ├── types.ts           # Request/Response types
│   ├── endpoints/         # Endpoint definitions
│   │   ├── auth.ts
│   │   ├── users.ts
│   │   ├── posts.ts
│   │   └── index.ts
│   ├── interceptors/      # Request/Response interceptors
│   │   ├── auth.ts
│   │   ├── error.ts
│   │   └── logging.ts
│   ├── utils/
│   │   ├── retry.ts       # Retry logic
│   │   ├── cache.ts       # Response caching
│   │   └── queue.ts       # Offline queue
│   └── mock/             # Mock API
│       ├── handlers.ts
│       └── data.ts

API Client Core

typescript
// src/api/client.ts
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
import NetInfo from '@react-native-community/netinfo';
import { setupInterceptors } from './interceptors';
import { OfflineQueue } from './utils/queue';
import { ResponseCache } from './utils/cache';

export class ApiClient {
  private instance: AxiosInstance;
  private offlineQueue: OfflineQueue;
  private cache: ResponseCache;
  
  constructor(config?: ApiConfig) {
    this.instance = axios.create({
      baseURL: config?.baseURL || process.env.API_BASE_URL,
      timeout: config?.timeout || 30000,
      headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/json',
        'X-Platform': Platform.OS,
        'X-App-Version': DeviceInfo.getVersion(),
      },
    });
    
    this.offlineQueue = new OfflineQueue();
    this.cache = new ResponseCache();
    
    // Setup interceptors
    setupInterceptors(this.instance, {
      onUnauthorized: this.handleUnauthorized,
      onNetworkError: this.handleNetworkError,
    });
    
    // Monitor network status
    this.setupNetworkMonitoring();
  }
  
  private setupNetworkMonitoring() {
    NetInfo.addEventListener((state) => {
      if (state.isConnected && this.offlineQueue.hasItems()) {
        this.processOfflineQueue();
      }
    });
  }
  
  private async processOfflineQueue() {
    const items = await this.offlineQueue.getAll();
    
    for (const item of items) {
      try {
        await this.request(item.config);
        await this.offlineQueue.remove(item.id);
      } catch (error) {
        console.error('Failed to process offline request:', error);
      }
    }
  }
  
  async request<T = any>(config: AxiosRequestConfig): Promise<T> {
    // Check cache
    if (config.method === 'GET' && this.cache.isEnabled) {
      const cached = await this.cache.get(config.url!);
      if (cached) return cached;
    }
    
    try {
      const response = await this.instance.request<T>(config);
      
      // Cache successful GET requests
      if (config.method === 'GET' && response.status === 200) {
        await this.cache.set(config.url!, response.data);
      }
      
      return response.data;
    } catch (error) {
      // Queue if offline
      const netInfo = await NetInfo.fetch();
      if (!netInfo.isConnected && config.method !== 'GET') {
        await this.offlineQueue.add(config);
        throw new Error('Request queued for offline sync');
      }
      
      throw error;
    }
  }
  
  // Convenience methods
  get<T = any>(url: string, config?: AxiosRequestConfig) {
    return this.request<T>({ ...config, method: 'GET', url });
  }
  
  post<T = any>(url: string, data?: any, config?: AxiosRequestConfig) {
    return this.request<T>({ ...config, method: 'POST', url, data });
  }
  
  put<T = any>(url: string, data?: any, config?: AxiosRequestConfig) {
    return this.request<T>({ ...config, method: 'PUT', url, data });
  }
  
  delete<T = any>(url: string, config?: AxiosRequestConfig) {
    return this.request<T>({ ...config, method: 'DELETE', url });
  }
}

// Singleton instance
export const apiClient = new ApiClient();

Type-Safe Endpoints

typescript
// src/api/endpoints/users.ts
import { apiClient } from '../client';
import { User, CreateUserDto, UpdateUserDto, PaginatedResponse } from '../types';

export const usersApi = {
  // Get all users with pagination
  async getUsers(params?: {
    page?: number;
    limit?: number;
    search?: string;
  }): Promise<PaginatedResponse<User>> {
    return apiClient.get('/users', { params });
  },
  
  // Get single user
  async getUser(id: string): Promise<User> {
    return apiClient.get(`/users/${id}`);
  },
  
  // Create user
  async createUser(data: CreateUserDto): Promise<User> {
    return apiClient.post('/users', data);
  },
  
  // Update user
  async updateUser(id: string, data: UpdateUserDto): Promise<User> {
    return apiClient.put(`/users/${id}`, data);
  },
  
  // Delete user
  async deleteUser(id: string): Promise<void> {
    return apiClient.delete(`/users/${id}`);
  },
  
  // Upload avatar
  async uploadAvatar(userId: string, file: FormData): Promise<{ url: string }> {
    return apiClient.post(`/users/${userId}/avatar`, file, {
      headers: {
        'Content-Type': 'multipart/form-data',
      },
    });
  },
  
  // Get user's posts
  async getUserPosts(userId: string, params?: {
    page?: number;
    limit?: number;
  }): Promise<PaginatedResponse<Post>> {
    return apiClient.get(`/users/${userId}/posts`, { params });
  },
};

Authentication Interceptor

typescript
// src/api/interceptors/auth.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
import { AxiosInstance } from 'axios';

export function setupAuthInterceptor(instance: AxiosInstance) {
  // Request interceptor - add auth token
  instance.interceptors.request.use(
    async (config) => {
      const token = await AsyncStorage.getItem('auth_token');
      
      if (token) {
        config.headers.Authorization = `Bearer ${token}`;
      }
      
      return config;
    },
    (error) => Promise.reject(error)
  );
  
  // Response interceptor - handle token refresh
  instance.interceptors.response.use(
    (response) => response,
    async (error) => {
      const originalRequest = error.config;
      
      if (error.response?.status === 401 && !originalRequest._retry) {
        originalRequest._retry = true;
        
        try {
          const refreshToken = await AsyncStorage.getItem('refresh_token');
          const response = await instance.post('/auth/refresh', {
            refreshToken,
          });
          
          const { accessToken } = response.data;
          await AsyncStorage.setItem('auth_token', accessToken);
          
          // Retry original request
          originalRequest.headers.Authorization = `Bearer ${accessToken}`;
          return instance(originalRequest);
        } catch (refreshError) {
          // Redirect to login
          navigation.navigate('Login');
          return Promise.reject(refreshError);
        }
      }
      
      return Promise.reject(error);
    }
  );
}

Retry Logic

typescript
// src/api/utils/retry.ts
import { AxiosInstance, AxiosError } from 'axios';
import axiosRetry from 'axios-retry';

export function setupRetry(instance: AxiosInstance) {
  axiosRetry(instance, {
    retries: 3,
    retryDelay: (retryCount) => {
      // Exponential backoff
      return Math.pow(2, retryCount) * 1000;
    },
    retryCondition: (error: AxiosError) => {
      // Retry on network errors and 5xx errors
      return (
        axiosRetry.isNetworkOrIdempotentRequestError(error) ||
        (error.response?.status ?? 0) >= 500
      );
    },
    onRetry: (retryCount, error, requestConfig) => {
      console.log(`Retrying request (${retryCount})`, requestConfig.url);
    },
  });
}

Response Caching

typescript
// src/api/utils/cache.ts
import AsyncStorage from '@react-native-async-storage/async-storage';

export class ResponseCache {
  private prefix = 'api_cache_';
  private ttl = 5 * 60 * 1000; // 5 minutes
  isEnabled = true;
  
  async get(key: string): Promise<any> {
    try {
      const cached = await AsyncStorage.getItem(this.prefix + key);
      if (!cached) return null;
      
      const { data, timestamp } = JSON.parse(cached);
      
      if (Date.now() - timestamp > this.ttl) {
        await this.remove(key);
        return null;
      }
      
      return data;
    } catch {
      return null;
    }
  }
  
  async set(key: string, data: any): Promise<void> {
    try {
      await AsyncStorage.setItem(
        this.prefix + key,
        JSON.stringify({
          data,
          timestamp: Date.now(),
        })
      );
    } catch (error) {
      console.error('Cache set error:', error);
    }
  }
  
  async remove(key: string): Promise<void> {
    await AsyncStorage.removeItem(this.prefix + key);
  }
  
  async clear(): Promise<void> {
    const keys = await AsyncStorage.getAllKeys();
    const cacheKeys = keys.filter(k => k.startsWith(this.prefix));
    await AsyncStorage.multiRemove(cacheKeys);
  }
}

API Hooks

typescript
// src/hooks/useApi.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { usersApi } from '../api/endpoints/users';

// Fetch hook with caching
export function useUsers(params?: UserQueryParams) {
  return useQuery({
    queryKey: ['users', params],
    queryFn: () => usersApi.getUsers(params),
    staleTime: 5 * 60 * 1000, // 5 minutes
    cacheTime: 10 * 60 * 1000, // 10 minutes
  });
}

// Single user hook
export function useUser(id: string) {
  return useQuery({
    queryKey: ['users', id],
    queryFn: () => usersApi.getUser(id),
    enabled: !!id,
  });
}

// Create user mutation
export function useCreateUser() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: usersApi.createUser,
    onSuccess: (data) => {
      // Invalidate and refetch users list
      queryClient.invalidateQueries({ queryKey: ['users'] });
      
      // Optimistically update the cache
      queryClient.setQueryData(['users', data.id], data);
    },
  });
}

// Update user mutation with optimistic update
export function useUpdateUser() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: ({ id, data }: { id: string; data: UpdateUserDto }) =>
      usersApi.updateUser(id, data),
    onMutate: async ({ id, data }) => {
      // Cancel in-flight queries
      await queryClient.cancelQueries({ queryKey: ['users', id] });
      
      // Snapshot previous value
      const previousUser = queryClient.getQueryData(['users', id]);
      
      // Optimistically update
      queryClient.setQueryData(['users', id], (old: User) => ({
        ...old,
        ...data,
      }));
      
      return { previousUser };
    },
    onError: (err, variables, context) => {
      // Rollback on error
      if (context?.previousUser) {
        queryClient.setQueryData(
          ['users', variables.id],
          context.previousUser
        );
      }
    },
    onSettled: (data, error, variables) => {
      // Refetch after mutation
      queryClient.invalidateQueries({ queryKey: ['users', variables.id] });
    },
  });
}

Mock API

typescript
// src/api/mock/handlers.ts
import { rest } from 'msw';
import { mockUsers } from './data';

export const handlers = [
  // Get users
  rest.get('/api/users', (req, res, ctx) => {
    const page = parseInt(req.url.searchParams.get('page') || '1');
    const limit = parseInt(req.url.searchParams.get('limit') || '10');
    
    const start = (page - 1) * limit;
    const end = start + limit;
    
    return res(
      ctx.delay(500),
      ctx.json({
        data: mockUsers.slice(start, end),
        total: mockUsers.length,
        page,
        limit,
      })
    );
  }),
  
  // Get single user
  rest.get('/api/users/:id', (req, res, ctx) => {
    const { id } = req.params;
    const user = mockUsers.find(u => u.id === id);
    
    if (!user) {
      return res(ctx.status(404), ctx.json({ error: 'User not found' }));
    }
    
    return res(ctx.json(user));
  }),
  
  // Create user
  rest.post('/api/users', async (req, res, ctx) => {
    const data = await req.json();
    const newUser = {
      id: Math.random().toString(36),
      ...data,
      createdAt: new Date().toISOString(),
    };
    
    mockUsers.push(newUser);
    
    return res(ctx.status(201), ctx.json(newUser));
  }),
];

Error Handling

Global Error Handler

typescript
export function setupErrorInterceptor(instance: AxiosInstance) {
  instance.interceptors.response.use(
    (response) => response,
    (error: AxiosError<ApiError>) => {
      const apiError = new ApiException(
        error.response?.data?.message || error.message,
        error.response?.status || 0,
        error.response?.data?.code
      );
      
      // Show user-friendly error
      if (apiError.status >= 500) {
        showToast('Server error. Please try again later.');
      } else if (apiError.status === 404) {
        showToast('Resource not found.');
      } else if (apiError.status === 0) {
        showToast('Network error. Please check your connection.');
      }
      
      return Promise.reject(apiError);
    }
  );
}

Testing

API Client Tests

typescript
describe('API Client', () => {
  it('adds auth token to requests', async () => {
    await AsyncStorage.setItem('auth_token', 'test-token');
    
    const response = await apiClient.get('/users');
    
    expect(mockAxios.history.get[0].headers.Authorization).toBe('Bearer test-token');
  });
  
  it('queues requests when offline', async () => {
    NetInfo.fetch.mockResolvedValueOnce({ isConnected: false });
    
    await expect(
      apiClient.post('/users', { name: 'Test' })
    ).rejects.toThrow('Request queued for offline sync');
    
    const queue = await offlineQueue.getAll();
    expect(queue).toHaveLength(1);
  });
});

Best Practices

  1. Type Everything: Use TypeScript for all API types
  2. Handle Errors Gracefully: Show user-friendly messages
  3. Implement Caching: Reduce unnecessary requests
  4. Queue Offline Requests: Don't lose user data
  5. Mock During Development: Use MSW for realistic mocks

Built with ❤️ for the AI Coding community, by Praney Behl