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
9 changes: 7 additions & 2 deletions src/app.module.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -75,4 +76,8 @@ 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 });
}
}
147 changes: 147 additions & 0 deletions src/common/middleware/api-version.middleware.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
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<string, string> = {}): jest.Mocked<ConfigService> {
const defaults: Record<string, string> = {
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<ConfigService>;
}

function buildRes(): jest.Mocked<Response> {
const res: Partial<jest.Mocked<Response>> = {
setHeader: jest.fn().mockReturnThis(),
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
};
return res as jest.Mocked<Response>;
}

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();
});
});
});
132 changes: 132 additions & 0 deletions src/common/middleware/api-version.middleware.ts
Original file line number Diff line number Diff line change
@@ -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: <ISO date>` 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<string, Date>;
/** Map of version → sunset date for deprecated-but-still-active versions. */
private readonly deprecatedVersions: Map<string, Date>;
/** URL to migration documentation returned in 410 responses. */
private readonly migrationDocsUrl: string;

constructor(private readonly configService: ConfigService) {
this.sunsetVersions = this.parseVersionDates(
this.configService.get<string>('SUNSET_VERSIONS', ''),
);
this.deprecatedVersions = this.parseVersionDates(
this.configService.get<string>('DEPRECATED_VERSIONS', ''),
);
this.migrationDocsUrl = this.configService.get<string>(
'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<string, Date> {
const result = new Map<string, Date>();

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