← Volver al listado de tecnologías

Capítulo 11: API Client con Autenticación

Por: Artiko
react-nativeaxiosclerktypescriptapi-clientinterceptorstesting

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

📚 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

📚 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.