diff --git a/src/app.module.ts b/src/app.module.ts index 81dd098b..0416d90a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { MiddlewareConsumer, Module, NestModule, RequestMethod } from '@nestjs/common'; import { APP_GUARD, APP_INTERCEPTOR, APP_FILTER } from '@nestjs/core'; import { ConfigModule } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; @@ -21,6 +21,7 @@ import { IncidentManagementModule } from './incident-management/incident-managem import { MonitoringModule } from './monitoring/monitoring.module'; import { RequestTimeoutInterceptor } from './common/interceptors/request-timeout.interceptor'; import { GlobalExceptionFilter } from './common/interceptors/global-exception.filter'; +import { ApiVersionMiddleware } from './common/middleware/api-version.middleware'; import { DeepLinkModule } from './deep-link/deep-link.module'; import { InvoicesModule } from './payments/invoices/invoices.module'; import { ReportingModule } from './payments/reporting/reporting.module'; @@ -75,4 +76,10 @@ const featureFlags = loadFeatureFlags(); { provide: APP_FILTER, useClass: GlobalExceptionFilter }, ], }) -export class AppModule {} +export class AppModule implements NestModule { + configure(consumer: MiddlewareConsumer): void { + consumer + .apply(ApiVersionMiddleware) + .forRoutes({ path: 'v*', method: RequestMethod.ALL }); + } +} diff --git a/src/common/middleware/api-version.middleware.spec.ts b/src/common/middleware/api-version.middleware.spec.ts new file mode 100644 index 00000000..cfb010ad --- /dev/null +++ b/src/common/middleware/api-version.middleware.spec.ts @@ -0,0 +1,149 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { ApiVersionMiddleware } from './api-version.middleware'; +import { Request, Response } from 'express'; + +function buildConfigService( + overrides: Record = {}, +): jest.Mocked { + const defaults: Record = { + SUNSET_VERSIONS: 'v1:2024-01-01', + DEPRECATED_VERSIONS: 'v2:2025-06-01', + API_MIGRATION_DOCS_URL: 'https://docs.example.com/migration', + ...overrides, + }; + return { + get: jest.fn((key: string, fallback?: string) => defaults[key] ?? fallback ?? ''), + } as unknown as jest.Mocked; +} + +function buildRes(): jest.Mocked { + const res: Partial> = { + setHeader: jest.fn().mockReturnThis(), + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + return res as jest.Mocked; +} + +describe('ApiVersionMiddleware', () => { + let middleware: ApiVersionMiddleware; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ApiVersionMiddleware, + { provide: ConfigService, useValue: buildConfigService() }, + ], + }).compile(); + + middleware = module.get(ApiVersionMiddleware); + }); + + describe('extractVersion', () => { + it('returns null for non-versioned paths', () => { + expect(middleware.extractVersion('/users')).toBeNull(); + expect(middleware.extractVersion('/')).toBeNull(); + expect(middleware.extractVersion('/health')).toBeNull(); + }); + + it('extracts version from path prefix', () => { + expect(middleware.extractVersion('/v1/users')).toBe('v1'); + expect(middleware.extractVersion('/v2/courses')).toBe('v2'); + expect(middleware.extractVersion('/V3/items')).toBe('v3'); + }); + + it('extracts version from bare version path', () => { + expect(middleware.extractVersion('/v1')).toBe('v1'); + }); + }); + + describe('sunset versions', () => { + it('returns 410 Gone for a sunset version', () => { + const req = { path: '/v1/users', method: 'GET' } as Request; + const res = buildRes(); + const next = jest.fn(); + + middleware.use(req, res, next); + + expect(res.status).toHaveBeenCalledWith(410); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: 410, + error: 'Gone', + }), + ); + expect(next).not.toHaveBeenCalled(); + }); + + it('sets Sunset and Link response headers', () => { + const req = { path: '/v1/courses', method: 'GET' } as Request; + const res = buildRes(); + + middleware.use(req, res, jest.fn()); + + expect(res.setHeader).toHaveBeenCalledWith('Sunset', expect.any(String)); + expect(res.setHeader).toHaveBeenCalledWith( + 'Link', + expect.stringContaining('successor-version'), + ); + }); + }); + + describe('deprecated versions', () => { + it('calls next() for deprecated (grace-period) versions', () => { + const req = { path: '/v2/users', method: 'GET' } as Request; + const res = buildRes(); + const next = jest.fn(); + + middleware.use(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('sets Deprecation and Sunset headers for deprecated versions', () => { + const req = { path: '/v2/courses', method: 'GET' } as Request; + const res = buildRes(); + + middleware.use(req, res, jest.fn()); + + expect(res.setHeader).toHaveBeenCalledWith('Deprecation', 'true'); + expect(res.setHeader).toHaveBeenCalledWith('Sunset', expect.any(String)); + }); + }); + + describe('non-versioned paths', () => { + it('passes through without touching response headers', () => { + const req = { path: '/health', method: 'GET' } as Request; + const res = buildRes(); + const next = jest.fn(); + + middleware.use(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.setHeader).not.toHaveBeenCalled(); + }); + }); + + describe('empty configuration', () => { + it('passes all requests when no versions are configured', async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ApiVersionMiddleware, + { provide: ConfigService, useValue: buildConfigService({ SUNSET_VERSIONS: '', DEPRECATED_VERSIONS: '' }) }, + ], + }).compile(); + + const mw = module.get(ApiVersionMiddleware); + const req = { path: '/v1/users', method: 'GET' } as Request; + const res = buildRes(); + const next = jest.fn(); + + mw.use(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/common/middleware/api-version.middleware.ts b/src/common/middleware/api-version.middleware.ts new file mode 100644 index 00000000..5ab59e61 --- /dev/null +++ b/src/common/middleware/api-version.middleware.ts @@ -0,0 +1,132 @@ +import { Injectable, Logger, NestMiddleware } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { NextFunction, Request, Response } from 'express'; + +/** + * Enforces API versioning policy at runtime. + * + * - Requests to **sunset** versions receive `410 Gone` with a migration link. + * - Requests to **deprecated** (grace-period) versions pass through but carry + * `Deprecation: true` and `Sunset: ` response headers so clients + * can discover the end-of-life date automatically. + * - Requests to non-versioned paths are unaffected. + * + * Configuration is driven by two environment variables: + * + * | Variable | Format | Example | + * |-----------------------|-------------------------------------|----------------------------------| + * | `SUNSET_VERSIONS` | Comma-separated `version:ISO-date` | `v1:2024-01-01,v2:2024-06-01` | + * | `DEPRECATED_VERSIONS` | Comma-separated `version:ISO-date` | `v3:2025-01-01` | + * + * The `version` token is matched against the **first path segment** of the URL, + * e.g. `/v1/users` → version token `v1`. + */ +@Injectable() +export class ApiVersionMiddleware implements NestMiddleware { + private readonly logger = new Logger(ApiVersionMiddleware.name); + + /** Map of version → sunset date for fully retired versions. */ + private readonly sunsetVersions: Map; + /** Map of version → sunset date for deprecated-but-still-active versions. */ + private readonly deprecatedVersions: Map; + /** URL to migration documentation returned in 410 responses. */ + private readonly migrationDocsUrl: string; + + constructor(private readonly configService: ConfigService) { + this.sunsetVersions = this.parseVersionDates( + this.configService.get('SUNSET_VERSIONS', ''), + ); + this.deprecatedVersions = this.parseVersionDates( + this.configService.get('DEPRECATED_VERSIONS', ''), + ); + this.migrationDocsUrl = this.configService.get( + 'API_MIGRATION_DOCS_URL', + 'https://docs.teachlink.io/api/migration', + ); + } + + use(req: Request, res: Response, next: NextFunction): void { + const version = this.extractVersion(req.path); + + if (!version) { + return next(); + } + + if (this.sunsetVersions.has(version)) { + const sunsetDate = this.sunsetVersions.get(version)!; + this.logger.warn(`Rejected request to sunset version ${version}: ${req.method} ${req.path}`); + + res.setHeader('Sunset', sunsetDate.toUTCString()); + res.setHeader('Link', `<${this.migrationDocsUrl}>; rel="successor-version"`); + res.status(410).json({ + statusCode: 410, + error: 'Gone', + message: `API version ${version} has been sunset as of ${sunsetDate.toISOString()}. Please migrate to a supported version. Migration guide: ${this.migrationDocsUrl}`, + sunsetDate: sunsetDate.toISOString(), + migrationDocs: this.migrationDocsUrl, + }); + return; + } + + if (this.deprecatedVersions.has(version)) { + const sunsetDate = this.deprecatedVersions.get(version)!; + this.logger.warn( + `Request to deprecated version ${version} (sunset: ${sunsetDate.toISOString()}): ${req.method} ${req.path}`, + ); + + res.setHeader('Deprecation', 'true'); + res.setHeader('Sunset', sunsetDate.toUTCString()); + res.setHeader('Link', `<${this.migrationDocsUrl}>; rel="successor-version"`); + } + + next(); + } + + /** + * Extracts the version token from the first path segment. + * Matches tokens like `v1`, `v2`, `v10`, etc. + * Returns `null` when the path does not start with a versioned segment. + */ + extractVersion(path: string): string | null { + const match = /^\/?(v\d+)\//i.exec(path) ?? /^\/?(v\d+)$/i.exec(path); + return match ? match[1].toLowerCase() : null; + } + + /** + * Parses a comma-separated list of `version:ISO-date` pairs into a Map. + * Silently skips malformed entries and logs a warning. + * + * @example `"v1:2024-01-01,v2:2024-06-01"` → Map { "v1" → Date, "v2" → Date } + */ + private parseVersionDates(raw: string): Map { + const result = new Map(); + + if (!raw || !raw.trim()) { + return result; + } + + for (const entry of raw.split(',')) { + const trimmed = entry.trim(); + if (!trimmed) continue; + + const colonIndex = trimmed.indexOf(':'); + if (colonIndex === -1) { + this.logger.warn(`Skipping malformed version entry (missing colon): "${trimmed}"`); + continue; + } + + const version = trimmed.slice(0, colonIndex).trim().toLowerCase(); + const dateStr = trimmed.slice(colonIndex + 1).trim(); + const parsed = new Date(dateStr); + + if (isNaN(parsed.getTime())) { + this.logger.warn(`Skipping malformed version entry (invalid date): "${trimmed}"`); + continue; + } + + result.set(version, parsed); + } + + return result; + } +}