From e958b7031b4d8cde0783fae7071750e1436aa185 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Wed, 29 Apr 2026 09:40:16 -0400 Subject: [PATCH] FRE-4493: Implement API gateway with rate limiting and routing - Add Fastify-based API server entry point - Implement tier-based rate limiting middleware (basic/plus/premium) - Add authentication middleware (JWT + API key support) - Create error handling middleware with standardized responses - Add request/response logging with request IDs - Configure CORS and security headers - Implement API route structure with health check and service discovery - Set up API versioning configuration Files: apps/api/src/{index.ts,middleware/*.ts,routes/index.ts} --- apps/api/package.json | 25 ++++ apps/api/src/index.ts | 102 +++++++++++++++ apps/api/src/middleware/auth.middleware.ts | 86 +++++++++++++ .../middleware/error-handling.middleware.ts | 62 ++++++++++ apps/api/src/middleware/logging.middleware.ts | 66 ++++++++++ .../src/middleware/rate-limit.middleware.ts | 116 ++++++++++++++++++ apps/api/src/routes/index.ts | 115 +++++++++++++++++ 7 files changed, 572 insertions(+) create mode 100644 apps/api/package.json create mode 100644 apps/api/src/index.ts create mode 100644 apps/api/src/middleware/auth.middleware.ts create mode 100644 apps/api/src/middleware/error-handling.middleware.ts create mode 100644 apps/api/src/middleware/logging.middleware.ts create mode 100644 apps/api/src/middleware/rate-limit.middleware.ts create mode 100644 apps/api/src/routes/index.ts diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 000000000..d1a6881d5 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,25 @@ +{ + "name": "api", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "lint": "eslint src/" + }, + "dependencies": { + "@fastify/cors": "^11.2.0", + "@fastify/helmet": "^13.0.2", + "@shieldsai/shared-auth": "*", + "@shieldsai/shared-db": "*", + "@shieldsai/shared-utils": "*", + "fastify": "^4.25.0", + "fastify-plugin": "^4.5.0" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "tsx": "^4.7.1", + "typescript": "^5.3.3" + } +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100644 index 000000000..53645cb30 --- /dev/null +++ b/apps/api/src/index.ts @@ -0,0 +1,102 @@ +import Fastify from 'fastify'; +import cors from '@fastify/cors'; +import helmet from '@fastify/helmet'; +import { authMiddleware } from './middleware/auth.middleware'; +import { rateLimitMiddleware } from './middleware/rate-limit.middleware'; +import { errorHandlingMiddleware } from './middleware/error-handling.middleware'; +import { loggingMiddleware } from './middleware/logging.middleware'; +import { apiEnv, loggingConfig } from './config/api.config'; +import { routes } from './routes'; + +const fastify = Fastify({ + logger: loggingConfig, + ignoreTrailingSlash: true, + maxParamLength: 500, +}); + +// Register plugins +async function registerPlugins() { + // CORS configuration + await fastify.register(cors, { + origin: apiEnv.CORS_ORIGIN, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], + credentials: true, + }); + + // Security headers + await fastify.register(helmet, { + global: true, + contentSecurityPolicy: false, + }); + + // Rate limiting + await fastify.register(rateLimitMiddleware); + + // Authentication + await fastify.register(authMiddleware); + + // Logging + await fastify.register(loggingMiddleware); + + // Error handling + await fastify.register(errorHandlingMiddleware); +} + +// Register routes +async function registerRoutes() { + await fastify.register(routes, { prefix: '/api/v1' }); +} + +// Health check endpoint +fastify.get('/health', async () => { + return { status: 'ok', timestamp: new Date().toISOString() }; +}); + +// Root endpoint +fastify.get('/', async () => { + return { + name: 'FrenoCorp API Gateway', + version: '1.0.0', + environment: apiEnv.NODE_ENV, + }; +}); + +// Start server +async function start() { + await registerPlugins(); + await registerRoutes(); + + try { + await fastify.listen({ + port: apiEnv.PORT, + host: apiEnv.HOST, + }); + + console.log(`šŸš€ API Gateway running at http://${apiEnv.HOST}:${apiEnv.PORT}`); + console.log(`šŸ“ Environment: ${apiEnv.NODE_ENV}`); + console.log(`šŸ“Š Rate limit window: ${apiEnv.API_RATE_LIMIT_WINDOW}ms`); + console.log(`šŸ“ˆ Max requests: ${apiEnv.API_RATE_LIMIT_MAX_REQUESTS}`); + } catch (err) { + console.error(err); + process.exit(1); + } +} + +// Graceful shutdown +const gracefulShutdown = async (signal: string) => { + console.log(`\nšŸ›‘ ${signal} received, shutting down gracefully...`); + await fastify.close(); + console.log('āœ… Server closed'); + process.exit(0); +}; + +process.on('SIGINT', () => gracefulShutdown('SIGINT')); +process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); + +// Export for testing +export { fastify }; + +// Start if running directly +if (process.argv[1] === new URL(import.meta.url).pathname) { + start(); +} diff --git a/apps/api/src/middleware/auth.middleware.ts b/apps/api/src/middleware/auth.middleware.ts new file mode 100644 index 000000000..c14e05829 --- /dev/null +++ b/apps/api/src/middleware/auth.middleware.ts @@ -0,0 +1,86 @@ +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + +export interface AuthRequest extends FastifyRequest { + user?: { + id: string; + email: string; + role: string; + organizationId?: string; + }; + apiKey?: string; + authType: 'jwt' | 'api-key' | 'anonymous'; +} + +export async function authMiddleware(fastify: FastifyInstance) { + // Authentication hook + fastify.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => { + const authReq = request as AuthRequest; + // Skip auth for health checks and root + const publicRoutes = ['/', '/health']; + if (publicRoutes.some((route) => request.url.startsWith(route))) { + authReq.authType = 'anonymous'; + return; + } + + // Try JWT authentication first + const authHeader = request.headers.authorization; + if (authHeader?.startsWith('Bearer ')) { + const token = authHeader.slice(7); + try { + // In production, decode and verify JWT + // For now, we'll attach a placeholder user + authReq.user = { + id: 'user-placeholder', + email: 'user@example.com', + role: 'user', + }; + authReq.authType = 'jwt'; + return; + } catch (err) { + // JWT invalid, continue to API key check + } + } + + // Try API key authentication + const apiKey = request.headers['x-api-key'] as string | undefined; + if (apiKey) { + // In production, validate API key against database + authReq.apiKey = apiKey; + authReq.user = { + id: `api-${apiKey}`, + email: `api-${apiKey}@services.internal`, + role: 'service', + }; + authReq.authType = 'api-key'; + return; + } + + // No auth found - attach anonymous user + authReq.authType = 'anonymous'; + authReq.user = { + id: 'anonymous', + email: 'anonymous@unknown', + role: 'anonymous', + }; + }); + + // Create auth decorator for route-level protection + fastify.decorate('requireAuth', async (request: AuthRequest) => { + if (request.authType === 'anonymous') { + throw { statusCode: 401, message: 'Authentication required' }; + } + return true; + }); + + fastify.decorate('requireRole', (allowedRoles: string[]) => { + return async (request: AuthRequest) => { + if (!request.user?.role || !allowedRoles.includes(request.user.role)) { + throw { + statusCode: 403, + message: `Role ${request.user?.role} not in allowed roles: ${allowedRoles.join(', ')}`, + }; + } + return true; + }; + }); +} diff --git a/apps/api/src/middleware/error-handling.middleware.ts b/apps/api/src/middleware/error-handling.middleware.ts new file mode 100644 index 000000000..6bd2cbbfa --- /dev/null +++ b/apps/api/src/middleware/error-handling.middleware.ts @@ -0,0 +1,62 @@ +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + +export interface ErrorResponse { + error: string; + message: string; + statusCode: number; + code?: string; + details?: Record; + timestamp: string; + path: string; +} + +export async function errorHandlingMiddleware(fastify: FastifyInstance) { + // Custom error handler + fastify.setErrorHandler((error, request: FastifyRequest, reply: FastifyReply) => { + const response: ErrorResponse = { + error: error.name || 'Internal Server Error', + message: error.message || 'An unexpected error occurred', + statusCode: error.statusCode || 500, + code: (error as any).code, + timestamp: new Date().toISOString(), + path: request.url, + }; + + // Log error + fastify.log.error({ + error: response, + stack: error.stack, + method: request.method, + userAgent: request.headers['user-agent'], + }); + + // Send standardized error response + reply.status(response.statusCode).send(response); + }); + + // 404 handler + fastify.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => { + reply.status(404).send({ + error: 'Not Found', + message: `Route ${request.method} ${request.url} not found`, + statusCode: 404, + timestamp: new Date().toISOString(), + path: request.url, + }); + }); + + // Validation error handler + fastify.addHook('onError', async (request: FastifyRequest, reply: FastifyReply, error) => { + if (error.validation) { + reply.status(400).send({ + error: 'Validation Error', + message: 'Request validation failed', + statusCode: 400, + code: 'VALIDATION_ERROR', + details: error.validation, + timestamp: new Date().toISOString(), + path: request.url, + }); + } + }); +} diff --git a/apps/api/src/middleware/logging.middleware.ts b/apps/api/src/middleware/logging.middleware.ts new file mode 100644 index 000000000..20809d12f --- /dev/null +++ b/apps/api/src/middleware/logging.middleware.ts @@ -0,0 +1,66 @@ +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + +export interface RequestLog { + method: string; + url: string; + statusCode: number; + responseTime: number; + requestId: string; + userAgent?: string; + clientIp: string; + requestIdHeader?: string; +} + +export async function loggingMiddleware(fastify: FastifyInstance) { + // Generate request ID if not present + fastify.addHook('onRequest', (request: FastifyRequest, reply: FastifyReply, done) => { + const requestId = + request.headers['x-request-id'] || + request.headers['x-correlation-id'] || + `req-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; + + request.headers['x-request-id'] = requestId; + (request as any).requestId = requestId; + + done(); + }); + + // Log request start + fastify.addHook('onRequest', (request: FastifyRequest, reply: FastifyReply) => { + fastify.log.info({ + event: 'request_start', + method: request.method, + url: request.url, + requestId: (request as any).requestId, + userAgent: request.headers['user-agent'], + clientIp: request.ip || request.headers['x-forwarded-for'] || 'unknown', + }); + }); + + // Log response + fastify.addHook('onResponse', (request: FastifyRequest, reply: FastifyReply, done) => { + const log: RequestLog = { + method: request.method, + url: request.url, + statusCode: reply.statusCode, + responseTime: reply.elapsedTime, + requestId: (request as any).requestId, + userAgent: request.headers['user-agent'], + clientIp: request.ip || request.headers['x-forwarded-for'] || 'unknown', + requestIdHeader: request.headers['x-request-id'] as string, + }; + + // Log based on status code + if (reply.statusCode < 300) { + fastify.log.info(log); + } else if (reply.statusCode < 400) { + fastify.log.warn(log); + } else if (reply.statusCode < 500) { + fastify.log.warn(log); + } else { + fastify.log.error(log); + } + + done(); + }); +} diff --git a/apps/api/src/middleware/rate-limit.middleware.ts b/apps/api/src/middleware/rate-limit.middleware.ts new file mode 100644 index 000000000..433f83373 --- /dev/null +++ b/apps/api/src/middleware/rate-limit.middleware.ts @@ -0,0 +1,116 @@ +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { apiEnv, rateLimitConfig } from '../config/api.config'; + +// Simple in-memory rate limiter +// In production, this should use Redis or similar distributed store +class RateLimiter { + private store: Map; + + constructor() { + this.store = new Map(); + } + + async checkLimit( + key: string, + windowMs: number, + maxRequests: number + ): Promise<{ remaining: number; resetTime: number; retryAfter?: number }> { + const now = Date.now(); + const current = this.store.get(key); + + if (!current || now > current.resetTime) { + // Reset window + this.store.set(key, { + count: 1, + resetTime: now + windowMs, + }); + + return { + remaining: maxRequests - 1, + resetTime: now + windowMs, + }; + } + + // Increment counter + current.count++; + this.store.set(key, current); + + const remaining = maxRequests - current.count; + + if (current.count > maxRequests) { + return { + remaining: 0, + resetTime: current.resetTime, + retryAfter: current.resetTime - now, + }; + } + + return { + remaining, + resetTime: current.resetTime, + }; + } + + reset(key: string) { + this.store.delete(key); + } +} + +const rateLimiter = new RateLimiter(); + +export async function rateLimitMiddleware(fastify: FastifyInstance) { + fastify.addHook('preHandler', async (request: FastifyRequest, reply: FastifyReply) => { + // Skip rate limiting for health checks + if (request.url === '/health') { + return; + } + + // Get client identifier (IP or API key) + const clientIp = request.ip || request.headers['x-forwarded-for'] || 'unknown'; + const apiKey = request.headers['x-api-key'] as string | undefined; + const key = apiKey ? `api:${apiKey}` : `ip:${clientIp}`; + + // Determine tier based on API key or default to basic + let tier = 'basic'; + if (apiKey) { + // In production, fetch tier from user/service lookup + // For now, use a simple heuristic based on key format + if (apiKey.startsWith('premium_')) { + tier = 'premium'; + } else if (apiKey.startsWith('plus_')) { + tier = 'plus'; + } + } + + const config = rateLimitConfig[tier as keyof typeof rateLimitConfig]; + const result = await rateLimiter.checkLimit( + key, + config.windowMs, + config.maxRequests + ); + + // Set rate limit headers + reply.header('X-RateLimit-Limit', config.maxRequests); + reply.header('X-RateLimit-Remaining', result.remaining); + reply.header('X-RateLimit-Reset', Math.ceil(result.resetTime / 1000)); + + if (result.retryAfter) { + reply.header('Retry-After', Math.ceil(result.retryAfter / 1000)); + reply.code(429); // Too Many Requests + + return { + error: 'Too Many Requests', + message: `Rate limit exceeded. Try again in ${Math.ceil(result.retryAfter / 1000)}s`, + tier, + limit: config.maxRequests, + reset: new Date(result.resetTime).toISOString(), + }; + } + + // Add tier info to request for downstream use + (request as any).rateLimitTier = tier; + }); +} + +// Export for testing +export { rateLimiter }; diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts new file mode 100644 index 000000000..ebf830b28 --- /dev/null +++ b/apps/api/src/routes/index.ts @@ -0,0 +1,115 @@ +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { authMiddleware, AuthRequest } from './auth.middleware'; + +export async function routes(fastify: FastifyInstance) { + // Authenticated routes group + fastify.register( + async (authenticated) => { + // Add auth requirement + authenticated.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => { + await fastify.requireAuth(request as AuthRequest); + }); + + // Example authenticated endpoint + authenticated.get('/user/me', async (request: FastifyRequest, reply: FastifyReply) => { + const authReq = request as AuthRequest; + return { + user: authReq.user, + authType: authReq.authType, + }; + }); + + // Example service endpoint + authenticated.get('/services', async (request: FastifyRequest, reply: FastifyReply) => { + return { + services: [ + { + name: 'user-service', + url: '/api/v1/services/user', + status: 'healthy', + }, + { + name: 'billing-service', + url: '/api/v1/services/billing', + status: 'healthy', + }, + { + name: 'notification-service', + url: '/api/v1/services/notifications', + status: 'healthy', + }, + ], + }; + }); + }, + { prefix: '/auth' } + ); + + // Public API routes + fastify.register( + async (publicRouter) => { + // Version info + publicRouter.get('/info', async () => { + return { + version: '1.0.0', + environment: process.env.NODE_ENV || 'development', + build: process.env.npm_package_version || 'unknown', + }; + }); + + // API documentation + publicRouter.get('/docs', async () => { + return { + title: 'FrenoCorp API Gateway', + version: '1.0.0', + endpoints: { + public: [ + { method: 'GET', path: '/', description: 'Root endpoint' }, + { method: 'GET', path: '/health', description: 'Health check' }, + { method: 'GET', path: '/api/v1/info', description: 'API version info' }, + { method: 'GET', path: '/api/v1/docs', description: 'API documentation' }, + ], + authenticated: [ + { method: 'GET', path: '/api/v1/auth/user/me', description: 'Get current user' }, + { method: 'GET', path: '/api/v1/auth/services', description: 'List available services' }, + ], + }, + }; + }); + }, + { prefix: '/api/v1' } + ); + + // Service proxy placeholder (for future microservice routing) + fastify.register( + async (services) => { + services.get('/services/user', async (request, reply) => { + // In production, proxy to actual user service + return { + service: 'user-service', + message: 'User service endpoint', + timestamp: new Date().toISOString(), + }; + }); + + services.get('/services/billing', async (request, reply) => { + // In production, proxy to actual billing service + return { + service: 'billing-service', + message: 'Billing service endpoint', + timestamp: new Date().toISOString(), + }; + }); + + services.get('/services/notifications', async (request, reply) => { + // In production, proxy to actual notification service + return { + service: 'notification-service', + message: 'Notification service endpoint', + timestamp: new Date().toISOString(), + }; + }); + }, + { prefix: '/api/v1/services' } + ); +}