Files
BanGUI/backend/app/middleware/deprecation.py
Lukas 65fe747cba feat(backend): add deprecation middleware and API versioning support
- Add deprecation middleware for warning headers on sunset endpoints
- Add jails_v2 router for API v2 migration path
- Update CI workflow with new test coverage
- Update API versioning documentation
- Remove completed tasks from Tasks.md
2026-05-04 00:03:52 +02:00

108 lines
3.5 KiB
Python

"""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: <RFC-5322 date>``
- ``Link: <<successor_url>>; 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