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"},
)

View File

@@ -0,0 +1,44 @@
#!/usr/bin/env python3
"""Generate the OpenAPI spec and save it to openapi.json.
This script is used by the CI OpenAPI breaking-change check. It creates
the FastAPI app (without starting the server) and serialises the OpenAPI
schema to stdout or a file.
Usage:
python scripts/generate_openapi.py [output_path]
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
if __name__ == "__main__":
# Add the backend directory to the path so we can import app modules.
backend_root = Path(__file__).parent.parent
sys.path.insert(0, str(backend_root))
from app.config import Settings
from app.main import create_app
settings = Settings(
database_path="/tmp/test.db",
fail2ban_socket="/tmp/fake.sock",
fail2ban_config_dir="/tmp/fail2ban",
session_secret="openapi-script-secret-do-not-use-in-production",
session_duration_minutes=60,
timezone="UTC",
log_level="critical",
)
app = create_app(settings=settings)
spec = app.openapi()
output = sys.argv[1] if len(sys.argv) > 1 else None
if output:
Path(output).write_text(json.dumps(spec, indent=2))
print(f"OpenAPI spec written to {output}", file=sys.stderr)
else:
print(json.dumps(spec, indent=2))

View File

@@ -0,0 +1,88 @@
"""Tests for the deprecation header middleware."""
from datetime import UTC, datetime, timedelta
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import create_app
from app.middleware.deprecation import (
_DEPRECATED_ENDPOINTS,
_is_deprecated,
register_deprecated_endpoint,
)
def _make_utc(days_from_now: int) -> datetime:
return datetime.now(UTC) + timedelta(days=days_from_now)
@pytest.fixture
def clean_registry() -> list:
"""Clear the deprecated endpoints registry before and after each test."""
original = list(_DEPRECATED_ENDPOINTS)
_DEPRECATED_ENDPOINTS.clear()
yield _DEPRECATED_ENDPOINTS
_DEPRECATED_ENDPOINTS.clear()
_DEPRECATED_ENDPOINTS.extend(original)
class TestIsDeprecated:
def test_path_matches_registered_prefix(self, clean_registry: list) -> None:
register_deprecated_endpoint("/api/v1/jails", _make_utc(180))
assert _is_deprecated("/api/v1/jails") is not None
assert _is_deprecated("/api/v1/jails/test-jail") is not None
def test_path_does_not_match_unregistered_prefix(self, clean_registry: list) -> None:
register_deprecated_endpoint("/api/v1/jails", _make_utc(180))
assert _is_deprecated("/api/v1/bans") is None
def test_empty_registry_returns_none(self, clean_registry: list) -> None:
assert _is_deprecated("/api/v1/jails") is None
class TestDeprecationHeadersIntegration:
@pytest.mark.asyncio
async def test_deprecated_endpoint_gets_headers(self, clean_registry: list) -> None:
register_deprecated_endpoint("/api/v1/jails", _make_utc(180), successor_url="/api/v2/jails")
settings = pytest.importorskip("app.config").Settings(
database_path="/tmp/test.db",
fail2ban_socket="/tmp/fake.sock",
fail2ban_config_dir="/tmp/fail2ban",
session_secret="test-secret-key-do-not-use-in-production",
session_duration_minutes=60,
timezone="UTC",
log_level="debug",
)
app = create_app(settings=settings)
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.get("/api/v1/jails")
# 307 = setup redirect (app redirects unauthenticated/unconfigured requests)
assert response.status_code in (200, 307, 401, 403, 404)
assert "Deprecation" in response.headers or "Sunset" in response.headers
@pytest.mark.asyncio
async def test_non_deprecated_endpoint_no_headers(self, clean_registry: list) -> None:
register_deprecated_endpoint("/api/v1/jails", _make_utc(180))
settings = pytest.importorskip("app.config").Settings(
database_path="/tmp/test.db",
fail2ban_socket="/tmp/fake.sock",
fail2ban_config_dir="/tmp/fail2ban",
session_secret="test-secret-key-do-not-use-in-production",
session_duration_minutes=60,
timezone="UTC",
log_level="debug",
)
app = create_app(settings=settings)
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.get("/api/v1/bans")
# No Deprecation header on non-deprecated path
assert "Deprecation" not in response.headers