Arquitectura y diseño
Principios de diseño
1. Separación de responsabilidades
Copy
// ❌ Incorrecto - Todo en una función
async function handleMessage(message) {
// Validar mensaje
if (!message.text) return;
// Procesar mensaje
const response = await processMessage(message.text);
// Enviar respuesta
await sendMessage(message.from, response);
// Guardar en base de datos
await saveToDatabase(message);
// Enviar notificación
await sendNotification(message);
}
// ✅ Correcto - Separado por responsabilidades
class MessageHandler {
constructor(messageValidator, messageProcessor, messageSender, messageRepository, notificationService) {
this.validator = messageValidator;
this.processor = messageProcessor;
this.sender = messageSender;
this.repository = messageRepository;
this.notificationService = notificationService;
}
async handleMessage(message) {
if (!this.validator.isValid(message)) {
throw new ValidationError('Invalid message');
}
const response = await this.processor.process(message);
await this.sender.send(message.from, response);
await this.repository.save(message);
await this.notificationService.notify(message);
}
}
2. Manejo de errores robusto
Copy
class NotMetaClient {
async sendMessage(messageData) {
try {
return await this.makeRequest('/messages', 'POST', messageData);
} catch (error) {
if (error.status === 429) {
throw new RateLimitError('Too many requests', error.retryAfter);
} else if (error.status === 401) {
throw new AuthenticationError('Invalid credentials');
} else if (error.status >= 500) {
throw new ServerError('NotMeta service unavailable');
} else {
throw new NotMetaError(error.message, error.status);
}
}
}
}
3. Configuración centralizada
Copy
// config/notmeta.js
module.exports = {
apiUrl: process.env.NOTMETA_API_URL || 'https://api.notmeta.com',
apiToken: process.env.NOTMETA_API_TOKEN,
webhookSecret: process.env.NOTMETA_WEBHOOK_SECRET,
timeout: parseInt(process.env.NOTMETA_TIMEOUT) || 30000,
retries: parseInt(process.env.NOTMETA_RETRIES) || 3,
rateLimit: {
requests: 100,
window: 60000 // 1 minuto
}
};
Gestión de datos
Caché inteligente
Copy
class ConversationCache {
constructor(redisClient, ttl = 300) { // 5 minutos
this.redis = redisClient;
this.ttl = ttl;
}
async getConversation(conversationId) {
const cacheKey = `conversation:${conversationId}`;
const cached = await this.redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// Obtener de la API
const conversation = await this.notMetaClient.getConversation(conversationId);
// Guardar en caché
await this.redis.setex(cacheKey, this.ttl, JSON.stringify(conversation));
return conversation;
}
async invalidateConversation(conversationId) {
const cacheKey = `conversation:${conversationId}`;
await this.redis.del(cacheKey);
}
}
Validación de datos
Copy
const Joi = require('joi');
const messageSchema = Joi.object({
to: Joi.string().pattern(/^\d+$/).required(),
type: Joi.string().valid('text', 'image', 'document', 'audio', 'video').required(),
text: Joi.string().when('type', {
is: 'text',
then: Joi.required(),
otherwise: Joi.forbidden()
}),
media: Joi.object().when('type', {
is: Joi.string().valid('image', 'document', 'audio', 'video'),
then: Joi.required(),
otherwise: Joi.forbidden()
})
});
function validateMessage(messageData) {
const { error, value } = messageSchema.validate(messageData);
if (error) {
throw new ValidationError(error.details[0].message);
}
return value;
}
Paginación eficiente
Copy
class ConversationService {
async getConversations(options = {}) {
const params = {
limit: Math.min(options.limit || 50, 100),
cursor: options.cursor,
status: options.status,
assigned_to: options.assignedTo
};
const response = await this.notMetaClient.get('/conversations', { params });
return {
conversations: response.data,
pagination: {
nextCursor: response.pagination?.next_cursor,
hasMore: response.pagination?.has_more
}
};
}
async getAllConversations(options = {}) {
const allConversations = [];
let cursor = null;
do {
const result = await this.getConversations({
...options,
cursor
});
allConversations.push(...result.conversations);
cursor = result.pagination.nextCursor;
} while (cursor);
return allConversations;
}
}
Performance y escalabilidad
Rate limiting
Copy
class RateLimiter {
constructor(requests, windowMs) {
this.requests = requests;
this.windowMs = windowMs;
this.requests = new Map();
}
async isAllowed(key) {
const now = Date.now();
const windowStart = now - this.windowMs;
// Limpiar requests antiguos
for (const [timestamp] of this.requests.entries()) {
if (timestamp < windowStart) {
this.requests.delete(timestamp);
}
}
// Verificar límite
if (this.requests.size >= this.requests) {
return false;
}
// Registrar request
this.requests.set(now, key);
return true;
}
}
class NotMetaClientWithRateLimit {
constructor(rateLimiter) {
this.rateLimiter = rateLimiter;
}
async makeRequest(url, method, data) {
if (!await this.rateLimiter.isAllowed('api')) {
throw new RateLimitError('Rate limit exceeded');
}
return await this.httpClient.request({ url, method, data });
}
}
Procesamiento asíncrono
Copy
const Queue = require('bull');
class MessageQueue {
constructor() {
this.queue = new Queue('message processing', {
redis: {
host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT
}
});
this.setupProcessors();
}
setupProcessors() {
this.queue.process('send-message', 10, async (job) => {
const { messageData } = job.data;
return await this.notMetaClient.sendMessage(messageData);
});
this.queue.process('process-webhook', 20, async (job) => {
const { event } = job.data;
return await this.handleWebhookEvent(event);
});
}
async addSendMessage(messageData, options = {}) {
return await this.queue.add('send-message', { messageData }, {
attempts: 3,
backoff: 'exponential',
...options
});
}
}
Circuit breaker
Copy
class CircuitBreaker {
constructor(threshold = 5, timeout = 60000) {
this.threshold = threshold;
this.timeout = timeout;
this.failureCount = 0;
this.lastFailureTime = null;
this.state = 'CLOSED'; // CLOSED, OPEN, HALF_OPEN
}
async execute(operation) {
if (this.state === 'OPEN') {
if (Date.now() - this.lastFailureTime > this.timeout) {
this.state = 'HALF_OPEN';
} else {
throw new CircuitBreakerOpenError('Circuit breaker is open');
}
}
try {
const result = await operation();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
onSuccess() {
this.failureCount = 0;
this.state = 'CLOSED';
}
onFailure() {
this.failureCount++;
this.lastFailureTime = Date.now();
if (this.failureCount >= this.threshold) {
this.state = 'OPEN';
}
}
}
Seguridad
Validación de webhooks
Copy
class WebhookValidator {
constructor(secret) {
this.secret = secret;
}
validate(payload, signature) {
const expectedSignature = this.calculateSignature(payload);
return this.secureCompare(signature, expectedSignature);
}
calculateSignature(payload) {
const crypto = require('crypto');
return 'sha256=' + crypto
.createHmac('sha256', this.secret)
.update(payload)
.digest('hex');
}
secureCompare(a, b) {
if (a.length !== b.length) {
return false;
}
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return result === 0;
}
}
Sanitización de datos
Copy
class DataSanitizer {
static sanitizePhoneNumber(phone) {
// Remover caracteres no numéricos
return phone.replace(/\D/g, '');
}
static sanitizeMessage(message) {
// Remover caracteres de control y limitar longitud
return message
.replace(/[\x00-\x1F\x7F]/g, '')
.substring(0, 4096);
}
static sanitizeFileName(filename) {
// Remover caracteres peligrosos
return filename.replace(/[^a-zA-Z0-9._-]/g, '_');
}
}
Logging seguro
Copy
class SecureLogger {
static log(message, data = {}) {
const sanitizedData = this.sanitizeLogData(data);
console.log(JSON.stringify({
timestamp: new Date().toISOString(),
message,
data: sanitizedData
}));
}
static sanitizeLogData(data) {
const sensitiveFields = ['token', 'secret', 'password', 'api_key'];
const sanitized = { ...data };
for (const field of sensitiveFields) {
if (sanitized[field]) {
sanitized[field] = '[REDACTED]';
}
}
return sanitized;
}
}
Monitoreo y observabilidad
Métricas personalizadas
Copy
class MetricsCollector {
constructor() {
this.metrics = new Map();
}
increment(counter, tags = {}) {
const key = this.getKey(counter, tags);
this.metrics.set(key, (this.metrics.get(key) || 0) + 1);
}
timing(timer, duration, tags = {}) {
const key = this.getKey(timer, tags);
const timings = this.metrics.get(key) || [];
timings.push(duration);
this.metrics.set(key, timings);
}
getKey(name, tags) {
const sortedTags = Object.keys(tags)
.sort()
.map(key => `${key}:${tags[key]}`)
.join(',');
return sortedTags ? `${name}{${sortedTags}}` : name;
}
}
// Uso
const metrics = new MetricsCollector();
metrics.increment('messages.sent', { status: 'success' });
metrics.timing('api.response_time', 150, { endpoint: '/messages' });
Health checks
Copy
class HealthChecker {
constructor(notMetaClient) {
this.notMetaClient = notMetaClient;
}
async checkHealth() {
const checks = {
database: await this.checkDatabase(),
notmeta: await this.checkNotMeta(),
redis: await this.checkRedis()
};
const isHealthy = Object.values(checks).every(check => check.status === 'healthy');
return {
status: isHealthy ? 'healthy' : 'unhealthy',
checks,
timestamp: new Date().toISOString()
};
}
async checkNotMeta() {
try {
await this.notMetaClient.get('/health');
return { status: 'healthy' };
} catch (error) {
return {
status: 'unhealthy',
error: error.message
};
}
}
}
Testing
Tests unitarios
Copy
describe('MessageHandler', () => {
let messageHandler;
let mockNotMetaClient;
beforeEach(() => {
mockNotMetaClient = {
sendMessage: jest.fn(),
getConversation: jest.fn()
};
messageHandler = new MessageHandler(mockNotMetaClient);
});
test('should send message successfully', async () => {
const messageData = {
to: '1234567890',
type: 'text',
text: 'Hello'
};
mockNotMetaClient.sendMessage.mockResolvedValue({ id: 'msg_123' });
const result = await messageHandler.sendMessage(messageData);
expect(mockNotMetaClient.sendMessage).toHaveBeenCalledWith(messageData);
expect(result.id).toBe('msg_123');
});
test('should handle API errors', async () => {
const messageData = {
to: '1234567890',
type: 'text',
text: 'Hello'
};
mockNotMetaClient.sendMessage.mockRejectedValue(
new Error('API Error')
);
await expect(messageHandler.sendMessage(messageData))
.rejects.toThrow('API Error');
});
});
Tests de integración
Copy
describe('Webhook Integration', () => {
let app;
let server;
beforeAll(async () => {
app = createApp();
server = app.listen(0);
});
afterAll(async () => {
await server.close();
});
test('should handle webhook events', async () => {
const event = {
id: 'evt_123',
type: 'message.received',
data: {
message: {
id: 'msg_123',
from: '1234567890',
text: 'Hello'
}
}
};
const signature = calculateWebhookSignature(event);
const response = await request(app)
.post('/webhook')
.set('X-NotMeta-Signature', signature)
.send(event);
expect(response.status).toBe(200);
expect(response.text).toBe('OK');
});
});
Recursos adicionales
Introducción para desarrolladores
Conceptos básicos de la API de NotMeta
API Reference
Documentación completa de la API