atomic-saga
Version:
A comprehensive npm package for ensuring atomic API operations in distributed Node.js applications using Saga patterns, compensating transactions, and idempotent operations
132 lines (111 loc) • 3.94 kB
text/typescript
import { Request, Response, NextFunction } from 'express';
import { IdempotencyStore, Logger } from '../types';
/**
* Idempotency Middleware for Express
* Ensures that API operations are idempotent using unique idempotency keys
*/
export class IdempotencyMiddleware {
private readonly store: IdempotencyStore;
private readonly logger: Logger;
private readonly keyHeader: string;
private readonly keyExpiryHours: number;
constructor(
store: IdempotencyStore,
logger: Logger,
keyHeader: string = 'X-Idempotency-Key',
keyExpiryHours: number = 24
) {
this.store = store;
this.logger = logger;
this.keyHeader = keyHeader;
this.keyExpiryHours = keyExpiryHours;
}
/**
* Express middleware function
*/
middleware() {
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
const idempotencyKey = req.headers[this.keyHeader.toLowerCase()] as string;
// Skip idempotency check if no key provided
if (!idempotencyKey) {
this.logger.debug('No idempotency key provided, skipping idempotency check');
return next();
}
try {
// Check if this request has been processed before
const existingKey = await this.store.get(idempotencyKey);
if (existingKey) {
this.logger.info('Idempotency key found, returning cached response', {
key: idempotencyKey,
method: req.method,
path: req.path
});
// Return 409 Conflict to indicate duplicate request
res.status(409).json({
error: 'Idempotency key already used',
message: 'This request has already been processed',
idempotencyKey
});
return;
}
// Store the idempotency key with expiry
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + this.keyExpiryHours);
await this.store.set(idempotencyKey, expiresAt);
this.logger.info('Idempotency key stored for new request', {
key: idempotencyKey,
method: req.method,
path: req.path,
expiresAt
});
// Continue with the request
next();
} catch (error) {
this.logger.error('Error in idempotency middleware', error as Error, {
key: idempotencyKey,
method: req.method,
path: req.path
});
// On error, continue with the request but log the issue
next();
}
};
}
/**
* Generate a unique idempotency key
*/
generateKey(): string {
return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Validate idempotency key format
*/
validateKey(key: string): boolean {
// Basic validation - can be customized based on requirements
return key.length > 0 && key.length <= 255;
}
/**
* Clean up expired idempotency keys
*/
async cleanupExpiredKeys(): Promise<void> {
// This would be implemented based on the specific store implementation
// For now, we'll leave it as a placeholder
this.logger.debug('Cleanup of expired idempotency keys not implemented for this store');
}
}
/**
* Decorator for marking methods as idempotent
*/
export function Idempotent(keyGenerator?: () => string) {
return function (_target: any, _propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]) {
const key = keyGenerator ? keyGenerator() : `method_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// This is a simplified implementation
// In a real scenario, you'd need access to the idempotency store
console.log(`Idempotent method called with key: ${key}`);
return originalMethod.apply(this, args);
};
return descriptor;
};
}