Files
flyer-crawler.projectium.com/src/middleware/apiVersion.middleware.ts
Torben Sorensen f10c6c0cd6
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 17m56s
Complete ADR-008 Phase 2
2026-01-27 11:06:09 -08:00

219 lines
7.3 KiB
TypeScript

// src/middleware/apiVersion.middleware.ts
/**
* @file API version detection middleware implementing ADR-008 Phase 2.
*
* Extracts API version from the request URL, validates it against supported versions,
* attaches version information to the request object, and handles unsupported versions.
*
* @see docs/architecture/api-versioning-infrastructure.md
* @see docs/adr/0008-api-versioning-strategy.md
*
* @example
* ```typescript
* // In versioned router factory (versioned.ts):
* import { detectApiVersion } from '../middleware/apiVersion.middleware';
*
* const router = Router({ mergeParams: true });
* router.use(detectApiVersion);
* ```
*/
import { Request, Response, NextFunction } from 'express';
import {
ApiVersion,
SUPPORTED_VERSIONS,
DEFAULT_VERSION,
isValidApiVersion,
getVersionDeprecation,
} from '../config/apiVersions';
import { sendError } from '../utils/apiResponse';
import { createScopedLogger } from '../services/logger.server';
// --- Module-level Logger ---
/**
* Module-scoped logger for API version middleware.
* Used for logging version detection events outside of request context.
*/
const moduleLogger = createScopedLogger('apiVersion-middleware');
// --- Error Codes ---
/**
* Error code for unsupported API version requests.
* This is specific to the versioning system and not part of the general ErrorCode enum.
*/
export const VERSION_ERROR_CODES = {
UNSUPPORTED_VERSION: 'UNSUPPORTED_VERSION',
} as const;
// --- Middleware Functions ---
/**
* Extracts the API version from the URL path parameter and attaches it to the request.
*
* This middleware expects to be used with a router that has a :version parameter
* (e.g., mounted at `/api/:version`). It validates the version against the list
* of supported versions and returns a 404 error for unsupported versions.
*
* For valid versions, it:
* - Sets `req.apiVersion` to the detected version
* - Sets `req.versionDeprecation` with deprecation info if the version is deprecated
*
* @param req - Express request object (expects `req.params.version`)
* @param res - Express response object
* @param next - Express next function
*
* @example
* ```typescript
* // Route setup:
* app.use('/api/:version', detectApiVersion, versionedRouter);
*
* // Request to /api/v1/users:
* // req.params.version = 'v1'
* // req.apiVersion = 'v1'
*
* // Request to /api/v99/users:
* // Returns 404 with UNSUPPORTED_VERSION error
* ```
*/
export function detectApiVersion(req: Request, res: Response, next: NextFunction): void {
// Get the request-scoped logger if available, otherwise use module logger
const log = req.log?.child({ middleware: 'detectApiVersion' }) ?? moduleLogger;
// Extract version from URL params (expects router mounted with :version param)
const versionParam = req.params?.version;
// If no version parameter found, this middleware was likely applied incorrectly.
// Default to the default version and continue (allows for fallback behavior).
if (!versionParam) {
log.debug('No version parameter found in request, using default version');
req.apiVersion = DEFAULT_VERSION;
req.versionDeprecation = getVersionDeprecation(DEFAULT_VERSION);
return next();
}
// Validate the version parameter
if (isValidApiVersion(versionParam)) {
// Valid version - attach to request
req.apiVersion = versionParam;
req.versionDeprecation = getVersionDeprecation(versionParam);
log.debug({ apiVersion: versionParam }, 'API version detected from URL');
return next();
}
// Invalid version - log warning and return 404
log.warn(
{
attemptedVersion: versionParam,
supportedVersions: SUPPORTED_VERSIONS,
path: req.path,
method: req.method,
ip: req.ip,
},
'Invalid API version requested',
);
// Return 404 with UNSUPPORTED_VERSION error code
// Using 404 because the versioned endpoint does not exist
sendError(
res,
VERSION_ERROR_CODES.UNSUPPORTED_VERSION,
`API version '${versionParam}' is not supported. Supported versions: ${SUPPORTED_VERSIONS.join(', ')}`,
404,
{
requestedVersion: versionParam,
supportedVersions: [...SUPPORTED_VERSIONS],
},
);
}
/**
* Extracts the API version from the URL path pattern and attaches it to the request.
*
* Unlike `detectApiVersion`, this middleware parses the version from the URL path
* directly using a regex pattern. This is useful when the middleware needs to run
* before or independently of parameterized routing.
*
* Pattern matched: `/v{number}/...` at the beginning of the path
* (e.g., `/v1/users`, `/v2/flyers/123`)
*
* If the version is valid, sets `req.apiVersion` and `req.versionDeprecation`.
* If the version is invalid or not present, defaults to `DEFAULT_VERSION`.
*
* This middleware does NOT return errors for invalid versions - it's designed for
* cases where version detection is informational rather than authoritative.
*
* @param req - Express request object
* @param _res - Express response object (unused)
* @param next - Express next function
*
* @example
* ```typescript
* // Applied early in middleware chain:
* app.use('/api', extractApiVersionFromPath, apiRouter);
*
* // For path /api/v1/users:
* // req.path = '/v1/users' (relative to /api mount point)
* // req.apiVersion = 'v1'
* ```
*/
export function extractApiVersionFromPath(req: Request, _res: Response, next: NextFunction): void {
// Get the request-scoped logger if available, otherwise use module logger
const log = req.log?.child({ middleware: 'extractApiVersionFromPath' }) ?? moduleLogger;
// Extract version from URL path using regex: /v{number}/
// The path is relative to the router's mount point
const pathMatch = req.path.match(/^\/v(\d+)\//);
if (pathMatch) {
const versionString = `v${pathMatch[1]}` as string;
if (isValidApiVersion(versionString)) {
req.apiVersion = versionString;
req.versionDeprecation = getVersionDeprecation(versionString);
log.debug({ apiVersion: versionString }, 'API version extracted from path');
return next();
}
// Version number in path but not in supported list - log and use default
log.warn(
{
attemptedVersion: versionString,
supportedVersions: SUPPORTED_VERSIONS,
path: req.path,
},
'Unsupported API version in path, falling back to default',
);
}
// No version detected or invalid - use default
req.apiVersion = DEFAULT_VERSION;
req.versionDeprecation = getVersionDeprecation(DEFAULT_VERSION);
log.debug({ apiVersion: DEFAULT_VERSION }, 'Using default API version');
return next();
}
/**
* Type guard to check if a request has a valid API version attached.
*
* @param req - Express request object
* @returns True if req.apiVersion is set to a valid ApiVersion
*/
export function hasApiVersion(req: Request): req is Request & { apiVersion: ApiVersion } {
return req.apiVersion !== undefined && isValidApiVersion(req.apiVersion);
}
/**
* Gets the API version from a request, with a fallback to the default version.
*
* @param req - Express request object
* @returns The API version from the request, or DEFAULT_VERSION if not set
*/
export function getRequestApiVersion(req: Request): ApiVersion {
if (req.apiVersion && isValidApiVersion(req.apiVersion)) {
return req.apiVersion;
}
return DEFAULT_VERSION;
}