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
21 changes: 20 additions & 1 deletion backend/src/api/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ import { compressionMiddleware } from "./middleware/compression";
import { requestId } from "./middleware/requestId";
import { requestLogger } from "./middleware/requestLogger";
import { errorHandler } from "./middleware/errorHandler";
import { versioningMiddleware } from "./middleware/versioning";
import { createV1TasksRouter } from "./routes/v1/tasks";
import { createV2TasksRouter } from "./routes/v2/tasks";
import { createLogger } from "../utils/logger";
import { createTaskDb, getTaskDb } from "../db/tasks";
import { createHeartbeatService, type HeartbeatServiceOptions } from "../services/heartbeat";
Expand Down Expand Up @@ -98,6 +101,7 @@ export function createApp(opts: AppOptions = {}): {
app.use(createCorsMiddleware());
app.use(requestId);
app.use(requestLogger);
app.use(versioningMiddleware);

// ── Response compression ────────────────────────────────────────────────────
// Applied early so that all downstream route handlers benefit automatically.
Expand Down Expand Up @@ -135,7 +139,22 @@ export function createApp(opts: AppOptions = {}): {
});

// ── Task routes ────────────────────────────────────────────────────────────
app.use("/api/tasks", createTasksRouter(dispatch, releasePayment));
// Create version-specific routers
const v1TasksRouter = createV1TasksRouter(dispatch, releasePayment);
const v2TasksRouter = createV2TasksRouter(dispatch, releasePayment);

// Version-specific task routing based on negotiated API version
app.use("/api/tasks", (req, res, next) => {
const apiVersion = res.locals.apiVersion || "1.0";

// Route to version-specific handler based on negotiated version
if (apiVersion.startsWith("1.")) {
return v1TasksRouter(req, res, next);
} else {
// Default to v2 for version 2.0 and above
return v2TasksRouter(req, res, next);
}
});

// ── Payment reconciliation routes ──────────────────────────────────────────
app.use("/api/reconciliation", createReconciliationRouter(opts.reconciliation));
Expand Down
184 changes: 184 additions & 0 deletions backend/src/api/middleware/versioning.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { Request, Response, NextFunction } from 'express';
import { versioningMiddleware, parseVersion, compareVersions } from './versioning';
import { loadConfig } from '../../config';

// Mock Express request/response
const mockRequest = (headers: Record<string, string> = {}): Partial<Request> => ({
headers: headers as any,
});

const mockResponse = (): Partial<Response> => {
const res: Partial<Response> = {
setHeader: jest.fn(),
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
locals: { apiVersion: undefined },
};
return res;
};

const mockNext: NextFunction = jest.fn();

describe('versioningMiddleware', () => {
beforeEach(() => {
// Reset mocks before each test
jest.clearAllMocks();

// Set default config for testing
process.env.API_LATEST_VERSION = '2.0';
process.env.API_SUPPORTED_VERSIONS = '1.0,1.1,2.0';
process.env.API_DEFAULT_VERSION = '1.0';
process.env.API_V1_SUNSET_DATE = '2024-12-31';

// Reload config to pick up env changes
loadConfig();
});

afterEach(() => {
// Clean up env vars
delete process.env.API_LATEST_VERSION;
delete process.env.API_SUPPORTED_VERSIONS;
delete process.env.API_DEFAULT_VERSION;
delete process.env.API_V1_SUNSET_DATE;
});

it('should default to configured default version when API-Version header is omitted', () => {
const req = mockRequest();
const res = mockResponse();

versioningMiddleware(req as Request, res as Response, mockNext);

expect(res.locals?.apiVersion).toBe('1.0');
expect(res.setHeader).toHaveBeenCalledWith('X-API-Version', '1.0');
expect(res.setHeader).toHaveBeenCalledWith('Deprecation', 'true');
expect(res.setHeader).toHaveBeenCalledWith('Sunset', '2024-12-31');
expect(mockNext).toHaveBeenCalled();
});

it('should use client-specified version when API-Version header is provided', () => {
const req = mockRequest({ 'api-version': '1.1' });
const res = mockResponse();

versioningMiddleware(req as Request, res as Response, mockNext);

expect(res.locals?.apiVersion).toBe('1.1');
expect(res.setHeader).toHaveBeenCalledWith('X-API-Version', '1.1');
expect(res.setHeader).toHaveBeenCalledWith('Deprecation', 'true');
expect(res.setHeader).toHaveBeenCalledWith('Sunset', '2024-12-31');
expect(mockNext).toHaveBeenCalled();
});

it('should reject unsupported version with 400 error', () => {
const req = mockRequest({ 'api-version': '3.0' });
const res = mockResponse();

versioningMiddleware(req as Request, res as Response, mockNext);

expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
error: {
message: expect.stringContaining('Unsupported API version: "3.0"'),
code: 'UNSUPPORTED_API_VERSION',
supportedVersions: ['1.0', '1.1', '2.0'],
},
});
expect(mockNext).not.toHaveBeenCalled();
});

it('should reject invalid version format with 400 error', () => {
const req = mockRequest({ 'api-version': 'invalid' });
const res = mockResponse();

versioningMiddleware(req as Request, res as Response, mockNext);

expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
error: {
message: expect.stringContaining('Unsupported API version: "invalid"'),
code: 'UNSUPPORTED_API_VERSION',
supportedVersions: ['1.0', '1.1', '2.0'],
},
});
expect(mockNext).not.toHaveBeenCalled();
});

it('should add deprecation headers for v1.x versions', () => {
const req = mockRequest({ 'api-version': '1.0' });
const res = mockResponse();

versioningMiddleware(req as Request, res as Response, mockNext);

expect(res.setHeader).toHaveBeenCalledWith('Deprecation', 'true');
expect(res.setHeader).toHaveBeenCalledWith('Sunset', '2024-12-31');
});

it('should not add deprecation headers for latest version', () => {
const req = mockRequest({ 'api-version': '2.0' });
const res = mockResponse();

versioningMiddleware(req as Request, res as Response, mockNext);

expect(res.setHeader).toHaveBeenCalledWith('X-API-Version', '2.0');
expect(res.setHeader).not.toHaveBeenCalledWith('Deprecation', 'true');
});

it('should handle missing sunset date gracefully', () => {
delete process.env.API_V1_SUNSET_DATE;
loadConfig();

const req = mockRequest({ 'api-version': '1.0' });
const res = mockResponse();

versioningMiddleware(req as Request, res as Response, mockNext);

expect(res.setHeader).toHaveBeenCalledWith('Deprecation', 'true');
expect(res.setHeader).not.toHaveBeenCalledWith('Sunset', expect.any(String));
});

it('should use configured default version when set to 2.0', () => {
process.env.API_DEFAULT_VERSION = '2.0';
loadConfig();

const req = mockRequest();
const res = mockResponse();

versioningMiddleware(req as Request, res as Response, mockNext);

expect(res.locals?.apiVersion).toBe('2.0');
expect(res.setHeader).toHaveBeenCalledWith('X-API-Version', '2.0');
expect(res.setHeader).not.toHaveBeenCalledWith('Deprecation', 'true');
expect(mockNext).toHaveBeenCalled();
});
});

describe('parseVersion', () => {
it('should parse version string into components', () => {
expect(parseVersion('1.0.0')).toEqual([1, 0, 0]);
expect(parseVersion('2.1')).toEqual([2, 1, 0]);
expect(parseVersion('3')).toEqual([3, 0, 0]);
});

it('should handle malformed versions gracefully', () => {
expect(parseVersion('invalid')).toEqual([0, 0, 0]);
expect(parseVersion('')).toEqual([0, 0, 0]);
});
});

describe('compareVersions', () => {
it('should return -1 when v1 < v2', () => {
expect(compareVersions('1.0', '2.0')).toBe(-1);
expect(compareVersions('1.1', '1.2')).toBe(-1);
expect(compareVersions('1.0.0', '1.0.1')).toBe(-1);
});

it('should return 0 when v1 == v2', () => {
expect(compareVersions('1.0', '1.0')).toBe(0);
expect(compareVersions('2.1.0', '2.1')).toBe(0);
});

it('should return 1 when v1 > v2', () => {
expect(compareVersions('2.0', '1.0')).toBe(1);
expect(compareVersions('1.2', '1.1')).toBe(1);
expect(compareVersions('1.0.1', '1.0.0')).toBe(1);
});
});
107 changes: 107 additions & 0 deletions backend/src/api/middleware/versioning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { Request, Response, NextFunction } from 'express';
import { getConfig } from '../../config';

/**
* API versioning middleware that handles header-based version negotiation.
*
* ### Version Negotiation
* - Clients specify their desired API version via the `API-Version` header (e.g., "1.0", "1.1", "2.0")
* - If the header is omitted, defaults to the configured default version (typically "1.0" for backward compatibility)
* - Invalid or unsupported versions return a 400 error
*
* ### Response Headers
* - `X-API-Version`: Echoes the API version used for the request
* - `Deprecation`: Added for deprecated versions (e.g., v1.0)
* - `Sunset`: Added for deprecated versions with a known sunset date
*
* ### Version Metadata
* The negotiated version is stored in `res.locals.apiVersion` for use by
* downstream route handlers and middleware.
*/
export function versioningMiddleware(req: Request, res: Response, next: NextFunction): void {
// Use default values if config is not loaded yet (e.g., during tests)
let supportedVersions = ['1.0', '1.1', '2.0'];
let latestVersion = '2.0';
let defaultVersion = '1.0';
let sunsetDate: string | undefined;

try {
const config = getConfig();
supportedVersions = config.API_SUPPORTED_VERSIONS.split(',').map(v => v.trim());
latestVersion = config.API_LATEST_VERSION;
defaultVersion = config.API_DEFAULT_VERSION;
sunsetDate = config.API_V1_SUNSET_DATE;
} catch (error) {
// Config not loaded, use defaults - this is acceptable for tests
}

// Get client-specified version from header
const clientVersion = req.headers['api-version'] as string | undefined;

// Default to configured default version if header is omitted (for backward compatibility)
const negotiatedVersion = clientVersion || defaultVersion;

// Validate the requested version
if (!supportedVersions.includes(negotiatedVersion)) {
res.status(400).json({
error: {
message: `Unsupported API version: "${negotiatedVersion}". Supported versions: ${supportedVersions.join(', ')}`,
code: 'UNSUPPORTED_API_VERSION',
supportedVersions,
},
});
return;
}

// Store negotiated version for downstream handlers
res.locals.apiVersion = negotiatedVersion;

// Add version response header
res.setHeader('X-API-Version', negotiatedVersion);

// Add deprecation headers for old versions
if (isDeprecatedVersion(negotiatedVersion, latestVersion)) {
res.setHeader('Deprecation', 'true');

// Add sunset header if configured
if (sunsetDate && negotiatedVersion.startsWith('1.')) {
res.setHeader('Sunset', sunsetDate);
}
}

next();
}

/**
* Determines if a version is considered deprecated.
* A version is deprecated if it's older than the latest major version.
*/
function isDeprecatedVersion(version: string, latestVersion: string): boolean {
const versionMajor = parseInt(version.split('.')[0], 10);
const latestMajor = parseInt(latestVersion.split('.')[0], 10);

return versionMajor < latestMajor;
}

/**
* Parses a version string into comparable parts.
* Returns [major, minor, patch] as numbers.
*/
export function parseVersion(version: string): [number, number, number] {
const parts = version.split('.').map(p => parseInt(p, 10));
return [parts[0] || 0, parts[1] || 0, parts[2] || 0];
}

/**
* Compares two version strings.
* Returns -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2.
*/
export function compareVersions(v1: string, v2: string): number {
const [major1, minor1, patch1] = parseVersion(v1);
const [major2, minor2, patch2] = parseVersion(v2);

if (major1 !== major2) return major1 < major2 ? -1 : 1;
if (minor1 !== minor2) return minor1 < minor2 ? -1 : 1;
if (patch1 !== patch2) return patch1 < patch2 ? -1 : 1;
return 0;
}
4 changes: 4 additions & 0 deletions backend/src/api/routes/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ const TaskListSchema = z.object({

// ── Router factory ───────────────────────────────────────────────────────────

/**
* @deprecated Use version-specific routers instead: createV1TasksRouter or createV2TasksRouter
* This router is kept for backward compatibility and will be removed in a future version.
*/
export function createTasksRouter(dispatch: DispatchFn, releasePayment: PaymentReleaseFn): Router {
const tasksRouter = Router();

Expand Down
Loading