Saltar al contenido principal

Mejores Prácticas

Guías listas para producción para integrar con la API de Partner de LosCenotes.

Mejores Prácticas de Seguridad

1. Gestión de Claves API

Almacenar Claves de Forma Segura

// ✅ Bueno - Variables de entorno
const apiKey = process.env.LOSCENOTES_API_KEY;

// ✅ Bueno - Servicio de gestión de claves seguro
const apiKey = await keyVault.getSecret("loscenotes-api-key");

// ❌ Malo - Claves codificadas directamente
const apiKey = "sk_test_1234567890abcdef";

// ❌ Malo - Exposición del lado del cliente
const client = new LosCenotesClient({
apiKey: "sk_test_1234567890abcdef", // ¡Nunca en código frontend!
});

Rotar Claves Regularmente

class ApiKeyRotator {
async rotateKey(): Promise<void> {
// Generar nueva clave vía API del Portal de Partner
const newKey = await this.generateNewKey();

// Actualizar todas las instancias
await this.updateDeployments(newKey);

// Verificar que la nueva clave funciona
await this.verifyNewKey(newKey);

// Revocar clave antigua
await this.revokeOldKey();

console.log("Rotación de clave API completada exitosamente");
}

private async verifyNewKey(apiKey: string): Promise<void> {
const client = new LosCenotesClient({ apiKey });
const response = await client.auth.verify();

if (!response.success) {
throw new Error("Verificación de nueva clave API falló");
}
}
}

2. Seguridad de Solicitudes

Usar Siempre HTTPS

const client = new LosCenotesClient({
apiKey: process.env.LOSCENOTES_API_KEY,
baseURL: "https://service-gateway.loscenotes.com", // Siempre usar HTTPS
timeout: 30000,
});

Validar Datos de Entrada

import Joi from "joi";

const reservationSchema = Joi.object({
cenoteId: Joi.string().uuid().required(),
date: Joi.date().min("now").required(),
visitors: Joi.number().integer().min(1).max(50).required(),
email: Joi.string().email().required(),
phone: Joi.string()
.pattern(/^\+?[1-9]\d{1,14}$/)
.required(),
});

async function createReservation(data: any) {
// Validar entrada antes de llamada API
const { error, value } = reservationSchema.validate(data);

if (error) {
throw new Error(`Validación falló: ${error.details[0].message}`);
}

return await client.reservations.create(value);
}

Sanitizar Datos

import DOMPurify from "isomorphic-dompurify";

function sanitizeReservationData(data: any) {
return {
...data,
guestName: DOMPurify.sanitize(data.guestName),
specialRequests: DOMPurify.sanitize(data.specialRequests),
// Sanitizar todas las cadenas proporcionadas por el usuario
};
}

Mejores Prácticas de Rendimiento

1. Estrategia de Caché

Implementar Caché Multi-Capa

class CacheManager {
private memoryCache = new Map<string, CacheItem>();
private redisClient: RedisClient;

async get<T>(key: string): Promise<T | null> {
// L1: Caché de memoria (más rápido)
const memoryItem = this.memoryCache.get(key);
if (memoryItem && !this.isExpired(memoryItem)) {
return memoryItem.data;
}

// L2: Caché Redis
const redisData = await this.redisClient.get(key);
if (redisData) {
const parsed = JSON.parse(redisData);
// Poblar caché de memoria
this.memoryCache.set(key, {
data: parsed,
expires: Date.now() + 300000, // 5 minutos
});
return parsed;
}

return null;
}

async set<T>(key: string, data: T, ttl: number = 3600): Promise<void> {
// Almacenar en ambos cachés
this.memoryCache.set(key, {
data,
expires: Date.now() + Math.min(ttl * 1000, 300000), // Máx 5 minutos en memoria
});

await this.redisClient.setex(key, ttl, JSON.stringify(data));
}
}

// Uso
const cache = new CacheManager();

async function getCenoteWithCache(cenoteId: string) {
const cacheKey = `cenote:${cenoteId}`;

// Intentar caché primero
let cenote = await cache.get(cacheKey);

if (!cenote) {
// Obtener de API
cenote = await client.cenotes.get(cenoteId);

// Cachear por 1 hora
await cache.set(cacheKey, cenote, 3600);
}

return cenote;
}

Estrategia de Invalidación de Caché

class SmartCacheManager extends CacheManager {
private dependencies = new Map<string, string[]>();

// Definir dependencias de caché
defineDependency(key: string, dependencies: string[]): void {
this.dependencies.set(key, dependencies);
}

async invalidate(pattern: string): Promise<void> {
const keys = await this.getMatchingKeys(pattern);

for (const key of keys) {
await this.delete(key);

// Invalidar cachés dependientes
const dependents = this.getDependents(key);
for (const dependent of dependents) {
await this.delete(dependent);
}
}
}

// Configurar dependencias
setupDependencies(): void {
this.defineDependency('cenotes:list', ['cenote:*']);
this.defineDependency('reservations:list', ['reservation:*']);
}
}

app.post('/webhooks/loscenotes', async (req, res) => {
const event = req.body;

switch (event.type) {
case 'cenote.updated':
await cacheManager.invalidate(`cenote:${event.data.id}`);
await cacheManager.invalidate('cenotes:list*');
break;

case 'reservation.updated':
await cacheManager.invalidate(`reservation:${event.data.id}`);
await cacheManager.invalidate('reservations:list*');
break;
}

res.status(200).json({ received: true });
});

2. Optimización de Solicitudes

Usar Endpoints de Lotes

// ❌ Ineficiente - Múltiples solicitudes
const cenotes = await Promise.all([
client.cenotes.get("id1"),
client.cenotes.get("id2"),
client.cenotes.get("id3"),
]);

// ✅ Eficiente - Solicitud de lote única
const cenotes = await client.cenotes.getBatch(["id1", "id2", "id3"]);

Implementar Deduplicación de Solicitudes

class RequestDeduplicator {
private pendingRequests = new Map<string, Promise<any>>();

async deduplicate<T>(key: string, request: () => Promise<T>): Promise<T> {
if (this.pendingRequests.has(key)) {
return this.pendingRequests.get(key)!;
}

const promise = request().finally(() => {
// Limpiar después de que la solicitud se complete
this.pendingRequests.delete(key);
});

this.pendingRequests.set(key, promise);
return promise;
}
}

const deduplicator = new RequestDeduplicator();

// Múltiples llamadas al mismo recurso serán deduplicadas
const cenote1 = await deduplicator.deduplicate(`cenote:${cenoteId}`, () =>
client.cenotes.get(cenoteId)
);

const cenote2 = await deduplicator.deduplicate(`cenote:${cenoteId}`, () =>
client.cenotes.get(cenoteId)
); // Esto retornará la misma promesa que cenote1

Usar Paginación Sabiamente

// ✅ Bueno - Tamaño de página razonable
const cenotes = await client.cenotes.list({
page: 1,
perPage: 20, // Equilibrado entre rendimiento y frescura de datos
});

// ❌ Malo - Demasiados elementos
const cenotes = await client.cenotes.list({
page: 1,
perPage: 1000, // Respuesta lenta, alto uso de memoria
});

// ✅ Bueno - Paginación basada en cursor para conjuntos de datos grandes
async function getAllCenotes(): Promise<Cenote[]> {
const allCenotes: Cenote[] = [];
let cursor: string | undefined;

do {
const response = await client.cenotes.list({
cursor,
perPage: 50,
});

allCenotes.push(...response.data);
cursor = response.pagination.nextCursor;
} while (cursor);

return allCenotes;
}

3. Gestión de Conexiones

Usar Pooling de Conexiones

import axios from "axios";
import { Agent } from "https";

const httpsAgent = new Agent({
keepAlive: true,
maxSockets: 10,
maxFreeSockets: 5,
timeout: 30000,
});

const client = new LosCenotesClient({
apiKey: process.env.LOSCENOTES_API_KEY,
httpClient: axios.create({
httpsAgent,
timeout: 30000,
}),
});

Mejores Prácticas de Manejo de Errores

1. Manejo Integral de Errores

interface ErrorContext {
operation: string;
cenoteId?: string;
reservationId?: string;
userId?: string;
timestamp: Date;
}

class ErrorHandler {
async handleApiError(error: any, context: ErrorContext): Promise<void> {
const errorInfo = {
...context,
errorCode: error.code,
errorMessage: error.message,
statusCode: error.status,
requestId: error.requestId,
};

// Registrar error con contexto
logger.error("Error de API LosCenotes", errorInfo);

// Enviar a servicio de seguimiento de errores
await this.reportError(error, context);

// Manejar tipos de error específicos
switch (error.code) {
case "RATE_LIMIT_EXCEEDED":
await this.handleRateLimit(error, context);
break;

case "CENOTE_NOT_AVAILABLE":
await this.handleUnavailability(error, context);
break;

case "INSUFFICIENT_CAPACITY":
await this.handleCapacityIssue(error, context);
break;

default:
await this.handleGenericError(error, context);
break;
}
}

private async handleRateLimit(
error: any,
context: ErrorContext
): Promise<void> {
// Implementar retroceso exponencial
const delay = error.details.retryAfter * 1000;
await this.delay(delay);

// Notificar sistema de monitoreo
await this.sendAlert("RATE_LIMIT_HIT", { context, delay });
}

private async reportError(error: any, context: ErrorContext): Promise<void> {
// Enviar a Sentry, Bugsnag, etc.
Sentry.captureException(error, {
tags: {
operation: context.operation,
errorCode: error.code,
},
extra: context,
});
}
}

2. Patrón Circuit Breaker

class CircuitBreaker {
private failures = 0;
private lastFailureTime = 0;
private state: "CLOSED" | "OPEN" | "HALF_OPEN" = "CLOSED";

constructor(
private threshold: number = 5,
private timeout: number = 60000,
private monitor: (state: string) => void = () => {}
) {}

async execute<T>(operation: () => Promise<T>): Promise<T> {
if (this.state === "OPEN") {
if (Date.now() - this.lastFailureTime > this.timeout) {
this.state = "HALF_OPEN";
this.monitor("HALF_OPEN");
} else {
throw new Error("Circuit breaker está ABIERTO");
}
}

try {
const result = await operation();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}

private onSuccess(): void {
this.failures = 0;
if (this.state === "HALF_OPEN") {
this.state = "CLOSED";
this.monitor("CLOSED");
}
}

private onFailure(): void {
this.failures++;
this.lastFailureTime = Date.now();

if (this.failures >= this.threshold) {
this.state = "OPEN";
this.monitor("OPEN");
}
}
}

// Uso
const circuitBreaker = new CircuitBreaker(5, 60000, (state) => {
console.log(`Estado del circuit breaker cambió a: ${state}`);
// Enviar alerta al sistema de monitoreo
});

const cenote = await circuitBreaker.execute(() => client.cenotes.get(cenoteId));

Monitoreo y Observabilidad

1. Registro Integral

class ApiLogger {
private logger: Logger;

constructor() {
this.logger = winston.createLogger({
level: "info",
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
transports: [
new winston.transports.File({
filename: "api-errors.log",
level: "error",
}),
new winston.transports.File({ filename: "api-combined.log" }),
],
});
}

logRequest(method: string, url: string, headers: any): string {
const requestId = this.generateRequestId();

this.logger.info("Solicitud API", {
requestId,
method,
url,
headers: this.sanitizeHeaders(headers),
});

return requestId;
}

logResponse(
requestId: string,
status: number,
responseTime: number,
data?: any
): void {
this.logger.info("Respuesta API", {
requestId,
status,
responseTime,
dataSize: data ? JSON.stringify(data).length : 0,
});
}

logError(requestId: string, error: any): void {
this.logger.error("Error API", {
requestId,
error: {
code: error.code,
message: error.message,
status: error.status,
stack: error.stack,
},
});
}

private sanitizeHeaders(headers: any): any {
const sanitized = { ...headers };
if (sanitized.authorization) {
sanitized.authorization = "***CENSURADO***";
}
return sanitized;
}

private generateRequestId(): string {
return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
}

2. Recolección de Métricas

class MetricsCollector {
private metrics = {
requestsTotal: 0,
requestsSuccess: 0,
requestsError: 0,
responseTimesMs: [] as number[],
errorsByCode: {} as Record<string, number>,
};

recordRequest(
success: boolean,
responseTime: number,
errorCode?: string
): void {
this.metrics.requestsTotal++;

if (success) {
this.metrics.requestsSuccess++;
} else {
this.metrics.requestsError++;
if (errorCode) {
this.metrics.errorsByCode[errorCode] =
(this.metrics.errorsByCode[errorCode] || 0) + 1;
}
}

this.metrics.responseTimesMs.push(responseTime);

// Enviar métricas al servicio de monitoreo
this.sendMetrics();
}

getStats() {
const responseTimes = this.metrics.responseTimesMs;

return {
total: this.metrics.requestsTotal,
successRate: this.metrics.requestsSuccess / this.metrics.requestsTotal,
averageResponseTime:
responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length,
p95ResponseTime: this.percentile(responseTimes, 0.95),
errorsByCode: this.metrics.errorsByCode,
};
}

private percentile(arr: number[], p: number): number {
const sorted = arr.sort((a, b) => a - b);
const index = Math.ceil(sorted.length * p) - 1;
return sorted[index];
}

private sendMetrics(): void {
// Enviar a DataDog, New Relic, CloudWatch, etc.
if (this.metrics.requestsTotal % 100 === 0) {
const stats = this.getStats();
console.log("Métricas API:", stats);
}
}
}

3. Verificaciones de Salud

class HealthChecker {
async performHealthCheck(): Promise<HealthStatus> {
const checks = [
this.checkApiConnectivity(),
this.checkDatabaseConnection(),
this.checkRedisConnection(),
this.checkExternalServices(),
];

const results = await Promise.allSettled(checks);

const status: HealthStatus = {
overall: "healthy",
checks: {
api: this.getCheckResult(results[0]),
database: this.getCheckResult(results[1]),
redis: this.getCheckResult(results[2]),
external: this.getCheckResult(results[3]),
},
timestamp: new Date().toISOString(),
};

// Determinar salud general
const hasFailures = Object.values(status.checks).some(
(check) => check.status !== "healthy"
);
status.overall = hasFailures ? "degraded" : "healthy";

return status;
}

private async checkApiConnectivity(): Promise<void> {
const client = new LosCenotesClient({
apiKey: process.env.LOSCENOTES_API_KEY,
timeout: 5000,
});

await client.auth.verify();
}

private getCheckResult(result: PromiseSettledResult<any>): CheckResult {
if (result.status === "fulfilled") {
return { status: "healthy", message: "OK" };
} else {
return {
status: "unhealthy",
message: result.reason.message,
error: result.reason.code,
};
}
}
}

interface HealthStatus {
overall: "healthy" | "degraded" | "unhealthy";
checks: Record<string, CheckResult>;
timestamp: string;
}

interface CheckResult {
status: "healthy" | "unhealthy";
message: string;
error?: string;
}

Mejores Prácticas de Despliegue

1. Configuración de Entornos

// config/environments.ts
export const environments = {
development: {
apiBaseUrl: "https://api-sandbox.loscenotes.com",
apiKey: process.env.LOSCENOTES_SANDBOX_API_KEY,
logLevel: "debug",
cacheEnabled: false,
},
staging: {
apiBaseUrl: "https://api-sandbox.loscenotes.com",
apiKey: process.env.LOSCENOTES_SANDBOX_API_KEY,
logLevel: "info",
cacheEnabled: true,
},
production: {
apiBaseUrl: "https://service-gateway.loscenotes.com",
apiKey: process.env.LOSCENOTES_PRODUCTION_API_KEY,
logLevel: "warn",
cacheEnabled: true,
},
};

const env = process.env.NODE_ENV || "development";
export const config = environments[env];

2. Estrategia de Despliegue Gradual

class FeatureToggle {
private toggles = new Map<string, boolean>();

async initializeToggles(): Promise<void> {
// Cargar toggles de características desde configuración remota
const response = await fetch("/api/feature-toggles");
const data = await response.json();

Object.entries(data).forEach(([key, value]) => {
this.toggles.set(key, value as boolean);
});
}

isEnabled(feature: string, userId?: string): boolean {
if (!this.toggles.has(feature)) {
return false;
}

// Despliegue gradual basado en ID de usuario
if (userId && feature === "new-booking-flow") {
const hash = this.hashUserId(userId);
return hash < 0.1; // Despliegue del 10%
}

return this.toggles.get(feature)!;
}

private hashUserId(userId: string): number {
let hash = 0;
for (let i = 0; i < userId.length; i++) {
hash = ((hash << 5) - hash + userId.charCodeAt(i)) & 0xffffffff;
}
return Math.abs(hash) / 0xffffffff;
}
}

// Uso en llamadas API
const featureToggle = new FeatureToggle();

async function createReservation(data: ReservationRequest, userId: string) {
if (featureToggle.isEnabled("new-booking-flow", userId)) {
return await createReservationV2(data);
} else {
return await createReservationV1(data);
}
}

3. Despliegue Blue-Green

class DeploymentManager {
async performBlueGreenDeploy(): Promise<void> {
const currentEnvironment = await this.getCurrentEnvironment();
const targetEnvironment = currentEnvironment === "blue" ? "green" : "blue";

try {
// Desplegar a entorno objetivo
await this.deployToEnvironment(targetEnvironment);

// Verificación de salud en entorno objetivo
await this.healthCheck(targetEnvironment);

// Cambio gradual de tráfico
await this.shiftTraffic(targetEnvironment, [10, 25, 50, 75, 100]);

// Monitorear errores
await this.monitorDeployment(targetEnvironment);

// Completar despliegue
await this.completeDeployment(targetEnvironment);

console.log(`Despliegue a ${targetEnvironment} completado exitosamente`);
} catch (error) {
// Rollback en caso de falla
await this.rollback(currentEnvironment);
throw error;
}
}

private async shiftTraffic(
target: string,
percentages: number[]
): Promise<void> {
for (const percentage of percentages) {
await this.updateLoadBalancer(target, percentage);
await this.delay(300000); // Esperar 5 minutos
await this.checkErrorRates(target);
}
}

private async checkErrorRates(environment: string): Promise<void> {
const metrics = await this.getMetrics(environment);

if (metrics.errorRate > 0.05) {
// Umbral de tasa de error del 5%
throw new Error(`Alta tasa de error detectada: ${metrics.errorRate}`);
}
}
}

Mejores Prácticas de Pruebas

1. Suite de Pruebas Integral

describe("Integración API LosCenotes", () => {
let client: LosCenotesClient;

beforeEach(() => {
client = new LosCenotesClient({
apiKey: "sk_test_mock_key",
environment: "test",
});
});

describe("API Cenotes", () => {
it("debería listar cenotes con paginación", async () => {
const response = await client.cenotes.list({ page: 1, perPage: 10 });

expect(response.success).toBe(true);
expect(response.data).toHaveLength(10);
expect(response.pagination).toBeDefined();
expect(response.pagination.currentPage).toBe(1);
});

it("debería manejar cenote no encontrado", async () => {
await expect(client.cenotes.get("non-existent-id")).rejects.toThrow(
"RESOURCE_NOT_FOUND"
);
});

it("debería manejar limitación de tasa de manera elegante", async () => {
// Simular respuesta de límite de tasa
mockApiResponse(429, {
error: {
code: "RATE_LIMIT_EXCEEDED",
details: { retryAfter: 1 },
},
});

await expect(client.cenotes.list()).rejects.toThrow(
"RATE_LIMIT_EXCEEDED"
);
});
});

describe("Manejo de Errores", () => {
it("debería reintentar en fallas transitorias", async () => {
let attemptCount = 0;

jest.spyOn(client.http, "request").mockImplementation(() => {
attemptCount++;
if (attemptCount < 3) {
return Promise.reject(new Error("Error de red"));
}
return Promise.resolve({ data: { success: true } });
});

const result = await client.cenotes.list();
expect(result.success).toBe(true);
expect(attemptCount).toBe(3);
});
});
});

2. Pruebas de Contrato

// Usar Pact para pruebas de contrato
import { Pact } from "@pact-foundation/pact";

describe("Contrato API LosCenotes", () => {
const provider = new Pact({
consumer: "TuApp",
provider: "LosCenotesAPI",
port: 1234,
});

beforeAll(() => provider.setup());
afterAll(() => provider.finalize());

it("debería obtener detalles del cenote", async () => {
await provider.addInteraction({
state: "cenote existe",
uponReceiving: "una solicitud para detalles del cenote",
withRequest: {
method: "GET",
path: "/api/partner/cenotes/cenote-123",
headers: {
Authorization: "Bearer sk_test_mock_key",
},
},
willRespondWith: {
status: 200,
headers: {
"Content-Type": "application/json",
},
body: {
success: true,
data: {
id: "cenote-123",
name: "Cenote de Prueba",
location: {
latitude: 20.123,
longitude: -87.456,
},
},
},
},
});

const client = new LosCenotesClient({
apiKey: "sk_test_mock_key",
baseURL: "http://localhost:1234",
});

const cenote = await client.cenotes.get("cenote-123");
expect(cenote.data.name).toBe("Cenote de Prueba");
});
});

Mejores Prácticas de Documentación

1. Documentación API

/**
* Crea una nueva reserva para una visita al cenote
*
* @param data - Detalles de la reserva
* @param data.cenoteId - UUID del cenote
* @param data.date - Fecha de visita en formato ISO 8601
* @param data.visitors - Número de visitantes (1-50)
* @param data.email - Dirección de email de contacto
* @param options - Opciones adicionales
* @param options.timeout - Tiempo de espera de solicitud en milisegundos
* @returns Promesa que resuelve a detalles de la reserva
*
* @example
* ```typescript
* const reservation = await client.reservations.create({
* cenoteId: 'c3n0t3-1234-5678-90ab',
* date: '2024-07-15T10:00:00Z',
* visitors: 4,
* email: 'guest@example.com'
* });
*
* console.log(reservation.data.id); // 'res_abcd1234'
* ```
*
* @throws {LosCenotesError} Cuando el cenote no está disponible
* @throws {ValidationError} Cuando los datos de entrada son inválidos
*/
async createReservation(
data: CreateReservationRequest,
options?: RequestOptions
): Promise<ReservationResponse> {
// Implementación
}

2. Documentación de Errores

// Crear documentación integral de errores
export const ErrorCodes = {
INVALID_API_KEY: {
status: 401,
description: "La clave API proporcionada es inválida o ha expirado",
resolution: "Verifica tu clave API y asegúrate de que no haya expirado",
},
CENOTE_NOT_AVAILABLE: {
status: 422,
description: "El cenote no está disponible para la fecha seleccionada",
resolution: "Elige una fecha diferente o cenote",
},
RATE_LIMIT_EXCEEDED: {
status: 429,
description: "Se han hecho demasiadas solicitudes",
resolution: "Espera antes de hacer solicitudes adicionales o actualiza tu plan",
},
} as const;

Próximos Pasos