Skip to content
Open
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
11 changes: 9 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 { 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,10 @@
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
],
})
export class AppModule {}
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer

Check failure on line 81 in src/app.module.ts

View workflow job for this annotation

GitHub Actions / validate

Replace `⏎······.apply(ApiVersionMiddleware)⏎······` with `.apply(ApiVersionMiddleware)`
.apply(ApiVersionMiddleware)
.forRoutes({ path: 'v*', method: RequestMethod.ALL });
}
}
149 changes: 149 additions & 0 deletions src/common/middleware/api-version.middleware.spec.ts
Original file line number Diff line number Diff line change
@@ -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(

Check failure on line 6 in src/common/middleware/api-version.middleware.spec.ts

View workflow job for this annotation

GitHub Actions / validate

Replace `⏎··overrides:·Record<string,·string>·=·{},⏎` with `overrides:·Record<string,·string>·=·{}`
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: [

Check failure on line 34 in src/common/middleware/api-version.middleware.spec.ts

View workflow job for this annotation

GitHub Actions / validate

Replace `⏎········ApiVersionMiddleware,⏎········{·provide:·ConfigService,·useValue:·buildConfigService()·},⏎······` with `ApiVersionMiddleware,·{·provide:·ConfigService,·useValue:·buildConfigService()·}`
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: '' }) },

Check failure on line 134 in src/common/middleware/api-version.middleware.spec.ts

View workflow job for this annotation

GitHub Actions / validate

Replace `·provide:·ConfigService,·useValue:·buildConfigService({·SUNSET_VERSIONS:·'',·DEPRECATED_VERSIONS:·''·})` with `⏎············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