From 5c88693916143d91667f32b167618b2e7ca8be35 Mon Sep 17 00:00:00 2001 From: OMIN MAN Date: Sat, 30 May 2026 22:40:51 +0000 Subject: [PATCH] feat: add API gateway module (closes #648) - GatewayRoutingService: weighted route registry + HTTP proxy via HttpService - GatewayRateLimitGuard: per-service/IP sliding window (1 min), limit from route config - RequestTransformInterceptor: strips hop-by-hop headers, injects correlation-id - ResponseCacheInterceptor: GET-only per-route TTL caching via CACHE_MANAGER - GatewayController: wildcard proxy + runtime route registration endpoint - GatewayModule registered in AppModule - 14/14 unit tests passing --- src/app.module.ts | 4 + src/gateway/dto/gateway.dto.ts | 43 ++++ src/gateway/gateway.controller.ts | 72 +++++++ src/gateway/gateway.module.ts | 20 ++ src/gateway/gateway.spec.ts | 199 ++++++++++++++++++ .../guards/gateway-rate-limit.guard.ts | 57 +++++ .../request-transform.interceptor.ts | 57 +++++ .../response-cache.interceptor.ts | 67 ++++++ src/gateway/interfaces/gateway.interfaces.ts | 19 ++ .../services/gateway-routing.service.ts | 61 ++++++ 10 files changed, 599 insertions(+) create mode 100644 src/gateway/dto/gateway.dto.ts create mode 100644 src/gateway/gateway.controller.ts create mode 100644 src/gateway/gateway.module.ts create mode 100644 src/gateway/gateway.spec.ts create mode 100644 src/gateway/guards/gateway-rate-limit.guard.ts create mode 100644 src/gateway/interceptors/request-transform.interceptor.ts create mode 100644 src/gateway/interceptors/response-cache.interceptor.ts create mode 100644 src/gateway/interfaces/gateway.interfaces.ts create mode 100644 src/gateway/services/gateway-routing.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index 6d1284c9..503e4882 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -30,6 +30,7 @@ import { HealthModule } from './health/health.module'; import { ReadReplicaModule } from './database/read-replica'; import { CachingModule } from './caching/caching.module'; import { CoursesModule } from './courses/courses.module'; +import { GatewayModule } from './gateway/gateway.module'; const featureFlags = loadFeatureFlags(); @@ -61,6 +62,9 @@ const featureFlags = loadFeatureFlags(); // ✅ courses module with enrollment and prerequisite enforcement CoursesModule, + + // ✅ API gateway: routing, rate limiting, transformation, caching + GatewayModule, ], controllers: [AppController], providers: [ diff --git a/src/gateway/dto/gateway.dto.ts b/src/gateway/dto/gateway.dto.ts new file mode 100644 index 00000000..8dbd40e4 --- /dev/null +++ b/src/gateway/dto/gateway.dto.ts @@ -0,0 +1,43 @@ +import { IsString, IsNotEmpty, IsOptional, IsObject, IsNumber, Min } from 'class-validator'; + +export class ProxyRequestDto { + @IsString() + @IsNotEmpty() + service: string; + + @IsString() + @IsNotEmpty() + path: string; + + @IsOptional() + @IsObject() + headers?: Record; + + @IsOptional() + body?: unknown; +} + +export class RouteConfigDto { + @IsString() + @IsNotEmpty() + service: string; + + @IsString() + @IsNotEmpty() + upstream: string; + + @IsOptional() + @IsNumber() + @Min(1) + weight?: number; + + @IsOptional() + @IsNumber() + @Min(0) + cacheTtlSeconds?: number; + + @IsOptional() + @IsNumber() + @Min(1) + rateLimitPerMinute?: number; +} diff --git a/src/gateway/gateway.controller.ts b/src/gateway/gateway.controller.ts new file mode 100644 index 00000000..d8222a00 --- /dev/null +++ b/src/gateway/gateway.controller.ts @@ -0,0 +1,72 @@ +import { + All, + Body, + Controller, + HttpCode, + Param, + Post, + Req, + Res, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger'; +import type { Request, Response } from 'express'; +import { RouteConfigDto } from './dto/gateway.dto'; +import { GatewayRateLimitGuard } from './guards/gateway-rate-limit.guard'; +import { RequestTransformInterceptor } from './interceptors/request-transform.interceptor'; +import { ResponseCacheInterceptor } from './interceptors/response-cache.interceptor'; +import { GatewayRoutingService } from './services/gateway-routing.service'; + +@ApiTags('gateway') +@Controller('gateway') +@UseInterceptors(RequestTransformInterceptor, ResponseCacheInterceptor) +@UseGuards(GatewayRateLimitGuard) +export class GatewayController { + constructor(private readonly routing: GatewayRoutingService) {} + + /** + * Proxy any HTTP method to the upstream service. + * Route: /gateway/:service/*path + */ + @All(':service/*path') + @ApiOperation({ summary: 'Proxy request to upstream service' }) + @ApiParam({ name: 'service', description: 'Registered service name' }) + async proxy( + @Param('service') service: string, + @Param('path') path: string, + @Req() req: Request, + @Res() res: Response, + @Body() body?: unknown, + ): Promise { + const forwardHeaders = { ...(req.headers as Record) }; + delete forwardHeaders['host']; + + const result = await this.routing.proxy( + service, + `/${path}`, + req.method, + forwardHeaders, + body, + ); + + res.status(result.status).json(result.data); + } + + /** + * Register or update a route at runtime. + */ + @Post('routes') + @HttpCode(201) + @ApiOperation({ summary: 'Register a new upstream route' }) + registerRoute(@Body() dto: RouteConfigDto): { message: string } { + this.routing.registerRoute({ + service: dto.service, + upstream: dto.upstream, + weight: dto.weight ?? 1, + cacheTtlSeconds: dto.cacheTtlSeconds ?? 0, + rateLimitPerMinute: dto.rateLimitPerMinute ?? 100, + }); + return { message: `Route "${dto.service}" registered` }; + } +} diff --git a/src/gateway/gateway.module.ts b/src/gateway/gateway.module.ts new file mode 100644 index 00000000..16fe6f0a --- /dev/null +++ b/src/gateway/gateway.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; +import { GatewayController } from './gateway.controller'; +import { GatewayRoutingService } from './services/gateway-routing.service'; +import { GatewayRateLimitGuard } from './guards/gateway-rate-limit.guard'; +import { RequestTransformInterceptor } from './interceptors/request-transform.interceptor'; +import { ResponseCacheInterceptor } from './interceptors/response-cache.interceptor'; + +@Module({ + imports: [HttpModule], + controllers: [GatewayController], + providers: [ + GatewayRoutingService, + GatewayRateLimitGuard, + RequestTransformInterceptor, + ResponseCacheInterceptor, + ], + exports: [GatewayRoutingService], +}) +export class GatewayModule {} diff --git a/src/gateway/gateway.spec.ts b/src/gateway/gateway.spec.ts new file mode 100644 index 00000000..f56e1aea --- /dev/null +++ b/src/gateway/gateway.spec.ts @@ -0,0 +1,199 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { HttpService } from '@nestjs/axios'; +import { NotFoundException } from '@nestjs/common'; +import { of } from 'rxjs'; +import { GatewayRoutingService } from './services/gateway-routing.service'; +import { GatewayRateLimitGuard } from './guards/gateway-rate-limit.guard'; +import { RequestTransformInterceptor } from './interceptors/request-transform.interceptor'; +import { ResponseCacheInterceptor } from './interceptors/response-cache.interceptor'; +import type { ExecutionContext, CallHandler } from '@nestjs/common'; + +// ─── GatewayRoutingService ──────────────────────────────────────────────────── + +describe('GatewayRoutingService', () => { + let service: GatewayRoutingService; + let http: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GatewayRoutingService, + { + provide: HttpService, + useValue: { request: jest.fn() }, + }, + ], + }).compile(); + + service = module.get(GatewayRoutingService); + http = module.get(HttpService); + }); + + it('returns a pre-seeded route', () => { + const route = service.getRoute('courses'); + expect(route.upstream).toBeDefined(); + expect(route.rateLimitPerMinute).toBeGreaterThan(0); + }); + + it('throws NotFoundException for unknown service', () => { + expect(() => service.getRoute('unknown-svc')).toThrow(NotFoundException); + }); + + it('registers a new route', () => { + service.registerRoute({ + service: 'payments', + upstream: 'http://payments:4000', + weight: 1, + cacheTtlSeconds: 0, + rateLimitPerMinute: 60, + }); + expect(service.getRoute('payments').upstream).toBe('http://payments:4000'); + }); + + it('proxies a request via HttpService', async () => { + (http.request as jest.Mock).mockReturnValue( + of({ status: 200, data: { ok: true }, headers: {} }), + ); + + const result = await service.proxy('courses', '/api/courses', 'GET', {}); + expect(result.status).toBe(200); + expect(result.data).toEqual({ ok: true }); + expect(result.cached).toBe(false); + }); +}); + +// ─── GatewayRateLimitGuard ──────────────────────────────────────────────────── + +describe('GatewayRateLimitGuard', () => { + let guard: GatewayRateLimitGuard; + let routing: jest.Mocked; + + const makeCtx = (service: string, ip = '127.0.0.1'): ExecutionContext => + ({ + switchToHttp: () => ({ + getRequest: () => ({ params: { service }, ip }), + }), + }) as unknown as ExecutionContext; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GatewayRateLimitGuard, + { + provide: GatewayRoutingService, + useValue: { + getRoute: jest.fn().mockReturnValue({ rateLimitPerMinute: 3 }), + }, + }, + ], + }).compile(); + + guard = module.get(GatewayRateLimitGuard); + routing = module.get(GatewayRoutingService); + }); + + it('allows requests within the limit', () => { + expect(guard.canActivate(makeCtx('courses'))).toBe(true); + expect(guard.canActivate(makeCtx('courses'))).toBe(true); + expect(guard.canActivate(makeCtx('courses'))).toBe(true); + }); + + it('throws TooManyRequestsException when limit exceeded', () => { + guard.canActivate(makeCtx('courses')); + guard.canActivate(makeCtx('courses')); + guard.canActivate(makeCtx('courses')); + expect(() => guard.canActivate(makeCtx('courses'))).toThrow(); + }); + + it('allows unknown service (defers to controller)', () => { + (routing.getRoute as jest.Mock).mockImplementation(() => { + throw new NotFoundException(); + }); + expect(guard.canActivate(makeCtx('unknown'))).toBe(true); + }); +}); + +// ─── RequestTransformInterceptor ───────────────────────────────────────────── + +describe('RequestTransformInterceptor', () => { + const interceptor = new RequestTransformInterceptor(); + + const makeCtx = (headers: Record = {}): ExecutionContext => + ({ + switchToHttp: () => ({ + getRequest: () => ({ headers, method: 'GET', path: '/test' }), + }), + }) as unknown as ExecutionContext; + + const makeHandler = (): CallHandler => ({ handle: () => of('response') }); + + it('injects x-correlation-id when absent', () => { + const headers: Record = {}; + interceptor.intercept(makeCtx(headers), makeHandler()); + expect(headers['x-correlation-id']).toBeDefined(); + }); + + it('preserves existing x-correlation-id', () => { + const headers = { 'x-correlation-id': 'my-id' }; + interceptor.intercept(makeCtx(headers), makeHandler()); + expect(headers['x-correlation-id']).toBe('my-id'); + }); + + it('strips hop-by-hop headers', () => { + const headers: Record = { connection: 'keep-alive', 'transfer-encoding': 'chunked' }; + interceptor.intercept(makeCtx(headers), makeHandler()); + expect(headers['connection']).toBeUndefined(); + expect(headers['transfer-encoding']).toBeUndefined(); + }); + + it('adds x-gateway-version header', () => { + const headers: Record = {}; + interceptor.intercept(makeCtx(headers), makeHandler()); + expect(headers['x-gateway-version']).toBe('1'); + }); +}); + +// ─── ResponseCacheInterceptor ───────────────────────────────────────────────── + +describe('ResponseCacheInterceptor', () => { + let interceptor: ResponseCacheInterceptor; + let cache: { get: jest.Mock; set: jest.Mock }; + let routing: jest.Mocked; + + const makeCtx = (method: string, service: string): ExecutionContext => + ({ + switchToHttp: () => ({ + getRequest: () => ({ method, params: { service }, path: `/api/${service}` }), + }), + }) as unknown as ExecutionContext; + + const makeHandler = (value = 'data'): CallHandler => ({ handle: () => of(value) }); + + beforeEach(() => { + cache = { get: jest.fn().mockResolvedValue(null), set: jest.fn().mockResolvedValue(undefined) }; + routing = { + getRoute: jest.fn().mockReturnValue({ cacheTtlSeconds: 60 }), + } as unknown as jest.Mocked; + interceptor = new ResponseCacheInterceptor(cache as never, routing); + }); + + it('skips caching for non-GET requests', async () => { + const obs = await interceptor.intercept(makeCtx('POST', 'courses'), makeHandler()); + obs.subscribe(); + expect(cache.get).not.toHaveBeenCalled(); + }); + + it('returns cached value on hit', async () => { + cache.get.mockResolvedValue('cached-data'); + const obs = await interceptor.intercept(makeCtx('GET', 'courses'), makeHandler()); + const result = await new Promise((r) => obs.subscribe(r)); + expect(result).toBe('cached-data'); + expect(cache.set).not.toHaveBeenCalled(); + }); + + it('stores response in cache on miss', async () => { + const obs = await interceptor.intercept(makeCtx('GET', 'courses'), makeHandler('fresh')); + await new Promise((r) => obs.subscribe(r)); + expect(cache.set).toHaveBeenCalledWith(expect.stringContaining('courses'), 'fresh', 60000); + }); +}); diff --git a/src/gateway/guards/gateway-rate-limit.guard.ts b/src/gateway/guards/gateway-rate-limit.guard.ts new file mode 100644 index 00000000..bf8b7de5 --- /dev/null +++ b/src/gateway/guards/gateway-rate-limit.guard.ts @@ -0,0 +1,57 @@ +import { + CanActivate, + ExecutionContext, + HttpException, + HttpStatus, + Injectable, + Logger, +} from '@nestjs/common'; +import type { Request } from 'express'; +import type { RateLimitState } from '../interfaces/gateway.interfaces'; +import { GatewayRoutingService } from '../services/gateway-routing.service'; + +@Injectable() +export class GatewayRateLimitGuard implements CanActivate { + private readonly logger = new Logger(GatewayRateLimitGuard.name); + private readonly store = new Map(); + + constructor(private readonly routing: GatewayRoutingService) {} + + canActivate(context: ExecutionContext): boolean { + const req = context.switchToHttp().getRequest(); + const rawService = req.params?.service; + const service = Array.isArray(rawService) ? rawService[0] : (rawService ?? 'default'); + const clientIp = (req.ip ?? 'unknown').replace(/^::ffff:/, ''); + + let route; + try { + route = this.routing.getRoute(service); + } catch { + // Unknown service — let the controller handle the 404 + return true; + } + + const limit = route.rateLimitPerMinute; + const key = `${service}:${clientIp}`; + const now = Date.now(); + const windowMs = 60_000; + + const state = this.store.get(key); + + if (!state || now > state.resetAt) { + this.store.set(key, { count: 1, resetAt: now + windowMs }); + return true; + } + + if (state.count >= limit) { + this.logger.warn(`Rate limit exceeded for ${key} (${state.count}/${limit})`); + throw new HttpException( + `Rate limit of ${limit} req/min exceeded for service "${service}"`, + HttpStatus.TOO_MANY_REQUESTS, + ); + } + + state.count += 1; + return true; + } +} diff --git a/src/gateway/interceptors/request-transform.interceptor.ts b/src/gateway/interceptors/request-transform.interceptor.ts new file mode 100644 index 00000000..9f6a1927 --- /dev/null +++ b/src/gateway/interceptors/request-transform.interceptor.ts @@ -0,0 +1,57 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + Logger, + NestInterceptor, +} from '@nestjs/common'; +import type { Request } from 'express'; +import { Observable } from 'rxjs'; +import { v4 as uuidv4 } from 'uuid'; + +/** + * Normalises inbound requests before they reach the gateway controller: + * - Injects a correlation-id header if absent + * - Strips hop-by-hop headers that must not be forwarded + * - Adds an X-Gateway-Version header + */ +@Injectable() +export class RequestTransformInterceptor implements NestInterceptor { + private readonly logger = new Logger(RequestTransformInterceptor.name); + + private static readonly HOP_BY_HOP = new Set([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailers', + 'transfer-encoding', + 'upgrade', + ]); + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const req = context.switchToHttp().getRequest(); + + // Assign correlation id + if (!req.headers['x-correlation-id']) { + req.headers['x-correlation-id'] = uuidv4(); + } + + // Remove hop-by-hop headers + for (const header of Object.keys(req.headers)) { + if (RequestTransformInterceptor.HOP_BY_HOP.has(header.toLowerCase())) { + delete req.headers[header]; + } + } + + // Tag the request as coming through the gateway + req.headers['x-gateway-version'] = '1'; + + this.logger.debug( + `[${req.headers['x-correlation-id']}] ${req.method} ${req.path}`, + ); + + return next.handle(); + } +} diff --git a/src/gateway/interceptors/response-cache.interceptor.ts b/src/gateway/interceptors/response-cache.interceptor.ts new file mode 100644 index 00000000..3dc32f96 --- /dev/null +++ b/src/gateway/interceptors/response-cache.interceptor.ts @@ -0,0 +1,67 @@ +import { + CallHandler, + ExecutionContext, + Inject, + Injectable, + Logger, + NestInterceptor, + Optional, +} from '@nestjs/common'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import type { Cache } from 'cache-manager'; +import type { Request } from 'express'; +import { Observable, of, tap } from 'rxjs'; +import { GatewayRoutingService } from '../services/gateway-routing.service'; + +/** + * Caches GET responses per service+path using the TTL configured on the route. + * Non-GET requests and routes with cacheTtlSeconds === 0 are skipped. + */ +@Injectable() +export class ResponseCacheInterceptor implements NestInterceptor { + private readonly logger = new Logger(ResponseCacheInterceptor.name); + + constructor( + @Optional() @Inject(CACHE_MANAGER) private readonly cache: Cache | null, + private readonly routing: GatewayRoutingService, + ) {} + + async intercept( + context: ExecutionContext, + next: CallHandler, + ): Promise> { + if (!this.cache) return next.handle(); + + const req = context.switchToHttp().getRequest(); + if (req.method !== 'GET') return next.handle(); + + const rawService = req.params?.service; + const service = Array.isArray(rawService) ? rawService[0] : rawService; + if (!service) return next.handle(); + + let route; + try { + route = this.routing.getRoute(service); + } catch { + return next.handle(); + } + + const ttl = route.cacheTtlSeconds; + if (!ttl) return next.handle(); + + const cacheKey = `gw:${service}:${req.path}`; + + const cached = await this.cache.get(cacheKey); + if (cached !== undefined && cached !== null) { + this.logger.debug(`Cache hit: ${cacheKey}`); + return of(cached); + } + + return next.handle().pipe( + tap(async (response) => { + await this.cache!.set(cacheKey, response, ttl * 1000); + this.logger.debug(`Cached: ${cacheKey} (${ttl}s)`); + }), + ); + } +} diff --git a/src/gateway/interfaces/gateway.interfaces.ts b/src/gateway/interfaces/gateway.interfaces.ts new file mode 100644 index 00000000..ea6fd2c6 --- /dev/null +++ b/src/gateway/interfaces/gateway.interfaces.ts @@ -0,0 +1,19 @@ +export interface RouteConfig { + service: string; + upstream: string; + weight: number; + cacheTtlSeconds: number; + rateLimitPerMinute: number; +} + +export interface ProxyResponse { + status: number; + data: T; + headers: Record; + cached: boolean; +} + +export interface RateLimitState { + count: number; + resetAt: number; +} diff --git a/src/gateway/services/gateway-routing.service.ts b/src/gateway/services/gateway-routing.service.ts new file mode 100644 index 00000000..74787766 --- /dev/null +++ b/src/gateway/services/gateway-routing.service.ts @@ -0,0 +1,61 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import { firstValueFrom } from 'rxjs'; +import type { RouteConfig, ProxyResponse } from '../interfaces/gateway.interfaces'; + +@Injectable() +export class GatewayRoutingService { + private readonly logger = new Logger(GatewayRoutingService.name); + + private readonly routes = new Map([ + [ + 'courses', + { service: 'courses', upstream: 'http://localhost:3000', weight: 1, cacheTtlSeconds: 60, rateLimitPerMinute: 100 }, + ], + [ + 'users', + { service: 'users', upstream: 'http://localhost:3000', weight: 1, cacheTtlSeconds: 30, rateLimitPerMinute: 200 }, + ], + [ + 'analytics', + { service: 'analytics', upstream: 'http://localhost:3000', weight: 1, cacheTtlSeconds: 120, rateLimitPerMinute: 50 }, + ], + ]); + + constructor(private readonly http: HttpService) {} + + getRoute(service: string): RouteConfig { + const route = this.routes.get(service); + if (!route) throw new NotFoundException(`No route configured for service: ${service}`); + return route; + } + + registerRoute(config: RouteConfig): void { + this.routes.set(config.service, config); + this.logger.log(`Route registered: ${config.service} -> ${config.upstream}`); + } + + async proxy( + service: string, + path: string, + method: string, + headers: Record, + body?: unknown, + ): Promise> { + const route = this.getRoute(service); + const url = `${route.upstream}${path}`; + + this.logger.debug(`Proxying ${method} ${url}`); + + const response = await firstValueFrom( + this.http.request({ method, url, headers, data: body }), + ); + + return { + status: response.status, + data: response.data, + headers: response.headers as Record, + cached: false, + }; + } +}