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.comWith Authentication
bash
/template add-api-client --auth jwt --retryFull-Featured Client
bash
/template add-api-client --auth bearer --retry --cache --mockWhat 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.tsAPI 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
- Type Everything: Use TypeScript for all API types
- Handle Errors Gracefully: Show user-friendly messages
- Implement Caching: Reduce unnecessary requests
- Queue Offline Requests: Don't lose user data
- Mock During Development: Use MSW for realistic mocks
Related Commands
sync-data-models- Generate API typesadd-offline-sync- Enhanced offline supportsetup-shared-backend- Connect to backend
