14 KiB
ADR-008: API Versioning Strategy
Date: 2025-12-12
Status: Accepted (Phase 2 Complete - All Tasks Done)
Updated: 2026-01-27
Completion Note: Phase 2 fully complete including test path migration. All 23 integration test files updated to use /api/v1/ paths. Test suite improved from 274/348 to 345/348 passing (3 remain as todo/skipped for known issues unrelated to versioning).
Context
As the application grows, the API will need to evolve. Making breaking changes to existing endpoints can disrupt clients (e.g., a mobile app or the web frontend). The current routing has no formal versioning scheme.
Current State
As of January 2026, the API operates without explicit versioning:
- All routes are mounted under
/api/*(e.g.,/api/flyers,/api/users/profile) - The frontend
apiClient.tsusesAPI_BASE_URL = '/api'as the base - No version prefix exists in route paths
- Breaking changes would immediately affect all consumers
Why Version Now?
- Future Mobile App: A native mobile app is planned, which will have slower update cycles than the web frontend
- Third-Party Integrations: Store partners may integrate with our API
- Deprecation Path: Need a clear way to deprecate and remove endpoints
- Documentation: OpenAPI documentation (ADR-018) should reflect versioned endpoints
Decision
We will adopt a URI-based versioning strategy for the API using a phased rollout approach. All routes will be prefixed with a version number (e.g., /api/v1/flyers).
Versioning Format
/api/v{MAJOR}/resource
- MAJOR: Incremented for breaking changes (v1, v2, v3...)
- Resource paths remain unchanged within a version
What Constitutes a Breaking Change?
The following changes require a new API version:
| Change Type | Breaking? | Example |
|---|---|---|
| Remove endpoint | Yes | DELETE /api/v1/legacy-feature |
| Remove response field | Yes | Remove user.email from response |
| Change response field type | Yes | id: number to id: string |
| Change required request field | Yes | Make email required when it was optional |
| Rename endpoint | Yes | /users to /accounts |
| Add optional response field | No | Add user.avatar_url |
| Add optional request field | No | Add optional page parameter |
| Add new endpoint | No | Add /api/v1/new-feature |
| Fix bug in behavior | No* | Correct calculation error |
*Bug fixes may warrant version increment if clients depend on the buggy behavior.
Implementation Phases
Phase 1: Namespace Migration (Current)
Goal: Add /v1/ prefix to all existing routes without behavioral changes.
Changes Required:
-
server.ts: Update all route registrations
// Before app.use('/api/auth', authRouter); // After app.use('/api/v1/auth', authRouter); -
apiClient.ts: Update base URL
// Before const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api'; // After const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/v1'; -
swagger.ts: Update server definition
servers: [ { url: '/api/v1', description: 'API v1 server', }, ], -
Redirect Middleware (optional): Support legacy clients
// Redirect unversioned routes to v1 app.use('/api/:resource', (req, res, next) => { if (req.params.resource !== 'v1') { return res.redirect(307, `/api/v1/${req.params.resource}${req.url}`); } next(); });
Acceptance Criteria:
- All existing functionality works at
/api/v1/* - Frontend makes requests to
/api/v1/* - OpenAPI documentation reflects
/api/v1/*paths - Integration tests pass with new paths
Phase 2: Versioning Infrastructure
Goal: Build tooling to support multiple API versions.
Components:
-
Version Router Factory
// src/routes/versioned.ts export function createVersionedRoutes(version: 'v1' | 'v2') { const router = express.Router(); if (version === 'v1') { router.use('/auth', authRouterV1); router.use('/users', userRouterV1); // ... } else if (version === 'v2') { router.use('/auth', authRouterV2); router.use('/users', userRouterV2); // ... } return router; } -
Version Detection Middleware
// Extract version from URL and attach to request app.use('/api/:version', (req, res, next) => { req.apiVersion = req.params.version; next(); }); -
Deprecation Headers
// Middleware to add deprecation headers function deprecateVersion(sunsetDate: string) { return (req, res, next) => { res.set('Deprecation', 'true'); res.set('Sunset', sunsetDate); res.set('Link', '</api/v2>; rel="successor-version"'); next(); }; }
Phase 3: Version 2 Support
Goal: Introduce v2 API when breaking changes are needed.
Triggers for v2:
- Major schema changes (e.g., unified item model)
- Response format overhaul
- Authentication mechanism changes
- Significant performance-driven restructuring
Parallel Support:
app.use('/api/v1', createVersionedRoutes('v1'));
app.use('/api/v2', createVersionedRoutes('v2'));
Migration Path
For Frontend (Web)
The web frontend is deployed alongside the API, so migration is straightforward:
- Update
API_BASE_URLinapiClient.ts - Update any hardcoded paths in tests
- Deploy frontend and backend together
For External Consumers
External consumers (mobile apps, partner integrations) need a transition period:
- Announcement: 30 days before deprecation of v(N-1)
- Deprecation Headers: Add headers 30 days before sunset
- Documentation: Maintain docs for both versions during transition
- Sunset: Remove v(N-1) after grace period
Deprecation Timeline
| Version | Status | Sunset Date | Notes |
|---|---|---|---|
Unversioned /api/* |
Deprecated | Phase 1 completion | Redirect to v1 |
| v1 | Active | TBD (when v2 releases) | Current version |
Support Policy
- Current Version (v(N)): Full support, all features
- Previous Version (v(N-1)): Security fixes only for 6 months after v(N) release
- Older Versions: No support, endpoints return 410 Gone
Backwards Compatibility Strategy
Redirect Middleware
For a smooth transition, implement redirects from unversioned to versioned endpoints:
// src/middleware/versionRedirect.ts
import { Request, Response, NextFunction } from 'express';
import { logger } from '../services/logger.server';
/**
* Middleware to redirect unversioned API requests to v1.
* This provides backwards compatibility during the transition period.
*
* Example: /api/flyers -> /api/v1/flyers (307 Temporary Redirect)
*/
export function versionRedirectMiddleware(req: Request, res: Response, next: NextFunction) {
const path = req.path;
// Skip if already versioned
if (path.startsWith('/v1') || path.startsWith('/v2')) {
return next();
}
// Skip health checks and documentation
if (path.startsWith('/health') || path.startsWith('/docs')) {
return next();
}
// Log deprecation warning
logger.warn(
{
path: req.originalUrl,
method: req.method,
ip: req.ip,
},
'Unversioned API request - redirecting to v1',
);
// Use 307 to preserve HTTP method
const redirectUrl = `/api/v1${path}${req.url.includes('?') ? req.url.substring(req.url.indexOf('?')) : ''}`;
return res.redirect(307, redirectUrl);
}
Response Versioning Headers
All API responses include version information:
// Middleware to add version headers
app.use('/api/v1', (req, res, next) => {
res.set('X-API-Version', 'v1');
next();
});
Consequences
Positive
- Clear Evolution Path: Establishes a critical pattern for long-term maintainability
- Client Protection: Allows the API to evolve without breaking existing clients
- Parallel Development: Can develop v2 features while maintaining v1 stability
- Documentation Clarity: Each version has its own complete documentation
- Graceful Deprecation: Clients have clear timelines and migration paths
Negative
- Routing Complexity: Adds complexity to the routing setup
- Code Duplication: May need to maintain multiple versions of handlers
- Testing Overhead: Tests may need to cover multiple versions
- Documentation Maintenance: Must keep docs for multiple versions in sync
Mitigation
- Use shared business logic with version-specific adapters
- Automate deprecation header addition
- Generate versioned OpenAPI specs from code
- Clear internal guidelines on when to increment versions
Key Files
| File | Purpose |
|---|---|
server.ts |
Route registration with version prefixes |
src/services/apiClient.ts |
Frontend API base URL configuration |
src/config/swagger.ts |
OpenAPI server URL and version info |
src/routes/*.routes.ts |
Individual route handlers |
src/middleware/versionRedirect.ts |
Backwards compatibility redirects (Phase 1) |
Related ADRs
- ADR-003 - Input Validation (consistent across versions)
- ADR-018 - API Documentation Strategy (versioned OpenAPI specs)
- ADR-028 - Response Standardization (envelope pattern applies to all versions)
- ADR-016 - Security Hardening (applies to all versions)
- ADR-057 - Test Remediation Post-API Versioning (documents test migration)
Implementation Checklist
Phase 1 Tasks
- Update
server.tsto mount all routes under/api/v1/ - Update
src/services/apiClient.tsAPI_BASE_URL to/api/v1 - Update
src/config/swagger.tsserver URL to/api/v1 - Add redirect middleware for unversioned requests
- Update integration tests to use versioned paths
- Update API documentation examples (Swagger server URL updated)
- Verify all health checks work at
/api/v1/health/*
Phase 2 Tasks
Implementation Guide: API Versioning Infrastructure Developer Guide: API Versioning Developer Guide
- Create version router factory (
src/routes/versioned.ts) - Implement deprecation header middleware (
src/middleware/deprecation.middleware.ts) - Add version detection to request context (
src/middleware/apiVersion.middleware.ts) - Add version types to Express Request (
src/types/express.d.ts) - Create version constants configuration (
src/config/apiVersions.ts) - Update server.ts to use version router factory
- Update swagger.ts for multi-server documentation
- Add unit tests for version middleware
- Add integration tests for versioned router
- Document versioning patterns for developers
- Migrate all test files to use
/api/v1/paths (23 files, ~70 occurrences)
Test Path Migration Summary (2026-01-27)
The final cleanup task for Phase 2 was completed by updating all integration test files to use versioned API paths:
| Metric | Value |
|---|---|
| Test files updated | 23 |
| Path occurrences changed | ~70 |
| Test failures resolved | 71 (274 -> 345 passing) |
| Tests remaining todo/skipped | 3 (known issues, not versioning-related) |
| Type check | Passing |
| Versioning-specific tests | 82/82 passing |
Test Results After Migration:
- Integration tests: 345/348 passing
- Unit tests: 3,375/3,391 passing (16 pre-existing failures unrelated to versioning)
Unit Test Path Fix (2026-01-27)
Following the test path migration, 16 unit test failures were discovered and fixed. These failures were caused by error log messages using hardcoded /api/ paths instead of versioned /api/v1/ paths.
Root Cause: Error log messages in route handlers used hardcoded path strings like:
// INCORRECT - hardcoded path doesn't reflect actual request URL
req.log.error({ error }, 'Error in /api/flyers/:id:');
Solution: Updated to use req.originalUrl for dynamic path logging:
// CORRECT - uses actual request URL including version prefix
req.log.error({ error }, `Error in ${req.originalUrl.split('?')[0]}:`);
Files Modified:
| File | Changes |
|---|---|
src/routes/recipe.routes.ts |
3 error log statements updated |
src/routes/stats.routes.ts |
1 error log statement updated |
src/routes/flyer.routes.ts |
2 error logs + 2 test expectations |
src/routes/personalization.routes.ts |
3 error log statements updated |
Test Results After Fix:
- Unit tests: 3,382/3,391 passing (0 failures in fixed files)
- Remaining 9 failures are pre-existing, unrelated issues (CSS/mocking)
Best Practice: See Error Logging Path Patterns for guidance on logging request paths in error handlers.
Migration Documentation: Test Path Migration Guide
Phase 3 Tasks (Future)
- Identify breaking changes requiring v2
- Create v2 route handlers
- Set deprecation timeline for v1
- Migrate documentation to multi-version format