- Configure workspace Git repository with comprehensive .gitignore - Add Odoo as submodule for ERP reference code - Include documentation: SETUP.md, GIT-STRUCTURE.md - Add gitignore templates for projects (backend, frontend, database) - Structure supports independent repos per project/subproject level Workspace includes: - core/ - Reusable patterns, modules, orchestration system - projects/ - Active projects (erp-suite, gamilit, trading-platform, etc.) - knowledge-base/ - Reference code and patterns (includes Odoo submodule) - devtools/ - Development tools and templates - customers/ - Client implementations template 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
19 KiB
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
- Introducción
- Estructura General
- Cliente Base Axios
- Módulos API Específicos
- Uso en Hooks y Componentes
- Convenciones de Rutas
- Ejemplos Completos
- Mejores Prácticas
- Anti-Patrones
- 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 | <GamifiedHeader /> |
⚙️ 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
// 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
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
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
/**
* 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
// 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<UserStats> => {
const { data } = await apiClient.get<UserStats>(
`/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<Achievement[]> => {
const { data } = await apiClient.get<Achievement[]>(
`/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<LeaderboardResponse> => {
const params = new URLSearchParams({
limit: limit.toString(),
offset: offset.toString(),
});
const { data } = await apiClient.get<LeaderboardResponse>(
`/gamification/leaderboard/global?${params.toString()}`
);
return data;
},
};
export default gamificationApi;
Convenciones
- Nombre del módulo:
{dominio}Api(camelCase) - Nombre del archivo:
{dominio}.api.ts(kebab-case) - Export named + default: Ambos exports disponibles
- JSDoc comments: Documentar cada método
- TypeScript types: Tipado completo en params y returns
- Rutas relativas: Sin
/api(ya está en baseURL)
🎨 Uso en Hooks y Componentes
✅ CORRECTO: Usar Módulos API
// 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<UserStats | null>(null);
const [achievements, setAchievements] = useState<Achievement[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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
// ❌ 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
// 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
// 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/
// ❌ 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
// 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<AuthResponse> => {
const { data } = await apiClient.post<AuthResponse>('/auth/login', credentials);
if (data.accessToken) {
localStorage.setItem('auth-token', data.accessToken);
}
return data;
},
logout: async (): Promise<void> => {
try {
await apiClient.post('/auth/logout');
} finally {
localStorage.removeItem('auth-token');
localStorage.removeItem('refresh-token');
}
},
getProfile: async (): Promise<AuthResponse['user']> => {
const { data } = await apiClient.get('/auth/profile');
return data;
},
};
export default authApi;
Ejemplo 2: Hook usando Auth API
// 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<AuthResponse['user'] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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
// ✅ BIEN: TypeScript types en params y returns
getUserStats: async (userId: string): Promise<UserStats> => {
const { data } = await apiClient.get<UserStats>(`/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
// ✅ 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
// ✅ 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
// ✅ 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<UserStats> => {
// ...
}
🚫 Anti-Patrones
❌ 1. Hard-coding de Rutas en Hooks
// ❌ 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:
// ✅ HAZ ESTO
const stats = await gamificationApi.getUserStats(userId);
❌ 2. Múltiples Instancias de Axios
// ❌ 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:
// ✅ HAZ ESTO
import apiClient from '@/services/api/apiClient';
const { data } = await apiClient.get('/gamification/users/.../stats');
❌ 3. Agregar /v1/ a las Rutas
// ❌ 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:
// ✅ HAZ ESTO
apiClient.get(`/gamification/users/${userId}/stats`);
❌ 4. No Usar Types de 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:
// ✅ HAZ ESTO
const getUserStats = async (userId: string): Promise<UserStats> => {
const { data } = await apiClient.get<UserStats>(`/gamification/users/${userId}/stats`);
return data; // data es `UserStats`
}
🧪 Testing
Mockear Módulos API
// 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
// 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
- Backend API Routes Constants
- Axios Documentation
- TypeScript Documentation
Versión: 1.0.0 Última actualización: 2025-11-23 Mantenido por: Frontend Team Proyecto: GAMILIT