feat(api-versioning): add API versioning — lifecycle, pins, deprecation

- types.ts: ApiVersionDoc, ClientVersionPinDoc + 4 Zod schemas
- repository.ts: version CRUD, client pin CRUD, active version lookup
- routes.ts: 10 endpoints (version lifecycle, current, pins CRUD)
- api-versioning.test.ts: 13 schema tests
- draft → active → deprecated → sunset lifecycle
- Client version pinning with auto-upgrade scheduling
- Cosmos containers: api_versions, api_version_pins
This commit is contained in:
saravanakumardb1 2026-03-19 23:50:23 -07:00
parent b5c83b1874
commit 946390f378
4 changed files with 677 additions and 0 deletions

View File

@ -0,0 +1,142 @@
/**
* API Versioning module unit tests.
*/
import { describe, it, expect } from 'vitest';
import {
CreateApiVersionSchema,
UpdateApiVersionSchema,
PinClientVersionSchema,
UpdateClientPinSchema,
} from './types.js';
describe('CreateApiVersionSchema', () => {
it('validates minimal version', () => {
const result = CreateApiVersionSchema.safeParse({
version: 'v1',
label: 'Initial Release',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.status).toBe('draft');
expect(result.data.breakingChanges).toEqual([]);
}
});
it('validates with all fields', () => {
const result = CreateApiVersionSchema.safeParse({
version: '2024-01-15',
label: 'January 2024 Release',
status: 'active',
releasedAt: '2024-01-15T00:00:00.000Z',
migrationGuide: '# Migration from v1\n\n- Rename field X to Y',
breakingChanges: ['Renamed /users to /accounts', 'Changed auth flow'],
addedEndpoints: ['POST /api/widgets', 'GET /api/widgets/:id'],
removedEndpoints: ['DELETE /api/legacy'],
modifiedEndpoints: ['PATCH /api/users → PATCH /api/accounts'],
});
expect(result.success).toBe(true);
});
it('rejects invalid version chars', () => {
expect(CreateApiVersionSchema.safeParse({ version: 'v1 beta', label: 'Test' }).success).toBe(
false
);
});
it('rejects empty version', () => {
expect(CreateApiVersionSchema.safeParse({ version: '', label: 'Test' }).success).toBe(false);
});
it('rejects invalid status', () => {
expect(
CreateApiVersionSchema.safeParse({ version: 'v1', label: 'Test', status: 'archived' }).success
).toBe(false);
});
it('validates date-based version', () => {
const result = CreateApiVersionSchema.safeParse({
version: '2025-06-01',
label: 'June 2025',
});
expect(result.success).toBe(true);
});
});
describe('UpdateApiVersionSchema', () => {
it('validates partial update', () => {
const result = UpdateApiVersionSchema.safeParse({
status: 'deprecated',
deprecatedAt: '2024-06-01T00:00:00.000Z',
sunsetAt: '2024-12-01T00:00:00.000Z',
});
expect(result.success).toBe(true);
});
it('validates migration guide update', () => {
expect(
UpdateApiVersionSchema.safeParse({
migrationGuide: '# New migration steps\n\nFollow these steps...',
}).success
).toBe(true);
});
it('validates null deprecatedAt (to clear)', () => {
expect(UpdateApiVersionSchema.safeParse({ deprecatedAt: null }).success).toBe(true);
});
});
describe('PinClientVersionSchema', () => {
it('validates minimal pin', () => {
const result = PinClientVersionSchema.safeParse({
clientId: 'app-ios-v3',
pinnedVersion: 'v1',
reason: 'Legacy iOS app needs v1 until next release',
});
expect(result.success).toBe(true);
});
it('validates with auto-upgrade', () => {
const result = PinClientVersionSchema.safeParse({
clientId: 'api-key-xyz',
pinnedVersion: '2024-01-15',
reason: 'Partner integration locked to January version',
autoUpgradeAt: '2025-01-01T00:00:00.000Z',
});
expect(result.success).toBe(true);
});
it('rejects empty clientId', () => {
expect(
PinClientVersionSchema.safeParse({
clientId: '',
pinnedVersion: 'v1',
reason: 'test',
}).success
).toBe(false);
});
it('rejects empty reason', () => {
expect(
PinClientVersionSchema.safeParse({
clientId: 'app-1',
pinnedVersion: 'v1',
reason: '',
}).success
).toBe(false);
});
});
describe('UpdateClientPinSchema', () => {
it('validates version change', () => {
expect(UpdateClientPinSchema.safeParse({ pinnedVersion: 'v2' }).success).toBe(true);
});
it('validates reason change', () => {
expect(UpdateClientPinSchema.safeParse({ reason: 'Updated integration' }).success).toBe(true);
});
it('validates null autoUpgradeAt (to clear)', () => {
expect(UpdateClientPinSchema.safeParse({ autoUpgradeAt: null }).success).toBe(true);
});
});

View File

@ -0,0 +1,213 @@
/**
* API Versioning repository Cosmos DB CRUD for versions and client pins.
* @module api-versioning/repository
*/
import { getContainer } from '../../lib/cosmos.js';
import type { ApiVersionDoc, ClientVersionPinDoc, VersionStatus } from './types.js';
// =============================================================================
// API Versions
// =============================================================================
export async function createVersion(doc: ApiVersionDoc): Promise<ApiVersionDoc> {
const container = getContainer('api_versions');
const { resource } = await container.items.create(doc);
if (!resource) throw new Error('Failed to create API version');
return resource as unknown as ApiVersionDoc;
}
export async function getVersion(id: string, productId: string): Promise<ApiVersionDoc | null> {
const container = getContainer('api_versions');
try {
const { resource } = await container.item(id, productId).read();
return resource as unknown as ApiVersionDoc | null;
} catch (err) {
if ((err as { code?: number }).code === 404) return null;
throw err;
}
}
export async function getVersionByString(
productId: string,
version: string
): Promise<ApiVersionDoc | null> {
const container = getContainer('api_versions');
const query = 'SELECT * FROM c WHERE c.productId = @productId AND c.version = @version';
const parameters = [
{ name: '@productId', value: productId },
{ name: '@version', value: version },
];
const { resources } = await container.items
.query<ApiVersionDoc>({ query, parameters })
.fetchAll();
return resources[0] ?? null;
}
export async function updateVersion(
id: string,
productId: string,
updates: Partial<ApiVersionDoc>
): Promise<ApiVersionDoc | null> {
const existing = await getVersion(id, productId);
if (!existing) return null;
const container = getContainer('api_versions');
const updated: ApiVersionDoc = {
...existing,
...updates,
id: existing.id,
productId: existing.productId,
updatedAt: new Date().toISOString(),
};
const { resource } = await container.items.upsert(updated);
if (!resource) throw new Error('Failed to update API version');
return resource as unknown as ApiVersionDoc;
}
export async function listVersions(
productId: string,
options?: { status?: VersionStatus; limit?: number }
): Promise<{ versions: ApiVersionDoc[]; total: number }> {
const container = getContainer('api_versions');
let query = 'SELECT * FROM c WHERE c.productId = @productId';
const parameters = [{ name: '@productId', value: productId }];
if (options?.status) {
query += ' AND c.status = @status';
parameters.push({ name: '@status', value: options.status });
}
query += ' ORDER BY c.createdAt DESC';
const countQuery = query.replace('SELECT *', 'SELECT VALUE COUNT(1)');
const { resources: countResult } = await container.items
.query<number>({ query: countQuery, parameters })
.fetchAll();
const total = countResult[0] ?? 0;
const safeLimit = Math.min(Math.max(options?.limit ?? 50, 1), 200);
query += ` OFFSET 0 LIMIT ${safeLimit}`;
const { resources } = await container.items
.query<ApiVersionDoc>({ query, parameters })
.fetchAll();
return { versions: resources, total };
}
export async function getActiveVersion(productId: string): Promise<ApiVersionDoc | null> {
const container = getContainer('api_versions');
const query =
'SELECT * FROM c WHERE c.productId = @productId AND c.status = "active" ORDER BY c.releasedAt DESC';
const parameters = [{ name: '@productId', value: productId }];
const { resources } = await container.items
.query<ApiVersionDoc>({ query, parameters })
.fetchAll();
return resources[0] ?? null;
}
export async function deleteVersion(id: string, productId: string): Promise<boolean> {
const container = getContainer('api_versions');
try {
await container.item(id, productId).delete();
return true;
} catch (err) {
if ((err as { code?: number }).code === 404) return false;
throw err;
}
}
// =============================================================================
// Client Version Pins
// =============================================================================
export async function createPin(doc: ClientVersionPinDoc): Promise<ClientVersionPinDoc> {
const container = getContainer('api_version_pins');
const { resource } = await container.items.create(doc);
if (!resource) throw new Error('Failed to create version pin');
return resource as unknown as ClientVersionPinDoc;
}
export async function getPin(
clientId: string,
productId: string
): Promise<ClientVersionPinDoc | null> {
const container = getContainer('api_version_pins');
const query = 'SELECT * FROM c WHERE c.productId = @productId AND c.clientId = @clientId';
const parameters = [
{ name: '@productId', value: productId },
{ name: '@clientId', value: clientId },
];
const { resources } = await container.items
.query<ClientVersionPinDoc>({ query, parameters })
.fetchAll();
return resources[0] ?? null;
}
export async function updatePin(
id: string,
productId: string,
updates: Partial<ClientVersionPinDoc>
): Promise<ClientVersionPinDoc | null> {
const container = getContainer('api_version_pins');
try {
const { resource: existing } = await container.item(id, productId).read();
if (!existing) return null;
const updated = {
...existing,
...updates,
updatedAt: new Date().toISOString(),
};
const { resource } = await container.items.upsert(updated);
return resource as unknown as ClientVersionPinDoc;
} catch (err) {
if ((err as { code?: number }).code === 404) return null;
throw err;
}
}
export async function deletePin(id: string, productId: string): Promise<boolean> {
const container = getContainer('api_version_pins');
try {
await container.item(id, productId).delete();
return true;
} catch (err) {
if ((err as { code?: number }).code === 404) return false;
throw err;
}
}
export async function listPins(
productId: string,
options?: { version?: string; limit?: number }
): Promise<{ pins: ClientVersionPinDoc[]; total: number }> {
const container = getContainer('api_version_pins');
let query = 'SELECT * FROM c WHERE c.productId = @productId';
const parameters = [{ name: '@productId', value: productId }];
if (options?.version) {
query += ' AND c.pinnedVersion = @version';
parameters.push({ name: '@version', value: options.version });
}
query += ' ORDER BY c.createdAt DESC';
const countQuery = query.replace('SELECT *', 'SELECT VALUE COUNT(1)');
const { resources: countResult } = await container.items
.query<number>({ query: countQuery, parameters })
.fetchAll();
const total = countResult[0] ?? 0;
const safeLimit = Math.min(Math.max(options?.limit ?? 50, 1), 200);
query += ` OFFSET 0 LIMIT ${safeLimit}`;
const { resources } = await container.items
.query<ClientVersionPinDoc>({ query, parameters })
.fetchAll();
return { pins: resources, total };
}

View File

@ -0,0 +1,217 @@
/**
* API Versioning routes version lifecycle, client pins, deprecation.
* @module api-versioning/routes
*/
import type { FastifyInstance } from 'fastify';
import {
UnauthorizedError,
ForbiddenError,
NotFoundError,
ConflictError,
} from '../../lib/errors.js';
import { getRequestProductId } from '../../lib/request-context.js';
import {
CreateApiVersionSchema,
UpdateApiVersionSchema,
PinClientVersionSchema,
UpdateClientPinSchema,
type VersionStatus,
} from './types.js';
import * as repo from './repository.js';
function requireAuth(req: { jwtPayload?: { sub: string } }): string {
if (!req.jwtPayload?.sub) throw new UnauthorizedError('Authentication required');
return req.jwtPayload.sub;
}
function requireAdmin(req: { jwtPayload?: { sub: string; role?: string } }): string {
const userId = requireAuth(req);
if (req.jwtPayload?.role !== 'admin') throw new ForbiddenError('Admin access required');
return userId;
}
export async function apiVersioningRoutes(app: FastifyInstance): Promise<void> {
// ── Create version ─────────────────────────────────────────
app.post('/api-versions', async (req, reply) => {
const userId = requireAdmin(req);
const productId = getRequestProductId(req);
const input = CreateApiVersionSchema.parse(req.body);
// Check uniqueness
const existing = await repo.getVersionByString(productId, input.version);
if (existing) {
throw new ConflictError(`API version "${input.version}" already exists`);
}
const now = new Date().toISOString();
const doc = {
id: `ver_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
productId,
version: input.version,
status: input.status,
label: input.label,
releasedAt: input.releasedAt ?? null,
deprecatedAt: null,
sunsetAt: null,
migrationGuide: input.migrationGuide ?? null,
breakingChanges: input.breakingChanges,
addedEndpoints: input.addedEndpoints,
removedEndpoints: input.removedEndpoints,
modifiedEndpoints: input.modifiedEndpoints,
createdAt: now,
updatedAt: now,
updatedBy: userId,
};
const created = await repo.createVersion(doc);
req.log.info({ versionId: created.id, version: input.version }, 'API version created');
reply.status(201);
return created;
});
// ── List versions ──────────────────────────────────────────
app.get('/api-versions', async req => {
requireAuth(req);
const productId = getRequestProductId(req);
const { status, limit: limitStr } = req.query as { status?: string; limit?: string };
const parsedLimit = limitStr ? parseInt(limitStr, 10) : 50;
const safeLimit =
Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 200) : 50;
return repo.listVersions(productId, {
status: status as VersionStatus | undefined,
limit: safeLimit,
});
});
// ── Get current active version ─────────────────────────────
app.get('/api-versions/current', async req => {
requireAuth(req);
const productId = getRequestProductId(req);
const active = await repo.getActiveVersion(productId);
if (!active) throw new NotFoundError('No active API version found');
return active;
});
// ── Get version ────────────────────────────────────────────
app.get<{ Params: { id: string } }>('/api-versions/:id', async req => {
requireAuth(req);
const productId = getRequestProductId(req);
const version = await repo.getVersion(req.params.id, productId);
if (!version) throw new NotFoundError('API version not found');
return version;
});
// ── Update version ─────────────────────────────────────────
app.patch<{ Params: { id: string } }>('/api-versions/:id', async req => {
const userId = requireAdmin(req);
const productId = getRequestProductId(req);
const input = UpdateApiVersionSchema.parse(req.body);
const updated = await repo.updateVersion(req.params.id, productId, {
...input,
updatedBy: userId,
});
if (!updated) throw new NotFoundError('API version not found');
req.log.info({ versionId: req.params.id, status: input.status }, 'API version updated');
return updated;
});
// ── Delete version (draft only) ────────────────────────────
app.delete<{ Params: { id: string } }>('/api-versions/:id', async (req, reply) => {
requireAdmin(req);
const productId = getRequestProductId(req);
const version = await repo.getVersion(req.params.id, productId);
if (!version) throw new NotFoundError('API version not found');
if (version.status !== 'draft') {
throw new ConflictError('Only draft versions can be deleted');
}
await repo.deleteVersion(req.params.id, productId);
req.log.info({ versionId: req.params.id }, 'API version deleted');
reply.status(204);
return;
});
// ── Pin client to version ──────────────────────────────────
app.post('/api-versions/pins', async (req, reply) => {
const userId = requireAdmin(req);
const productId = getRequestProductId(req);
const input = PinClientVersionSchema.parse(req.body);
// Verify version exists
const version = await repo.getVersionByString(productId, input.pinnedVersion);
if (!version) throw new NotFoundError(`API version "${input.pinnedVersion}" not found`);
// Check for existing pin
const existing = await repo.getPin(input.clientId, productId);
if (existing) {
throw new ConflictError(`Client "${input.clientId}" already has a version pin`);
}
const now = new Date().toISOString();
const doc = {
id: `pin_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
productId,
clientId: input.clientId,
pinnedVersion: input.pinnedVersion,
reason: input.reason,
autoUpgradeAt: input.autoUpgradeAt ?? null,
createdAt: now,
updatedAt: now,
updatedBy: userId,
};
const created = await repo.createPin(doc);
req.log.info(
{ clientId: input.clientId, version: input.pinnedVersion },
'Client version pinned'
);
reply.status(201);
return created;
});
// ── List pins ──────────────────────────────────────────────
app.get('/api-versions/pins', async req => {
requireAdmin(req);
const productId = getRequestProductId(req);
const { version, limit: limitStr } = req.query as { version?: string; limit?: string };
const parsedLimit = limitStr ? parseInt(limitStr, 10) : 50;
const safeLimit =
Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 200) : 50;
return repo.listPins(productId, { version, limit: safeLimit });
});
// ── Update pin ─────────────────────────────────────────────
app.patch<{ Params: { id: string } }>('/api-versions/pins/:id', async req => {
const userId = requireAdmin(req);
const productId = getRequestProductId(req);
const input = UpdateClientPinSchema.parse(req.body);
const updated = await repo.updatePin(req.params.id, productId, {
...input,
updatedBy: userId,
});
if (!updated) throw new NotFoundError('Version pin not found');
req.log.info({ pinId: req.params.id }, 'Version pin updated');
return updated;
});
// ── Delete pin ─────────────────────────────────────────────
app.delete<{ Params: { id: string } }>('/api-versions/pins/:id', async (req, reply) => {
requireAdmin(req);
const productId = getRequestProductId(req);
const deleted = await repo.deletePin(req.params.id, productId);
if (!deleted) throw new NotFoundError('Version pin not found');
req.log.info({ pinId: req.params.id }, 'Version pin deleted');
reply.status(204);
return;
});
}

View File

@ -0,0 +1,105 @@
/**
* API Versioning module types and schemas.
* Manages API version lifecycle, deprecation notices, migration guides,
* and per-client version pinning.
*/
import { z } from 'zod';
// ── API Version Types ────────────────────────────────────────────
export type VersionStatus = 'draft' | 'active' | 'deprecated' | 'sunset';
export interface ApiVersionDoc {
id: string;
productId: string;
/** Version string (e.g. "v1", "v2", "2024-01-15") */
version: string;
status: VersionStatus;
/** Human-readable label */
label: string;
/** Release date */
releasedAt: string | null;
/** Deprecation date (when deprecated notice starts) */
deprecatedAt: string | null;
/** Sunset date (when version is removed) */
sunsetAt: string | null;
/** Migration guide URL or inline markdown */
migrationGuide: string | null;
/** Breaking changes summary */
breakingChanges: string[];
/** Endpoints added in this version */
addedEndpoints: string[];
/** Endpoints removed in this version */
removedEndpoints: string[];
/** Endpoints modified in this version */
modifiedEndpoints: string[];
createdAt: string;
updatedAt: string;
updatedBy: string;
}
export interface ClientVersionPinDoc {
id: string;
productId: string;
/** Client identifier (API key ID, app ID, or user ID) */
clientId: string;
/** Pinned version */
pinnedVersion: string;
/** Why the client is pinned (e.g. "Legacy integration") */
reason: string;
/** Auto-upgrade after this date */
autoUpgradeAt: string | null;
createdAt: string;
updatedAt: string;
updatedBy: string;
}
// ── Schemas ──────────────────────────────────────────────────────
export const CreateApiVersionSchema = z.object({
version: z
.string()
.min(1)
.max(32)
.regex(/^[a-zA-Z0-9._-]+$/, 'Version must be alphanumeric with dots, hyphens, underscores'),
label: z.string().min(1).max(200),
status: z.enum(['draft', 'active', 'deprecated', 'sunset']).default('draft'),
releasedAt: z.string().datetime().nullable().optional(),
migrationGuide: z.string().max(5000).nullable().optional(),
breakingChanges: z.array(z.string().max(500)).max(50).default([]),
addedEndpoints: z.array(z.string().max(200)).max(100).default([]),
removedEndpoints: z.array(z.string().max(200)).max(100).default([]),
modifiedEndpoints: z.array(z.string().max(200)).max(100).default([]),
});
export const UpdateApiVersionSchema = z.object({
label: z.string().min(1).max(200).optional(),
status: z.enum(['draft', 'active', 'deprecated', 'sunset']).optional(),
releasedAt: z.string().datetime().nullable().optional(),
deprecatedAt: z.string().datetime().nullable().optional(),
sunsetAt: z.string().datetime().nullable().optional(),
migrationGuide: z.string().max(5000).nullable().optional(),
breakingChanges: z.array(z.string().max(500)).max(50).optional(),
addedEndpoints: z.array(z.string().max(200)).max(100).optional(),
removedEndpoints: z.array(z.string().max(200)).max(100).optional(),
modifiedEndpoints: z.array(z.string().max(200)).max(100).optional(),
});
export const PinClientVersionSchema = z.object({
clientId: z.string().min(1).max(128),
pinnedVersion: z.string().min(1).max(32),
reason: z.string().min(1).max(500),
autoUpgradeAt: z.string().datetime().nullable().optional(),
});
export const UpdateClientPinSchema = z.object({
pinnedVersion: z.string().min(1).max(32).optional(),
reason: z.string().min(1).max(500).optional(),
autoUpgradeAt: z.string().datetime().nullable().optional(),
});
export type CreateApiVersionInput = z.infer<typeof CreateApiVersionSchema>;
export type UpdateApiVersionInput = z.infer<typeof UpdateApiVersionSchema>;
export type PinClientVersionInput = z.infer<typeof PinClientVersionSchema>;
export type UpdateClientPinInput = z.infer<typeof UpdateClientPinSchema>;