Capítulo 11: API Client con Autenticación
Capítulo 11: API Client con Autenticación
En este capítulo implementaremos un cliente HTTP robusto con autenticación automática usando Clerk. Aprenderemos a configurar interceptors, manejo de errores y tipos TypeScript para nuestras APIs.
🎯 Objetivos del Capítulo
- Configurar Axios como cliente HTTP principal
- Implementar interceptors de autenticación con Clerk
- Crear tipos TypeScript para API responses
- Manejar errores de red y autenticación
- Implementar refresh automático de tokens
- Testing del API client con MSW
📚 Contenido
1. Instalación y Configuración Inicial
Primero instalemos las dependencias necesarias:
# Cliente HTTP y utilidades
npm install axios react-native-mmkv
# Tipos para desarrollo
npm install -D @types/axios
2. Configuración del Cliente Base
Creamos la estructura base de nuestro API client:
// src/services/api/client.ts
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
import { useAuth } from '@clerk/clerk-expo';
import { MMKV } from 'react-native-mmkv';
// Storage para tokens y cache
const storage = new MMKV({
id: 'api-storage',
encryptionKey: 'your-encryption-key-here'
});
// Configuración base del cliente
const API_BASE_URL = __DEV__
? 'http://localhost:3000/api'
: 'https://your-api.com/api';
interface ApiClientConfig {
baseURL: string;
timeout: number;
headers: Record<string, string>;
}
class ApiClient {
private client: AxiosInstance;
private getToken: (() => Promise<string | null>) | null = null;
constructor(config: ApiClientConfig) {
this.client = axios.create({
baseURL: config.baseURL,
timeout: config.timeout,
headers: {
'Content-Type': 'application/json',
...config.headers,
},
});
this.setupInterceptors();
}
// Configurar función para obtener token
public setTokenProvider(getToken: () => Promise<string | null>) {
this.getToken = getToken;
}
private setupInterceptors() {
// Request interceptor - agregar token automáticamente
this.client.interceptors.request.use(
async (config) => {
if (this.getToken) {
const token = await this.getToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor - manejo de errores
this.client.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// Si es error 401 y no hemos reintentado
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
// Intentar refrescar token
if (this.getToken) {
const newToken = await this.getToken();
if (newToken) {
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return this.client(originalRequest);
}
}
} catch (refreshError) {
// Si falla el refresh, redirigir a login
console.log('Token refresh failed:', refreshError);
// Aquí podrías disparar un evento para redirigir al login
}
}
return Promise.reject(error);
}
);
}
// Métodos públicos
public async get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response = await this.client.get<T>(url, config);
return response.data;
}
public async post<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
const response = await this.client.post<T>(url, data, config);
return response.data;
}
public async put<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
const response = await this.client.put<T>(url, data, config);
return response.data;
}
public async delete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response = await this.client.delete<T>(url, config);
return response.data;
}
public async patch<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
const response = await this.client.patch<T>(url, data, config);
return response.data;
}
}
// Instancia singleton
export const apiClient = new ApiClient({
baseURL: API_BASE_URL,
timeout: 10000,
headers: {
'Accept': 'application/json',
},
});
3. Integración con Clerk
Creamos un hook personalizado para conectar el API client con Clerk:
// src/hooks/useApiClient.ts
import { useAuth } from '@clerk/clerk-expo';
import { useEffect } from 'react';
import { apiClient } from '../services/api/client';
export const useApiClient = () => {
const { getToken, isSignedIn } = useAuth();
useEffect(() => {
if (isSignedIn) {
// Configurar el proveedor de tokens
apiClient.setTokenProvider(async () => {
try {
return await getToken();
} catch (error) {
console.error('Error getting token:', error);
return null;
}
});
}
}, [isSignedIn, getToken]);
return apiClient;
};
4. Tipos TypeScript para API
Definimos tipos robustos para nuestras APIs:
// src/types/api.ts
export interface ApiResponse<T = any> {
data: T;
message: string;
status: 'success' | 'error';
timestamp: string;
}
export interface PaginatedResponse<T> {
data: T[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}
export interface ApiError {
message: string;
code: string;
details?: Record<string, any>;
}
// Tipos específicos para TODO app
export interface Todo {
id: string;
title: string;
description?: string;
completed: boolean;
priority: 'low' | 'medium' | 'high';
dueDate?: string;
categoryId?: string;
userId: string;
createdAt: string;
updatedAt: string;
}
export interface CreateTodoRequest {
title: string;
description?: string;
priority?: 'low' | 'medium' | 'high';
dueDate?: string;
categoryId?: string;
}
export interface UpdateTodoRequest extends Partial<CreateTodoRequest> {
completed?: boolean;
}
export interface Category {
id: string;
name: string;
color: string;
userId: string;
createdAt: string;
updatedAt: string;
}
export interface CreateCategoryRequest {
name: string;
color: string;
}
5. Servicios de API Específicos
Creamos servicios específicos para cada entidad:
// src/services/api/todoService.ts
import { apiClient } from './client';
import {
Todo,
CreateTodoRequest,
UpdateTodoRequest,
ApiResponse,
PaginatedResponse
} from '../../types/api';
export class TodoService {
private static readonly BASE_PATH = '/todos';
static async getTodos(params?: {
page?: number;
limit?: number;
completed?: boolean;
categoryId?: string;
}): Promise<PaginatedResponse<Todo>> {
const searchParams = new URLSearchParams();
if (params?.page) searchParams.append('page', params.page.toString());
if (params?.limit) searchParams.append('limit', params.limit.toString());
if (params?.completed !== undefined) searchParams.append('completed', params.completed.toString());
if (params?.categoryId) searchParams.append('categoryId', params.categoryId);
const query = searchParams.toString();
const url = query ? `${this.BASE_PATH}?${query}` : this.BASE_PATH;
return apiClient.get<PaginatedResponse<Todo>>(url);
}
static async getTodoById(id: string): Promise<ApiResponse<Todo>> {
return apiClient.get<ApiResponse<Todo>>(`${this.BASE_PATH}/${id}`);
}
static async createTodo(data: CreateTodoRequest): Promise<ApiResponse<Todo>> {
return apiClient.post<ApiResponse<Todo>>(this.BASE_PATH, data);
}
static async updateTodo(id: string, data: UpdateTodoRequest): Promise<ApiResponse<Todo>> {
return apiClient.put<ApiResponse<Todo>>(`${this.BASE_PATH}/${id}`, data);
}
static async deleteTodo(id: string): Promise<ApiResponse<void>> {
return apiClient.delete<ApiResponse<void>>(`${this.BASE_PATH}/${id}`);
}
static async toggleTodoComplete(id: string): Promise<ApiResponse<Todo>> {
return apiClient.patch<ApiResponse<Todo>>(`${this.BASE_PATH}/${id}/toggle`);
}
}
// src/services/api/categoryService.ts
import { apiClient } from './client';
import {
Category,
CreateCategoryRequest,
ApiResponse
} from '../../types/api';
export class CategoryService {
private static readonly BASE_PATH = '/categories';
static async getCategories(): Promise<ApiResponse<Category[]>> {
return apiClient.get<ApiResponse<Category[]>>(this.BASE_PATH);
}
static async getCategoryById(id: string): Promise<ApiResponse<Category>> {
return apiClient.get<ApiResponse<Category>>(`${this.BASE_PATH}/${id}`);
}
static async createCategory(data: CreateCategoryRequest): Promise<ApiResponse<Category>> {
return apiClient.post<ApiResponse<Category>>(this.BASE_PATH, data);
}
static async updateCategory(id: string, data: Partial<CreateCategoryRequest>): Promise<ApiResponse<Category>> {
return apiClient.put<ApiResponse<Category>>(`${this.BASE_PATH}/${id}`, data);
}
static async deleteCategory(id: string): Promise<ApiResponse<void>> {
return apiClient.delete<ApiResponse<void>>(`${this.BASE_PATH}/${id}`);
}
}
6. Manejo de Errores Avanzado
Implementamos un sistema robusto de manejo de errores:
// src/services/api/errorHandler.ts
import { AxiosError } from 'axios';
import { ApiError } from '../../types/api';
export enum ApiErrorType {
NETWORK_ERROR = 'NETWORK_ERROR',
UNAUTHORIZED = 'UNAUTHORIZED',
FORBIDDEN = 'FORBIDDEN',
NOT_FOUND = 'NOT_FOUND',
VALIDATION_ERROR = 'VALIDATION_ERROR',
SERVER_ERROR = 'SERVER_ERROR',
UNKNOWN_ERROR = 'UNKNOWN_ERROR',
}
export class ApiErrorHandler {
static handleError(error: AxiosError): ApiError {
if (!error.response) {
// Error de red
return {
message: 'Error de conexión. Verifica tu conexión a internet.',
code: ApiErrorType.NETWORK_ERROR,
details: { originalError: error.message }
};
}
const { status, data } = error.response;
switch (status) {
case 401:
return {
message: 'Sesión expirada. Por favor, inicia sesión nuevamente.',
code: ApiErrorType.UNAUTHORIZED,
details: data
};
case 403:
return {
message: 'No tienes permisos para realizar esta acción.',
code: ApiErrorType.FORBIDDEN,
details: data
};
case 404:
return {
message: 'El recurso solicitado no fue encontrado.',
code: ApiErrorType.NOT_FOUND,
details: data
};
case 422:
return {
message: 'Los datos enviados no son válidos.',
code: ApiErrorType.VALIDATION_ERROR,
details: data
};
case 500:
case 502:
case 503:
return {
message: 'Error del servidor. Intenta nuevamente más tarde.',
code: ApiErrorType.SERVER_ERROR,
details: data
};
default:
return {
message: 'Ha ocurrido un error inesperado.',
code: ApiErrorType.UNKNOWN_ERROR,
details: data
};
}
}
static isRetryableError(error: ApiError): boolean {
return [
ApiErrorType.NETWORK_ERROR,
ApiErrorType.SERVER_ERROR
].includes(error.code as ApiErrorType);
}
}
7. Provider de API Client
Creamos un provider para el contexto de la aplicación:
// src/providers/ApiClientProvider.tsx
import React, { createContext, useContext, useEffect, ReactNode } from 'react';
import { useAuth } from '@clerk/clerk-expo';
import { apiClient } from '../services/api/client';
interface ApiClientContextType {
client: typeof apiClient;
isConfigured: boolean;
}
const ApiClientContext = createContext<ApiClientContextType | undefined>(undefined);
interface ApiClientProviderProps {
children: ReactNode;
}
export const ApiClientProvider: React.FC<ApiClientProviderProps> = ({ children }) => {
const { getToken, isSignedIn } = useAuth();
const [isConfigured, setIsConfigured] = React.useState(false);
useEffect(() => {
if (isSignedIn) {
apiClient.setTokenProvider(async () => {
try {
return await getToken();
} catch (error) {
console.error('Error getting token:', error);
return null;
}
});
setIsConfigured(true);
} else {
setIsConfigured(false);
}
}, [isSignedIn, getToken]);
return (
<ApiClientContext.Provider value={{ client: apiClient, isConfigured }}>
{children}
</ApiClientContext.Provider>
);
};
export const useApiClientContext = () => {
const context = useContext(ApiClientContext);
if (!context) {
throw new Error('useApiClientContext must be used within ApiClientProvider');
}
return context;
};
8. Testing del API Client
Implementamos tests completos usando MSW:
// src/services/api/__tests__/client.test.ts
import { apiClient } from '../client';
import { server } from '../../../__mocks__/server';
import { rest } from 'msw';
describe('ApiClient', () => {
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('GET requests', () => {
it('should make successful GET request', async () => {
const mockData = { id: 1, name: 'Test' };
server.use(
rest.get('http://localhost:3000/api/test', (req, res, ctx) => {
return res(ctx.json(mockData));
})
);
const result = await apiClient.get('/test');
expect(result).toEqual(mockData);
});
it('should handle GET request errors', async () => {
server.use(
rest.get('http://localhost:3000/api/test', (req, res, ctx) => {
return res(ctx.status(500), ctx.json({ error: 'Server error' }));
})
);
await expect(apiClient.get('/test')).rejects.toThrow();
});
});
describe('Authentication', () => {
it('should add authorization header when token is provided', async () => {
const mockToken = 'mock-token';
apiClient.setTokenProvider(async () => mockToken);
server.use(
rest.get('http://localhost:3000/api/protected', (req, res, ctx) => {
const authHeader = req.headers.get('Authorization');
expect(authHeader).toBe(`Bearer ${mockToken}`);
return res(ctx.json({ success: true }));
})
);
await apiClient.get('/protected');
});
it('should retry request with new token on 401 error', async () => {
let callCount = 0;
const tokens = ['expired-token', 'fresh-token'];
apiClient.setTokenProvider(async () => tokens[callCount++]);
server.use(
rest.get('http://localhost:3000/api/protected', (req, res, ctx) => {
const authHeader = req.headers.get('Authorization');
if (authHeader === 'Bearer expired-token') {
return res(ctx.status(401), ctx.json({ error: 'Unauthorized' }));
}
if (authHeader === 'Bearer fresh-token') {
return res(ctx.json({ success: true }));
}
return res(ctx.status(401));
})
);
const result = await apiClient.get('/protected');
expect(result).toEqual({ success: true });
expect(callCount).toBe(2);
});
});
});
// src/services/api/__tests__/todoService.test.ts
import { TodoService } from '../todoService';
import { server } from '../../../__mocks__/server';
import { rest } from 'msw';
import { Todo, CreateTodoRequest } from '../../../types/api';
describe('TodoService', () => {
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
const mockTodo: Todo = {
id: '1',
title: 'Test Todo',
description: 'Test description',
completed: false,
priority: 'medium',
userId: 'user_123',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z'
};
describe('getTodos', () => {
it('should fetch todos successfully', async () => {
const mockResponse = {
data: [mockTodo],
pagination: {
page: 1,
limit: 10,
total: 1,
totalPages: 1
}
};
server.use(
rest.get('http://localhost:3000/api/todos', (req, res, ctx) => {
return res(ctx.json(mockResponse));
})
);
const result = await TodoService.getTodos();
expect(result).toEqual(mockResponse);
});
it('should handle query parameters', async () => {
server.use(
rest.get('http://localhost:3000/api/todos', (req, res, ctx) => {
const url = new URL(req.url);
expect(url.searchParams.get('page')).toBe('1');
expect(url.searchParams.get('completed')).toBe('false');
return res(ctx.json({ data: [], pagination: {} }));
})
);
await TodoService.getTodos({ page: 1, completed: false });
});
});
describe('createTodo', () => {
it('should create todo successfully', async () => {
const createRequest: CreateTodoRequest = {
title: 'New Todo',
description: 'New description',
priority: 'high'
};
const mockResponse = {
data: { ...mockTodo, ...createRequest },
message: 'Todo created successfully',
status: 'success' as const,
timestamp: '2024-01-01T00:00:00Z'
};
server.use(
rest.post('http://localhost:3000/api/todos', (req, res, ctx) => {
return res(ctx.json(mockResponse));
})
);
const result = await TodoService.createTodo(createRequest);
expect(result).toEqual(mockResponse);
});
});
});
9. Configuración en App.tsx
Integramos el provider en la aplicación:
// App.tsx
import React from 'react';
import { ClerkProvider } from '@clerk/clerk-expo';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ApiClientProvider } from './src/providers/ApiClientProvider';
import { AppNavigator } from './src/navigation/AppNavigator';
const queryClient = new QueryClient();
export default function App() {
return (
<ClerkProvider publishableKey={process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!}>
<QueryClientProvider client={queryClient}>
<ApiClientProvider>
<AppNavigator />
</ApiClientProvider>
</QueryClientProvider>
</ClerkProvider>
);
}
10. Uso en Componentes
Ejemplo de uso en un componente:
// src/components/TodoList.tsx
import React from 'react';
import { View, Text, FlatList } from 'react-native';
import { useQuery } from '@tanstack/react-query';
import { TodoService } from '../services/api/todoService';
import { Todo } from '../types/api';
export const TodoList: React.FC = () => {
const {
data: todosResponse,
isLoading,
error
} = useQuery({
queryKey: ['todos'],
queryFn: () => TodoService.getTodos(),
});
if (isLoading) {
return (
<View>
<Text>Cargando todos...</Text>
</View>
);
}
if (error) {
return (
<View>
<Text>Error al cargar todos: {error.message}</Text>
</View>
);
}
const renderTodo = ({ item }: { item: Todo }) => (
<View>
<Text>{item.title}</Text>
<Text>{item.description}</Text>
</View>
);
return (
<FlatList
data={todosResponse?.data || []}
renderItem={renderTodo}
keyExtractor={(item) => item.id}
/>
);
};
🧪 Testing
Configuración de MSW para Testing
// src/__mocks__/handlers.ts
import { rest } from 'msw';
import { Todo, Category } from '../types/api';
const mockTodos: Todo[] = [
{
id: '1',
title: 'Test Todo 1',
description: 'Description 1',
completed: false,
priority: 'high',
userId: 'user_123',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z'
}
];
export const handlers = [
// Todos endpoints
rest.get('http://localhost:3000/api/todos', (req, res, ctx) => {
return res(
ctx.json({
data: mockTodos,
pagination: {
page: 1,
limit: 10,
total: mockTodos.length,
totalPages: 1
}
})
);
}),
rest.post('http://localhost:3000/api/todos', (req, res, ctx) => {
return res(
ctx.json({
data: { ...mockTodos[0], id: '2' },
message: 'Todo created successfully',
status: 'success',
timestamp: new Date().toISOString()
})
);
}),
// Categories endpoints
rest.get('http://localhost:3000/api/categories', (req, res, ctx) => {
return res(
ctx.json({
data: [],
message: 'Categories retrieved successfully',
status: 'success',
timestamp: new Date().toISOString()
})
);
}),
// Error simulation
rest.get('http://localhost:3000/api/error', (req, res, ctx) => {
return res(
ctx.status(500),
ctx.json({
message: 'Internal server error',
code: 'SERVER_ERROR'
})
);
})
];
📝 Mejores Prácticas
1. Configuración de Timeouts
// Diferentes timeouts para diferentes tipos de requests
const apiClient = new ApiClient({
baseURL: API_BASE_URL,
timeout: 10000, // 10 segundos por defecto
headers: {
'Accept': 'application/json',
},
});
// Para uploads de archivos
const uploadClient = new ApiClient({
baseURL: API_BASE_URL,
timeout: 60000, // 60 segundos para uploads
headers: {
'Accept': 'application/json',
},
});
2. Cache de Requests
// Implementar cache simple para requests GET
const requestCache = new Map<string, { data: any; timestamp: number }>();
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutos
const getCachedData = (key: string) => {
const cached = requestCache.get(key);
if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
return cached.data;
}
return null;
};
3. Retry Logic
// Implementar retry automático para requests fallidos
const retryRequest = async (request: () => Promise<any>, maxRetries = 3) => {
for (let i = 0; i < maxRetries; i++) {
try {
return await request();
} catch (error) {
if (i === maxRetries - 1) throw error;
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i)));
}
}
};
🔗 Navegación
- ← Capítulo 10: Configuración de TanStack Query
- → Capítulo 12: Queries y Mutations Autenticadas
- 🏠 Volver al índice
📚 Recursos Adicionales
¡Excelente trabajo! Has implementado un sistema robusto de API client con autenticación automática. En el próximo capítulo integraremos este cliente con TanStack Query para crear un sistema completo de gestión de datos del servidor.