|
| 1 | +"""Deprecation middleware for v1 API endpoints. |
| 2 | +
|
| 3 | +This middleware adds deprecation headers to v1 API responses and tracks |
| 4 | +usage metrics to help monitor the migration to v2. |
| 5 | +""" |
| 6 | + |
| 7 | +from collections import Counter |
| 8 | +from datetime import datetime, timedelta |
| 9 | + |
| 10 | +from fastapi import Request |
| 11 | +from loguru import logger |
| 12 | +from starlette.middleware.base import BaseHTTPMiddleware |
| 13 | + |
| 14 | + |
| 15 | +class DeprecationMetrics: |
| 16 | + """Track v1 and v2 API usage for migration planning.""" |
| 17 | + |
| 18 | + def __init__(self): |
| 19 | + """Initialize metrics counters.""" |
| 20 | + self.v1_calls = Counter() |
| 21 | + self.v2_calls = Counter() |
| 22 | + |
| 23 | + def record_v1_call(self, endpoint: str, client: str | None = None): |
| 24 | + """Record a v1 API call. |
| 25 | +
|
| 26 | + Args: |
| 27 | + endpoint: The endpoint path that was called |
| 28 | + client: Optional client identifier |
| 29 | + """ |
| 30 | + self.v1_calls[endpoint] += 1 |
| 31 | + |
| 32 | + def record_v2_call(self, endpoint: str): |
| 33 | + """Record a v2 API call. |
| 34 | +
|
| 35 | + Args: |
| 36 | + endpoint: The endpoint path that was called |
| 37 | + """ |
| 38 | + self.v2_calls[endpoint] += 1 |
| 39 | + |
| 40 | + def get_stats(self) -> dict: |
| 41 | + """Get usage statistics. |
| 42 | +
|
| 43 | + Returns: |
| 44 | + Dictionary with v1/v2 call counts and adoption metrics |
| 45 | + """ |
| 46 | + total_v1 = sum(self.v1_calls.values()) |
| 47 | + total_v2 = sum(self.v2_calls.values()) |
| 48 | + total = total_v1 + total_v2 |
| 49 | + |
| 50 | + return { |
| 51 | + "v1_calls": total_v1, |
| 52 | + "v2_calls": total_v2, |
| 53 | + "total_calls": total, |
| 54 | + "v2_adoption_rate": total_v2 / total if total > 0 else 0, |
| 55 | + "top_v1_endpoints": self.v1_calls.most_common(10), |
| 56 | + "top_v2_endpoints": self.v2_calls.most_common(10), |
| 57 | + } |
| 58 | + |
| 59 | + |
| 60 | +class DeprecationMiddleware(BaseHTTPMiddleware): |
| 61 | + """Add deprecation headers to v1 API responses. |
| 62 | +
|
| 63 | + This middleware: |
| 64 | + - Adds standard deprecation headers to v1 endpoints |
| 65 | + - Logs v1 API usage for monitoring |
| 66 | + - Tracks metrics for v1 and v2 adoption |
| 67 | + - Provides sunset date information |
| 68 | + """ |
| 69 | + |
| 70 | + def __init__( |
| 71 | + self, app, sunset_date: str | None = None, metrics: DeprecationMetrics | None = None |
| 72 | + ): |
| 73 | + """Initialize deprecation middleware. |
| 74 | +
|
| 75 | + Args: |
| 76 | + app: FastAPI application |
| 77 | + sunset_date: ISO 8601 date string for v1 sunset (default: 6 months from now) |
| 78 | + metrics: Optional DeprecationMetrics instance for tracking |
| 79 | + """ |
| 80 | + super().__init__(app) |
| 81 | + self.sunset_date = sunset_date or self._calculate_sunset_date() |
| 82 | + self.metrics = metrics or DeprecationMetrics() |
| 83 | + |
| 84 | + def _calculate_sunset_date(self) -> str: |
| 85 | + """Calculate sunset date 6 months from now. |
| 86 | +
|
| 87 | + Returns: |
| 88 | + HTTP date string for sunset header |
| 89 | + """ |
| 90 | + sunset = datetime.now() + timedelta(days=180) |
| 91 | + return sunset.strftime("%a, %d %b %Y 23:59:59 GMT") |
| 92 | + |
| 93 | + async def dispatch(self, request: Request, call_next): |
| 94 | + """Process request and add deprecation headers to v1 responses. |
| 95 | +
|
| 96 | + Args: |
| 97 | + request: Incoming HTTP request |
| 98 | + call_next: Next middleware in chain |
| 99 | +
|
| 100 | + Returns: |
| 101 | + HTTP response with deprecation headers if applicable |
| 102 | + """ |
| 103 | + response = await call_next(request) |
| 104 | + |
| 105 | + path = request.url.path |
| 106 | + |
| 107 | + # Check if this is a v2 endpoint |
| 108 | + if path.startswith("/v2"): |
| 109 | + self.metrics.record_v2_call(path) |
| 110 | + return response |
| 111 | + |
| 112 | + # Check if this is a deprecated v1 endpoint |
| 113 | + if self._is_deprecated_endpoint(path): |
| 114 | + # Add deprecation headers |
| 115 | + response.headers["Deprecation"] = "true" |
| 116 | + response.headers["Sunset"] = self.sunset_date |
| 117 | + response.headers["Link"] = '</v2>; rel="successor-version"' |
| 118 | + response.headers["X-API-Warn"] = ( |
| 119 | + "This API version is deprecated. " |
| 120 | + "Please migrate to /v2 endpoints. " |
| 121 | + f"Support ends: {self.sunset_date}" |
| 122 | + ) |
| 123 | + |
| 124 | + # Record metrics |
| 125 | + self.metrics.record_v1_call(path, request.client.host if request.client else None) |
| 126 | + |
| 127 | + # Log v1 usage |
| 128 | + logger.warning( |
| 129 | + "V1 API endpoint accessed (deprecated)", |
| 130 | + endpoint=path, |
| 131 | + method=request.method, |
| 132 | + client=request.client.host if request.client else None, |
| 133 | + sunset_date=self.sunset_date, |
| 134 | + ) |
| 135 | + |
| 136 | + return response |
| 137 | + |
| 138 | + def _is_deprecated_endpoint(self, path: str) -> bool: |
| 139 | + """Check if path is a deprecated v1 endpoint. |
| 140 | +
|
| 141 | + Args: |
| 142 | + path: Request path |
| 143 | +
|
| 144 | + Returns: |
| 145 | + True if this is a v1 endpoint that should show deprecation warnings |
| 146 | + """ |
| 147 | + # List of v1 endpoint prefixes that are deprecated |
| 148 | + deprecated_patterns = [ |
| 149 | + "/knowledge/", |
| 150 | + "/memory/", |
| 151 | + "/search/", |
| 152 | + "/resource/", |
| 153 | + "/directory/", |
| 154 | + "/prompt/", |
| 155 | + ] |
| 156 | + |
| 157 | + # Skip non-API paths |
| 158 | + if path.startswith("/docs") or path.startswith("/openapi") or path == "/": |
| 159 | + return False |
| 160 | + |
| 161 | + # Check if path contains any deprecated prefix |
| 162 | + # (accounting for /{project} prefix in URLs like /myproject/knowledge/entities) |
| 163 | + return any(pattern in path for pattern in deprecated_patterns) |
0 commit comments