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