diff --git a/services/platform-service/src/modules/api-versioning/api-versioning.test.ts b/services/platform-service/src/modules/api-versioning/api-versioning.test.ts new file mode 100644 index 00000000..4fb67139 --- /dev/null +++ b/services/platform-service/src/modules/api-versioning/api-versioning.test.ts @@ -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); + }); +}); diff --git a/services/platform-service/src/modules/api-versioning/repository.ts b/services/platform-service/src/modules/api-versioning/repository.ts new file mode 100644 index 00000000..14185826 --- /dev/null +++ b/services/platform-service/src/modules/api-versioning/repository.ts @@ -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 { + 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 { + 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 { + 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({ query, parameters }) + .fetchAll(); + return resources[0] ?? null; +} + +export async function updateVersion( + id: string, + productId: string, + updates: Partial +): Promise { + 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({ 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({ query, parameters }) + .fetchAll(); + + return { versions: resources, total }; +} + +export async function getActiveVersion(productId: string): Promise { + 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({ query, parameters }) + .fetchAll(); + return resources[0] ?? null; +} + +export async function deleteVersion(id: string, productId: string): Promise { + 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 { + 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 { + 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({ query, parameters }) + .fetchAll(); + return resources[0] ?? null; +} + +export async function updatePin( + id: string, + productId: string, + updates: Partial +): Promise { + 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 { + 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({ 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({ query, parameters }) + .fetchAll(); + + return { pins: resources, total }; +} diff --git a/services/platform-service/src/modules/api-versioning/routes.ts b/services/platform-service/src/modules/api-versioning/routes.ts new file mode 100644 index 00000000..d71981cb --- /dev/null +++ b/services/platform-service/src/modules/api-versioning/routes.ts @@ -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 { + // ── 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; + }); +} diff --git a/services/platform-service/src/modules/api-versioning/types.ts b/services/platform-service/src/modules/api-versioning/types.ts new file mode 100644 index 00000000..8a209340 --- /dev/null +++ b/services/platform-service/src/modules/api-versioning/types.ts @@ -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; +export type UpdateApiVersionInput = z.infer; +export type PinClientVersionInput = z.infer; +export type UpdateClientPinInput = z.infer;