15 KiB
API Versioning Infrastructure (ADR-008 Phase 2)
Status: Complete
Date: 2026-01-27
Prerequisite: ADR-008 Phase 1 Complete (all routes at /api/v1/*)
Implementation Summary
Phase 2 has been fully implemented with the following results:
| Metric | Value |
|---|---|
| New Files Created | 5 |
| Files Modified | 2 (server.ts, express.d.ts) |
| Unit Tests | 82 passing (100%) |
| Integration Tests | 48 new versioning tests |
| RFC Compliance | RFC 8594 (Sunset), RFC 8288 (Link) |
| Supported Versions | v1 (active), v2 (infrastructure ready) |
Developer Guide: API-VERSIONING.md
Purpose
Build infrastructure to support parallel API versions, version detection, and deprecation workflows. Enables future v2 API without breaking existing clients.
Architecture Overview
Request → Version Router → Version Middleware → Domain Router → Handler
↓ ↓
createVersionedRouter() attachVersionInfo()
↓ ↓
/api/v1/* | /api/v2/* req.apiVersion = 'v1'|'v2'
Key Components
| Component | File | Responsibility |
|---|---|---|
| Version Router Factory | src/routes/versioned.ts |
Create version-specific Express routers |
| Version Middleware | src/middleware/apiVersion.middleware.ts |
Extract version, attach to request context |
| Deprecation Middleware | src/middleware/deprecation.middleware.ts |
Add RFC 8594 deprecation headers |
| Version Types | src/types/express.d.ts |
Extend Express Request with apiVersion |
| Version Constants | src/config/apiVersions.ts |
Centralized version definitions |
Implementation Tasks
Task 1: Version Types (Foundation)
File: src/types/express.d.ts
declare global {
namespace Express {
interface Request {
apiVersion?: 'v1' | 'v2';
versionDeprecated?: boolean;
}
}
}
Dependencies: None
Testing: Type-check only (npm run type-check)
Task 2: Version Constants
File: src/config/apiVersions.ts
export const API_VERSIONS = ['v1', 'v2'] as const;
export type ApiVersion = (typeof API_VERSIONS)[number];
export const CURRENT_VERSION: ApiVersion = 'v1';
export const DEFAULT_VERSION: ApiVersion = 'v1';
export interface VersionConfig {
version: ApiVersion;
status: 'active' | 'deprecated' | 'sunset';
sunsetDate?: string; // ISO 8601
successorVersion?: ApiVersion;
}
export const VERSION_CONFIG: Record<ApiVersion, VersionConfig> = {
v1: { version: 'v1', status: 'active' },
v2: { version: 'v2', status: 'active' },
};
Dependencies: None Testing: Unit test for version validation
Task 3: Version Detection Middleware
File: src/middleware/apiVersion.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { API_VERSIONS, ApiVersion, DEFAULT_VERSION } from '../config/apiVersions';
export function extractApiVersion(req: Request, _res: Response, next: NextFunction) {
// Extract from URL path: /api/v1/... → 'v1'
const pathMatch = req.path.match(/^\/v(\d+)\//);
if (pathMatch) {
const version = `v${pathMatch[1]}` as ApiVersion;
if (API_VERSIONS.includes(version)) {
req.apiVersion = version;
}
}
// Fallback to default if not detected
req.apiVersion = req.apiVersion || DEFAULT_VERSION;
next();
}
Pattern: Attach to request before route handlers
Integration Point: server.ts before versioned route mounting
Testing: Unit tests for path extraction, default fallback
Task 4: Deprecation Headers Middleware
File: src/middleware/deprecation.middleware.ts
Implements RFC 8594 (Sunset Header) and draft-ietf-httpapi-deprecation-header.
import { Request, Response, NextFunction } from 'express';
import { VERSION_CONFIG, ApiVersion } from '../config/apiVersions';
import { logger } from '../services/logger.server';
export function deprecationHeaders(version: ApiVersion) {
const config = VERSION_CONFIG[version];
return (req: Request, res: Response, next: NextFunction) => {
if (config.status === 'deprecated') {
res.set('Deprecation', 'true');
if (config.sunsetDate) {
res.set('Sunset', config.sunsetDate);
}
if (config.successorVersion) {
res.set('Link', `</api/${config.successorVersion}>; rel="successor-version"`);
}
req.versionDeprecated = true;
// Log deprecation access for monitoring
logger.warn(
{
apiVersion: version,
path: req.path,
method: req.method,
sunsetDate: config.sunsetDate,
},
'Deprecated API version accessed',
);
}
// Always set version header
res.set('X-API-Version', version);
next();
};
}
RFC Compliance:
Deprecation: true(draft-ietf-httpapi-deprecation-header)Sunset: <date>(RFC 8594)Link: <url>; rel="successor-version"(RFC 8288)
Testing: Unit tests for header presence, version status variations
Task 5: Version Router Factory
File: src/routes/versioned.ts
import { Router } from 'express';
import { ApiVersion } from '../config/apiVersions';
import { extractApiVersion } from '../middleware/apiVersion.middleware';
import { deprecationHeaders } from '../middleware/deprecation.middleware';
// Import domain routers
import authRouter from './auth.routes';
import userRouter from './user.routes';
import flyerRouter from './flyer.routes';
// ... all domain routers
interface VersionedRouters {
v1: Record<string, Router>;
v2: Record<string, Router>;
}
const ROUTERS: VersionedRouters = {
v1: {
auth: authRouter,
users: userRouter,
flyers: flyerRouter,
// ... all v1 routers (current implementation)
},
v2: {
// Future: v2-specific routers
// auth: authRouterV2,
// For now, fallback to v1
},
};
export function createVersionedRouter(version: ApiVersion): Router {
const router = Router();
// Apply version middleware
router.use(extractApiVersion);
router.use(deprecationHeaders(version));
// Get routers for this version, fallback to v1
const versionRouters = ROUTERS[version] || ROUTERS.v1;
// Mount domain routers
Object.entries(versionRouters).forEach(([path, domainRouter]) => {
router.use(`/${path}`, domainRouter);
});
return router;
}
Pattern: Factory function returns configured Router Fallback Strategy: v2 uses v1 routers until v2-specific handlers exist Testing: Integration test verifying route mounting
Task 6: Server Integration
File: server.ts (modifications)
// Before (current implementation - Phase 1):
app.use('/api/v1/auth', authRouter);
app.use('/api/v1/users', userRouter);
// ... individual route mounting
// After (Phase 2):
import { createVersionedRouter } from './src/routes/versioned';
// Mount versioned API routers
app.use('/api/v1', createVersionedRouter('v1'));
app.use('/api/v2', createVersionedRouter('v2')); // Placeholder for future
// Keep redirect middleware for unversioned requests
app.use('/api', versionRedirectMiddleware);
Breaking Change Risk: Low (same routes, different mounting)
Rollback: Revert to individual app.use() calls
Testing: Full integration test suite must pass
Task 7: Request Context Propagation
Pattern: Version flows through request lifecycle for conditional logic.
// In any route handler or service:
function handler(req: Request, res: Response) {
if (req.apiVersion === 'v2') {
// v2-specific behavior
return sendSuccess(res, transformV2(data));
}
// v1 behavior (default)
return sendSuccess(res, data);
}
Use Cases:
- Response transformation based on version
- Feature flags per version
- Metric tagging by version
Task 8: Documentation Update
File: src/config/swagger.ts (modifications)
const swaggerDefinition: OpenAPIV3.Document = {
// ...
servers: [
{
url: '/api/v1',
description: 'API v1 (Current)',
},
{
url: '/api/v2',
description: 'API v2 (Future)',
},
],
// ...
};
File: docs/adr/0008-api-versioning-strategy.md (update checklist)
Task 9: Unit Tests
File: src/middleware/apiVersion.middleware.test.ts
describe('extractApiVersion', () => {
it('extracts v1 from /api/v1/users', () => {
/* ... */
});
it('extracts v2 from /api/v2/users', () => {
/* ... */
});
it('defaults to v1 for unversioned paths', () => {
/* ... */
});
it('ignores invalid version numbers', () => {
/* ... */
});
});
File: src/middleware/deprecation.middleware.test.ts
describe('deprecationHeaders', () => {
it('adds no headers for active version', () => {
/* ... */
});
it('adds Deprecation header for deprecated version', () => {
/* ... */
});
it('adds Sunset header when sunsetDate configured', () => {
/* ... */
});
it('adds Link header for successor version', () => {
/* ... */
});
it('always sets X-API-Version header', () => {
/* ... */
});
});
Task 10: Integration Tests
File: src/routes/versioned.test.ts
describe('Versioned Router Integration', () => {
it('mounts all v1 routes correctly', () => {
/* ... */
});
it('v2 falls back to v1 handlers', () => {
/* ... */
});
it('sets X-API-Version response header', () => {
/* ... */
});
it('deprecation headers appear when configured', () => {
/* ... */
});
});
Run in Container: podman exec -it flyer-crawler-dev npm test -- versioned
Implementation Sequence
[Task 1] → [Task 2] → [Task 3] → [Task 4] → [Task 5] → [Task 6]
Types Config Middleware Middleware Factory Server
↓ ↓ ↓ ↓
[Task 7] [Task 9] [Task 10] [Task 8]
Context Unit Integ Docs
Critical Path: 1 → 2 → 3 → 5 → 6 (server integration)
File Structure After Implementation
src/
├── config/
│ ├── apiVersions.ts # NEW: Version constants
│ └── swagger.ts # MODIFIED: Multi-server
├── middleware/
│ ├── apiVersion.middleware.ts # NEW: Version extraction
│ ├── apiVersion.middleware.test.ts # NEW: Unit tests
│ ├── deprecation.middleware.ts # NEW: RFC 8594 headers
│ └── deprecation.middleware.test.ts # NEW: Unit tests
├── routes/
│ ├── versioned.ts # NEW: Router factory
│ ├── versioned.test.ts # NEW: Integration tests
│ └── *.routes.ts # UNCHANGED: Domain routers
├── types/
│ └── express.d.ts # MODIFIED: Add apiVersion
server.ts # MODIFIED: Use versioned router
Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Route registration order breaks | Medium | High | Full integration test suite |
| Middleware not applied to all routes | Low | Medium | Factory pattern ensures consistency |
| Performance impact from middleware | Low | Low | Minimal overhead (path regex) |
| Type errors in extended Request | Low | Medium | TypeScript strict mode catches |
Rollback Procedure
- Revert
server.tsto individual route mounting - Remove new middleware files (not breaking)
- Remove version types from
express.d.ts - Run
npm run type-check && npm testto verify
Success Criteria
- All existing tests pass (
npm testin container) X-API-Version: v1header on all/api/v1/*responses- TypeScript compiles without errors (
npm run type-check) - No performance regression (< 5ms added latency)
- Deprecation headers work when v1 marked deprecated (manual test)
Known Issues and Follow-up Work
Integration Tests Using Unversioned Paths
Issue: Some existing integration tests make requests to unversioned paths (e.g., /api/flyers instead of /api/v1/flyers). These tests now receive 301 redirects due to the backwards compatibility middleware.
Impact: 74 integration tests may need updates to use versioned paths explicitly.
Workaround Options:
- Update test paths to use
/api/v1/*explicitly (recommended) - Configure supertest to follow redirects automatically
- Accept 301 as valid response in affected tests
Resolution: Phase 3 work item - update integration tests to use versioned endpoints consistently.
Phase 3 Prerequisites
Before marking v1 as deprecated and implementing v2:
- Update all integration tests to use versioned paths
- Define breaking changes requiring v2
- Create v2-specific route handlers where needed
- Set deprecation timeline for v1
Related ADRs
| ADR | Relationship |
|---|---|
| ADR-008 | Parent decision (this implements Phase 2) |
| ADR-003 | Validation middleware pattern applies per-version |
| ADR-028 | Response format consistent across versions |
| ADR-018 | OpenAPI docs reflect versioned endpoints |
| ADR-043 | Middleware pipeline order considerations |
Usage Examples
Checking Version in Handler
// src/routes/flyer.routes.ts
router.get('/', async (req, res) => {
const flyers = await flyerRepo.getFlyers(req.log);
// Version-specific response transformation
if (req.apiVersion === 'v2') {
return sendSuccess(res, flyers.map(transformFlyerV2));
}
return sendSuccess(res, flyers);
});
Marking Version as Deprecated
// src/config/apiVersions.ts
export const VERSION_CONFIG = {
v1: {
version: 'v1',
status: 'deprecated',
sunsetDate: '2027-01-01T00:00:00Z',
successorVersion: 'v2',
},
v2: { version: 'v2', status: 'active' },
};
Testing Deprecation Headers
curl -I https://localhost:3001/api/v1/flyers
# When v1 deprecated:
# Deprecation: true
# Sunset: 2027-01-01T00:00:00Z
# Link: </api/v2>; rel="successor-version"
# X-API-Version: v1