"""Deprecation header middleware for versioned API endpoints. Adds ``Deprecation``, ``Sunset``, and ``Link`` response headers to endpoints that have been scheduled for removal, following RFC 8599 and the BanGUI API_VERSIONING.md lifecycle policy. """ from __future__ import annotations from datetime import datetime # noqa: TC003 # Used in stringized type annotations (future annotations) from typing import TYPE_CHECKING from starlette.middleware.base import BaseHTTPMiddleware if TYPE_CHECKING: from collections.abc import Awaitable, Callable from starlette.requests import Request from starlette.responses import Response as StarletteResponse class DeprecatedEndpoint: """Describes a deprecated API endpoint and its removal schedule.""" __slots__ = ("path_prefix", "sunset_date", "successor_url") def __init__( self, path_prefix: str, sunset_date: datetime, successor_url: str | None = None, ) -> None: self.path_prefix = path_prefix self.sunset_date = sunset_date self.successor_url = successor_url # Registry of deprecated endpoints. # Add entries here when an endpoint is scheduled for removal. # sunset_date must be a timezone-aware datetime in UTC. _DEPRECATED_ENDPOINTS: list[DeprecatedEndpoint] = [] def register_deprecated_endpoint( path_prefix: str, sunset_date: datetime, successor_url: str | None = None, ) -> None: """Register a deprecated endpoint for deprecation header injection.""" _DEPRECATED_ENDPOINTS.append( DeprecatedEndpoint(path_prefix, sunset_date, successor_url) ) def _is_deprecated(path: str) -> DeprecatedEndpoint | None: for endpoint in _DEPRECATED_ENDPOINTS: if path.startswith(endpoint.path_prefix): return endpoint return None def _format_rfc5322(dt: datetime) -> str: return dt.strftime("%a, %d %b %Y %H:%M:%S GMT") class DeprecationHeaderMiddleware(BaseHTTPMiddleware): """Inject deprecation headers on responses from deprecated endpoints. For any response from a path registered in ``_DEPRECATED_ENDPOINTS``, this middleware appends: - ``Deprecation: true`` - ``Sunset: `` - ``Link: <>; rel="successor-version"`` (if successor_url set) The middleware runs after the response is generated so it has access to the final status code and can choose to only add headers for 2xx responses (non-error responses from a deprecated endpoint are what clients need to be warned about). """ async def dispatch( self, request: Request, call_next: Callable[[Request], Awaitable[StarletteResponse]], ) -> StarletteResponse: response: StarletteResponse = await call_next(request) # Add deprecation headers for 2xx and 3xx responses from deprecated paths. # Skipping 4xx/5xx avoids polluting error responses with deprecation headers. if response.status_code < 200 or response.status_code >= 400: return response deprecated = _is_deprecated(request.url.path) if deprecated is None: return response # All deprecation dates are stored in UTC. sunset_str = _format_rfc5322(deprecated.sunset_date) response.headers["Deprecation"] = "true" response.headers["Sunset"] = sunset_str if deprecated.successor_url: response.headers["Link"] = f'<{deprecated.successor_url}>; rel="successor-version"' return response