"""FastAPI dependency providers. All ``Depends()`` callables that inject shared resources (database connection, settings, services, auth guard) are defined here. Routers import directly from this module — never from ``app.state`` directly — to keep coupling explicit and testable. """ import time from typing import Annotated import aiosqlite import structlog from fastapi import Depends, HTTPException, Request, status from app.config import Settings from app.models.auth import Session from app.utils.time_utils import utc_now log: structlog.stdlib.BoundLogger = structlog.get_logger() _COOKIE_NAME = "bangui_session" # --------------------------------------------------------------------------- # Session validation cache # --------------------------------------------------------------------------- #: How long (seconds) a validated session token is served from the in-memory #: cache without re-querying SQLite. Eliminates repeated DB lookups for the #: same token arriving in near-simultaneous parallel requests. _SESSION_CACHE_TTL: float = 10.0 #: ``token → (Session, cache_expiry_monotonic_time)`` _session_cache: dict[str, tuple[Session, float]] = {} def clear_session_cache() -> None: """Flush the entire in-memory session validation cache. Useful in tests to prevent stale state from leaking between test cases. """ _session_cache.clear() def invalidate_session_cache(token: str) -> None: """Evict *token* from the in-memory session cache. Must be called during logout so the revoked token is no longer served from cache without a DB round-trip. Args: token: The session token to remove. """ _session_cache.pop(token, None) async def get_db(request: Request) -> aiosqlite.Connection: """Provide the shared :class:`aiosqlite.Connection` from ``app.state``. Args: request: The current FastAPI request (injected automatically). Returns: The application-wide aiosqlite connection opened during startup. Raises: HTTPException: 503 if the database has not been initialised. """ db: aiosqlite.Connection | None = getattr(request.app.state, "db", None) if db is None: log.error("database_not_initialised") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Database is not available.", ) return db async def get_settings(request: Request) -> Settings: """Provide the :class:`~app.config.Settings` instance from ``app.state``. Args: request: The current FastAPI request (injected automatically). Returns: The application settings loaded at startup. """ return request.app.state.settings # type: ignore[no-any-return] async def require_auth( request: Request, db: Annotated[aiosqlite.Connection, Depends(get_db)], ) -> Session: """Validate the session token and return the active session. The token is read from the ``bangui_session`` cookie or the ``Authorization: Bearer`` header. Validated tokens are cached in memory for :data:`_SESSION_CACHE_TTL` seconds so that concurrent requests sharing the same token avoid repeated SQLite round-trips. The cache is bypassed on expiry and explicitly cleared by :func:`invalidate_session_cache` on logout. Args: request: The incoming FastAPI request. db: Injected aiosqlite connection. Returns: The active :class:`~app.models.auth.Session`. Raises: HTTPException: 401 if no valid session token is found. """ from app.services import auth_service # noqa: PLC0415 token: str | None = request.cookies.get(_COOKIE_NAME) if not token: auth_header: str = request.headers.get("Authorization", "") if auth_header.startswith("Bearer "): token = auth_header[len("Bearer "):] if not token: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required.", headers={"WWW-Authenticate": "Bearer"}, ) # Fast path: serve from in-memory cache when the entry is still fresh and # the session itself has not yet exceeded its own expiry time. cached = _session_cache.get(token) if cached is not None: session, cache_expires_at = cached if time.monotonic() < cache_expires_at and session.expires_at > utc_now().isoformat(): return session # Stale cache entry — evict and fall through to DB. _session_cache.pop(token, None) try: session = await auth_service.validate_session(db, token) except ValueError as exc: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc), headers={"WWW-Authenticate": "Bearer"}, ) from exc _session_cache[token] = (session, time.monotonic() + _SESSION_CACHE_TTL) return session # Convenience type aliases for route signatures. DbDep = Annotated[aiosqlite.Connection, Depends(get_db)] SettingsDep = Annotated[Settings, Depends(get_settings)] AuthDep = Annotated[Session, Depends(require_auth)]