Files
flyer-crawler.projectium.com/docs/architecture/api-versioning-infrastructure.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

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

  1. Revert server.ts to individual route mounting
  2. Remove new middleware files (not breaking)
  3. Remove version types from express.d.ts
  4. Run npm run type-check && npm test to verify

Success Criteria

  • All existing tests pass (npm test in container)
  • X-API-Version: v1 header 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:

  1. Update test paths to use /api/v1/* explicitly (recommended)
  2. Configure supertest to follow redirects automatically
  3. 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:

  1. Update all integration tests to use versioned paths
  2. Define breaking changes requiring v2
  3. Create v2-specific route handlers where needed
  4. Set deprecation timeline for v1
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