← back to Wine Finder Next

lib/apiVersioning.ts

357 lines

// API Versioning System
// Handles multiple API versions with backward compatibility

import { NextRequest, NextResponse } from 'next/server';

interface APIVersion {
  version: string;
  deprecated: boolean;
  deprecationDate?: Date;
  sunsetDate?: Date;
  changes: string[];
}

interface VersionedEndpoint {
  path: string;
  versions: Map<string, Function>;
  defaultVersion: string;
}

// API version registry
const API_VERSIONS: Map<string, APIVersion> = new Map([
  ['v1', {
    version: 'v1',
    deprecated: true,
    deprecationDate: new Date('2024-01-01'),
    sunsetDate: new Date('2025-06-01'),
    changes: ['Initial API version']
  }],
  ['v2', {
    version: 'v2',
    deprecated: false,
    changes: [
      'Enhanced security features',
      'Improved rate limiting',
      'Better error messages',
      'Added CSRF protection',
      'Session management improvements'
    ]
  }],
  ['v3', {
    version: 'v3',
    deprecated: false,
    changes: [
      'GraphQL support',
      'WebSocket real-time updates',
      'Advanced filtering options',
      'Batch operations support'
    ]
  }]
]);

// Current and default versions
const CURRENT_VERSION = 'v3';
const DEFAULT_VERSION = 'v2';
const MINIMUM_SUPPORTED_VERSION = 'v1';

// Versioned endpoints registry
const versionedEndpoints = new Map<string, VersionedEndpoint>();

// Extract API version from request
export function extractAPIVersion(request: NextRequest): {
  version: string;
  source: 'header' | 'path' | 'query' | 'default';
} {
  // 1. Check Accept header (preferred)
  const acceptHeader = request.headers.get('accept');
  if (acceptHeader) {
    const versionMatch = acceptHeader.match(/application\/vnd\.winedao\.([^+]+)\+json/);
    if (versionMatch) {
      return { version: versionMatch[1], source: 'header' };
    }
  }

  // 2. Check custom version header
  const versionHeader = request.headers.get('x-api-version');
  if (versionHeader) {
    return { version: versionHeader, source: 'header' };
  }

  // 3. Check URL path
  const pathMatch = request.nextUrl.pathname.match(/\/api\/(v\d+)\//);
  if (pathMatch) {
    return { version: pathMatch[1], source: 'path' };
  }

  // 4. Check query parameter
  const queryVersion = request.nextUrl.searchParams.get('api_version');
  if (queryVersion) {
    return { version: queryVersion, source: 'query' };
  }

  // 5. Default version
  return { version: DEFAULT_VERSION, source: 'default' };
}

// Validate API version
export function validateAPIVersion(version: string): {
  valid: boolean;
  error?: string;
  warnings?: string[];
} {
  const versionInfo = API_VERSIONS.get(version);

  if (!versionInfo) {
    return {
      valid: false,
      error: `API version ${version} is not supported. Available versions: ${Array.from(API_VERSIONS.keys()).join(', ')}`
    };
  }

  const warnings: string[] = [];

  if (versionInfo.deprecated) {
    warnings.push(`API version ${version} is deprecated since ${versionInfo.deprecationDate?.toISOString()}`);

    if (versionInfo.sunsetDate) {
      const daysUntilSunset = Math.ceil(
        (versionInfo.sunsetDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24)
      );

      if (daysUntilSunset <= 0) {
        return {
          valid: false,
          error: `API version ${version} has been sunset and is no longer available`
        };
      }

      warnings.push(`This version will be sunset in ${daysUntilSunset} days (${versionInfo.sunsetDate.toISOString()})`);
    }
  }

  return { valid: true, warnings };
}

// Version compatibility middleware
export function versionMiddleware(
  request: NextRequest,
  handler: (req: NextRequest, version: string) => Promise<NextResponse>
): Promise<NextResponse> {
  const { version, source } = extractAPIVersion(request);
  const validation = validateAPIVersion(version);

  if (!validation.valid) {
    return Promise.resolve(
      NextResponse.json({
        success: false,
        error: validation.error,
        supportedVersions: Array.from(API_VERSIONS.keys()),
        currentVersion: CURRENT_VERSION
      }, { status: 400 })
    );
  }

  // Add version headers to response
  return handler(request, version).then(response => {
    response.headers.set('x-api-version', version);
    response.headers.set('x-api-version-source', source);

    if (validation.warnings && validation.warnings.length > 0) {
      response.headers.set('x-api-deprecation', validation.warnings.join('; '));
    }

    // Add supported versions header
    response.headers.set('x-api-supported-versions', Array.from(API_VERSIONS.keys()).join(', '));

    return response;
  });
}

// Register versioned endpoint
export function registerVersionedEndpoint(
  path: string,
  versions: Record<string, Function>,
  defaultVersion?: string
): void {
  const versionMap = new Map(Object.entries(versions));

  versionedEndpoints.set(path, {
    path,
    versions: versionMap,
    defaultVersion: defaultVersion || DEFAULT_VERSION
  });
}

// Get handler for specific version
export function getVersionedHandler(
  path: string,
  version: string
): Function | undefined {
  const endpoint = versionedEndpoints.get(path);

  if (!endpoint) {
    return undefined;
  }

  // Try exact version match
  let handler = endpoint.versions.get(version);

  if (handler) {
    return handler;
  }

  // Try fallback to previous version
  const versionNumber = parseInt(version.replace('v', ''));
  for (let v = versionNumber - 1; v >= 1; v--) {
    handler = endpoint.versions.get(`v${v}`);
    if (handler) {
      console.warn(`[API Version] Falling back from ${version} to v${v} for ${path}`);
      return handler;
    }
  }

  // Use default version
  return endpoint.versions.get(endpoint.defaultVersion);
}

// Transform response for version compatibility
export function transformResponseForVersion(
  data: any,
  fromVersion: string,
  toVersion: string
): any {
  // Handle version-specific transformations
  if (fromVersion === 'v3' && toVersion === 'v2') {
    // Remove v3-specific fields
    if (Array.isArray(data)) {
      return data.map(item => transformV3toV2(item));
    }
    return transformV3toV2(data);
  }

  if (fromVersion === 'v2' && toVersion === 'v1') {
    // Remove v2-specific fields
    if (Array.isArray(data)) {
      return data.map(item => transformV2toV1(item));
    }
    return transformV2toV1(data);
  }

  return data;
}

// Version-specific transformations
function transformV3toV2(data: any): any {
  if (!data || typeof data !== 'object') {
    return data;
  }

  const transformed = { ...data };

  // Remove v3-specific fields
  delete transformed.graphqlSchema;
  delete transformed.websocketUrl;
  delete transformed.batchOperations;

  // Transform nested objects
  if (transformed.metadata) {
    transformed.metadata = transformV3toV2(transformed.metadata);
  }

  return transformed;
}

function transformV2toV1(data: any): any {
  if (!data || typeof data !== 'object') {
    return data;
  }

  const transformed = { ...data };

  // Remove v2-specific fields
  delete transformed.csrfToken;
  delete transformed.sessionInfo;
  delete transformed.securityHeaders;

  // Simplify error messages for v1
  if (transformed.error && typeof transformed.error === 'object') {
    transformed.error = transformed.error.message || 'An error occurred';
  }

  return transformed;
}

// Get API version information
export function getAPIVersionInfo(version?: string): APIVersion | APIVersion[] {
  if (version) {
    return API_VERSIONS.get(version) || {
      version,
      deprecated: false,
      changes: ['Unknown version']
    };
  }

  return Array.from(API_VERSIONS.values());
}

// Version negotiation helper
export function negotiateVersion(
  acceptedVersions: string[],
  requiredFeatures?: string[]
): string {
  // Find best matching version
  for (const version of acceptedVersions) {
    if (API_VERSIONS.has(version)) {
      const versionInfo = API_VERSIONS.get(version)!;

      // Check if version supports required features
      if (requiredFeatures) {
        const supportsAllFeatures = requiredFeatures.every(feature =>
          versionInfo.changes.some(change =>
            change.toLowerCase().includes(feature.toLowerCase())
          )
        );

        if (supportsAllFeatures) {
          return version;
        }
      } else {
        return version;
      }
    }
  }

  return DEFAULT_VERSION;
}

// Generate version documentation
export function generateVersionDocs(): {
  current: string;
  default: string;
  minimum: string;
  versions: APIVersion[];
  endpoints: Array<{
    path: string;
    supportedVersions: string[];
  }>;
} {
  const endpoints = Array.from(versionedEndpoints.entries()).map(([path, endpoint]) => ({
    path,
    supportedVersions: Array.from(endpoint.versions.keys())
  }));

  return {
    current: CURRENT_VERSION,
    default: DEFAULT_VERSION,
    minimum: MINIMUM_SUPPORTED_VERSION,
    versions: Array.from(API_VERSIONS.values()),
    endpoints
  };
}

// Export constants for use in other modules
export {
  CURRENT_VERSION,
  DEFAULT_VERSION,
  MINIMUM_SUPPORTED_VERSION
};