import type { Request, Response, NextFunction } from "express"; /** * Versioning + deprecation (TASK-0127, docs/api/versioning.md). * * `/api/v1` is the STABLE public surface: breaking changes ship only in a * new major (`/api/v2`). A route is deprecated by ADDING it to * {@link DEPRECATED_ROUTES} (method + path, `:param` segments match * literally); every response from that route then carries: * * Deprecation: true * Sunset: (minimum 6 months from deprecation) * * and the route appears as `deprecated: true` in `docs/api/openapi.yaml` * (kept by hand — the contract test enforces the rest). * * The map is intentionally EMPTY at Phase-13 ship: the mechanism + a * documented example are what the spec asks for (the synthetic example * route is exercised in `deprecation.test.ts` via {@link createDeprecationMiddleware}, * not added to the live router). */ export interface DeprecatedRoute { /** RFC 1123 date, e.g. "Tue, 01 Jul 2026 00:00:00 GMT". */ sunset: string; /** Optional human note (surfaced in the docs site only). */ reason?: string; } /** Key = `${METHOD} ${path}` (path in request form, e.g. `/api/v1/…`). */ export const DEPRECATED_ROUTES: Record = { // Example (mechanism documentation — NOT a live route; the contract test // asserts the map's shape instead): // "POST /api/v1/workspaces/:id/pages/duplicate-legacy": { // sunset: "Tue, 01 Jul 2026 00:00:00 GMT", // reason: "Use POST /workspaces/:id/pages/:id/duplicate" // } }; /** * Compile a `"METHOD /api/v1/path/with/:params"` key into a predicate that * matches a concrete request. `:param` segments match exactly one non-slash * segment; literal segments match byte-for-byte. (Express's router already * owns the live route; this map is a DEPRECATION overlay, so it must * independently match the request path — a param-aware exact match.) */ function routeMatcher(methodPath: string): (method: string, path: string) => boolean { const space = methodPath.indexOf(" "); const method = methodPath.slice(0, space).toUpperCase(); const template = methodPath.slice(space + 1); const segments = template.split("/"); return (reqMethod: string, reqPath: string) => { if (reqMethod.toUpperCase() !== method) return false; const reqSegs = reqPath.split("/"); if (reqSegs.length !== segments.length) return false; for (let i = 0; i < segments.length; i++) { const tpl = segments[i]; if (tpl.startsWith(":")) continue; // any single segment if (tpl !== reqSegs[i]) return false; } return true; }; } /** * Build an Express middleware for a route→config map. Exported separately * so the synthetic-route test (and a future per-deployment override) can * pass its own map. `:param` template segments match concrete ids. */ export function createDeprecationMiddleware( routes: Record = DEPRECATED_ROUTES ): (req: Request, res: Response, next: NextFunction) => void { const matchers = Object.keys(routes).map((key) => ({ match: routeMatcher(key), cfg: routes[key] })); return (req, res, next) => { if (req.path.startsWith("/api/v1/")) { for (const { match, cfg } of matchers) { if (match(req.method, req.path)) { res.setHeader("Deprecation", "true"); res.setHeader("Sunset", cfg.sunset); break; } } } next(); }; } /** The default-mapped middleware (wired in main.ts + the test app). */ export const deprecationMiddleware = createDeprecationMiddleware();