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:
parent
b5c83b1874
commit
946390f378
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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 };
|
||||||
|
}
|
||||||
217
services/platform-service/src/modules/api-versioning/routes.ts
Normal file
217
services/platform-service/src/modules/api-versioning/routes.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
105
services/platform-service/src/modules/api-versioning/types.ts
Normal file
105
services/platform-service/src/modules/api-versioning/types.ts
Normal 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>;
|
||||||
Loading…
Reference in New Issue
Block a user