# Arquitectura de API Clients - Frontend **Versión:** 1.0.0 **Fecha:** 2025-11-23 **Proyecto:** GAMILIT - Sistema de Gamificación Educativa **Mantenido por:** Frontend Team --- ## 📋 Tabla de Contenidos 1. [Introducción](#introducción) 2. [Estructura General](#estructura-general) 3. [Cliente Base Axios](#cliente-base-axios) 4. [Módulos API Específicos](#módulos-api-específicos) 5. [Uso en Hooks y Componentes](#uso-en-hooks-y-componentes) 6. [Convenciones de Rutas](#convenciones-de-rutas) 7. [Ejemplos Completos](#ejemplos-completos) 8. [Mejores Prácticas](#mejores-prácticas) 9. [Anti-Patrones](#anti-patrones) 10. [Testing](#testing) --- ## 🎯 Introducción Este documento describe la arquitectura de API clients en el frontend de GAMILIT. Define cómo estructurar, implementar y usar clientes API para comunicarse con el backend. ### Objetivos - ✅ **Centralización:** Rutas API definidas en un solo lugar - ✅ **Type Safety:** Uso completo de TypeScript types - ✅ **Mantenibilidad:** Fácil de actualizar cuando el backend cambia - ✅ **Consistencia:** Patrón único en toda la aplicación - ✅ **Testability:** Fácil de mockear y testear ### Audiencia - Desarrolladores frontend de GAMILIT - Tech leads revisando PRs - Nuevos miembros del equipo --- ## 🏗️ Estructura General ``` apps/frontend/src/ ├── services/ │ └── api/ │ ├── apiClient.ts # ⭐ Cliente Axios base │ ├── apiConfig.ts # Configuraciones │ ├── apiErrorHandler.ts # Manejo de errores │ ├── apiInterceptors.ts # Interceptors adicionales │ └── apiTypes.ts # Types comunes │ └── lib/ └── api/ ├── auth.api.ts # 🔵 Módulo API: Autenticación ├── gamification.api.ts # 🔵 Módulo API: Gamificación ├── progress.api.ts # 🔵 Módulo API: Progreso ├── educational.api.ts # 🔵 Módulo API: Contenido Educativo └── index.ts # Re-exportación ``` ### División de Responsabilidades | Capa | Responsabilidad | Ejemplo | |------|----------------|---------| | **services/api/apiClient.ts** | Cliente Axios base, interceptors, config | `apiClient.get()` | | **lib/api/*.api.ts** | Métodos API por dominio | `gamificationApi.getUserStats()` | | **hooks/** | Lógica de UI + state management | `useUserGamification()` | | **components/** | Presentación de datos | `` | --- ## ⚙️ Cliente Base Axios ### Ubicación `apps/frontend/src/services/api/apiClient.ts` ### Responsabilidad El cliente base Axios es la **única instancia de Axios** configurada globalmente. Proporciona: - ✅ BaseURL configurado (`/api`) - ✅ Timeout global (30s) - ✅ Headers por defecto (`Content-Type: application/json`) - ✅ Interceptor de request (auth token, tenant-id) - ✅ Interceptor de response (refresh token, manejo de errores) - ✅ Funciones utilitarias (setAuthToken, clearAuthTokens, isAuthenticated) ### Configuración ```typescript // apps/frontend/src/services/api/apiClient.ts import axios, { AxiosInstance } from 'axios'; /** * API Base URL from environment * Default: http://localhost:3006/api */ const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3006/api'; /** * Base Axios instance */ export const apiClient: AxiosInstance = axios.create({ baseURL: API_BASE_URL, // http://localhost:3006/api timeout: 30000, // 30 segundos headers: { 'Content-Type': 'application/json', }, }); ``` ### Request Interceptor ```typescript apiClient.interceptors.request.use( (config) => { // Add JWT token from localStorage const token = localStorage.getItem('auth-token'); if (token && config.headers) { config.headers.Authorization = `Bearer ${token}`; } // Add tenant-id header const tenantId = localStorage.getItem('tenant-id'); if (tenantId && config.headers) { config.headers['X-Tenant-Id'] = tenantId; } return config; }, (error) => Promise.reject(error) ); ``` ### Response Interceptor ```typescript apiClient.interceptors.response.use( (response) => response, async (error) => { const originalRequest = error.config; // Handle 401 - Token expired if (error.response?.status === 401 && !originalRequest._retry) { originalRequest._retry = true; try { const refreshToken = localStorage.getItem('refresh-token'); const { data } = await axios.post(`${API_BASE_URL}/auth/refresh`, { refreshToken, }); localStorage.setItem('auth-token', data.token); originalRequest.headers.Authorization = `Bearer ${data.token}`; return apiClient(originalRequest); } catch (refreshError) { // Redirect to login localStorage.clear(); window.location.href = '/login'; return Promise.reject(refreshError); } } return Promise.reject(error); } ); ``` ### Utility Functions ```typescript /** * Set authentication token */ export const setAuthToken = (token: string): void => { localStorage.setItem('auth-token', token); }; /** * Clear authentication tokens */ export const clearAuthTokens = (): void => { localStorage.removeItem('auth-token'); localStorage.removeItem('refresh-token'); }; /** * Check if user is authenticated */ export const isAuthenticated = (): boolean => { return !!localStorage.getItem('auth-token'); }; export default apiClient; ``` --- ## 🔵 Módulos API Específicos ### Ubicación `apps/frontend/src/lib/api/` ### Responsabilidad Los módulos API específicos definen métodos para cada dominio del backend: - **auth.api.ts** → Autenticación (login, register, logout, profile) - **gamification.api.ts** → Gamificación (stats, achievements, leaderboard, ML coins) - **progress.api.ts** → Progreso (módulos, sesiones, intentos, actividades) - **educational.api.ts** → Contenido educativo (módulos, ejercicios) ### Patrón de Implementación ```typescript // apps/frontend/src/lib/api/gamification.api.ts import apiClient from '@/services/api/apiClient'; import type { UserStats, Achievement, LeaderboardResponse, } from '@/shared/types'; /** * Gamification API Client * Provides methods to interact with gamification backend module */ export const gamificationApi = { /** * Get user statistics * @param userId - User ID * @returns User stats including XP, level, streak, etc. */ getUserStats: async (userId: string): Promise => { const { data } = await apiClient.get( `/gamification/users/${userId}/stats` ); return data; }, /** * Get user achievements * @param userId - User ID * @returns List of user achievements with progress */ getUserAchievements: async (userId: string): Promise => { const { data } = await apiClient.get( `/gamification/users/${userId}/achievements` ); return data; }, /** * Get global leaderboard * @param limit - Number of entries (default: 100) * @param offset - Pagination offset (default: 0) * @returns Leaderboard with top users */ getGlobalLeaderboard: async ( limit: number = 100, offset: number = 0 ): Promise => { const params = new URLSearchParams({ limit: limit.toString(), offset: offset.toString(), }); const { data } = await apiClient.get( `/gamification/leaderboard/global?${params.toString()}` ); return data; }, }; export default gamificationApi; ``` ### Convenciones 1. **Nombre del módulo:** `{dominio}Api` (camelCase) 2. **Nombre del archivo:** `{dominio}.api.ts` (kebab-case) 3. **Export named + default:** Ambos exports disponibles 4. **JSDoc comments:** Documentar cada método 5. **TypeScript types:** Tipado completo en params y returns 6. **Rutas relativas:** Sin `/api` (ya está en baseURL) --- ## 🎨 Uso en Hooks y Componentes ### ✅ CORRECTO: Usar Módulos API ```typescript // apps/frontend/src/hooks/useUserGamification.ts import { useState, useEffect } from 'react'; import { gamificationApi } from '@/lib/api/gamification.api'; import type { UserStats, Achievement } from '@/shared/types'; export function useUserGamification(userId: string) { const [stats, setStats] = useState(null); const [achievements, setAchievements] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const fetchData = async () => { try { setLoading(true); setError(null); // ✅ Usa métodos del API module const [statsData, achievementsData] = await Promise.all([ gamificationApi.getUserStats(userId), gamificationApi.getUserAchievements(userId), ]); setStats(statsData); setAchievements(achievementsData); } catch (err) { setError(err.message); } finally { setLoading(false); } }; fetchData(); }, [userId]); return { stats, achievements, loading, error }; } ``` ### ❌ INCORRECTO: Hard-coding de Rutas ```typescript // ❌ NO HAGAS ESTO import { apiClient } from '@/services/api/apiClient'; export function useUserGamification(userId: string) { const fetchData = async () => { // ❌ Hard-coded routes const statsResponse = await apiClient.get(`/gamification/users/${userId}/stats`); const achievementsResponse = await apiClient.get(`/gamification/users/${userId}/achievements`); const stats = statsResponse.data; const achievements = achievementsResponse.data; }; } ``` **Problemas:** - ❌ Rutas hard-coded (difícil de mantener) - ❌ Duplicación de rutas en múltiples hooks - ❌ Sin TypeScript types en respuestas - ❌ Propenso a errores de tipeo --- ## 🛣️ Convenciones de Rutas ### Regla Principal **Las rutas frontend deben coincidir EXACTAMENTE con las expuestas por el backend.** ### Backend Configuration ```typescript // apps/backend/src/main.ts app.setGlobalPrefix('api'); // Global prefix: /api // apps/backend/src/modules/gamification/controllers/user-stats.controller.ts @Controller('gamification') // Controller base: gamification @Get('users/:userId/stats') // Route method: users/:userId/stats // Ruta completa backend: /api/gamification/users/:userId/stats ``` ### Frontend Configuration ```typescript // apps/frontend/src/services/api/apiClient.ts const API_BASE_URL = 'http://localhost:3006/api'; // baseURL incluye /api // apps/frontend/src/lib/api/gamification.api.ts apiClient.get(`/gamification/users/${userId}/stats`) // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ // Ruta sin /api (ya está en baseURL) // Ruta completa llamada: http://localhost:3006/api/gamification/users/:userId/stats ✅ ``` ### ⚠️ IMPORTANTE: NO agregar `/v1/` ```typescript // ❌ INCORRECTO - con /v1/ apiClient.get(`/v1/gamification/users/${userId}/stats`) // Llamará a: http://localhost:3006/api/v1/gamification/users/:userId/stats // Backend no tiene /v1/ en global prefix → 404 Error // ✅ CORRECTO - sin /v1/ apiClient.get(`/gamification/users/${userId}/stats`) // Llamará a: http://localhost:3006/api/gamification/users/:userId/stats // Backend: /api + /gamification + /users/:userId/stats → Match ✅ ``` ### Mapeo Frontend ↔ Backend | Frontend Route | Backend Route | Match | |----------------|---------------|-------| | `/auth/login` | `/api/auth/login` | ✅ | | `/gamification/users/:id/stats` | `/api/gamification/users/:id/stats` | ✅ | | `/progress/users/:id` | `/api/progress/users/:id` | ✅ | | `/educational/modules` | `/api/educational/modules` | ✅ | --- ## 💡 Ejemplos Completos ### Ejemplo 1: Auth API Module ```typescript // apps/frontend/src/lib/api/auth.api.ts import apiClient from '@/services/api/apiClient'; export interface LoginCredentials { email: string; password: string; } export interface AuthResponse { accessToken: string; refreshToken?: string; user: { id: string; email: string; role: string; }; } export const authApi = { login: async (credentials: LoginCredentials): Promise => { const { data } = await apiClient.post('/auth/login', credentials); if (data.accessToken) { localStorage.setItem('auth-token', data.accessToken); } return data; }, logout: async (): Promise => { try { await apiClient.post('/auth/logout'); } finally { localStorage.removeItem('auth-token'); localStorage.removeItem('refresh-token'); } }, getProfile: async (): Promise => { const { data } = await apiClient.get('/auth/profile'); return data; }, }; export default authApi; ``` ### Ejemplo 2: Hook usando Auth API ```typescript // apps/frontend/src/hooks/useAuth.ts import { useState } from 'react'; import { authApi, LoginCredentials, AuthResponse } from '@/lib/api/auth.api'; export function useAuth() { const [user, setUser] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const login = async (credentials: LoginCredentials) => { try { setLoading(true); setError(null); const response = await authApi.login(credentials); // ✅ Usa authApi setUser(response.user); return response; } catch (err) { setError(err.message); throw err; } finally { setLoading(false); } }; const logout = async () => { try { await authApi.logout(); // ✅ Usa authApi setUser(null); } catch (err) { console.error('Logout error:', err); } }; return { user, login, logout, loading, error }; } ``` --- ## ✅ Mejores Prácticas ### 1. Type Safety ```typescript // ✅ BIEN: TypeScript types en params y returns getUserStats: async (userId: string): Promise => { const { data } = await apiClient.get(`/gamification/users/${userId}/stats`); return data; } // ❌ MAL: Sin types getUserStats: async (userId) => { const { data } = await apiClient.get(`/gamification/users/${userId}/stats`); return data; } ``` ### 2. Error Handling ```typescript // ✅ BIEN: Error handling en hook, no en API module export function useUserStats(userId: string) { try { const stats = await gamificationApi.getUserStats(userId); setStats(stats); } catch (error) { // Handle error en el hook setError(error.message); // Log, toast notification, etc. } } // ❌ MAL: Error handling en API module getUserStats: async (userId: string) => { try { const { data } = await apiClient.get(`...`); return data; } catch (error) { // ❌ No manejar errores aquí console.error(error); } } ``` ### 3. Query Parameters ```typescript // ✅ BIEN: URLSearchParams para query strings getLeaderboard: async (limit: number, offset: number) => { const params = new URLSearchParams({ limit: limit.toString(), offset: offset.toString(), }); const { data } = await apiClient.get(`/gamification/leaderboard?${params.toString()}`); return data; } // ❌ MAL: String concatenation getLeaderboard: async (limit: number, offset: number) => { const { data } = await apiClient.get(`/gamification/leaderboard?limit=${limit}&offset=${offset}`); return data; } ``` ### 4. Documentación ```typescript // ✅ BIEN: JSDoc completo /** * Get user statistics * * @param userId - User ID (UUID format) * @returns User stats including XP, level, streak, etc. * @throws {AxiosError} If user not found or unauthorized * * @example * const stats = await gamificationApi.getUserStats('550e8400-...'); */ getUserStats: async (userId: string): Promise => { // ... } ``` --- ## 🚫 Anti-Patrones ### ❌ 1. Hard-coding de Rutas en Hooks ```typescript // ❌ NO HAGAS ESTO const { data } = await apiClient.get(`/gamification/users/${userId}/stats`); ``` **Por qué es malo:** - Rutas duplicadas en múltiples lugares - Difícil de actualizar si backend cambia - Sin TypeScript types **Solución:** ```typescript // ✅ HAZ ESTO const stats = await gamificationApi.getUserStats(userId); ``` --- ### ❌ 2. Múltiples Instancias de Axios ```typescript // ❌ NO HAGAS ESTO import axios from 'axios'; const customClient = axios.create({ baseURL: 'http://localhost:3006/api' }); const data = await customClient.get('/gamification/users/.../stats'); ``` **Por qué es malo:** - No usa interceptors configurados - No tiene manejo de refresh token - Inconsistente con el resto de la app **Solución:** ```typescript // ✅ HAZ ESTO import apiClient from '@/services/api/apiClient'; const { data } = await apiClient.get('/gamification/users/.../stats'); ``` --- ### ❌ 3. Agregar `/v1/` a las Rutas ```typescript // ❌ NO HAGAS ESTO apiClient.get(`/v1/gamification/users/${userId}/stats`); ``` **Por qué es malo:** - Backend NO tiene `/v1/` en global prefix - Resulta en 404 errors **Solución:** ```typescript // ✅ HAZ ESTO apiClient.get(`/gamification/users/${userId}/stats`); ``` --- ### ❌ 4. No Usar Types de TypeScript ```typescript // ❌ NO HAGAS ESTO const getUserStats = async (userId: string) => { const { data } = await apiClient.get(`/gamification/users/${userId}/stats`); return data; // data es `any` } ``` **Por qué es malo:** - Pierde type safety - No hay IntelliSense - Errores en runtime **Solución:** ```typescript // ✅ HAZ ESTO const getUserStats = async (userId: string): Promise => { const { data } = await apiClient.get(`/gamification/users/${userId}/stats`); return data; // data es `UserStats` } ``` --- ## 🧪 Testing ### Mockear Módulos API ```typescript // apps/frontend/src/lib/api/__mocks__/gamification.api.ts export const gamificationApi = { getUserStats: jest.fn().mockResolvedValue({ user_id: 'test-user-id', level: 5, total_xp: 500, ml_coins: 1000, current_rank: 'Nacom', }), getUserAchievements: jest.fn().mockResolvedValue([ { id: 'ach-1', name: 'First Step', unlocked: true, }, ]), }; export default gamificationApi; ``` ### Test de Hook ```typescript // apps/frontend/src/hooks/__tests__/useUserGamification.test.ts import { renderHook, waitFor } from '@testing-library/react'; import { useUserGamification } from '../useUserGamification'; // Mock el módulo API jest.mock('@/lib/api/gamification.api'); describe('useUserGamification', () => { it('should fetch user stats and achievements', async () => { const { result } = renderHook(() => useUserGamification('test-user-id')); await waitFor(() => { expect(result.current.loading).toBe(false); }); expect(result.current.stats).toEqual({ user_id: 'test-user-id', level: 5, total_xp: 500, ml_coins: 1000, current_rank: 'Nacom', }); expect(result.current.achievements).toHaveLength(1); }); }); ``` --- ## 📚 Referencias - [ADR-011: Frontend API Client Structure](../97-adr/ADR-011-frontend-api-client-structure.md) - [Backend API Routes Constants](../../apps/backend/src/shared/constants/routes.constants.ts) - [Axios Documentation](https://axios-http.com/docs/intro) - [TypeScript Documentation](https://www.typescriptlang.org/docs/) --- **Versión:** 1.0.0 **Última actualización:** 2025-11-23 **Mantenido por:** Frontend Team **Proyecto:** GAMILIT