workspace/projects/gamilit/docs/95-guias-desarrollo/frontend/API-ARCHITECTURE.md
rckrdmrd ea1879f4ad feat: Initial workspace structure with multi-level Git configuration
- 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>
2025-12-08 10:44:23 -06:00

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

  1. Introducción
  2. Estructura General
  3. Cliente Base Axios
  4. Módulos API Específicos
  5. Uso en Hooks y Componentes
  6. Convenciones de Rutas
  7. Ejemplos Completos
  8. Mejores Prácticas
  9. Anti-Patrones
  10. 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

  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

// 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


Versión: 1.0.0 Última actualización: 2025-11-23 Mantenido por: Frontend Team Proyecto: GAMILIT