diff --git a/api/src/handlers/files/upload-file.ts b/api/src/handlers/files/upload-file.ts index 786f757..8d73605 100644 --- a/api/src/handlers/files/upload-file.ts +++ b/api/src/handlers/files/upload-file.ts @@ -11,6 +11,11 @@ import { APIGatewayResponse } from '../../types/api'; import { AuthenticatedEvent, withAuth } from '../../middleware/auth-middleware'; import { FileRepository } from '../../repositories/file-repository.impl'; import { createDatabaseConnection } from '../../utils/database-connection'; +import { createLogger } from '../../middleware/logging'; +import { + parseMultipartFormData as parseMultipart, + validateMultipartRequest, +} from '../../utils/multipart-parser'; import { ValidationError, FileProcessingError, @@ -75,10 +80,57 @@ interface FileUploadData { /** * Parse multipart form data from API Gateway event - * Simplified parser for base64-encoded binary data + * Supports both multipart/form-data and legacy JSON/base64 formats */ function parseMultipartFormData(event: AuthenticatedEvent): FileUploadData { - // For base64-encoded body (API Gateway binary support) + const contentType = event.headers['content-type'] || event.headers['Content-Type'] || ''; + + // Handle multipart/form-data + if (contentType.includes('multipart/form-data')) { + const validation = validateMultipartRequest(contentType); + if (!validation.isValid || !validation.boundary) { + throw new ValidationError(validation.error || 'Invalid multipart request'); + } + + if (!event.body) { + throw new ValidationError('No request body provided'); + } + + // Parse multipart data + const body = event.isBase64Encoded + ? Buffer.from(event.body, 'base64') + : Buffer.from(event.body); + const parsed = parseMultipart(body, validation.boundary); + + // Get the first file + if (parsed.files.length === 0) { + throw new ValidationError('No file found in multipart request'); + } + + const file = parsed.files[0]; + + // Extract metadata from fields + const metadata: Record = {}; + for (const [key, value] of Object.entries(parsed.fields)) { + if (key !== 'file' && key !== 'fileName' && key !== 'mimeType') { + try { + metadata[key] = JSON.parse(value); + } catch { + metadata[key] = value; + } + } + } + + return { + fileName: file.filename, + fileSize: file.size, + mimeType: file.contentType, + fileContent: file.data, + metadata: Object.keys(metadata).length > 0 ? metadata : undefined, + }; + } + + // Legacy: For base64-encoded body (API Gateway binary support) if (event.isBase64Encoded && event.body) { const buffer = Buffer.from(event.body, 'base64'); @@ -110,7 +162,7 @@ function parseMultipartFormData(event: AuthenticatedEvent): FileUploadData { }; } - // For JSON body with base64 file content + // Legacy: For JSON body with base64 file content if (event.body) { try { const body = JSON.parse(event.body); @@ -213,7 +265,11 @@ async function uploadToS3( s3Bucket: S3_BUCKET_NAME, }; } catch (error) { - console.error('S3 upload error:', error); + const logger = createLogger({ userId, s3Key }); + logger.error('S3 upload failed', { + error: error instanceof Error ? error.message : 'Unknown error', + errorStack: error instanceof Error ? error.stack : undefined, + }); throw new ExternalServiceError('S3', 'Failed to upload file to storage', { error: error instanceof Error ? error.message : 'Unknown error', }); @@ -246,7 +302,12 @@ async function storeFileMetadata( return fileRecord.id; } catch (error) { - console.error('Database error:', error); + const logger = createLogger({ userId, s3Key }); + logger.error('Database operation failed', { + operation: 'storeFileMetadata', + error: error instanceof Error ? error.message : 'Unknown error', + errorStack: error instanceof Error ? error.stack : undefined, + }); throw new DatabaseError('Failed to store file metadata', { error: error instanceof Error ? error.message : 'Unknown error', }); @@ -284,8 +345,14 @@ function createSuccessResponse( /** * Create error response */ -function createErrorResponse(error: Error): APIGatewayResponse { - console.error('Handler error:', error); +function createErrorResponse(error: Error, requestId?: string): APIGatewayResponse { + const logger = createLogger({ requestId }); + + logger.error('File upload handler error', { + errorType: error.name, + message: error.message, + stack: error.stack, + }); // Handle known error types if ( @@ -321,6 +388,8 @@ function createErrorResponse(error: Error): APIGatewayResponse { * Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 3.6 */ async function handleFileUpload(event: AuthenticatedEvent): Promise { + const requestId = event.requestContext?.requestId; + try { // Ensure user is authenticated (middleware handles this) if (!event.user?.userId) { @@ -344,7 +413,7 @@ async function handleFileUpload(event: AuthenticatedEvent): Promise { + const startTime = Date.now(); + + try { + const db = await createDatabaseConnection(); + + // Simple query to check connectivity + await db.query('SELECT 1'); + await db.disconnect(); + + const responseTime = Date.now() - startTime; + + return { + status: 'healthy', + message: 'Database connection successful', + responseTime, + }; + } catch (error) { + const responseTime = Date.now() - startTime; + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + + return { + status: 'unhealthy', + message: `Database connection failed: ${errorMessage}`, + responseTime, + }; + } +} + +/** + * Check memory health + */ +function checkMemoryHealth(): ComponentHealth { + const usage = process.memoryUsage(); + const heapUsedMB = Math.round(usage.heapUsed / 1024 / 1024); + const heapTotalMB = Math.round(usage.heapTotal / 1024 / 1024); + const percentUsed = Math.round((usage.heapUsed / usage.heapTotal) * 100); + + // Consider unhealthy if using more than 90% of heap + if (percentUsed > 90) { + return { + status: 'unhealthy', + message: `High memory usage: ${heapUsedMB}MB / ${heapTotalMB}MB (${percentUsed}%)`, + }; + } + + // Consider degraded if using more than 75% of heap + if (percentUsed > 75) { + return { + status: 'degraded', + message: `Elevated memory usage: ${heapUsedMB}MB / ${heapTotalMB}MB (${percentUsed}%)`, + }; + } + + return { + status: 'healthy', + message: `Memory usage normal: ${heapUsedMB}MB / ${heapTotalMB}MB (${percentUsed}%)`, + }; +} + +/** + * Determine overall health status + */ +function determineOverallStatus(components: { + database: ComponentHealth; + memory: ComponentHealth; +}): HealthStatus { + // If any component is unhealthy, overall is unhealthy + if (components.database.status === 'unhealthy' || components.memory.status === 'unhealthy') { + return 'unhealthy'; + } + + // If any component is degraded, overall is degraded + if (components.database.status === 'degraded' || components.memory.status === 'degraded') { + return 'degraded'; + } + + // All components healthy + return 'healthy'; +} + +/** + * Main health check handler + */ +async function handleHealthCheck(event: APIGatewayEvent): Promise { + const logger = createLogger({ + handler: 'healthCheck', + requestId: event.requestContext?.requestId, + }); + + logger.info('Health check requested'); + + try { + // Check all components + const [databaseHealth, memoryHealth] = await Promise.all([ + checkDatabaseHealth(), + checkMemoryHealth(), + ]); + + const components = { + database: databaseHealth, + memory: memoryHealth, + }; + + const overallStatus = determineOverallStatus(components); + + const response: HealthCheckResponse = { + status: overallStatus, + timestamp: new Date().toISOString(), + uptime: process.uptime(), + version: process.env.APP_VERSION || '1.0.0', + components, + }; + + // Return appropriate HTTP status code + const statusCode = overallStatus === 'healthy' ? 200 : overallStatus === 'degraded' ? 200 : 503; + + logger.info('Health check completed', { status: overallStatus, statusCode }); + + return { + statusCode, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'no-cache, no-store, must-revalidate', + }, + body: JSON.stringify(response), + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error('Health check failed', { error: errorMessage }); + + return { + statusCode: 503, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + body: JSON.stringify({ + status: 'unhealthy', + timestamp: new Date().toISOString(), + message: 'Health check failed', + error: errorMessage, + }), + }; + } +} + +/** + * Lambda handler export + */ +export const handler = handleHealthCheck; diff --git a/api/src/handlers/health/metrics.ts b/api/src/handlers/health/metrics.ts new file mode 100644 index 0000000..e198796 --- /dev/null +++ b/api/src/handlers/health/metrics.ts @@ -0,0 +1,76 @@ +/** + * Metrics Endpoint Lambda Handler + * Exposes collected metrics for monitoring + * Requirements: Monitoring and observability + * Following SOLID principles: Single Responsibility + */ + +import { APIGatewayEvent, APIGatewayResponse } from '../../types/api'; +import { getMetricsStore } from '../../middleware/metrics'; +import { createLogger } from '../../middleware/logging'; + +/** + * Metrics endpoint handler + * Returns aggregated metrics in JSON format + */ +async function handleMetricsRequest(event: APIGatewayEvent): Promise { + const logger = createLogger({ + handler: 'metrics', + requestId: event.requestContext?.requestId, + }); + + logger.info('Metrics requested'); + + try { + const metricsStore = getMetricsStore(); + const summary = metricsStore.getMetricsSummary(); + + // Add system metrics + const systemMetrics = { + uptime: process.uptime(), + memory: { + heapUsed: Math.round(process.memoryUsage().heapUsed / 1024 / 1024), // MB + heapTotal: Math.round(process.memoryUsage().heapTotal / 1024 / 1024), // MB + external: Math.round(process.memoryUsage().external / 1024 / 1024), // MB + rss: Math.round(process.memoryUsage().rss / 1024 / 1024), // MB + }, + cpu: process.cpuUsage(), + }; + + const response = { + timestamp: new Date().toISOString(), + system: systemMetrics, + metrics: summary, + }; + + return { + statusCode: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'no-cache, no-store, must-revalidate', + }, + body: JSON.stringify(response, null, 2), + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error('Failed to retrieve metrics', { error: errorMessage }); + + return { + statusCode: 500, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + body: JSON.stringify({ + error: 'Failed to retrieve metrics', + message: errorMessage, + }), + }; + } +} + +/** + * Lambda handler export + */ +export const handler = handleMetricsRequest; diff --git a/api/src/handlers/health/readiness-check.ts b/api/src/handlers/health/readiness-check.ts new file mode 100644 index 0000000..0ef80fa --- /dev/null +++ b/api/src/handlers/health/readiness-check.ts @@ -0,0 +1,33 @@ +/** + * Readiness Check Lambda Handler + * Lightweight check for load balancer readiness probes + * Requirements: Monitoring and observability + * Following SOLID principles: Single Responsibility + */ + +import { APIGatewayEvent, APIGatewayResponse } from '../../types/api'; + +/** + * Simple readiness check handler + * Returns 200 if the Lambda is ready to accept requests + * No heavy checks - just confirms the Lambda is alive + */ +async function handleReadinessCheck(_event: APIGatewayEvent): Promise { + return { + statusCode: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'no-cache, no-store, must-revalidate', + }, + body: JSON.stringify({ + ready: true, + timestamp: new Date().toISOString(), + }), + }; +} + +/** + * Lambda handler export + */ +export const handler = handleReadinessCheck; diff --git a/api/src/handlers/index.ts b/api/src/handlers/index.ts index 6db76e2..a68b2a9 100644 --- a/api/src/handlers/index.ts +++ b/api/src/handlers/index.ts @@ -10,3 +10,8 @@ export { handler as uploadFileHandler } from './files/upload-file'; export { handler as getUserFilesHandler } from './api/get-user-files'; export { handler as getFileMetadataHandler } from './api/get-file-metadata'; export { handler as generatePresignedUrlHandler } from './api/generate-presigned-url'; + +// Health check handlers +export { handler as healthCheckHandler } from './health/health-check'; +export { handler as readinessCheckHandler } from './health/readiness-check'; +export { handler as metricsHandler } from './health/metrics'; diff --git a/api/src/middleware/cache.ts b/api/src/middleware/cache.ts new file mode 100644 index 0000000..b5f57ce --- /dev/null +++ b/api/src/middleware/cache.ts @@ -0,0 +1,353 @@ +/** + * Response Caching Middleware for Lambda Functions + * Implements in-memory caching with TTL + * Requirements: Performance optimization + * Following SOLID principles: Single Responsibility + */ + +import { APIGatewayEvent, APIGatewayResponse } from '../types/api'; +import { createLogger } from './logging'; + +/** + * Cache entry + */ +interface CacheEntry { + response: APIGatewayResponse; + timestamp: number; + ttl: number; + hits: number; +} + +/** + * Cache configuration + */ +export interface CacheConfig { + /** + * Time to live in seconds + */ + ttl: number; + + /** + * Key generator function + */ + keyGenerator?: (event: APIGatewayEvent) => string; + + /** + * Methods to cache (default: GET only) + */ + methods?: string[]; + + /** + * Skip caching for certain requests + */ + skip?: (event: APIGatewayEvent) => boolean; + + /** + * Only cache responses with these status codes + */ + statusCodes?: number[]; + + /** + * Include query parameters in cache key + */ + includeQueryParams?: boolean; + + /** + * Include headers in cache key (e.g., for authorization) + */ + includeHeaders?: string[]; +} + +/** + * Cache store + */ +class CacheStore { + private cache: Map = new Map(); + private cleanupInterval: NodeJS.Timeout | null = null; + + constructor() { + // Clean up expired entries every minute + this.cleanupInterval = setInterval(() => { + this.cleanup(); + }, 60 * 1000); + } + + /** + * Get cached entry + */ + get(key: string): APIGatewayResponse | null { + const entry = this.cache.get(key); + + if (!entry) { + return null; + } + + // Check if expired + const now = Date.now(); + if (now - entry.timestamp > entry.ttl * 1000) { + this.cache.delete(key); + return null; + } + + // Increment hit counter + entry.hits++; + + return entry.response; + } + + /** + * Set cache entry + */ + set(key: string, response: APIGatewayResponse, ttl: number): void { + this.cache.set(key, { + response, + timestamp: Date.now(), + ttl, + hits: 0, + }); + } + + /** + * Delete cache entry + */ + delete(key: string): void { + this.cache.delete(key); + } + + /** + * Clear all cache entries + */ + clear(): void { + this.cache.clear(); + } + + /** + * Clean up expired entries + */ + private cleanup(): void { + const now = Date.now(); + const entriesToDelete: string[] = []; + + for (const [key, entry] of this.cache.entries()) { + if (now - entry.timestamp > entry.ttl * 1000) { + entriesToDelete.push(key); + } + } + + for (const key of entriesToDelete) { + this.cache.delete(key); + } + } + + /** + * Get cache statistics + */ + getStats(): { + size: number; + entries: Array<{ key: string; hits: number; age: number }>; + } { + const now = Date.now(); + const entries = Array.from(this.cache.entries()).map(([key, entry]) => ({ + key, + hits: entry.hits, + age: Math.floor((now - entry.timestamp) / 1000), + })); + + return { + size: this.cache.size, + entries, + }; + } + + /** + * Destroy the store + */ + destroy(): void { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + this.cache.clear(); + } +} + +/** + * Global cache store + */ +const cacheStore = new CacheStore(); + +/** + * Lambda handler type + */ +export type CacheWrappedHandler = (event: APIGatewayEvent) => Promise; + +/** + * Default key generator + */ +function defaultKeyGenerator( + event: APIGatewayEvent, + includeQueryParams: boolean = true, + includeHeaders: string[] = [] +): string { + const parts: string[] = [event.httpMethod || '', event.path || '']; + + // Include query parameters + if (includeQueryParams && event.queryStringParameters) { + const sortedParams = Object.entries(event.queryStringParameters) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => `${k}=${v}`) + .join('&'); + if (sortedParams) { + parts.push(sortedParams); + } + } + + // Include specific headers + if (includeHeaders.length > 0 && event.headers) { + for (const header of includeHeaders) { + const value = event.headers[header] || event.headers[header.toLowerCase()]; + if (value) { + parts.push(`${header}:${value}`); + } + } + } + + return parts.join('|'); +} + +/** + * Cache middleware + * Caches API responses to improve performance + * + * @param config - Cache configuration + * @returns Middleware function + * + * @example + * ```typescript + * export const handler = compose( + * cacheMiddleware({ ttl: 300, methods: ['GET'] }), + * async (event) => { + * // Handler logic + * return { statusCode: 200, body: '{}' }; + * } + * ); + * ``` + */ +export function cacheMiddleware(config: CacheConfig) { + const logger = createLogger({ middleware: 'cacheMiddleware' }); + + const { + ttl, + keyGenerator, + methods = ['GET'], + skip, + statusCodes = [200], + includeQueryParams = true, + includeHeaders = [], + } = config; + + return function (handler: CacheWrappedHandler): CacheWrappedHandler { + return async (event: APIGatewayEvent): Promise => { + const method = event.httpMethod || ''; + + // Skip if method not in allowed list + if (!methods.includes(method)) { + return handler(event); + } + + // Skip if custom skip function returns true + if (skip && skip(event)) { + return handler(event); + } + + // Generate cache key + const cacheKey = keyGenerator + ? keyGenerator(event) + : defaultKeyGenerator(event, includeQueryParams, includeHeaders); + + // Try to get from cache + const cached = cacheStore.get(cacheKey); + if (cached) { + logger.debug('Cache hit', { cacheKey, path: event.path }); + + // Add cache hit header + return { + ...cached, + headers: { + ...cached.headers, + 'X-Cache': 'HIT', + }, + }; + } + + logger.debug('Cache miss', { cacheKey, path: event.path }); + + // Execute handler + const response = await handler(event); + + // Cache response if status code matches + if (statusCodes.includes(response.statusCode)) { + cacheStore.set(cacheKey, response, ttl); + logger.debug('Response cached', { cacheKey, ttl, statusCode: response.statusCode }); + } + + // Add cache miss header + return { + ...response, + headers: { + ...response.headers, + 'X-Cache': 'MISS', + 'Cache-Control': `public, max-age=${ttl}`, + }, + }; + }; + }; +} + +/** + * Get cache store (for cache management) + */ +export function getCacheStore(): CacheStore { + return cacheStore; +} + +/** + * Invalidate cache entry + */ +export function invalidateCache(key: string): void { + cacheStore.delete(key); +} + +/** + * Invalidate cache by pattern + */ +export function invalidateCacheByPattern(pattern: RegExp): number { + const stats = cacheStore.getStats(); + let count = 0; + + for (const entry of stats.entries) { + if (pattern.test(entry.key)) { + cacheStore.delete(entry.key); + count++; + } + } + + return count; +} + +/** + * Clear all cache + */ +export function clearCache(): void { + cacheStore.clear(); +} + +/** + * Get cache statistics + */ +export function getCacheStats(): { + size: number; + entries: Array<{ key: string; hits: number; age: number }>; +} { + return cacheStore.getStats(); +} diff --git a/api/src/middleware/error-handler.ts b/api/src/middleware/error-handler.ts index e3b8f30..e60e5de 100644 --- a/api/src/middleware/error-handler.ts +++ b/api/src/middleware/error-handler.ts @@ -9,6 +9,7 @@ import { APIGatewayResponse } from '../types/api'; import { AppError } from '../types/errors'; +import { createLogger } from './logging'; /** * Lambda handler type that may throw errors @@ -18,9 +19,19 @@ export type ErrorHandlerWrappedHandler = (...args: any[]) => Promise; +} + +/** + * Metric types + */ +export enum MetricType { + COUNTER = 'counter', + GAUGE = 'gauge', + HISTOGRAM = 'histogram', +} + +/** + * Metric definition + */ +interface Metric { + name: string; + type: MetricType; + description: string; + data: MetricDataPoint[]; +} + +/** + * Metrics store + */ +class MetricsStore { + private metrics: Map = new Map(); + private readonly maxDataPoints = 1000; + + /** + * Record a counter metric + */ + recordCounter(name: string, value: number = 1, labels?: Record): void { + this.ensureMetric(name, MetricType.COUNTER, `Counter metric: ${name}`); + this.addDataPoint(name, value, labels); + } + + /** + * Record a gauge metric + */ + recordGauge(name: string, value: number, labels?: Record): void { + this.ensureMetric(name, MetricType.GAUGE, `Gauge metric: ${name}`); + this.addDataPoint(name, value, labels); + } + + /** + * Record a histogram metric (for timing) + */ + recordHistogram(name: string, value: number, labels?: Record): void { + this.ensureMetric(name, MetricType.HISTOGRAM, `Histogram metric: ${name}`); + this.addDataPoint(name, value, labels); + } + + /** + * Ensure metric exists + */ + private ensureMetric(name: string, type: MetricType, description: string): void { + if (!this.metrics.has(name)) { + this.metrics.set(name, { + name, + type, + description, + data: [], + }); + } + } + + /** + * Add data point to metric + */ + private addDataPoint(name: string, value: number, labels?: Record): void { + const metric = this.metrics.get(name); + if (!metric) return; + + metric.data.push({ + timestamp: Date.now(), + value, + labels, + }); + + // Limit data points to prevent memory issues + if (metric.data.length > this.maxDataPoints) { + metric.data.shift(); + } + } + + /** + * Get all metrics + */ + getAllMetrics(): Map { + return new Map(this.metrics); + } + + /** + * Get metric by name + */ + getMetric(name: string): Metric | undefined { + return this.metrics.get(name); + } + + /** + * Get aggregated metrics summary + */ + getMetricsSummary(): Record { + const summary: Record = {}; + + for (const [name, metric] of this.metrics) { + if (metric.data.length === 0) continue; + + const values = metric.data.map((d) => d.value); + const sum = values.reduce((a, b) => a + b, 0); + const count = values.length; + + switch (metric.type) { + case MetricType.COUNTER: + summary[name] = { + type: 'counter', + total: sum, + count, + }; + break; + + case MetricType.GAUGE: + summary[name] = { + type: 'gauge', + current: values[values.length - 1], + min: Math.min(...values), + max: Math.max(...values), + avg: sum / count, + }; + break; + + case MetricType.HISTOGRAM: { + const sorted = [...values].sort((a, b) => a - b); + summary[name] = { + type: 'histogram', + count, + sum, + min: sorted[0], + max: sorted[sorted.length - 1], + avg: sum / count, + p50: sorted[Math.floor(count * 0.5)], + p95: sorted[Math.floor(count * 0.95)], + p99: sorted[Math.floor(count * 0.99)], + }; + break; + } + } + } + + return summary; + } + + /** + * Clear all metrics + */ + clear(): void { + this.metrics.clear(); + } +} + +/** + * Global metrics store + */ +const metricsStore = new MetricsStore(); + +/** + * Lambda handler type + */ +export type MetricsWrappedHandler = (event: APIGatewayEvent) => Promise; + +/** + * Metrics middleware + * Collects performance and usage metrics for each request + * + * @param handler - Lambda handler function + * @returns Wrapped handler with metrics collection + * + * @example + * ```typescript + * export const handler = compose( + * metricsMiddleware, + * async (event) => { + * // Handler logic + * return { statusCode: 200, body: '{}' }; + * } + * ); + * ``` + */ +export function metricsMiddleware(handler: MetricsWrappedHandler): MetricsWrappedHandler { + return async (event: APIGatewayEvent): Promise => { + const startTime = Date.now(); + const path = event.path || 'unknown'; + const method = event.httpMethod || 'unknown'; + + const logger = createLogger({ + middleware: 'metricsMiddleware', + requestId: event.requestContext?.requestId, + }); + + try { + // Record request count + metricsStore.recordCounter('http_requests_total', 1, { path, method }); + + // Execute handler + const response = await handler(event); + + // Record response time + const duration = Date.now() - startTime; + metricsStore.recordHistogram('http_request_duration_ms', duration, { + path, + method, + status: response.statusCode.toString(), + }); + + // Record status code + metricsStore.recordCounter('http_response_status_total', 1, { + path, + method, + status: response.statusCode.toString(), + }); + + // Record success/error + if (response.statusCode >= 200 && response.statusCode < 400) { + metricsStore.recordCounter('http_requests_success_total', 1, { path, method }); + } else { + metricsStore.recordCounter('http_requests_error_total', 1, { + path, + method, + status: response.statusCode.toString(), + }); + } + + logger.debug('Metrics recorded', { duration, statusCode: response.statusCode }); + + return response; + } catch (error) { + // Record error + const duration = Date.now() - startTime; + metricsStore.recordHistogram('http_request_duration_ms', duration, { + path, + method, + status: '500', + }); + metricsStore.recordCounter('http_requests_error_total', 1, { path, method }); + + logger.error('Request failed, metrics recorded', { duration, error }); + + throw error; + } + }; +} + +/** + * Get metrics store (for metrics endpoint) + */ +export function getMetricsStore(): MetricsStore { + return metricsStore; +} + +/** + * Record custom metric + */ +export function recordMetric( + name: string, + value: number, + type: MetricType = MetricType.COUNTER, + labels?: Record +): void { + switch (type) { + case MetricType.COUNTER: + metricsStore.recordCounter(name, value, labels); + break; + case MetricType.GAUGE: + metricsStore.recordGauge(name, value, labels); + break; + case MetricType.HISTOGRAM: + metricsStore.recordHistogram(name, value, labels); + break; + } +} diff --git a/api/src/middleware/rate-limit.ts b/api/src/middleware/rate-limit.ts new file mode 100644 index 0000000..615b7ad --- /dev/null +++ b/api/src/middleware/rate-limit.ts @@ -0,0 +1,279 @@ +/** + * Rate Limiting Middleware for Lambda Functions + * Implements token bucket algorithm to prevent API abuse + * Requirements: Security best practices + * Following SOLID principles: Single Responsibility + */ + +import { APIGatewayEvent, APIGatewayResponse } from '../types/api'; +import { createLogger } from './logging'; +import { AppError } from '../types/errors'; + +/** + * Rate limit exceeded error + */ +export class RateLimitError extends AppError { + constructor(retryAfter: number) { + super( + 'RATE_LIMIT_EXCEEDED', + `Rate limit exceeded. Retry after ${retryAfter} seconds`, + 429, + true, + { retryAfter } + ); + } +} + +/** + * Rate limit configuration + */ +export interface RateLimitConfig { + /** + * Maximum number of requests allowed in the window + */ + maxRequests: number; + + /** + * Time window in seconds + */ + windowSeconds: number; + + /** + * Key extractor function (defaults to IP address) + */ + keyExtractor?: (event: APIGatewayEvent) => string; + + /** + * Skip rate limiting for certain requests + */ + skip?: (event: APIGatewayEvent) => boolean; +} + +/** + * Rate limit bucket entry + */ +interface RateLimitBucket { + tokens: number; + lastRefill: number; + requestCount: number; +} + +/** + * In-memory rate limit store + * Note: This is suitable for Lambda but resets on cold starts + * For production, consider using DynamoDB or Redis + */ +class RateLimitStore { + private buckets: Map = new Map(); + private cleanupInterval: NodeJS.Timeout | null = null; + + constructor() { + // Clean up old entries every 5 minutes + this.cleanupInterval = setInterval( + () => { + this.cleanup(); + }, + 5 * 60 * 1000 + ); + } + + /** + * Get or create a bucket for a key + */ + getBucket(key: string, maxRequests: number): RateLimitBucket { + if (!this.buckets.has(key)) { + this.buckets.set(key, { + tokens: maxRequests, + lastRefill: Date.now(), + requestCount: 0, + }); + } + return this.buckets.get(key)!; + } + + /** + * Update bucket + */ + updateBucket(key: string, bucket: RateLimitBucket): void { + this.buckets.set(key, bucket); + } + + /** + * Clean up old entries (older than 1 hour) + */ + private cleanup(): void { + const oneHourAgo = Date.now() - 60 * 60 * 1000; + for (const [key, bucket] of this.buckets.entries()) { + if (bucket.lastRefill < oneHourAgo) { + this.buckets.delete(key); + } + } + } + + /** + * Get store size (for monitoring) + */ + getSize(): number { + return this.buckets.size; + } + + /** + * Clear all buckets (for testing) + */ + clear(): void { + this.buckets.clear(); + } + + /** + * Destroy the store + */ + destroy(): void { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + this.buckets.clear(); + } +} + +/** + * Global rate limit store instance + */ +const rateLimitStore = new RateLimitStore(); + +/** + * Lambda handler type + */ +export type RateLimitWrappedHandler = (event: APIGatewayEvent) => Promise; + +/** + * Default key extractor - uses source IP address + */ +function defaultKeyExtractor(event: APIGatewayEvent): string { + const sourceIp = event.requestContext?.identity?.sourceIp || 'unknown'; + const path = event.path || ''; + return `${sourceIp}:${path}`; +} + +/** + * Check rate limit for a key + */ +function checkRateLimit( + key: string, + config: RateLimitConfig +): { allowed: boolean; retryAfter: number } { + const now = Date.now(); + const bucket = rateLimitStore.getBucket(key, config.maxRequests); + + // Refill tokens based on time elapsed + const timeElapsed = (now - bucket.lastRefill) / 1000; // seconds + const refillRate = config.maxRequests / config.windowSeconds; + const tokensToAdd = timeElapsed * refillRate; + + bucket.tokens = Math.min(config.maxRequests, bucket.tokens + tokensToAdd); + bucket.lastRefill = now; + + // Check if request can be allowed + if (bucket.tokens >= 1) { + bucket.tokens -= 1; + bucket.requestCount += 1; + rateLimitStore.updateBucket(key, bucket); + return { allowed: true, retryAfter: 0 }; + } + + // Calculate retry after time + const tokensNeeded = 1 - bucket.tokens; + const retryAfter = Math.ceil(tokensNeeded / refillRate); + + return { allowed: false, retryAfter }; +} + +/** + * Rate limiting middleware + * Implements token bucket algorithm to limit requests per IP/user + * + * @param config - Rate limit configuration + * @returns Middleware function + * + * @example + * ```typescript + * export const handler = compose( + * rateLimitMiddleware({ maxRequests: 100, windowSeconds: 60 }), + * async (event) => { + * // Handler logic + * return { statusCode: 200, body: '{}' }; + * } + * ); + * ``` + */ +export function rateLimitMiddleware(config: RateLimitConfig) { + const logger = createLogger({ middleware: 'rateLimitMiddleware' }); + + return function (handler: RateLimitWrappedHandler): RateLimitWrappedHandler { + return async (event: APIGatewayEvent): Promise => { + // Skip rate limiting if configured + if (config.skip && config.skip(event)) { + return handler(event); + } + + // Extract key (IP address or custom) + const keyExtractor = config.keyExtractor || defaultKeyExtractor; + const key = keyExtractor(event); + + // Check rate limit + const { allowed, retryAfter } = checkRateLimit(key, config); + + if (!allowed) { + logger.warn('Rate limit exceeded', { + key, + path: event.path, + method: event.httpMethod, + retryAfter, + }); + + const error = new RateLimitError(retryAfter); + return { + statusCode: error.statusCode, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Retry-After': retryAfter.toString(), + 'X-RateLimit-Limit': config.maxRequests.toString(), + 'X-RateLimit-Remaining': '0', + 'X-RateLimit-Reset': (Date.now() + retryAfter * 1000).toString(), + }, + body: JSON.stringify(error.toJSON()), + }; + } + + // Add rate limit headers to response + const response = await handler(event); + + // Add rate limit info to headers + return { + ...response, + headers: { + ...response.headers, + 'X-RateLimit-Limit': config.maxRequests.toString(), + 'X-RateLimit-Remaining': Math.floor( + rateLimitStore.getBucket(key, config.maxRequests).tokens + ).toString(), + }, + }; + }; + }; +} + +/** + * Get rate limit store (for testing and monitoring) + */ +export function getRateLimitStore(): RateLimitStore { + return rateLimitStore; +} + +/** + * Clear rate limit store (for testing) + */ +export function clearRateLimitStore(): void { + rateLimitStore.clear(); +} diff --git a/api/src/middleware/validation.ts b/api/src/middleware/validation.ts new file mode 100644 index 0000000..d214d0d --- /dev/null +++ b/api/src/middleware/validation.ts @@ -0,0 +1,428 @@ +/** + * Request Validation Middleware for Lambda Functions + * Validates request body, query parameters, and headers + * Requirements: Input validation and security + * Following SOLID principles: Single Responsibility + */ + +import { APIGatewayEvent, APIGatewayResponse } from '../types/api'; +import { ValidationError } from '../types/errors'; +import { createLogger } from './logging'; + +/** + * Validation rule type + */ +export type ValidationRule = (value: T) => boolean | string; + +/** + * Schema field definition + */ +export interface SchemaField { + /** + * Field type + */ + type: 'string' | 'number' | 'boolean' | 'object' | 'array'; + + /** + * Whether the field is required + */ + required?: boolean; + + /** + * Minimum length (for strings) or value (for numbers) + */ + min?: number; + + /** + * Maximum length (for strings) or value (for numbers) + */ + max?: number; + + /** + * Pattern to match (for strings) + */ + pattern?: RegExp; + + /** + * Allowed values (enum) + */ + enum?: unknown[]; + + /** + * Custom validation rules + */ + rules?: ValidationRule[]; + + /** + * Nested schema (for objects) + */ + schema?: ValidationSchema; + + /** + * Item schema (for arrays) + */ + items?: SchemaField; + + /** + * Custom error message + */ + message?: string; +} + +/** + * Validation schema + */ +export interface ValidationSchema { + [key: string]: SchemaField; +} + +/** + * Validation options + */ +export interface ValidationOptions { + /** + * Validate request body + */ + body?: ValidationSchema; + + /** + * Validate query parameters + */ + query?: ValidationSchema; + + /** + * Validate headers + */ + headers?: ValidationSchema; + + /** + * Allow additional fields not in schema + */ + allowAdditional?: boolean; + + /** + * Strip unknown fields + */ + stripUnknown?: boolean; +} + +/** + * Validation result + */ +interface ValidationResult { + isValid: boolean; + errors: string[]; + value?: unknown; +} + +/** + * Validate value against schema field + */ +function validateField( + fieldName: string, + value: unknown, + field: SchemaField +): { isValid: boolean; error?: string } { + // Check required + if (field.required && (value === undefined || value === null || value === '')) { + return { + isValid: false, + error: field.message || `${fieldName} is required`, + }; + } + + // Allow optional fields + if (!field.required && (value === undefined || value === null)) { + return { isValid: true }; + } + + // Type validation + const actualType = Array.isArray(value) ? 'array' : typeof value; + if (actualType !== field.type && value !== undefined && value !== null) { + return { + isValid: false, + error: field.message || `${fieldName} must be of type ${field.type}`, + }; + } + + // String validation + if (field.type === 'string' && typeof value === 'string') { + if (field.min !== undefined && value.length < field.min) { + return { + isValid: false, + error: field.message || `${fieldName} must be at least ${field.min} characters`, + }; + } + + if (field.max !== undefined && value.length > field.max) { + return { + isValid: false, + error: field.message || `${fieldName} must be at most ${field.max} characters`, + }; + } + + if (field.pattern && !field.pattern.test(value)) { + return { + isValid: false, + error: field.message || `${fieldName} does not match required pattern`, + }; + } + } + + // Number validation + if (field.type === 'number' && typeof value === 'number') { + if (field.min !== undefined && value < field.min) { + return { + isValid: false, + error: field.message || `${fieldName} must be at least ${field.min}`, + }; + } + + if (field.max !== undefined && value > field.max) { + return { + isValid: false, + error: field.message || `${fieldName} must be at most ${field.max}`, + }; + } + } + + // Enum validation + if (field.enum && !field.enum.includes(value)) { + return { + isValid: false, + error: field.message || `${fieldName} must be one of: ${field.enum.join(', ')}`, + }; + } + + // Array validation + if (field.type === 'array' && Array.isArray(value)) { + if (field.items) { + for (let i = 0; i < value.length; i++) { + const itemResult = validateField(`${fieldName}[${i}]`, value[i], field.items); + if (!itemResult.isValid) { + return itemResult; + } + } + } + } + + // Object validation + if (field.type === 'object' && field.schema && typeof value === 'object') { + const nestedResult = validateSchema(value as Record, field.schema, { + allowAdditional: true, + }); + if (!nestedResult.isValid) { + return { + isValid: false, + error: nestedResult.errors.join(', '), + }; + } + } + + // Custom rules + if (field.rules) { + for (const rule of field.rules) { + const result = rule(value); + if (result !== true) { + return { + isValid: false, + error: typeof result === 'string' ? result : field.message || `${fieldName} is invalid`, + }; + } + } + } + + return { isValid: true }; +} + +/** + * Validate data against schema + */ +function validateSchema( + data: Record, + schema: ValidationSchema, + options: { allowAdditional?: boolean; stripUnknown?: boolean } = {} +): ValidationResult { + const errors: string[] = []; + const validated: Record = {}; + + // Validate each field in schema + for (const [fieldName, fieldDef] of Object.entries(schema)) { + const value = data[fieldName]; + const result = validateField(fieldName, value, fieldDef); + + if (!result.isValid) { + errors.push(result.error!); + } else if (value !== undefined) { + validated[fieldName] = value; + } + } + + // Check for additional fields + if (!options.allowAdditional && !options.stripUnknown) { + for (const key of Object.keys(data)) { + if (!(key in schema)) { + errors.push(`Unknown field: ${key}`); + } + } + } + + // Include additional fields if allowed + if (options.allowAdditional || options.stripUnknown) { + for (const [key, value] of Object.entries(data)) { + if (!(key in schema)) { + if (options.allowAdditional) { + validated[key] = value; + } + // If stripUnknown, we simply don't include it + } + } + } + + return { + isValid: errors.length === 0, + errors, + value: validated, + }; +} + +/** + * Lambda handler type + */ +export type ValidationWrappedHandler = (event: APIGatewayEvent) => Promise; + +/** + * Validation middleware + * Validates request data against provided schemas + * + * @param options - Validation options + * @returns Middleware function + * + * @example + * ```typescript + * export const handler = compose( + * validationMiddleware({ + * body: { + * email: { type: 'string', required: true, pattern: /^.+@.+\..+$/ }, + * age: { type: 'number', min: 0, max: 150 } + * } + * }), + * async (event) => { + * // Handler logic - body is validated + * return { statusCode: 200, body: '{}' }; + * } + * ); + * ``` + */ +export function validationMiddleware(options: ValidationOptions) { + const logger = createLogger({ middleware: 'validationMiddleware' }); + + return function (handler: ValidationWrappedHandler): ValidationWrappedHandler { + return async (event: APIGatewayEvent): Promise => { + const validationErrors: string[] = []; + + // Validate body + if (options.body && event.body) { + try { + const bodyData = JSON.parse(event.body); + const result = validateSchema(bodyData, options.body, { + allowAdditional: options.allowAdditional, + stripUnknown: options.stripUnknown, + }); + + if (!result.isValid) { + validationErrors.push(...result.errors.map((e) => `Body: ${e}`)); + } + } catch (error) { + validationErrors.push('Body: Invalid JSON'); + } + } + + // Validate query parameters + if (options.query && event.queryStringParameters) { + const result = validateSchema(event.queryStringParameters, options.query, { + allowAdditional: options.allowAdditional, + stripUnknown: options.stripUnknown, + }); + + if (!result.isValid) { + validationErrors.push(...result.errors.map((e) => `Query: ${e}`)); + } + } + + // Validate headers + if (options.headers && event.headers) { + const result = validateSchema(event.headers, options.headers, { + allowAdditional: true, // Always allow additional headers + stripUnknown: false, + }); + + if (!result.isValid) { + validationErrors.push(...result.errors.map((e) => `Headers: ${e}`)); + } + } + + // If validation failed, return error + if (validationErrors.length > 0) { + logger.warn('Validation failed', { errors: validationErrors }); + + const error = new ValidationError(validationErrors.join('; ')); + return { + statusCode: error.statusCode, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + body: JSON.stringify(error.toJSON()), + }; + } + + // Validation passed, execute handler + return handler(event); + }; + }; +} + +/** + * Common validation rules + */ +export const ValidationRules = { + /** + * Email validation + */ + email: (): ValidationRule => (value: string) => + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) || 'Must be a valid email address', + + /** + * URL validation + */ + url: (): ValidationRule => (value: string) => { + try { + new URL(value); + return true; + } catch { + return 'Must be a valid URL'; + } + }, + + /** + * UUID validation + */ + uuid: (): ValidationRule => (value: string) => + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value) || + 'Must be a valid UUID', + + /** + * Alphanumeric validation + */ + alphanumeric: (): ValidationRule => (value: string) => + /^[a-zA-Z0-9]+$/.test(value) || 'Must contain only letters and numbers', + + /** + * Custom regex validation + */ + matches: + (pattern: RegExp, message?: string): ValidationRule => + (value: string) => + pattern.test(value) || message || 'Does not match required pattern', +}; diff --git a/api/src/utils/multipart-parser.ts b/api/src/utils/multipart-parser.ts new file mode 100644 index 0000000..038d2fc --- /dev/null +++ b/api/src/utils/multipart-parser.ts @@ -0,0 +1,258 @@ +/** + * Multipart Form Data Parser for Lambda Functions + * Parses multipart/form-data without external dependencies + * Requirements: Enhanced file upload support + * Following SOLID principles: Single Responsibility + */ + +import { ValidationError } from '../types/errors'; + +/** + * Parsed multipart field + */ +export interface MultipartField { + name: string; + value: string; + contentType?: string; +} + +/** + * Parsed multipart file + */ +export interface MultipartFile { + name: string; + filename: string; + contentType: string; + data: Buffer; + size: number; +} + +/** + * Parsed multipart form data + */ +export interface ParsedMultipartData { + fields: Record; + files: MultipartFile[]; +} + +/** + * Parse Content-Disposition header + */ +function parseContentDisposition(header: string): { + name?: string; + filename?: string; +} { + const result: { name?: string; filename?: string } = {}; + + // Match name="value" or name=value patterns + const nameMatch = header.match(/name="?([^";\r\n]+)"?/); + if (nameMatch) { + result.name = nameMatch[1]; + } + + // Match filename="value" or filename=value patterns + const filenameMatch = header.match(/filename="?([^";\r\n]+)"?/); + if (filenameMatch) { + result.filename = filenameMatch[1]; + } + + return result; +} + +/** + * Parse Content-Type header + */ +function parseContentType(header: string): string { + const match = header.match(/^([^;\s]+)/); + return match ? match[1].trim() : 'application/octet-stream'; +} + +/** + * Extract boundary from Content-Type header + */ +export function extractBoundary(contentType: string): string | null { + const match = contentType.match(/boundary=(?:"([^"]+)"|([^;\s]+))/); + return match ? match[1] || match[2] : null; +} + +/** + * Parse multipart form data + * + * @param body - Raw body as Buffer or base64 string + * @param boundary - Multipart boundary string + * @returns Parsed fields and files + * + * @example + * ```typescript + * const boundary = extractBoundary(event.headers['content-type']); + * const parsed = parseMultipartFormData(event.body, boundary); + * ``` + */ +export function parseMultipartFormData( + body: Buffer | string, + boundary: string +): ParsedMultipartData { + if (!boundary) { + throw new ValidationError('Missing boundary in multipart request'); + } + + // Convert to Buffer if needed + const buffer = Buffer.isBuffer(body) ? body : Buffer.from(body, 'base64'); + + const fields: Record = {}; + const files: MultipartFile[] = []; + + // Split by boundary + const boundaryBuffer = Buffer.from(`--${boundary}`); + const parts = splitBuffer(buffer, boundaryBuffer); + + for (const part of parts) { + if (part.length === 0 || part.toString().trim() === '--') { + continue; + } + + // Split headers and body by double CRLF + const headerEndIndex = findSequence(part, Buffer.from('\r\n\r\n')); + if (headerEndIndex === -1) { + continue; + } + + const headersBuffer = part.slice(0, headerEndIndex); + const bodyBuffer = part.slice(headerEndIndex + 4); + + // Parse headers + const headers = parseHeaders(headersBuffer.toString()); + + // Get Content-Disposition + const contentDisposition = headers['content-disposition']; + if (!contentDisposition) { + continue; + } + + const { name, filename } = parseContentDisposition(contentDisposition); + if (!name) { + continue; + } + + // If it has a filename, it's a file + if (filename) { + const contentType = headers['content-type'] + ? parseContentType(headers['content-type']) + : 'application/octet-stream'; + + // Remove trailing CRLF from body + let fileData = bodyBuffer; + if ( + fileData.length >= 2 && + fileData[fileData.length - 2] === 0x0d && + fileData[fileData.length - 1] === 0x0a + ) { + fileData = fileData.slice(0, -2); + } + + files.push({ + name, + filename, + contentType, + data: fileData, + size: fileData.length, + }); + } else { + // It's a regular field + let fieldValue = bodyBuffer.toString('utf-8'); + // Remove trailing CRLF + if (fieldValue.endsWith('\r\n')) { + fieldValue = fieldValue.slice(0, -2); + } + fields[name] = fieldValue; + } + } + + return { fields, files }; +} + +/** + * Parse headers from buffer + */ +function parseHeaders(headerText: string): Record { + const headers: Record = {}; + const lines = headerText.split('\r\n'); + + for (const line of lines) { + const colonIndex = line.indexOf(':'); + if (colonIndex > 0) { + const key = line.slice(0, colonIndex).trim().toLowerCase(); + const value = line.slice(colonIndex + 1).trim(); + headers[key] = value; + } + } + + return headers; +} + +/** + * Split buffer by delimiter + */ +function splitBuffer(buffer: Buffer, delimiter: Buffer): Buffer[] { + const parts: Buffer[] = []; + let start = 0; + let index = findSequence(buffer, delimiter, start); + + while (index !== -1) { + parts.push(buffer.slice(start, index)); + start = index + delimiter.length; + index = findSequence(buffer, delimiter, start); + } + + // Add remaining part + if (start < buffer.length) { + parts.push(buffer.slice(start)); + } + + return parts; +} + +/** + * Find sequence in buffer (Boyer-Moore-like search) + */ +function findSequence(buffer: Buffer, sequence: Buffer, start = 0): number { + if (sequence.length === 0) return -1; + if (start + sequence.length > buffer.length) return -1; + + for (let i = start; i <= buffer.length - sequence.length; i++) { + let found = true; + for (let j = 0; j < sequence.length; j++) { + if (buffer[i + j] !== sequence[j]) { + found = false; + break; + } + } + if (found) return i; + } + + return -1; +} + +/** + * Validate multipart request + */ +export function validateMultipartRequest(contentType?: string): { + isValid: boolean; + boundary?: string; + error?: string; +} { + if (!contentType) { + return { isValid: false, error: 'Missing Content-Type header' }; + } + + if (!contentType.includes('multipart/form-data')) { + return { isValid: false, error: 'Content-Type must be multipart/form-data' }; + } + + const boundary = extractBoundary(contentType); + if (!boundary) { + return { isValid: false, error: 'Missing boundary in Content-Type' }; + } + + return { isValid: true, boundary }; +} diff --git a/web-app/src/components/common/Card.tsx b/web-app/src/components/common/Card.tsx index 81b20de..238068a 100644 --- a/web-app/src/components/common/Card.tsx +++ b/web-app/src/components/common/Card.tsx @@ -66,6 +66,11 @@ export interface ICardProps extends IBaseComponent { * Click handler for the entire card */ onClick?: () => void; + + /** + * ARIA label for accessibility + */ + ariaLabel?: string; } /** @@ -86,6 +91,7 @@ export const Card: React.FC = ({ className, testId, disabled = false, + ariaLabel, }) => { return ( = ({ onClick={onClick} className={className} data-testid={testId} + role={onClick ? 'button' : 'article'} + aria-label={ariaLabel || title} + tabIndex={onClick ? 0 : undefined} + onKeyDown={ + onClick + ? (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick(); + } + } + : undefined + } sx={{ cursor: onClick ? 'pointer' : 'default', opacity: disabled ? 0.6 : 1, pointerEvents: disabled ? 'none' : 'auto', + '&:focus-visible': onClick + ? { + outline: '2px solid', + outlineColor: 'primary.main', + outlineOffset: '2px', + } + : undefined, }} > {(title || subtitle || headerAction) && (