File size: 4,425 Bytes
b9b47ab | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 | """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
|