Skip to content

Commit 39b726c

Browse files
Merge pull request #914 from Xaxxoo/feat/875-api-version-middleware
feat: add API versioning enforcement middleware
2 parents 25e2fc4 + 14bb18e commit 39b726c

3 files changed

Lines changed: 286 additions & 2 deletions

File tree

src/app.module.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Module } from '@nestjs/common';
1+
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from '@nestjs/common';
22
import { APP_GUARD, APP_INTERCEPTOR, APP_FILTER } from '@nestjs/core';
33
import { ConfigModule } from '@nestjs/config';
44
import { TypeOrmModule } from '@nestjs/typeorm';
@@ -21,6 +21,7 @@ import { IncidentManagementModule } from './incident-management/incident-managem
2121
import { MonitoringModule } from './monitoring/monitoring.module';
2222
import { RequestTimeoutInterceptor } from './common/interceptors/request-timeout.interceptor';
2323
import { GlobalExceptionFilter } from './common/interceptors/global-exception.filter';
24+
import { ApiVersionMiddleware } from './common/middleware/api-version.middleware';
2425
import { DeepLinkModule } from './deep-link/deep-link.module';
2526
import { InvoicesModule } from './payments/invoices/invoices.module';
2627
import { ReportingModule } from './payments/reporting/reporting.module';
@@ -75,4 +76,8 @@ const featureFlags = loadFeatureFlags();
7576
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
7677
],
7778
})
78-
export class AppModule {}
79+
export class AppModule implements NestModule {
80+
configure(consumer: MiddlewareConsumer): void {
81+
consumer.apply(ApiVersionMiddleware).forRoutes({ path: 'v*', method: RequestMethod.ALL });
82+
}
83+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { ConfigService } from '@nestjs/config';
3+
import { ApiVersionMiddleware } from './api-version.middleware';
4+
import { Request, Response } from 'express';
5+
6+
function buildConfigService(overrides: Record<string, string> = {}): jest.Mocked<ConfigService> {
7+
const defaults: Record<string, string> = {
8+
SUNSET_VERSIONS: 'v1:2024-01-01',
9+
DEPRECATED_VERSIONS: 'v2:2025-06-01',
10+
API_MIGRATION_DOCS_URL: 'https://docs.example.com/migration',
11+
...overrides,
12+
};
13+
return {
14+
get: jest.fn((key: string, fallback?: string) => defaults[key] ?? fallback ?? ''),
15+
} as unknown as jest.Mocked<ConfigService>;
16+
}
17+
18+
function buildRes(): jest.Mocked<Response> {
19+
const res: Partial<jest.Mocked<Response>> = {
20+
setHeader: jest.fn().mockReturnThis(),
21+
status: jest.fn().mockReturnThis(),
22+
json: jest.fn().mockReturnThis(),
23+
};
24+
return res as jest.Mocked<Response>;
25+
}
26+
27+
describe('ApiVersionMiddleware', () => {
28+
let middleware: ApiVersionMiddleware;
29+
30+
beforeEach(async () => {
31+
const module: TestingModule = await Test.createTestingModule({
32+
providers: [ApiVersionMiddleware, { provide: ConfigService, useValue: buildConfigService() }],
33+
}).compile();
34+
35+
middleware = module.get(ApiVersionMiddleware);
36+
});
37+
38+
describe('extractVersion', () => {
39+
it('returns null for non-versioned paths', () => {
40+
expect(middleware.extractVersion('/users')).toBeNull();
41+
expect(middleware.extractVersion('/')).toBeNull();
42+
expect(middleware.extractVersion('/health')).toBeNull();
43+
});
44+
45+
it('extracts version from path prefix', () => {
46+
expect(middleware.extractVersion('/v1/users')).toBe('v1');
47+
expect(middleware.extractVersion('/v2/courses')).toBe('v2');
48+
expect(middleware.extractVersion('/V3/items')).toBe('v3');
49+
});
50+
51+
it('extracts version from bare version path', () => {
52+
expect(middleware.extractVersion('/v1')).toBe('v1');
53+
});
54+
});
55+
56+
describe('sunset versions', () => {
57+
it('returns 410 Gone for a sunset version', () => {
58+
const req = { path: '/v1/users', method: 'GET' } as Request;
59+
const res = buildRes();
60+
const next = jest.fn();
61+
62+
middleware.use(req, res, next);
63+
64+
expect(res.status).toHaveBeenCalledWith(410);
65+
expect(res.json).toHaveBeenCalledWith(
66+
expect.objectContaining({
67+
statusCode: 410,
68+
error: 'Gone',
69+
}),
70+
);
71+
expect(next).not.toHaveBeenCalled();
72+
});
73+
74+
it('sets Sunset and Link response headers', () => {
75+
const req = { path: '/v1/courses', method: 'GET' } as Request;
76+
const res = buildRes();
77+
78+
middleware.use(req, res, jest.fn());
79+
80+
expect(res.setHeader).toHaveBeenCalledWith('Sunset', expect.any(String));
81+
expect(res.setHeader).toHaveBeenCalledWith(
82+
'Link',
83+
expect.stringContaining('successor-version'),
84+
);
85+
});
86+
});
87+
88+
describe('deprecated versions', () => {
89+
it('calls next() for deprecated (grace-period) versions', () => {
90+
const req = { path: '/v2/users', method: 'GET' } as Request;
91+
const res = buildRes();
92+
const next = jest.fn();
93+
94+
middleware.use(req, res, next);
95+
96+
expect(next).toHaveBeenCalled();
97+
expect(res.status).not.toHaveBeenCalled();
98+
});
99+
100+
it('sets Deprecation and Sunset headers for deprecated versions', () => {
101+
const req = { path: '/v2/courses', method: 'GET' } as Request;
102+
const res = buildRes();
103+
104+
middleware.use(req, res, jest.fn());
105+
106+
expect(res.setHeader).toHaveBeenCalledWith('Deprecation', 'true');
107+
expect(res.setHeader).toHaveBeenCalledWith('Sunset', expect.any(String));
108+
});
109+
});
110+
111+
describe('non-versioned paths', () => {
112+
it('passes through without touching response headers', () => {
113+
const req = { path: '/health', method: 'GET' } as Request;
114+
const res = buildRes();
115+
const next = jest.fn();
116+
117+
middleware.use(req, res, next);
118+
119+
expect(next).toHaveBeenCalled();
120+
expect(res.setHeader).not.toHaveBeenCalled();
121+
});
122+
});
123+
124+
describe('empty configuration', () => {
125+
it('passes all requests when no versions are configured', async () => {
126+
const module: TestingModule = await Test.createTestingModule({
127+
providers: [
128+
ApiVersionMiddleware,
129+
{
130+
provide: ConfigService,
131+
useValue: buildConfigService({ SUNSET_VERSIONS: '', DEPRECATED_VERSIONS: '' }),
132+
},
133+
],
134+
}).compile();
135+
136+
const mw = module.get(ApiVersionMiddleware);
137+
const req = { path: '/v1/users', method: 'GET' } as Request;
138+
const res = buildRes();
139+
const next = jest.fn();
140+
141+
mw.use(req, res, next);
142+
143+
expect(next).toHaveBeenCalled();
144+
expect(res.status).not.toHaveBeenCalled();
145+
});
146+
});
147+
});
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { Injectable, Logger, NestMiddleware } from '@nestjs/common';
2+
import { ConfigService } from '@nestjs/config';
3+
import { NextFunction, Request, Response } from 'express';
4+
5+
/**
6+
* Enforces API versioning policy at runtime.
7+
*
8+
* - Requests to **sunset** versions receive `410 Gone` with a migration link.
9+
* - Requests to **deprecated** (grace-period) versions pass through but carry
10+
* `Deprecation: true` and `Sunset: <ISO date>` response headers so clients
11+
* can discover the end-of-life date automatically.
12+
* - Requests to non-versioned paths are unaffected.
13+
*
14+
* Configuration is driven by two environment variables:
15+
*
16+
* | Variable | Format | Example |
17+
* |-----------------------|-------------------------------------|----------------------------------|
18+
* | `SUNSET_VERSIONS` | Comma-separated `version:ISO-date` | `v1:2024-01-01,v2:2024-06-01` |
19+
* | `DEPRECATED_VERSIONS` | Comma-separated `version:ISO-date` | `v3:2025-01-01` |
20+
*
21+
* The `version` token is matched against the **first path segment** of the URL,
22+
* e.g. `/v1/users` → version token `v1`.
23+
*/
24+
@Injectable()
25+
export class ApiVersionMiddleware implements NestMiddleware {
26+
private readonly logger = new Logger(ApiVersionMiddleware.name);
27+
28+
/** Map of version → sunset date for fully retired versions. */
29+
private readonly sunsetVersions: Map<string, Date>;
30+
/** Map of version → sunset date for deprecated-but-still-active versions. */
31+
private readonly deprecatedVersions: Map<string, Date>;
32+
/** URL to migration documentation returned in 410 responses. */
33+
private readonly migrationDocsUrl: string;
34+
35+
constructor(private readonly configService: ConfigService) {
36+
this.sunsetVersions = this.parseVersionDates(
37+
this.configService.get<string>('SUNSET_VERSIONS', ''),
38+
);
39+
this.deprecatedVersions = this.parseVersionDates(
40+
this.configService.get<string>('DEPRECATED_VERSIONS', ''),
41+
);
42+
this.migrationDocsUrl = this.configService.get<string>(
43+
'API_MIGRATION_DOCS_URL',
44+
'https://docs.teachlink.io/api/migration',
45+
);
46+
}
47+
48+
use(req: Request, res: Response, next: NextFunction): void {
49+
const version = this.extractVersion(req.path);
50+
51+
if (!version) {
52+
return next();
53+
}
54+
55+
if (this.sunsetVersions.has(version)) {
56+
const sunsetDate = this.sunsetVersions.get(version)!;
57+
this.logger.warn(`Rejected request to sunset version ${version}: ${req.method} ${req.path}`);
58+
59+
res.setHeader('Sunset', sunsetDate.toUTCString());
60+
res.setHeader('Link', `<${this.migrationDocsUrl}>; rel="successor-version"`);
61+
res.status(410).json({
62+
statusCode: 410,
63+
error: 'Gone',
64+
message: `API version ${version} has been sunset as of ${sunsetDate.toISOString()}. Please migrate to a supported version. Migration guide: ${this.migrationDocsUrl}`,
65+
sunsetDate: sunsetDate.toISOString(),
66+
migrationDocs: this.migrationDocsUrl,
67+
});
68+
return;
69+
}
70+
71+
if (this.deprecatedVersions.has(version)) {
72+
const sunsetDate = this.deprecatedVersions.get(version)!;
73+
this.logger.warn(
74+
`Request to deprecated version ${version} (sunset: ${sunsetDate.toISOString()}): ${req.method} ${req.path}`,
75+
);
76+
77+
res.setHeader('Deprecation', 'true');
78+
res.setHeader('Sunset', sunsetDate.toUTCString());
79+
res.setHeader('Link', `<${this.migrationDocsUrl}>; rel="successor-version"`);
80+
}
81+
82+
next();
83+
}
84+
85+
/**
86+
* Extracts the version token from the first path segment.
87+
* Matches tokens like `v1`, `v2`, `v10`, etc.
88+
* Returns `null` when the path does not start with a versioned segment.
89+
*/
90+
extractVersion(path: string): string | null {
91+
const match = /^\/?(v\d+)\//i.exec(path) ?? /^\/?(v\d+)$/i.exec(path);
92+
return match ? match[1].toLowerCase() : null;
93+
}
94+
95+
/**
96+
* Parses a comma-separated list of `version:ISO-date` pairs into a Map.
97+
* Silently skips malformed entries and logs a warning.
98+
*
99+
* @example `"v1:2024-01-01,v2:2024-06-01"` → Map { "v1" → Date, "v2" → Date }
100+
*/
101+
private parseVersionDates(raw: string): Map<string, Date> {
102+
const result = new Map<string, Date>();
103+
104+
if (!raw || !raw.trim()) {
105+
return result;
106+
}
107+
108+
for (const entry of raw.split(',')) {
109+
const trimmed = entry.trim();
110+
if (!trimmed) continue;
111+
112+
const colonIndex = trimmed.indexOf(':');
113+
if (colonIndex === -1) {
114+
this.logger.warn(`Skipping malformed version entry (missing colon): "${trimmed}"`);
115+
continue;
116+
}
117+
118+
const version = trimmed.slice(0, colonIndex).trim().toLowerCase();
119+
const dateStr = trimmed.slice(colonIndex + 1).trim();
120+
const parsed = new Date(dateStr);
121+
122+
if (isNaN(parsed.getTime())) {
123+
this.logger.warn(`Skipping malformed version entry (invalid date): "${trimmed}"`);
124+
continue;
125+
}
126+
127+
result.set(version, parsed);
128+
}
129+
130+
return result;
131+
}
132+
}

0 commit comments

Comments
 (0)