Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions apps/backend/src/lib/api/version-migration.middleware.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<MigrationVersion, (body: unknown) => 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<NextResponse>;

/**
* 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<NextResponse> => {
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;
};
}
161 changes: 161 additions & 0 deletions apps/backend/tests/api/version-migration.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = { '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();
});
});
Loading