From a16326abeb3a9e4d181295a7fc6fc5c96ff29ee7 Mon Sep 17 00:00:00 2001 From: Darkdruce Date: Fri, 26 Jun 2026 13:10:32 +0000 Subject: [PATCH] feat(api): add version negotiation middleware with backward-compatible schema migration Closes #760 --- .../lib/api/version-migration.middleware.ts | 132 ++++++++++++++ .../tests/api/version-migration.test.ts | 161 ++++++++++++++++++ 2 files changed, 293 insertions(+) create mode 100644 apps/backend/src/lib/api/version-migration.middleware.ts create mode 100644 apps/backend/tests/api/version-migration.test.ts diff --git a/apps/backend/src/lib/api/version-migration.middleware.ts b/apps/backend/src/lib/api/version-migration.middleware.ts new file mode 100644 index 00000000..c8dbb46f --- /dev/null +++ b/apps/backend/src/lib/api/version-migration.middleware.ts @@ -0,0 +1,132 @@ +/** + * API Version Migration Middleware + * + * Negotiates API version via the Accept-Version header + * (e.g. application/vnd.craft.v1+json) and applies backward-compatible + * schema migrations so all route handlers receive the current internal format. + * + * Supported versions: v1, v2 (internal) + * + * Schema migration: + * v1 → internal: repositoryName (string) → repository.name (nested object) + * v2 → internal: identity (v2 IS the internal format) + * + * Behaviour: + * - Unknown version → 406 Not Acceptable + * - Missing header → defaults to v2 (latest), adds deprecation warning header + * - Valid version → migrates body, attaches (req as any).migratedBody, adds X-Api-Version header + * + * Issue: #760 + */ + +import { NextRequest, NextResponse } from 'next/server'; + +// ── Version constants ───────────────────────────────────────────────────────── + +export const SUPPORTED_VERSIONS = [1, 2] as const; +export type MigrationVersion = typeof SUPPORTED_VERSIONS[number]; + +// ── Schema transforms ───────────────────────────────────────────────────────── + +/** + * Migrates a v1 DeploymentRequest body to the internal (v2) format. + * v1 uses a flat `repositoryName` string; v2 nests it under `repository.name`. + */ +export function migrateV1ToInternal(body: unknown): unknown { + if (!body || typeof body !== 'object') return body; + const b = body as Record; + const { repositoryName, ...rest } = b; + if (repositoryName !== undefined) { + return { ...rest, repository: { name: repositoryName } }; + } + return body; +} + +/** + * v2 is the internal format — identity transform. + */ +export function migrateV2ToInternal(body: unknown): unknown { + return body; +} + +const MIGRATIONS: Record unknown> = { + 1: migrateV1ToInternal, + 2: migrateV2ToInternal, +}; + +// ── Version parsing ─────────────────────────────────────────────────────────── + +/** + * Parses the numeric version from an Accept-Version header value. + * Expects the format: application/vnd.craft.vN+json + * Returns null if the header is absent or unparseable. + */ +export function parseAcceptVersion(header: string | null): MigrationVersion | null { + if (!header) return null; + const match = header.match(/application\/vnd\.craft\.v(\d+)\+json/); + if (!match) return null; + const v = parseInt(match[1], 10) as MigrationVersion; + return (SUPPORTED_VERSIONS as readonly number[]).includes(v) ? v : null; +} + +// ── Middleware ──────────────────────────────────────────────────────────────── + +type RouteHandler = (req: NextRequest, ctx?: any) => Promise; + +/** + * Wraps a route handler with Accept-Version negotiation and body migration. + * + * The migrated body is attached to the request as `(req as any).migratedBody` + * so handlers can read it without re-parsing the (already-consumed) body. + */ +export function withVersionMigration(handler: RouteHandler): RouteHandler { + return async (req: NextRequest, ctx?: any): Promise => { + const acceptVersion = req.headers.get('accept-version'); + let version: MigrationVersion; + let defaulted = false; + + if (acceptVersion === null) { + // No header → default to latest (v2) + version = 2; + defaulted = true; + } else { + const parsed = parseAcceptVersion(acceptVersion); + if (parsed === null) { + return NextResponse.json( + { + error: `Unsupported API version: ${acceptVersion}. Supported versions: ${SUPPORTED_VERSIONS.map((v) => `application/vnd.craft.v${v}+json`).join(', ')}`, + }, + { status: 406 } + ); + } + version = parsed; + } + + // Parse and migrate body (best-effort; handlers that don't need a body + // will simply see migratedBody = undefined). + let migratedBody: unknown; + try { + const raw = await req.json(); + migratedBody = MIGRATIONS[version](raw); + } catch { + migratedBody = undefined; + } + + // Attach migrated body to request for downstream handlers + (req as any).migratedBody = migratedBody; + + const response = await handler(req, ctx); + + // Stamp version onto response + response.headers.set('x-api-version', String(version)); + + if (defaulted) { + response.headers.set( + 'x-api-deprecation-warning', + 'Specify Accept-Version header; defaulting to v2' + ); + } + + return response; + }; +} diff --git a/apps/backend/tests/api/version-migration.test.ts b/apps/backend/tests/api/version-migration.test.ts new file mode 100644 index 00000000..042e9f21 --- /dev/null +++ b/apps/backend/tests/api/version-migration.test.ts @@ -0,0 +1,161 @@ +// @vitest-environment node +/** + * Tests for API versioning schema migration middleware (#760) + * + * Covers: + * - v1 body with repositoryName gets migrated to repository.name + * - v2 body passes through unchanged + * - Missing Accept-Version header defaults to v2 with deprecation warning header + * - Unknown version returns 406 Not Acceptable + * - Valid v1 header sets X-Api-Version: 1 response header + * - Valid v2 header sets X-Api-Version: 2 response header + */ + +import { describe, it, expect, vi } from 'vitest'; +import { NextRequest, NextResponse } from 'next/server'; +import { + migrateV1ToInternal, + migrateV2ToInternal, + parseAcceptVersion, + withVersionMigration, +} from '@/lib/api/version-migration.middleware'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeRequest(body: unknown, acceptVersion?: string): NextRequest { + const headers: Record = { 'content-type': 'application/json' }; + if (acceptVersion !== undefined) { + headers['accept-version'] = acceptVersion; + } + return new NextRequest('http://localhost/api/deployments', { + method: 'POST', + headers, + body: JSON.stringify(body), + }); +} + +// ── migrateV1ToInternal ─────────────────────────────────────────────────────── + +describe('migrateV1ToInternal', () => { + it('maps repositoryName to repository.name', () => { + const result = migrateV1ToInternal({ repositoryName: 'my-repo', foo: 'bar' }); + expect(result).toEqual({ repository: { name: 'my-repo' }, foo: 'bar' }); + }); + + it('passes through body without repositoryName unchanged', () => { + const body = { repository: { name: 'already-v2' }, foo: 'bar' }; + expect(migrateV1ToInternal(body)).toEqual(body); + }); + + it('returns non-object input as-is', () => { + expect(migrateV1ToInternal(null)).toBe(null); + expect(migrateV1ToInternal('string')).toBe('string'); + }); +}); + +// ── migrateV2ToInternal ─────────────────────────────────────────────────────── + +describe('migrateV2ToInternal', () => { + it('returns body unchanged', () => { + const body = { repository: { name: 'my-repo' } }; + expect(migrateV2ToInternal(body)).toBe(body); + }); +}); + +// ── parseAcceptVersion ──────────────────────────────────────────────────────── + +describe('parseAcceptVersion', () => { + it('parses v1 header', () => { + expect(parseAcceptVersion('application/vnd.craft.v1+json')).toBe(1); + }); + + it('parses v2 header', () => { + expect(parseAcceptVersion('application/vnd.craft.v2+json')).toBe(2); + }); + + it('returns null for unknown version', () => { + expect(parseAcceptVersion('application/vnd.craft.v99+json')).toBe(null); + }); + + it('returns null for null input', () => { + expect(parseAcceptVersion(null)).toBe(null); + }); + + it('returns null for unrecognised format', () => { + expect(parseAcceptVersion('application/json')).toBe(null); + }); +}); + +// ── withVersionMigration ────────────────────────────────────────────────────── + +describe('withVersionMigration', () => { + it('migrates v1 repositoryName to repository.name', async () => { + let captured: unknown; + const handler = vi.fn().mockImplementation((req: NextRequest) => { + captured = (req as any).migratedBody; + return NextResponse.json({ ok: true }); + }); + + const wrapped = withVersionMigration(handler); + await wrapped( + makeRequest({ repositoryName: 'my-repo' }, 'application/vnd.craft.v1+json') + ); + + expect(captured).toEqual({ repository: { name: 'my-repo' } }); + }); + + it('passes v2 body through unchanged', async () => { + let captured: unknown; + const handler = vi.fn().mockImplementation((req: NextRequest) => { + captured = (req as any).migratedBody; + return NextResponse.json({ ok: true }); + }); + + const body = { repository: { name: 'my-repo' } }; + const wrapped = withVersionMigration(handler); + await wrapped(makeRequest(body, 'application/vnd.craft.v2+json')); + + expect(captured).toEqual(body); + }); + + it('defaults to v2 when Accept-Version is absent and adds deprecation header', async () => { + const handler = vi.fn().mockResolvedValue(NextResponse.json({ ok: true })); + const wrapped = withVersionMigration(handler); + const res = await wrapped(makeRequest({ repository: { name: 'r' } })); + + expect(res.status).toBe(200); + expect(res.headers.get('x-api-version')).toBe('2'); + expect(res.headers.get('x-api-deprecation-warning')).toMatch(/Accept-Version/); + }); + + it('returns 406 for an unknown version', async () => { + const handler = vi.fn().mockResolvedValue(NextResponse.json({ ok: true })); + const wrapped = withVersionMigration(handler); + const res = await wrapped( + makeRequest({}, 'application/vnd.craft.v99+json') + ); + + expect(res.status).toBe(406); + expect(handler).not.toHaveBeenCalled(); + }); + + it('sets X-Api-Version: 1 for v1 requests', async () => { + const handler = vi.fn().mockResolvedValue(NextResponse.json({ ok: true })); + const wrapped = withVersionMigration(handler); + const res = await wrapped( + makeRequest({ repositoryName: 'r' }, 'application/vnd.craft.v1+json') + ); + expect(res.headers.get('x-api-version')).toBe('1'); + }); + + it('sets X-Api-Version: 2 for v2 requests', async () => { + const handler = vi.fn().mockResolvedValue(NextResponse.json({ ok: true })); + const wrapped = withVersionMigration(handler); + const res = await wrapped( + makeRequest({ repository: { name: 'r' } }, 'application/vnd.craft.v2+json') + ); + expect(res.headers.get('x-api-version')).toBe('2'); + // No deprecation warning for explicit header + expect(res.headers.get('x-api-deprecation-warning')).toBeNull(); + }); +});