24 KiB
API Versioning Developer Guide
Status: Complete (Phase 2) Last Updated: 2026-01-27 Implements: ADR-008 Phase 2 Architecture: api-versioning-infrastructure.md
This guide covers the API versioning infrastructure for the Flyer Crawler application. It explains how versioning works, how to add new versions, and how to deprecate old ones.
Implementation Status
| Component | Status | Tests |
|---|---|---|
| Version Constants | Complete | Unit tests |
| Version Detection Middleware | Complete | 25 unit tests |
| Deprecation Headers Middleware | Complete | 30 unit tests |
| Version Router Factory | Complete | Integration tests |
| Server Integration | Complete | 48 integration tests |
| Developer Documentation | Complete | This guide |
Total Tests: 82 versioning-specific tests (100% passing)
Table of Contents
- Overview
- Architecture
- Key Concepts
- Developer Workflows
- Version Headers
- Testing Versioned Endpoints
- Migration Guide: v1 to v2
- Troubleshooting
- Related Documentation
Overview
The API uses URI-based versioning with the format /api/v{MAJOR}/resource. All endpoints are accessible at versioned paths like /api/v1/flyers or /api/v2/users.
Current Version Status
| Version | Status | Description |
|---|---|---|
| v1 | Active | Current production version |
| v2 | Active | Future version (infrastructure ready) |
Key Features
- Automatic version detection from URL path
- RFC 8594 compliant deprecation headers when versions are deprecated
- Backwards compatibility via 301 redirects from unversioned paths
- Version-aware request context for conditional logic in handlers
- Centralized configuration for version lifecycle management
Architecture
Request Flow
Client Request: GET /api/v1/flyers
|
v
+------+-------+
| server.ts |
| - Redirect |
| middleware |
+------+-------+
|
v
+------+-------+
| createApi |
| Router() |
+------+-------+
|
v
+------+-------+
| detectApi |
| Version |
| middleware |
+------+-------+
| req.apiVersion = 'v1'
v
+------+-------+
| Versioned |
| Router |
| (v1) |
+------+-------+
|
v
+------+-------+
| addDepreca |
| tionHeaders |
| middleware |
+------+-------+
| X-API-Version: v1
v
+------+-------+
| Domain |
| Router |
| (flyers) |
+------+-------+
|
v
Response
Component Overview
| Component | File | Purpose |
|---|---|---|
| Version Constants | src/config/apiVersions.ts |
Type definitions, version configs, utility functions |
| Version Detection | src/middleware/apiVersion.middleware.ts |
Extract version from URL, validate, attach to request |
| Deprecation Headers | src/middleware/deprecation.middleware.ts |
Add RFC 8594 headers for deprecated versions |
| Router Factory | src/routes/versioned.ts |
Create version-specific Express routers |
| Type Extensions | src/types/express.d.ts |
Add apiVersion and versionDeprecation to Request |
Key Concepts
1. Version Configuration
All version definitions live in src/config/apiVersions.ts:
// src/config/apiVersions.ts
// Supported versions as a const tuple
export const API_VERSIONS = ['v1', 'v2'] as const;
// Union type: 'v1' | 'v2'
export type ApiVersion = (typeof API_VERSIONS)[number];
// Version lifecycle status
export type VersionStatus = 'active' | 'deprecated' | 'sunset';
// Configuration for each version
export const VERSION_CONFIGS: Record<ApiVersion, VersionConfig> = {
v1: {
version: 'v1',
status: 'active',
},
v2: {
version: 'v2',
status: 'active',
},
};
2. Version Detection
The detectApiVersion middleware extracts the version from req.params.version and validates it:
// How it works (src/middleware/apiVersion.middleware.ts)
// For valid versions:
// GET /api/v1/flyers -> req.apiVersion = 'v1'
// For invalid versions:
// GET /api/v99/flyers -> 404 with UNSUPPORTED_VERSION error
3. Request Context
After middleware runs, the request object has version information:
// In any route handler
router.get('/flyers', async (req, res) => {
// Access the detected version
const version = req.apiVersion; // 'v1' | 'v2'
// Check deprecation status
if (req.versionDeprecation?.deprecated) {
req.log.warn(
{
sunset: req.versionDeprecation.sunsetDate,
},
'Client using deprecated API',
);
}
// Version-specific behavior
if (req.apiVersion === 'v2') {
return sendSuccess(res, transformV2(data));
}
return sendSuccess(res, data);
});
4. Route Registration
Routes are registered in src/routes/versioned.ts with version availability:
// src/routes/versioned.ts
export const ROUTES: RouteRegistration[] = [
{
path: 'auth',
router: authRouter,
description: 'Authentication routes',
// Available in all versions (no versions array)
},
{
path: 'flyers',
router: flyerRouter,
description: 'Flyer management',
// Available in all versions
},
{
path: 'new-feature',
router: newFeatureRouter,
description: 'New feature only in v2',
versions: ['v2'], // Only available in v2
},
];
Developer Workflows
Adding a New API Version (e.g., v3)
Step 1: Add version to constants (src/config/apiVersions.ts)
// Before
export const API_VERSIONS = ['v1', 'v2'] as const;
// After
export const API_VERSIONS = ['v1', 'v2', 'v3'] as const;
// Add configuration
export const VERSION_CONFIGS: Record<ApiVersion, VersionConfig> = {
v1: { version: 'v1', status: 'active' },
v2: { version: 'v2', status: 'active' },
v3: { version: 'v3', status: 'active' }, // NEW
};
Step 2: Router cache auto-updates (no changes needed)
The versioned router cache in src/routes/versioned.ts automatically creates routers for all versions defined in API_VERSIONS.
Step 3: Update OpenAPI documentation (src/config/swagger.ts)
servers: [
{ url: '/api/v1', description: 'API v1' },
{ url: '/api/v2', description: 'API v2' },
{ url: '/api/v3', description: 'API v3 (New)' }, // NEW
],
Step 4: Test the new version
# In dev container
podman exec -it flyer-crawler-dev npm test
# Manual verification
curl -i http://localhost:3001/api/v3/health
# Should return 200 with X-API-Version: v3 header
Marking a Version as Deprecated
Step 1: Update version config (src/config/apiVersions.ts)
export const VERSION_CONFIGS: Record<ApiVersion, VersionConfig> = {
v1: {
version: 'v1',
status: 'deprecated', // Changed from 'active'
sunsetDate: '2027-01-01T00:00:00Z', // When it will be removed
successorVersion: 'v2', // Migration target
},
v2: {
version: 'v2',
status: 'active',
},
};
Step 2: Verify deprecation headers
curl -I http://localhost:3001/api/v1/health
# Expected headers:
# X-API-Version: v1
# Deprecation: true
# Sunset: 2027-01-01T00:00:00Z
# Link: </api/v2>; rel="successor-version"
# X-API-Deprecation-Notice: API v1 is deprecated and will be sunset...
Step 3: Monitor deprecation usage
Check logs for Deprecated API version accessed messages with context about which clients are still using deprecated versions.
Adding Version-Specific Routes
Scenario: Add a new endpoint only available in v2+
Step 1: Create the route handler (new or existing file)
// src/routes/newFeature.routes.ts
import { Router } from 'express';
import { sendSuccess } from '../utils/apiResponse';
const router = Router();
router.get('/', async (req, res) => {
// This endpoint only exists in v2+
sendSuccess(res, { feature: 'new-feature-data' });
});
export default router;
Step 2: Register with version restriction (src/routes/versioned.ts)
import newFeatureRouter from './newFeature.routes';
export const ROUTES: RouteRegistration[] = [
// ... existing routes ...
{
path: 'new-feature',
router: newFeatureRouter,
description: 'New feature only available in v2+',
versions: ['v2'], // Not available in v1
},
];
Step 3: Verify route availability
# v1 - should return 404
curl -i http://localhost:3001/api/v1/new-feature
# HTTP/1.1 404 Not Found
# v2 - should work
curl -i http://localhost:3001/api/v2/new-feature
# HTTP/1.1 200 OK
# X-API-Version: v2
Adding Version-Specific Behavior in Existing Routes
For routes that exist in multiple versions but behave differently:
// src/routes/flyer.routes.ts
router.get('/:id', async (req, res) => {
const flyer = await flyerService.getFlyer(req.params.id, req.log);
// Different response format per version
if (req.apiVersion === 'v2') {
// v2 returns expanded store data
return sendSuccess(res, {
...flyer,
store: await storeService.getStore(flyer.store_id, req.log),
});
}
// v1 returns just the flyer
return sendSuccess(res, flyer);
});
Version Headers
Response Headers
All versioned API responses include these headers:
| Header | Always Present | Description |
|---|---|---|
X-API-Version |
Yes | The API version handling the request |
Deprecation |
Only if deprecated | true when version is deprecated |
Sunset |
Only if configured | ISO 8601 date when version will be removed |
Link |
Only if configured | URL to successor version with rel="successor-version" |
X-API-Deprecation-Notice |
Only if deprecated | Human-readable deprecation message |
Example: Active Version Response
HTTP/1.1 200 OK
X-API-Version: v2
Content-Type: application/json
{"success":true,"data":{...}}
Example: Deprecated Version Response
HTTP/1.1 200 OK
X-API-Version: v1
Deprecation: true
Sunset: 2027-01-01T00:00:00Z
Link: </api/v2>; rel="successor-version"
X-API-Deprecation-Notice: API v1 is deprecated and will be sunset on 2027-01-01T00:00:00Z. Please migrate to v2.
Content-Type: application/json
{"success":true,"data":{...}}
RFC Compliance
The deprecation headers follow these standards:
- RFC 8594: The "Sunset" HTTP Header Field
- draft-ietf-httpapi-deprecation-header: The "Deprecation" HTTP Header Field
- RFC 8288: Web Linking (for
rel="successor-version")
Testing Versioned Endpoints
Unit Testing Middleware
See test files for patterns:
src/middleware/apiVersion.middleware.test.tssrc/middleware/deprecation.middleware.test.ts
Testing version detection:
// src/middleware/apiVersion.middleware.test.ts
import { detectApiVersion } from './apiVersion.middleware';
import { createMockRequest } from '../tests/utils/createMockRequest';
describe('detectApiVersion', () => {
it('should extract v1 from req.params.version', () => {
const mockRequest = createMockRequest({
params: { version: 'v1' },
});
const mockResponse = { status: vi.fn().mockReturnThis(), json: vi.fn() };
const mockNext = vi.fn();
detectApiVersion(mockRequest, mockResponse, mockNext);
expect(mockRequest.apiVersion).toBe('v1');
expect(mockNext).toHaveBeenCalled();
});
it('should return 404 for invalid version', () => {
const mockRequest = createMockRequest({
params: { version: 'v99' },
});
const mockResponse = {
status: vi.fn().mockReturnThis(),
json: vi.fn(),
};
const mockNext = vi.fn();
detectApiVersion(mockRequest, mockResponse, mockNext);
expect(mockNext).not.toHaveBeenCalled();
expect(mockResponse.status).toHaveBeenCalledWith(404);
});
});
Testing deprecation headers:
// src/middleware/deprecation.middleware.test.ts
import { addDeprecationHeaders } from './deprecation.middleware';
import { VERSION_CONFIGS } from '../config/apiVersions';
describe('addDeprecationHeaders', () => {
beforeEach(() => {
// Mark v1 as deprecated for test
VERSION_CONFIGS.v1 = {
version: 'v1',
status: 'deprecated',
sunsetDate: '2027-01-01T00:00:00Z',
successorVersion: 'v2',
};
});
it('should add all deprecation headers', () => {
const setHeader = vi.fn();
const middleware = addDeprecationHeaders('v1');
middleware(mockRequest, { set: setHeader }, mockNext);
expect(setHeader).toHaveBeenCalledWith('Deprecation', 'true');
expect(setHeader).toHaveBeenCalledWith('Sunset', '2027-01-01T00:00:00Z');
expect(setHeader).toHaveBeenCalledWith('Link', '</api/v2>; rel="successor-version"');
});
});
Integration Testing
Test versioned endpoints:
import request from 'supertest';
import app from '../../server';
describe('API Versioning Integration', () => {
it('should return X-API-Version header for v1', async () => {
const response = await request(app).get('/api/v1/health').expect(200);
expect(response.headers['x-api-version']).toBe('v1');
});
it('should return 404 for unsupported version', async () => {
const response = await request(app).get('/api/v99/health').expect(404);
expect(response.body.error.code).toBe('UNSUPPORTED_VERSION');
});
it('should redirect unversioned paths to v1', async () => {
const response = await request(app).get('/api/health').expect(301);
expect(response.headers.location).toBe('/api/v1/health');
});
});
Running Tests
# Run all tests in container (required)
podman exec -it flyer-crawler-dev npm test
# Run only middleware tests
podman exec -it flyer-crawler-dev npm test -- apiVersion
podman exec -it flyer-crawler-dev npm test -- deprecation
# Type check
podman exec -it flyer-crawler-dev npm run type-check
Migration Guide: v1 to v2
When v2 is introduced with breaking changes, follow this migration process.
For API Consumers (Frontend/Mobile)
Step 1: Check current API version usage
// Frontend apiClient.ts
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/v1';
Step 2: Monitor deprecation headers
When v1 is deprecated, responses will include:
Deprecation: true
Sunset: 2027-01-01T00:00:00Z
Link: </api/v2>; rel="successor-version"
Step 3: Update to v2
// Change API base URL
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/v2';
Step 4: Handle response format changes
If v2 changes response formats, update your type definitions and parsing logic:
// v1 response
interface FlyerResponseV1 {
id: number;
store_id: number;
}
// v2 response (example: includes embedded store)
interface FlyerResponseV2 {
id: string; // Changed to UUID
store: {
id: string;
name: string;
};
}
For Backend Developers
Step 1: Create v2-specific handlers (if needed)
For breaking changes, create version-specific route files:
src/routes/
flyer.routes.ts # Shared/v1 handlers
flyer.v2.routes.ts # v2-specific handlers (if significantly different)
Step 2: Register version-specific routes
// src/routes/versioned.ts
export const ROUTES: RouteRegistration[] = [
{
path: 'flyers',
router: flyerRouter,
description: 'Flyer routes (v1)',
versions: ['v1'],
},
{
path: 'flyers',
router: flyerRouterV2,
description: 'Flyer routes (v2 with breaking changes)',
versions: ['v2'],
},
];
Step 3: Document changes
Update OpenAPI documentation to reflect v2 changes and mark v1 as deprecated.
Timeline Example
| Date | Action |
|---|---|
| T+0 | v2 released, v1 marked deprecated |
| T+0 | Deprecation headers added to v1 responses |
| T+30 days | Sunset warning emails to known integrators |
| T+90 days | v1 returns 410 Gone |
| T+120 days | v1 code removed |
Troubleshooting
Issue: "UNSUPPORTED_VERSION" Error
Symptom: Request to /api/v3/... returns 404 with UNSUPPORTED_VERSION
Cause: Version v3 is not defined in API_VERSIONS
Solution: Add the version to src/config/apiVersions.ts:
export const API_VERSIONS = ['v1', 'v2', 'v3'] as const;
export const VERSION_CONFIGS = {
// ...
v3: { version: 'v3', status: 'active' },
};
Issue: Missing X-API-Version Header
Symptom: Response doesn't include X-API-Version header
Cause: Request didn't go through versioned router
Solution: Ensure the route is registered in src/routes/versioned.ts and mounted under /api/:version
Issue: Deprecation Headers Not Appearing
Symptom: Deprecated version works but no deprecation headers
Cause: Version status not set to 'deprecated' in config
Solution: Update VERSION_CONFIGS:
v1: {
version: 'v1',
status: 'deprecated', // Must be 'deprecated', not 'active'
sunsetDate: '2027-01-01T00:00:00Z',
successorVersion: 'v2',
},
Issue: Route Available in Wrong Version
Symptom: Route works in v1 but should only be in v2
Cause: Missing versions restriction in route registration
Solution: Add versions array:
{
path: 'new-feature',
router: newFeatureRouter,
versions: ['v2'], // Add this to restrict availability
},
Issue: Unversioned Paths Not Redirecting
Symptom: /api/flyers returns 404 instead of redirecting to /api/v1/flyers
Cause: Redirect middleware order issue in server.ts
Solution: Ensure redirect middleware is mounted BEFORE createApiRouter():
// server.ts - correct order
app.use('/api', redirectMiddleware); // First
app.use('/api', createApiRouter()); // Second
Issue: TypeScript Errors on req.apiVersion
Symptom: Property 'apiVersion' does not exist on type 'Request'
Cause: Type extensions not being picked up
Solution: Ensure src/types/express.d.ts is included in tsconfig:
{
"compilerOptions": {
"typeRoots": ["./node_modules/@types", "./src/types"]
},
"include": ["src/**/*"]
}
Issue: Router Cache Stale After Config Change
Symptom: Version behavior doesn't update after changing VERSION_CONFIGS
Cause: Routers are cached at startup
Solution: Use refreshRouterCache() or restart the server:
import { refreshRouterCache } from './src/routes/versioned';
// After config changes
refreshRouterCache();
Related Documentation
Architecture Decision Records
| ADR | Title |
|---|---|
| ADR-008 | API Versioning Strategy |
| ADR-003 | Input Validation |
| ADR-028 | API Response Standardization |
| ADR-018 | API Documentation Strategy |
Implementation Files
| File | Description |
|---|---|
src/config/apiVersions.ts |
Version constants and config |
src/middleware/apiVersion.middleware.ts |
Version detection |
src/middleware/deprecation.middleware.ts |
Deprecation headers |
src/routes/versioned.ts |
Router factory |
src/types/express.d.ts |
Request type extensions |
server.ts |
Application entry point |
Test Files
| File | Description |
|---|---|
src/middleware/apiVersion.middleware.test.ts |
Version detection tests |
src/middleware/deprecation.middleware.test.ts |
Deprecation header tests |
External References
- RFC 8594: The "Sunset" HTTP Header Field
- draft-ietf-httpapi-deprecation-header
- RFC 8288: Web Linking
Quick Reference
Files to Modify for Common Tasks
| Task | Files |
|---|---|
| Add new version | src/config/apiVersions.ts, src/config/swagger.ts |
| Deprecate version | src/config/apiVersions.ts |
| Add version-specific route | src/routes/versioned.ts |
| Version-specific handler logic | Route file (e.g., src/routes/flyer.routes.ts) |
Key Functions
// Check if version is valid
isValidApiVersion('v1'); // true
isValidApiVersion('v99'); // false
// Get version from request with fallback
getRequestApiVersion(req); // Returns 'v1' | 'v2'
// Check if request has valid version
hasApiVersion(req); // boolean
// Get deprecation info
getVersionDeprecation('v1'); // { deprecated: false, ... }
Commands
# Run all tests
podman exec -it flyer-crawler-dev npm test
# Type check
podman exec -it flyer-crawler-dev npm run type-check
# Check version headers manually
curl -I http://localhost:3001/api/v1/health
# Test deprecation (after marking v1 deprecated)
curl -I http://localhost:3001/api/v1/health | grep -E "(Deprecation|Sunset|Link|X-API)"