Files
flyer-crawler.projectium.com/src/routes/versioned.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

479 lines
15 KiB
TypeScript

// src/routes/versioned.ts
/**
* @file Version Router Factory - ADR-008 Phase 2 Implementation
*
* Creates version-specific Express routers that manage route registration
* for different API versions. This factory ensures consistent middleware
* application and proper route ordering across all API versions.
*
* Key responsibilities:
* - Create routers for each supported API version
* - Apply version detection and deprecation middleware
* - Register domain routers in correct precedence order
* - Support version-specific route availability
* - Add X-API-Version header to all responses
*
* @see docs/architecture/api-versioning-infrastructure.md
* @see docs/adr/0008-api-versioning-strategy.md
*
* @example
* ```typescript
* // In server.ts:
* import { createApiRouter, createVersionedRouter } from './src/routes/versioned';
*
* // Option 1: Mount all versions at once
* app.use('/api', createApiRouter());
*
* // Option 2: Mount versions individually
* app.use('/api/v1', createVersionedRouter('v1'));
* app.use('/api/v2', createVersionedRouter('v2'));
* ```
*/
import { Router } from 'express';
import { ApiVersion, API_VERSIONS, SUPPORTED_VERSIONS } from '../config/apiVersions';
import { detectApiVersion } from '../middleware/apiVersion.middleware';
import { addDeprecationHeaders } from '../middleware/deprecation.middleware';
import { createScopedLogger } from '../services/logger.server';
// --- Domain Router Imports ---
// These are imported in the order they are registered in server.ts
import authRouter from './auth.routes';
import healthRouter from './health.routes';
import systemRouter from './system.routes';
import userRouter from './user.routes';
import aiRouter from './ai.routes';
import adminRouter from './admin.routes';
import budgetRouter from './budget.routes';
import gamificationRouter from './gamification.routes';
import flyerRouter from './flyer.routes';
import recipeRouter from './recipe.routes';
import personalizationRouter from './personalization.routes';
import priceRouter from './price.routes';
import statsRouter from './stats.routes';
import upcRouter from './upc.routes';
import inventoryRouter from './inventory.routes';
import receiptRouter from './receipt.routes';
import dealsRouter from './deals.routes';
import reactionsRouter from './reactions.routes';
import storeRouter from './store.routes';
import categoryRouter from './category.routes';
// Module-scoped logger for versioned router operations
const versionedRouterLogger = createScopedLogger('versioned-router');
// --- Type Definitions ---
/**
* Configuration for registering a route under versioned API.
*
* @property path - The URL path segment (e.g., 'auth', 'users', 'flyers')
* @property router - The Express router instance handling this path
* @property description - Human-readable description of the route's purpose
* @property versions - Optional array of versions where this route is available.
* If omitted, the route is available in all versions.
*/
export interface RouteRegistration {
/** URL path segment (mounted at /{version}/{path}) */
path: string;
/** Express router instance for this domain */
router: Router;
/** Human-readable description for documentation and logging */
description: string;
/** Optional: Specific versions where this route is available (defaults to all) */
versions?: ApiVersion[];
}
/**
* Options for creating a versioned router.
*/
export interface VersionedRouterOptions {
/** Whether to apply version detection middleware (default: true) */
applyVersionDetection?: boolean;
/** Whether to apply deprecation headers middleware (default: true) */
applyDeprecationHeaders?: boolean;
}
// --- Route Registration Configuration ---
/**
* Master list of all route registrations.
*
* IMPORTANT: The order of routes is critical for correct matching.
* More specific routes should be registered before more general ones.
* This order mirrors the registration in server.ts exactly.
*
* Each entry includes:
* - path: The URL segment (e.g., 'auth' -> /api/v1/auth)
* - router: The Express router handling the routes
* - description: Purpose documentation
* - versions: Optional array to restrict availability to specific versions
*/
export const ROUTES: RouteRegistration[] = [
// 1. Authentication routes for login, registration, etc.
{
path: 'auth',
router: authRouter,
description: 'Authentication routes for login, registration, password reset',
},
// 2. Health check routes for monitoring and liveness probes
{
path: 'health',
router: healthRouter,
description: 'Health check endpoints for monitoring and liveness probes',
},
// 3. System routes for PM2 status, server info, etc.
{
path: 'system',
router: systemRouter,
description: 'System administration routes for PM2 status and server info',
},
// 4. General authenticated user routes
{
path: 'users',
router: userRouter,
description: 'User profile and account management routes',
},
// 5. AI routes, some of which use optional authentication
{
path: 'ai',
router: aiRouter,
description: 'AI-powered features including flyer processing and analysis',
},
// 6. Admin routes, protected by admin-level checks
{
path: 'admin',
router: adminRouter,
description: 'Administrative routes for user and system management',
},
// 7. Budgeting and spending analysis routes
{
path: 'budgets',
router: budgetRouter,
description: 'Budget management and spending analysis routes',
},
// 8. Gamification routes for achievements
{
path: 'achievements',
router: gamificationRouter,
description: 'Gamification and achievement system routes',
},
// 9. Public flyer routes
{
path: 'flyers',
router: flyerRouter,
description: 'Flyer listing, search, and item management routes',
},
// 10. Public recipe routes
{
path: 'recipes',
router: recipeRouter,
description: 'Recipe discovery, saving, and recommendation routes',
},
// 11. Public personalization data routes (master items, etc.)
{
path: 'personalization',
router: personalizationRouter,
description: 'Personalization data including master items and preferences',
},
// 12. Price history routes
{
path: 'price-history',
router: priceRouter,
description: 'Price history tracking and trend analysis routes',
},
// 13. Public statistics routes
{
path: 'stats',
router: statsRouter,
description: 'Public statistics and analytics routes',
},
// 14. UPC barcode scanning routes
{
path: 'upc',
router: upcRouter,
description: 'UPC barcode scanning and product lookup routes',
},
// 15. Inventory and expiry tracking routes
{
path: 'inventory',
router: inventoryRouter,
description: 'Inventory management and expiry tracking routes',
},
// 16. Receipt scanning routes
{
path: 'receipts',
router: receiptRouter,
description: 'Receipt scanning and purchase history routes',
},
// 17. Deals and best prices routes
{
path: 'deals',
router: dealsRouter,
description: 'Deal discovery and best price comparison routes',
},
// 18. Reactions/social features routes
{
path: 'reactions',
router: reactionsRouter,
description: 'Social features including reactions and sharing',
},
// 19. Store management routes
{
path: 'stores',
router: storeRouter,
description: 'Store discovery, favorites, and location routes',
},
// 20. Category discovery routes (ADR-023: Database Normalization)
{
path: 'categories',
router: categoryRouter,
description: 'Category browsing and product categorization routes',
},
];
// --- Factory Functions ---
/**
* Creates a versioned Express router for a specific API version.
*
* This factory function:
* 1. Creates a new Router instance with merged params
* 2. Applies deprecation headers middleware (adds X-API-Version header)
* 3. Registers all routes that are available for the specified version
* 4. Maintains correct route registration order (specific before general)
*
* @param version - The API version to create a router for (e.g., 'v1', 'v2')
* @param options - Optional configuration for middleware application
* @returns Configured Express Router for the specified version
*
* @example
* ```typescript
* // Create a v1 router
* const v1Router = createVersionedRouter('v1');
* app.use('/api/v1', v1Router);
*
* // Create a v2 router with custom options
* const v2Router = createVersionedRouter('v2', {
* applyDeprecationHeaders: true,
* });
* ```
*/
export function createVersionedRouter(
version: ApiVersion,
options: VersionedRouterOptions = {},
): Router {
const { applyDeprecationHeaders: shouldApplyDeprecationHeaders = true } = options;
const router = Router({ mergeParams: true });
versionedRouterLogger.info({ version, routeCount: ROUTES.length }, 'Creating versioned router');
// Apply deprecation headers middleware.
// This adds X-API-Version header to all responses and deprecation headers
// when the version is marked as deprecated.
if (shouldApplyDeprecationHeaders) {
router.use(addDeprecationHeaders(version));
}
// Register all routes that are available for this version
let registeredCount = 0;
for (const route of ROUTES) {
// Check if this route is available for the specified version.
// If versions array is not specified, the route is available for all versions.
if (route.versions && !route.versions.includes(version)) {
versionedRouterLogger.debug(
{ version, path: route.path },
'Skipping route not available for this version',
);
continue;
}
// Mount the router at the specified path
router.use(`/${route.path}`, route.router);
registeredCount++;
versionedRouterLogger.debug(
{ version, path: route.path, description: route.description },
'Registered route',
);
}
versionedRouterLogger.info(
{ version, registeredCount, totalRoutes: ROUTES.length },
'Versioned router created successfully',
);
return router;
}
/**
* Creates the main API router that mounts all versioned routers.
*
* This function creates a parent router that:
* 1. Applies version detection middleware at the /api/:version level
* 2. Mounts versioned routers for each supported API version
* 3. Returns 404 for unsupported versions via detectApiVersion middleware
*
* The router is designed to be mounted at `/api` in the main application:
* - `/api/v1/*` routes to v1 router
* - `/api/v2/*` routes to v2 router
* - `/api/v99/*` returns 404 (unsupported version)
*
* @returns Express Router configured with all version-specific sub-routers
*
* @example
* ```typescript
* // In server.ts:
* import { createApiRouter } from './src/routes/versioned';
*
* // Mount at /api - handles /api/v1/*, /api/v2/*, etc.
* app.use('/api', createApiRouter());
*
* // Then add backwards compatibility redirect for unversioned paths:
* app.use('/api', (req, res, next) => {
* if (!req.path.startsWith('/v1') && !req.path.startsWith('/v2')) {
* return res.redirect(301, `/api/v1${req.path}`);
* }
* next();
* });
* ```
*/
export function createApiRouter(): Router {
const router = Router({ mergeParams: true });
versionedRouterLogger.info(
{ supportedVersions: SUPPORTED_VERSIONS },
'Creating API router with all versions',
);
// Mount versioned routers under /:version path.
// The detectApiVersion middleware validates the version and returns 404 for
// unsupported versions before the domain routers are reached.
router.use('/:version', detectApiVersion, (req, res, next) => {
// At this point, req.apiVersion is guaranteed to be valid
// (detectApiVersion returns 404 for invalid versions).
// Route to the appropriate versioned router based on the detected version.
const version = req.apiVersion;
if (!version) {
// This should not happen if detectApiVersion ran correctly,
// but handle it defensively.
return next('route');
}
// Get or create the versioned router.
// We use a cache to avoid recreating routers on every request.
const versionedRouter = versionedRouterCache.get(version);
if (versionedRouter) {
return versionedRouter(req, res, next);
}
// Fallback: version not in cache (should not happen with proper setup)
versionedRouterLogger.warn(
{ version },
'Versioned router not found in cache, creating on-demand',
);
const newRouter = createVersionedRouter(version);
versionedRouterCache.set(version, newRouter);
return newRouter(req, res, next);
});
versionedRouterLogger.info('API router created successfully');
return router;
}
// --- Router Cache ---
/**
* Cache for versioned routers to avoid recreation on every request.
* Pre-populated with routers for all supported versions.
*/
const versionedRouterCache = new Map<ApiVersion, Router>();
// Pre-populate the cache with all supported versions
for (const version of API_VERSIONS) {
versionedRouterCache.set(version, createVersionedRouter(version));
}
versionedRouterLogger.debug(
{ cachedVersions: Array.from(versionedRouterCache.keys()) },
'Versioned router cache initialized',
);
// --- Utility Functions ---
/**
* Gets the list of all registered route paths.
* Useful for documentation and debugging.
*
* @returns Array of registered route paths
*/
export function getRegisteredPaths(): string[] {
return ROUTES.map((route) => route.path);
}
/**
* Gets route registration details for a specific path.
*
* @param path - The route path to look up
* @returns RouteRegistration if found, undefined otherwise
*/
export function getRouteByPath(path: string): RouteRegistration | undefined {
return ROUTES.find((route) => route.path === path);
}
/**
* Gets all routes available for a specific API version.
*
* @param version - The API version to filter by
* @returns Array of RouteRegistrations available for the version
*/
export function getRoutesForVersion(version: ApiVersion): RouteRegistration[] {
return ROUTES.filter((route) => !route.versions || route.versions.includes(version));
}
/**
* Clears the versioned router cache.
* Primarily useful for testing to ensure fresh router instances.
*/
export function clearRouterCache(): void {
versionedRouterCache.clear();
versionedRouterLogger.debug('Versioned router cache cleared');
}
/**
* Refreshes the versioned router cache by recreating all routers.
* Useful after configuration changes.
*/
export function refreshRouterCache(): void {
clearRouterCache();
for (const version of API_VERSIONS) {
versionedRouterCache.set(version, createVersionedRouter(version));
}
versionedRouterLogger.debug(
{ cachedVersions: Array.from(versionedRouterCache.keys()) },
'Versioned router cache refreshed',
);
}