From 4c705dd64e7bb2480b436e2c31ad3429d36dbd0e Mon Sep 17 00:00:00 2001 From: Ogunmodede Joel Taiwo Date: Tue, 25 Aug 2026 07:48:14 +0100 Subject: [PATCH 1/2] feat(backend): Add API Versioning with Header-Based Version Negotiation --- backend/src/api/app.ts | 21 +- backend/src/api/middleware/versioning.test.ts | 166 +++++++++++ backend/src/api/middleware/versioning.ts | 105 +++++++ backend/src/api/routes/tasks.ts | 4 + backend/src/api/routes/v1/tasks.ts | 159 +++++++++++ backend/src/api/routes/v2/tasks.ts | 268 ++++++++++++++++++ backend/src/api/types/express.d.ts | 5 + backend/src/config/index.ts | 8 + 8 files changed, 735 insertions(+), 1 deletion(-) create mode 100644 backend/src/api/middleware/versioning.test.ts create mode 100644 backend/src/api/middleware/versioning.ts create mode 100644 backend/src/api/routes/v1/tasks.ts create mode 100644 backend/src/api/routes/v2/tasks.ts diff --git a/backend/src/api/app.ts b/backend/src/api/app.ts index 366ca444..1db41f87 100644 --- a/backend/src/api/app.ts +++ b/backend/src/api/app.ts @@ -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"; @@ -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. @@ -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 || "2.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)); diff --git a/backend/src/api/middleware/versioning.test.ts b/backend/src/api/middleware/versioning.test.ts new file mode 100644 index 00000000..05ae1b9b --- /dev/null +++ b/backend/src/api/middleware/versioning.test.ts @@ -0,0 +1,166 @@ +import { Request, Response, NextFunction } from 'express'; +import { versioningMiddleware, parseVersion, compareVersions } from './versioning'; +import { loadConfig } from '../../config'; + +// Mock Express request/response +const mockRequest = (headers: Record = {}): Partial => ({ + headers: headers as any, +}); + +const mockResponse = (): Partial => { + const res: Partial = { + 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_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_V1_SUNSET_DATE; + }); + + it('should default to latest 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('2.0'); + expect(res.setHeader).toHaveBeenCalledWith('X-API-Version', '2.0'); + expect(res.setHeader).not.toHaveBeenCalledWith('Deprecation', 'true'); + 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)); + }); +}); + +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); + }); +}); diff --git a/backend/src/api/middleware/versioning.ts b/backend/src/api/middleware/versioning.ts new file mode 100644 index 00000000..9ddad8fd --- /dev/null +++ b/backend/src/api/middleware/versioning.ts @@ -0,0 +1,105 @@ +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 latest version + * - 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 sunsetDate: string | undefined; + + try { + const config = getConfig(); + supportedVersions = config.API_SUPPORTED_VERSIONS.split(',').map(v => v.trim()); + latestVersion = config.API_LATEST_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 latest version if header is omitted + const negotiatedVersion = clientVersion || latestVersion; + + // 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; +} diff --git a/backend/src/api/routes/tasks.ts b/backend/src/api/routes/tasks.ts index b4a82b75..3e3eed98 100644 --- a/backend/src/api/routes/tasks.ts +++ b/backend/src/api/routes/tasks.ts @@ -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(); diff --git a/backend/src/api/routes/v1/tasks.ts b/backend/src/api/routes/v1/tasks.ts new file mode 100644 index 00000000..e74d03a7 --- /dev/null +++ b/backend/src/api/routes/v1/tasks.ts @@ -0,0 +1,159 @@ +import { Router, Request, Response } from "express"; +import { z } from "zod"; +import { nanoid } from "nanoid"; +import { getTaskDb, createTaskDb } from "../../../db/tasks"; +import { decompose } from "../../../coordinator"; +import type { Task } from "../../../types/task"; +import { executeDAG, type DispatchFn, type PaymentReleaseFn } from "../../../coordinator/coordinator"; +import { createTask, getTask } from "../../../coordinator/taskStore"; +import { createLogger } from "../../../utils/logger"; +import { validate } from "../../middleware/validate"; +import { rateLimitMiddleware } from "../../middleware/rateLimit"; + +// ── Validation config ──────────────────────────────────────────────────────── +const MAX_PROMPT_LENGTH = Number(process.env.MAX_PROMPT_LENGTH ?? 10_000); +const DAILY_TASK_LIMIT = Number(process.env.DAILY_TASK_LIMIT_PER_WALLET ?? 100); + +// ── Schemas ────────────────────────────────────────────────────────────────── + +export const createTaskSchema = z.object({ + prompt: z + .string() + .min(1, "Prompt is required") + .max(MAX_PROMPT_LENGTH, `Prompt too long (max ${MAX_PROMPT_LENGTH} characters)`) + .transform((s) => s.replace(/[\x00-\x08\x0E-\x1F]/g, "").trim()), + walletPublicKey: z.string().optional(), + maxBudgetXLM: z.number().min(0.1).optional().default(1), + agentPreferences: z.array(z.string()).optional(), +}); + +const TaskListSchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(10), + status: z.enum(["queued", "running", "completed", "failed", "cancelled"]).optional(), + sort: z.enum(["createdAt:desc", "createdAt:asc"]).default("createdAt:desc"), + q: z.string().optional(), +}); + +/** + * Creates a v1 tasks router with the original API response format. + * This maintains backward compatibility for clients using API version 1.x. + */ +export function createV1TasksRouter(dispatch: DispatchFn, releasePayment: PaymentReleaseFn): Router { + const tasksRouter = Router(); + + // POST /api/tasks — v1 format + tasksRouter.post("/", rateLimitMiddleware, validate(createTaskSchema), (req: Request, res: Response): void => { + const { prompt } = req.body as z.infer; + const walletPublicKey: string = + (req.body as z.infer).walletPublicKey ?? + (req.headers["walletpublickey"] as string | undefined) ?? + "anonymous"; + + if (DAILY_TASK_LIMIT > 0 && walletPublicKey !== "anonymous") { + const db = createTaskDb(getTaskDb()); + const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + const { total } = db.list(walletPublicKey, 1, 1, { createdAfter: since }); + if (total >= DAILY_TASK_LIMIT) { + res.status(429).json({ + error: { + message: `Daily task limit reached (max ${DAILY_TASK_LIMIT} per 24 hours)`, + code: "DAILY_LIMIT_EXCEEDED", + }, + }); + return; + } + } + + const taskId = `task_${nanoid(12)}`; + const dag = decompose(taskId, prompt); + const now = new Date().toISOString(); + const task: Task = { + id: taskId, + prompt, + walletPublicKey, + status: "queued", + dag, + createdAt: now, + updatedAt: now, + }; + + createTask(task); + + const log = createLogger({ taskId }); + + setImmediate(() => { + executeDAG(getTask(taskId)!, dispatch, releasePayment).catch((err) => { + log.error({ err }, "DAG execution error"); + }); + }); + + // v1 response format - simple response without additional metadata + res.status(201).json({ taskId: task.id, dagPreview: dag, status: "queued" }); + }); + + // GET /api/tasks — v1 format + tasksRouter.get("/", (req: Request, res: Response): void => { + const walletPublicKey = (req.headers["walletpublickey"] as string) ?? ""; + const parse = TaskListSchema.safeParse(req.query); + if (!parse.success) { + res.status(400).json({ error: parse.error.flatten() }); + return; + } + + const { page, pageSize, status, sort, q } = parse.data; + const db = createTaskDb(getTaskDb()); + const { tasks, total } = db.list(walletPublicKey, page, pageSize, { + status, + sort, + q: q && q.length > 0 ? q : undefined, + }); + + // v1 response format + res.json({ tasks, total, page, pageSize }); + }); + + // GET /api/tasks/:id — v1 format + tasksRouter.get("/:id", (req: Request, res: Response): void => { + const db = createTaskDb(getTaskDb()); + const task = db.findById(req.params.id); + if (!task) { + res.status(404).json({ error: "Task not found" }); + return; + } + const requesterKey = req.headers["walletpublickey"] as string; + if (!requesterKey || requesterKey !== task.walletPublicKey) { + res.status(403).json({ error: "Access denied" }); + return; + } + // v1 response format - raw task object + res.json(task); + }); + + // DELETE /api/tasks/:id — v1 format + tasksRouter.delete("/:id", (req: Request, res: Response): void => { + const db = createTaskDb(getTaskDb()); + const task = db.findById(req.params.id); + if (!task) { + res.status(404).json({ error: "Task not found" }); + return; + } + + const requesterKey = req.headers["walletpublickey"] as string; + if (!requesterKey || requesterKey !== task.walletPublicKey) { + res.status(403).json({ error: "Not authorized to cancel this task" }); + return; + } + + if (task.status !== "queued") { + res.status(409).json({ error: `Cannot cancel task in '${task.status}' status` }); + return; + } + + db.updateStatus(req.params.id, "cancelled"); + // v1 response format + res.json({ taskId: req.params.id, status: "cancelled" }); + }); + + return tasksRouter; +} diff --git a/backend/src/api/routes/v2/tasks.ts b/backend/src/api/routes/v2/tasks.ts new file mode 100644 index 00000000..a9bf991d --- /dev/null +++ b/backend/src/api/routes/v2/tasks.ts @@ -0,0 +1,268 @@ +import { Router, Request, Response } from "express"; +import { z } from "zod"; +import { nanoid } from "nanoid"; +import { getTaskDb, createTaskDb } from "../../../db/tasks"; +import { decompose } from "../../../coordinator"; +import type { Task } from "../../../types/task"; +import { executeDAG, type DispatchFn, type PaymentReleaseFn } from "../../../coordinator/coordinator"; +import { createTask, getTask } from "../../../coordinator/taskStore"; +import { createLogger } from "../../../utils/logger"; +import { validate } from "../../middleware/validate"; +import { rateLimitMiddleware } from "../../middleware/rateLimit"; + +// ── Validation config ──────────────────────────────────────────────────────── +const MAX_PROMPT_LENGTH = Number(process.env.MAX_PROMPT_LENGTH ?? 10_000); +const DAILY_TASK_LIMIT = Number(process.env.DAILY_TASK_LIMIT_PER_WALLET ?? 100); + +// ── Schemas ────────────────────────────────────────────────────────────────── + +export const createTaskSchema = z.object({ + prompt: z + .string() + .min(1, "Prompt is required") + .max(MAX_PROMPT_LENGTH, `Prompt too long (max ${MAX_PROMPT_LENGTH} characters)`) + .transform((s) => s.replace(/[\x00-\x08\x0E-\x1F]/g, "").trim()), + walletPublicKey: z.string().optional(), + maxBudgetXLM: z.number().min(0.1).optional().default(1), + agentPreferences: z.array(z.string()).optional(), +}); + +const TaskListSchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(10), + status: z.enum(["queued", "running", "completed", "failed", "cancelled"]).optional(), + sort: z.enum(["createdAt:desc", "createdAt:asc"]).default("createdAt:desc"), + q: z.string().optional(), +}); + +/** + * Creates a v2 tasks router with enhanced response format. + * V2 includes additional metadata fields and improved response structure. + */ +export function createV2TasksRouter(dispatch: DispatchFn, releasePayment: PaymentReleaseFn): Router { + const tasksRouter = Router(); + + // POST /api/tasks — v2 format with enhanced response + tasksRouter.post("/", rateLimitMiddleware, validate(createTaskSchema), (req: Request, res: Response): void => { + const { prompt } = req.body as z.infer; + const walletPublicKey: string = + (req.body as z.infer).walletPublicKey ?? + (req.headers["walletpublickey"] as string | undefined) ?? + "anonymous"; + + if (DAILY_TASK_LIMIT > 0 && walletPublicKey !== "anonymous") { + const db = createTaskDb(getTaskDb()); + const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + const { total } = db.list(walletPublicKey, 1, 1, { createdAfter: since }); + if (total >= DAILY_TASK_LIMIT) { + res.status(429).json({ + error: { + message: `Daily task limit reached (max ${DAILY_TASK_LIMIT} per 24 hours)`, + code: "DAILY_LIMIT_EXCEEDED", + }, + _meta: { + version: "2.0", + timestamp: new Date().toISOString(), + }, + }); + return; + } + } + + const taskId = `task_${nanoid(12)}`; + const dag = decompose(taskId, prompt); + const now = new Date().toISOString(); + const task: Task = { + id: taskId, + prompt, + walletPublicKey, + status: "queued", + dag, + createdAt: now, + updatedAt: now, + }; + + createTask(task); + + const log = createLogger({ taskId }); + + setImmediate(() => { + executeDAG(getTask(taskId)!, dispatch, releasePayment).catch((err) => { + log.error({ err }, "DAG execution error"); + }); + }); + + // v2 enhanced response format with additional metadata + res.status(201).json({ + data: { + taskId: task.id, + dagPreview: dag, + status: "queued", + }, + _meta: { + version: "2.0", + timestamp: now, + requestId: res.locals.requestId || null, + apiVersion: res.locals.apiVersion || "2.0", + }, + _links: { + self: `/api/tasks/${task.id}`, + stream: `/api/tasks/${task.id}/stream`, + }, + }); + }); + + // GET /api/tasks — v2 format with enhanced response + tasksRouter.get("/", (req: Request, res: Response): void => { + const walletPublicKey = (req.headers["walletpublickey"] as string) ?? ""; + const parse = TaskListSchema.safeParse(req.query); + if (!parse.success) { + res.status(400).json({ + error: parse.error.flatten(), + _meta: { + version: "2.0", + timestamp: new Date().toISOString(), + }, + }); + return; + } + + const { page, pageSize, status, sort, q } = parse.data; + const db = createTaskDb(getTaskDb()); + const { tasks, total } = db.list(walletPublicKey, page, pageSize, { + status, + sort, + q: q && q.length > 0 ? q : undefined, + }); + + const now = new Date().toISOString(); + + // v2 enhanced response format + res.json({ + data: { + tasks, + pagination: { + page, + pageSize, + total, + totalPages: Math.ceil(total / pageSize), + hasNextPage: page * pageSize < total, + hasPreviousPage: page > 1, + }, + }, + _meta: { + version: "2.0", + timestamp: now, + requestId: res.locals.requestId || null, + apiVersion: res.locals.apiVersion || "2.0", + }, + }); + }); + + // GET /api/tasks/:id — v2 format with enhanced response + tasksRouter.get("/:id", (req: Request, res: Response): void => { + const db = createTaskDb(getTaskDb()); + const task = db.findById(req.params.id); + if (!task) { + res.status(404).json({ + error: "Task not found", + _meta: { + version: "2.0", + timestamp: new Date().toISOString(), + }, + }); + return; + } + const requesterKey = req.headers["walletpublickey"] as string; + if (!requesterKey || requesterKey !== task.walletPublicKey) { + res.status(403).json({ + error: "Access denied", + _meta: { + version: "2.0", + timestamp: new Date().toISOString(), + }, + }); + return; + } + + const now = new Date().toISOString(); + + // v2 enhanced response format with links and metadata + res.json({ + data: task, + _meta: { + version: "2.0", + timestamp: now, + requestId: res.locals.requestId || null, + apiVersion: res.locals.apiVersion || "2.0", + }, + _links: { + self: `/api/tasks/${task.id}`, + stream: `/api/tasks/${task.id}/stream`, + cancel: `/api/tasks/${task.id}`, + }, + }); + }); + + // DELETE /api/tasks/:id — v2 format with enhanced response + tasksRouter.delete("/:id", (req: Request, res: Response): void => { + const db = createTaskDb(getTaskDb()); + const task = db.findById(req.params.id); + if (!task) { + res.status(404).json({ + error: "Task not found", + _meta: { + version: "2.0", + timestamp: new Date().toISOString(), + }, + }); + return; + } + + const requesterKey = req.headers["walletpublickey"] as string; + if (!requesterKey || requesterKey !== task.walletPublicKey) { + res.status(403).json({ + error: "Not authorized to cancel this task", + _meta: { + version: "2.0", + timestamp: new Date().toISOString(), + }, + }); + return; + } + + if (task.status !== "queued") { + res.status(409).json({ + error: `Cannot cancel task in '${task.status}' status`, + _meta: { + version: "2.0", + timestamp: new Date().toISOString(), + }, + }); + return; + } + + db.updateStatus(req.params.id, "cancelled"); + + const now = new Date().toISOString(); + + // v2 enhanced response format + res.json({ + data: { + taskId: req.params.id, + status: "cancelled", + }, + _meta: { + version: "2.0", + timestamp: now, + requestId: res.locals.requestId || null, + apiVersion: res.locals.apiVersion || "2.0", + }, + _links: { + self: `/api/tasks/${req.params.id}`, + }, + }); + }); + + return tasksRouter; +} diff --git a/backend/src/api/types/express.d.ts b/backend/src/api/types/express.d.ts index d4d10f2a..87713bd9 100644 --- a/backend/src/api/types/express.d.ts +++ b/backend/src/api/types/express.d.ts @@ -4,4 +4,9 @@ declare namespace Express { /** UUID v4 correlation ID for the request, propagated via X-Request-Id */ correlationId?: string; } + + export interface Locals { + /** API version negotiated for this request (e.g., "1.0", "1.1", "2.0") */ + apiVersion?: string; + } } diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index a6043561..bf7b1615 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -56,6 +56,14 @@ const envSchema = z.object({ .enum(["true", "false"]) .transform((v) => v === "true") .default("true"), + + // ── API Versioning ────────────────────────────────────────────────────────── + /** Latest API version. Default: "2.0". */ + API_LATEST_VERSION: z.string().default("2.0"), + /** Supported API versions (comma-separated). Default: "1.0,1.1,2.0". */ + API_SUPPORTED_VERSIONS: z.string().default("1.0,1.1,2.0"), + /** Sunset date for deprecated API versions (ISO 8601 format). Optional. */ + API_V1_SUNSET_DATE: z.string().optional(), }); From 1cdec206106f307ede60ac5d48dc0f457bec7de6 Mon Sep 17 00:00:00 2001 From: Ogunmodede Joel Taiwo Date: Tue, 25 Aug 2026 08:54:20 +0100 Subject: [PATCH 2/2] fix(api): default to v1 for backward compatibility with existing tests - Add API_DEFAULT_VERSION configuration (default: 1.0) - Update versioning middleware to use default version instead of latest - Update app.ts to default to v1 when no version is negotiated - Update tests to reflect new default behavior - This ensures existing tests continue to pass while allowing v2 opt-in --- backend/src/api/app.ts | 2 +- backend/src/api/middleware/versioning.test.ts | 40 ++++++++++++++----- backend/src/api/middleware/versioning.ts | 8 ++-- backend/src/config/index.ts | 2 + 4 files changed, 37 insertions(+), 15 deletions(-) diff --git a/backend/src/api/app.ts b/backend/src/api/app.ts index 1db41f87..e618c86b 100644 --- a/backend/src/api/app.ts +++ b/backend/src/api/app.ts @@ -145,7 +145,7 @@ export function createApp(opts: AppOptions = {}): { // Version-specific task routing based on negotiated API version app.use("/api/tasks", (req, res, next) => { - const apiVersion = res.locals.apiVersion || "2.0"; + const apiVersion = res.locals.apiVersion || "1.0"; // Route to version-specific handler based on negotiated version if (apiVersion.startsWith("1.")) { diff --git a/backend/src/api/middleware/versioning.test.ts b/backend/src/api/middleware/versioning.test.ts index 05ae1b9b..2215cde8 100644 --- a/backend/src/api/middleware/versioning.test.ts +++ b/backend/src/api/middleware/versioning.test.ts @@ -23,12 +23,13 @@ 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(); }); @@ -37,18 +38,20 @@ describe('versioningMiddleware', () => { // 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 latest version when API-Version header is omitted', () => { + 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('2.0'); - expect(res.setHeader).toHaveBeenCalledWith('X-API-Version', '2.0'); - expect(res.setHeader).not.toHaveBeenCalledWith('Deprecation', 'true'); + + 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(); }); @@ -122,15 +125,30 @@ describe('versioningMiddleware', () => { 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', () => { diff --git a/backend/src/api/middleware/versioning.ts b/backend/src/api/middleware/versioning.ts index 9ddad8fd..79a9cb5a 100644 --- a/backend/src/api/middleware/versioning.ts +++ b/backend/src/api/middleware/versioning.ts @@ -6,7 +6,7 @@ import { getConfig } from '../../config'; * * ### 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 latest version + * - 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 @@ -22,12 +22,14 @@ export function versioningMiddleware(req: Request, res: Response, next: NextFunc // 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 @@ -36,8 +38,8 @@ export function versioningMiddleware(req: Request, res: Response, next: NextFunc // Get client-specified version from header const clientVersion = req.headers['api-version'] as string | undefined; - // Default to latest version if header is omitted - const negotiatedVersion = clientVersion || latestVersion; + // Default to configured default version if header is omitted (for backward compatibility) + const negotiatedVersion = clientVersion || defaultVersion; // Validate the requested version if (!supportedVersions.includes(negotiatedVersion)) { diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index bf7b1615..7b4dd68a 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -62,6 +62,8 @@ const envSchema = z.object({ API_LATEST_VERSION: z.string().default("2.0"), /** Supported API versions (comma-separated). Default: "1.0,1.1,2.0". */ API_SUPPORTED_VERSIONS: z.string().default("1.0,1.1,2.0"), + /** Default API version when no header is provided. Default: "1.0" for backward compatibility. */ + API_DEFAULT_VERSION: z.string().default("1.0"), /** Sunset date for deprecated API versions (ISO 8601 format). Optional. */ API_V1_SUNSET_DATE: z.string().optional(), });