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:
@@ -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
|
||||
|
||||
107
backend/app/middleware/deprecation.py
Normal file
107
backend/app/middleware/deprecation.py
Normal 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
|
||||
40
backend/app/routers/jails_v2.py
Normal file
40
backend/app/routers/jails_v2.py
Normal 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"},
|
||||
)
|
||||
Reference in New Issue
Block a user