Files
plantillas-proyectos/frontend/src/lib/server/api.ts
2026-02-05 12:35:03 -06:00

384 lines
10 KiB
TypeScript

/**
* Utilidades para llamadas a la API desde el servidor (SSR)
* Centraliza la lógica de configuración de URL, autenticación y manejo de tokens
*/
import { redirect, type Cookies } from '@sveltejs/kit';
/**
* Obtiene y normaliza la URL base de la API para llamadas desde el servidor
* Automáticamente reemplaza localhost/127.0.0.1 con 'backend' para Docker
*/
export function getServerApiUrl(): string {
// Primero intentar con INTERNAL_API_URL (para llamadas server-side en Docker)
let apiUrl = process.env.INTERNAL_API_URL;
// Si no está definida, usar VITE_API_URL del entorno runtime (no import.meta.env)
if (!apiUrl) {
apiUrl = process.env.VITE_API_URL;
}
// Como último recurso, usar el valor de build-time
if (!apiUrl) {
apiUrl = import.meta.env.VITE_API_URL;
// Reemplazar 'localhost' con 'backend' para llamadas desde el servidor (SSR)
apiUrl = apiUrl?.replace('localhost', 'backend').replace('127.0.0.1', 'backend').replace('anexo76-dev.aduanasoft.com', 'backend');
}
// Normalizar la URL: asegurar que termine con '/'
return apiUrl?.endsWith('/') ? apiUrl : `${apiUrl}/`;
}
/**
* Obtiene los tokens de autenticación de las cookies
*/
export function getAuthTokens(cookies: Cookies) {
return {
accessToken: cookies.get('access_token'),
refreshToken: cookies.get('refresh_token')
};
}
/**
* Establece los tokens de autenticación en las cookies
*/
export function setAuthTokens(
cookies: Cookies,
accessToken: string,
refreshToken?: string
) {
cookies.set('access_token', accessToken, {
path: '/',
httpOnly: false,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 7 // 7 días
});
if (refreshToken) {
cookies.set('refresh_token', refreshToken, {
path: '/',
httpOnly: false,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 30 // 30 días
});
}
}
/**
* Limpia todos los tokens de autenticación de las cookies
*/
export function clearAuthTokens(cookies: Cookies) {
cookies.delete('access_token', { path: '/' });
cookies.delete('refresh_token', { path: '/' });
cookies.delete('active_company_id', { path: '/' });
}
/**
* Crea headers de autorización con el token Bearer
*/
export function createAuthHeaders(token: string, additionalHeaders?: Record<string, string>) {
return {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
...additionalHeaders
};
}
/**
* Intenta refrescar el token de acceso usando el refresh token
* @returns El nuevo access token o null si falla
*/
export async function refreshAccessToken(
cookies: Cookies,
fetch: typeof globalThis.fetch
): Promise<string | null> {
const { refreshToken } = getAuthTokens(cookies);
if (!refreshToken) {
return null;
}
try {
const baseUrl = getServerApiUrl();
const response = await fetch(`${baseUrl}v1/auth/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ refresh_token: refreshToken })
});
if (!response.ok) {
return null;
}
const data = await response.json();
// Actualizar las cookies con los nuevos tokens
setAuthTokens(cookies, data.access_token, data.refresh_token);
return data.access_token;
} catch (error) {
console.error('🔄 [API] Error al refrescar token:', error);
return null;
}
}
/**
* Realiza una petición autenticada a la API con manejo automático de refresh
* @param endpoint - Endpoint relativo (ej: 'v1/auth/me')
* @param options - Opciones de fetch
* @param cookies - Objeto de cookies de SvelteKit
* @param fetch - Función fetch de SvelteKit
* @param redirectUrl - URL a la que redirigir si falla la autenticación (opcional)
* @param timeout - Timeout en milisegundos (default: 30000ms)
*/
export async function authenticatedFetch(
endpoint: string,
options: RequestInit = {},
cookies: Cookies,
fetch: typeof globalThis.fetch,
redirectUrl?: string,
timeout: number = 30000
): Promise<Response> {
try {
const baseUrl = getServerApiUrl();
let { accessToken } = getAuthTokens(cookies);
// Si no hay token, redirigir o lanzar error
if (!accessToken) {
if (redirectUrl) {
throw redirect(303, redirectUrl);
}
throw new Error('No access token available');
}
// Construir URL completa
const url = endpoint.startsWith('http') ? endpoint : `${baseUrl}${endpoint}`;
// Crear AbortController para timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => {
console.error(`⏱️ [API] Timeout después de ${timeout}ms:`, endpoint);
controller.abort();
}, timeout);
// Realizar la petición inicial
// Si el body es FormData, no incluir Content-Type (el navegador lo establece con el boundary)
const isFormData = options.body instanceof FormData;
const headers = isFormData
? { 'Authorization': `Bearer ${accessToken}`, ...(options.headers as Record<string, string> || {}) }
: createAuthHeaders(accessToken, options.headers as Record<string, string>);
let response = await fetch(url, {
...options,
headers,
signal: controller.signal
});
clearTimeout(timeoutId);
// Si es 403, no intentar refrescar - es un problema de permisos
if (response.status === 403) {
console.warn('🚫 [API] Acceso denegado (403):', endpoint);
return response; // Retornar directamente para que el llamador maneje el error
}
// Si es 401, intentar refrescar el token
if (response.status === 401) {
const newToken = await refreshAccessToken(cookies, fetch);
if (newToken) {
// Reintentar la petición con el nuevo token
const newController = new AbortController();
const newTimeoutId = setTimeout(() => {
console.error(`⏱️ [API] Timeout en retry después de ${timeout}ms:`, endpoint);
newController.abort();
}, timeout);
// Si el body es FormData, no incluir Content-Type
const newHeaders = isFormData
? { 'Authorization': `Bearer ${newToken}`, ...(options.headers as Record<string, string> || {}) }
: createAuthHeaders(newToken, options.headers as Record<string, string>);
response = await fetch(url, {
...options,
headers: newHeaders,
signal: newController.signal
});
clearTimeout(newTimeoutId);
} else {
// No se pudo refrescar, limpiar y redirigir
clearAuthTokens(cookies);
if (redirectUrl) {
throw redirect(303, redirectUrl);
}
}
}
return response;
} catch (error) {
// Si es un redirect, re-lanzarlo
if (error && typeof error === 'object' && 'status' in error && 'location' in error) {
throw error;
}
console.error('🔴 [API] Error en authenticatedFetch:', endpoint, error);
// Retornar una respuesta de error simulada en lugar de lanzar
return new Response(JSON.stringify({ error: 'Network error', details: String(error) }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
/**
* Valida que el usuario esté autenticado y obtiene sus datos
* @returns Los datos del usuario o null si no está autenticado
*/
export async function validateAuth(
cookies: Cookies,
fetch: typeof globalThis.fetch,
redirectOnFail?: string
): Promise<any> {
try {
const response = await authenticatedFetch(
'v1/auth/me',
{},
cookies,
fetch,
redirectOnFail
);
if (!response.ok) {
if (redirectOnFail) {
clearAuthTokens(cookies);
throw redirect(303, redirectOnFail);
}
return null;
}
const keycloakData = await response.json();
// Obtener perfil adicional del usuario (avatar, bio, etc.)
try {
const profileResponse = await authenticatedFetch(
'v1/core/users/me/profile',
{},
cookies,
fetch
);
if (profileResponse.ok) {
const profileData = await profileResponse.json();
// Combinar datos de Keycloak con datos del perfil
return {
...keycloakData,
avatar_url: profileData.avatar_url,
phone: profileData.phone,
bio: profileData.bio,
preferences: profileData.preferences
};
}
} catch (profileError) {
console.warn('⚠️ [API] No se pudo cargar el perfil del usuario, usando solo datos de Keycloak');
}
return keycloakData;
} catch (error) {
// Si es un redirect, re-lanzarlo
if (error && typeof error === 'object' && 'status' in error && 'location' in error) {
throw error;
}
console.error('🔐 [API] Error validando autenticación:', error);
if (redirectOnFail) {
clearAuthTokens(cookies);
throw redirect(303, redirectOnFail);
}
return null;
}
}
/**
* Obtiene las compañías del usuario autenticado
*/
export async function getUserCompanies(
cookies: Cookies,
fetch: typeof globalThis.fetch
): Promise<any[]> {
try {
const response = await authenticatedFetch(
'v1/a76/company/my-companies',
{},
cookies,
fetch
);
if (!response.ok) {
console.error('🏢 [API] Error cargando compañías:', response.status);
return [];
}
return await response.json();
} catch (error) {
console.error('🏢 [API] Error cargando compañías:', error);
return [];
}
}
/**
* Obtiene el ID de la compañía activa, o la primera disponible si no hay ninguna seleccionada
*/
export async function getActiveCompanyId(
cookies: Cookies,
fetch: typeof globalThis.fetch
): Promise<string | null> {
let companyId = cookies.get('active_company_id');
// Si no hay companyId en cookie, obtener las compañías del usuario y usar la primera
if (!companyId) {
const companies = await getUserCompanies(cookies, fetch);
if (companies.length > 0) {
companyId = companies[0].id.toString();
}
}
return companyId || null;
}
/**
* Helper para manejar respuestas de API y convertir errores 403 en formato adecuado
* para mostrar toasts en el cliente
*/
export async function handleApiResponse<T = any>(
response: Response
): Promise<{ data?: T; error?: { detail: string; status: number; isForbidden?: boolean } }> {
if (response.ok) {
// Para respuestas sin contenido (204)
if (response.status === 204) {
return { data: null as T };
}
const data = await response.json();
return { data };
}
// Manejar errores
const errorData = await response.json().catch(() => ({ detail: 'Error desconocido' }));
const error = {
detail: errorData.detail || errorData.message || 'Error en la petición',
status: response.status,
isForbidden: response.status === 403
};
return { error };
}