Muhammad Usman
feat(phase-7): implement FastAPI backend, React SPA frontend, and Docker deployment
b9b47ab
Raw History Blame Contribute Delete
4.43 kB
"""Shared dependencies for the Phase 7 HTTP API.
Holds the single process-wide storage handle, the lazily constructed research pipeline,
and the FastAPI dependencies that turn an incoming request into either an authenticated
identity or a scoped guest identity.
"""
import re
import threading
from typing import Optional
from fastapi import Depends, Header, HTTPException, Query, status
from src.auth import UserIdentity, verify_access_token
from src.logger import get_logger
from src.pipeline import ResearchPipeline
from src.rate_limiter import RateLimiter
from src.storage import ResearchStorage
logger = get_logger(__name__)
# Guest identifiers are client-supplied, so the transport layer bounds them to a safe
# shape before they are ever used as a storage/rate-limit key.
_GUEST_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
_storage: Optional[ResearchStorage] = None
_rate_limiter: Optional[RateLimiter] = None
_pipeline: Optional[ResearchPipeline] = None
_pipeline_lock = threading.Lock()
def get_storage() -> ResearchStorage:
"""Return the process-wide ResearchStorage handle."""
global _storage
if _storage is None:
_storage = ResearchStorage()
return _storage
def get_rate_limiter() -> RateLimiter:
"""Return the process-wide RateLimiter bound to the shared storage handle."""
global _rate_limiter
if _rate_limiter is None:
_rate_limiter = RateLimiter(storage=get_storage())
return _rate_limiter
def get_pipeline() -> ResearchPipeline:
"""Return the process-wide ResearchPipeline, constructing it on first use.
Construction validates API keys and builds LLM clients, so it is deferred until a
research request actually arrives - auth and history endpoints stay usable without it.
"""
global _pipeline
if _pipeline is None:
with _pipeline_lock:
if _pipeline is None:
_pipeline = ResearchPipeline(storage=get_storage())
return _pipeline
def reset_state(
storage: Optional[ResearchStorage] = None,
rate_limiter: Optional[RateLimiter] = None,
pipeline: Optional[ResearchPipeline] = None,
) -> None:
"""Replace the process-wide handles. Used by tests to inject isolated fixtures."""
global _storage, _rate_limiter, _pipeline
_storage = storage
_rate_limiter = rate_limiter if rate_limiter is not None else (
RateLimiter(storage=storage) if storage is not None else None
)
_pipeline = pipeline
def _extract_bearer_token(authorization: Optional[str]) -> Optional[str]:
"""Pull the raw JWT out of an 'Authorization: Bearer <token>' header."""
if not authorization:
return None
parts = authorization.split(None, 1)
if len(parts) != 2 or parts[0].lower() != "bearer":
return None
token = parts[1].strip()
return token or None
def get_optional_identity(
authorization: Optional[str] = Header(default=None),
) -> Optional[UserIdentity]:
"""Resolve an authenticated identity if a valid bearer token was supplied.
A malformed or expired token is an explicit error rather than a silent downgrade to
guest: a user whose session expired must be told, not quietly de-authenticated.
"""
token = _extract_bearer_token(authorization)
if not token:
return None
return verify_access_token(token)
def require_identity(
identity: Optional[UserIdentity] = Depends(get_optional_identity),
) -> UserIdentity:
"""Require an authenticated identity, rejecting anonymous requests with 401."""
if identity is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required.",
headers={"WWW-Authenticate": "Bearer"},
)
return identity
def get_guest_id(
session_id: Optional[str] = Query(
default=None,
description="Client-generated guest session identifier used to scope guest rate limits.",
),
) -> Optional[str]:
"""Validate and return the client-supplied guest session identifier."""
if session_id is None:
return None
candidate = session_id.strip()
if not candidate:
return None
if not _GUEST_ID_RE.match(candidate):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="session_id must be 1-64 characters of letters, digits, '-' or '_'.",
)
return candidate