Files
flyer-crawler.projectium.com/docs/development/API-VERSIONING.md
Torben Sorensen f10c6c0cd6
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 17m56s
Complete ADR-008 Phase 2
2026-01-27 11:06:09 -08:00

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

  1. Overview
  2. Architecture
  3. Key Concepts
  4. Developer Workflows
  5. Version Headers
  6. Testing Versioned Endpoints
  7. Migration Guide: v1 to v2
  8. Troubleshooting
  9. 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.ts
  • src/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();

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


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)"