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
This commit is contained in:
2026-05-04 00:03:52 +02:00
parent c8b48b5b65
commit 65fe747cba
8 changed files with 409 additions and 39 deletions

View File

@@ -48,6 +48,7 @@ from app.exceptions import (
)
from app.middleware.correlation import CorrelationIdMiddleware
from app.middleware.csrf import CsrfMiddleware
from app.middleware.deprecation import DeprecationHeaderMiddleware
from app.middleware.metrics import MetricsMiddleware
from app.middleware.rate_limit import RateLimitMiddleware
from app.models.response import ErrorResponse
@@ -62,6 +63,7 @@ from app.routers import (
health,
history,
jails,
jails_v2,
metrics,
server,
setup,
@@ -1074,6 +1076,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
app.add_middleware(SetupRedirectMiddleware)
app.add_middleware(MetricsMiddleware)
app.add_middleware(CsrfMiddleware)
app.add_middleware(DeprecationHeaderMiddleware)
app.add_middleware(
RateLimitMiddleware,
rate_limiter=app.state.global_rate_limiter,
@@ -1131,5 +1134,6 @@ def create_app(settings: Settings | None = None) -> FastAPI:
app.include_router(server.router)
app.include_router(history.router)
app.include_router(blocklist.router)
app.include_router(jails_v2.router)
return app

View File

@@ -0,0 +1,107 @@
"""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

View File

@@ -0,0 +1,40 @@
"""Jails router — v2 (pre-production).
This router contains the next-generation version of the jails API,
intended for the v2 breaking-change release. It is registered in
``app/main.py`` but is NOT active for production traffic until the
v2 launch milestone is completed.
To activate v2 for a specific endpoint, move the handler from the v1
router to this router and remove the ``include_in_schema=False`` flag.
Until then, all endpoints here return ``404 Not Found`` so they do not
interfere with live v1 traffic.
"""
from __future__ import annotations
from fastapi import APIRouter, status
from fastapi.responses import JSONResponse
#: Set to ``True`` once v2 is declared production-ready.
_V2_ENABLED: bool = False
router: APIRouter = APIRouter(prefix="/api/v2/jails", tags=["Jails (v2)"])
@router.get(
"",
include_in_schema=False,
)
async def jails_v2_placeholder() -> JSONResponse:
"""Placeholder handler — v2 not yet enabled.
Returns 404 so this router never serves real traffic until v2 is
explicitly activated. When v2 goes live, remove this handler and
expose the real endpoints.
"""
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content={"detail": "v2 endpoint not yet available"},
)