Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import { SearchModule } from './search/search.module';
import { AnalyticsModule } from './analytics/analytics.module';

import { MessagingModule } from './messaging/messaging.module';

Check warning on line 11 in src/app.module.ts

View workflow job for this annotation

GitHub Actions / Quality Gates (lint)

'MessagingModule' is defined but never used. Allowed unused vars must match /^_/u

Check warning on line 11 in src/app.module.ts

View workflow job for this annotation

GitHub Actions / ESLint

'MessagingModule' is defined but never used. Allowed unused vars must match /^_/u
import { IndexOptimizationModule } from './database/index-optimization/index-optimization.module';
import { RateLimitingModule } from './rate-limiting/rate-limiting.module';
import { QuotaGuard } from './rate-limiting/guards/quota.guard';
Expand All @@ -30,6 +30,7 @@
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();

Expand Down Expand Up @@ -61,6 +62,9 @@

// ✅ courses module with enrollment and prerequisite enforcement
CoursesModule,

// ✅ API gateway: routing, rate limiting, transformation, caching
GatewayModule,
],
controllers: [AppController],
providers: [
Expand All @@ -68,3 +72,3 @@
{ provide: APP_INTERCEPTOR, useClass: RequestTimeoutInterceptor },
],
})
Expand Down
43 changes: 43 additions & 0 deletions src/gateway/dto/gateway.dto.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;

@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;
}
72 changes: 72 additions & 0 deletions src/gateway/gateway.controller.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
const forwardHeaders = { ...(req.headers as Record<string, string>) };
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` };
}
}
20 changes: 20 additions & 0 deletions src/gateway/gateway.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
199 changes: 199 additions & 0 deletions src/gateway/gateway.spec.ts
Original file line number Diff line number Diff line change
@@ -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<HttpService>;

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<GatewayRoutingService>;

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<string, string> = {}): 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<string, string> = {};
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<string, string> = { 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<string, string> = {};
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<GatewayRoutingService>;

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<GatewayRoutingService>;
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);
});
});
Loading
Loading