feat: implement API versioning /api/v1/
- All backend routers moved to /api/v1/ prefix
- Frontend BASE_URL updated to /api/v1
- Setup redirect middleware updated to redirect to /api/v1/setup
- Health router path fixed: prefix=/api/v1/health, @router.get('')
- conftest.py: set server_status=online for test fixture
- Created Docs/API_VERSIONING.md with deprecation policy
- Updated Docs/Backend-Development.md with versioning section
- Updated Instructions.md curl examples
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -41,6 +41,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler # type: ignore[impo
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request, status
|
||||
|
||||
from app.config import Settings
|
||||
from app.exceptions import RateLimitError
|
||||
from app.models.auth import Session
|
||||
from app.models.config import PendingRecovery
|
||||
from app.models.server import ServerStatus
|
||||
@@ -57,7 +58,7 @@ from app.repositories.protocols import (
|
||||
from app.services.geo_cache import GeoCache
|
||||
from app.services.protocols import Fail2BanMetadataService
|
||||
from app.utils.constants import SESSION_COOKIE_NAME
|
||||
from app.utils.rate_limiter import RateLimiter
|
||||
from app.utils.rate_limiter import GlobalRateLimiter, RateLimiter
|
||||
from app.utils.runtime_state import ApplicationState, JailServiceState, RuntimeState
|
||||
from app.utils.session_cache import NoOpSessionCache, SessionCache
|
||||
|
||||
@@ -93,6 +94,7 @@ class ApplicationContext:
|
||||
runtime_state: RuntimeState
|
||||
session_cache: SessionCache | None
|
||||
login_rate_limiter: RateLimiter
|
||||
global_rate_limiter: GlobalRateLimiter
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -122,6 +124,10 @@ def _build_app_context(request: Request) -> ApplicationContext:
|
||||
if login_rate_limiter is None:
|
||||
login_rate_limiter = RateLimiter()
|
||||
|
||||
global_rate_limiter: GlobalRateLimiter = getattr(state, "global_rate_limiter", None)
|
||||
if global_rate_limiter is None:
|
||||
global_rate_limiter = GlobalRateLimiter()
|
||||
|
||||
return ApplicationContext(
|
||||
settings=state.settings,
|
||||
http_session=getattr(state, "http_session", None),
|
||||
@@ -133,6 +139,7 @@ def _build_app_context(request: Request) -> ApplicationContext:
|
||||
runtime_state=state.runtime_state,
|
||||
session_cache=session_cache,
|
||||
login_rate_limiter=login_rate_limiter,
|
||||
global_rate_limiter=global_rate_limiter,
|
||||
)
|
||||
|
||||
|
||||
@@ -264,6 +271,62 @@ async def get_login_rate_limiter(
|
||||
return app_context.login_rate_limiter
|
||||
|
||||
|
||||
async def get_global_rate_limiter(
|
||||
app_context: Annotated[ApplicationContext, Depends(get_app_context)],
|
||||
) -> GlobalRateLimiter:
|
||||
"""Provide the global rate limiter from application context."""
|
||||
return app_context.global_rate_limiter
|
||||
|
||||
|
||||
def rate_limit_dependency(
|
||||
bucket: str,
|
||||
max_requests: int,
|
||||
window_seconds: int,
|
||||
) -> Callable[[Request, "GlobalRateLimiter"], None]:
|
||||
"""Create a rate limit dependency for a specific bucket and limit.
|
||||
|
||||
Use this factory to create per-endpoint rate limit dependencies.
|
||||
Each call returns a configured dependency that enforces the
|
||||
specified rate limit before the endpoint handler runs.
|
||||
|
||||
Args:
|
||||
bucket: Bucket name (e.g., "bans:ban", "blocklist:import").
|
||||
max_requests: Maximum requests allowed within the window.
|
||||
window_seconds: Time window for this bucket.
|
||||
|
||||
Returns:
|
||||
A callable that can be used as a FastAPI Depends() dependency.
|
||||
"""
|
||||
async def check_rate_limit(
|
||||
request: Request,
|
||||
rate_limiter: GlobalRateLimiterDep,
|
||||
) -> None:
|
||||
from app.utils.client_ip import get_client_ip
|
||||
|
||||
settings: Settings = request.app.state.settings
|
||||
client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies)
|
||||
|
||||
is_allowed, retry_after = rate_limiter.check_allowed_for_bucket(
|
||||
bucket, client_ip, max_requests, window_seconds
|
||||
)
|
||||
|
||||
if not is_allowed:
|
||||
log.warning(
|
||||
"operation_rate_limit_exceeded",
|
||||
client_ip=client_ip,
|
||||
bucket=bucket,
|
||||
path=request.url.path,
|
||||
method=request.method,
|
||||
retry_after=retry_after,
|
||||
)
|
||||
raise RateLimitError(
|
||||
f"Rate limit exceeded for {bucket}. Please try again later.",
|
||||
retry_after_seconds=retry_after,
|
||||
)
|
||||
|
||||
return check_rate_limit
|
||||
|
||||
|
||||
async def get_session_repo() -> SessionRepository:
|
||||
"""Provide the concrete session repository implementation.
|
||||
|
||||
@@ -668,6 +731,7 @@ AppStateDep = Annotated[ApplicationContext, Depends(get_app_state)]
|
||||
AppDep = Annotated[FastAPI, Depends(get_app)]
|
||||
AuthDep = Annotated[Session, Depends(require_auth)]
|
||||
LoginRateLimiterDep = Annotated[RateLimiter, Depends(get_login_rate_limiter)]
|
||||
GlobalRateLimiterDep = Annotated[GlobalRateLimiter, Depends(get_global_rate_limiter)]
|
||||
Fail2BanMetadataServiceDep = Annotated[Fail2BanMetadataService, Depends(get_fail2ban_metadata_service)]
|
||||
|
||||
# Service context dependencies (db + repositories combined for routers)
|
||||
|
||||
Reference in New Issue
Block a user