"""Shared API helpers: JSON response class (orjson), pagination, admin/owner auth dependencies, tiny in-process TTL cache.""" from __future__ import annotations import hashlib import hmac import time from collections import OrderedDict from typing import Annotated, Any import orjson from fastapi import Depends, Header, HTTPException, Query, Request, Response from fastapi.responses import JSONResponse from companyatlas.config import settings class AtlasJSONResponse(JSONResponse): media_type = "application/json" def render(self, content: Any) -> bytes: return orjson.dumps(content, option=orjson.OPT_NON_STR_KEYS | orjson.OPT_UTC_Z | orjson.OPT_SERIALIZE_NUMPY, default=_default) def _default(obj: Any) -> Any: if hasattr(obj, "isoformat"): return obj.isoformat() if hasattr(obj, "__float__"): return float(obj) if isinstance(obj, set | frozenset): return sorted(obj) raise TypeError(f"not serialisable: {type(obj)!r}") # ------------------------------------------------------------------------------------------------ pagination class PageParams: def __init__(self, page: int = Query(1, ge=1), per_page: int = Query(25, ge=1, le=200)): self.page = page self.per_page = per_page @property def offset(self) -> int: return (self.page - 1) * self.per_page PageDep = Annotated[PageParams, Depends()] # `p: PageDep` in route signatures (avoids call-in-default lint) def page_payload(items: list[Any], total: int, p: PageParams) -> dict[str, Any]: return {"items": items, "page": p.page, "per_page": p.per_page, "total": total, "pages": max(1, -(-total // p.per_page))} # ------------------------------------------------------------------------------------------------ auth def require_admin(x_ca_admin_token: str | None = Header(None, alias="X-CA-Admin-Token")) -> None: if not settings.admin_token or not x_ca_admin_token or not hmac.compare_digest(x_ca_admin_token, settings.admin_token): raise HTTPException(status_code=401, detail="admin token required") def owner_hash(x_ca_owner_token: str | None = Header(None, alias="X-CA-Owner-Token")) -> str: """Anonymous owner identity for watchlists/alerts: a client-generated random token (≥ 24 chars), stored hashed.""" if not x_ca_owner_token or len(x_ca_owner_token) < 24 or len(x_ca_owner_token) > 200: raise HTTPException(status_code=401, detail="owner token required (X-CA-Owner-Token, ≥ 24 characters)") return hashlib.sha256(x_ca_owner_token.encode()).hexdigest() def client_ip(request: Request) -> str: fwd = request.headers.get("x-forwarded-for") if fwd: return fwd.split(",")[0].strip() return request.client.host if request.client else "0.0.0.0" # ------------------------------------------------------------------------------------------------ cache (per process) class TTLCache: def __init__(self, max_items: int = 4096): self._data: OrderedDict[str, tuple[float, Any]] = OrderedDict() self._max = max_items def get(self, key: str) -> Any | None: item = self._data.get(key) if item is None: return None exp, value = item if exp < time.monotonic(): self._data.pop(key, None) return None self._data.move_to_end(key) return value def set(self, key: str, value: Any, ttl_s: float) -> None: self._data[key] = (time.monotonic() + ttl_s, value) self._data.move_to_end(key) while len(self._data) > self._max: self._data.popitem(last=False) def clear(self, prefix: str | None = None) -> None: if prefix is None: self._data.clear() else: for k in [k for k in self._data if k.startswith(prefix)]: self._data.pop(k, None) cache = TTLCache() async def cached(key: str, ttl_s: float, producer): # type: ignore[no-untyped-def] hit = cache.get(key) if hit is not None: return hit value = await producer() cache.set(key, value, ttl_s) return value # ------------------------------------------------------------------------------------------------ cache headers / conditional GET NO_STORE = "no-store" def public_cache_value(max_age_s: int) -> str: return f"public, max-age={max_age_s}, stale-while-revalidate={max_age_s * 2}" def cached_response(request: Request, payload: Any, max_age_s: int) -> Any: """JSON response for a cached public aggregate: `Cache-Control: public, max-age=…` + weak ETag, `304` when `If-None-Match` matches.""" body = AtlasJSONResponse(payload).body etag = 'W/"' + hashlib.sha1(body).hexdigest()[:20] + '"' headers = {"cache-control": public_cache_value(max_age_s), "etag": etag, "vary": "Accept-Encoding"} inm = request.headers.get("if-none-match") if inm and etag in [x.strip() for x in inm.split(",")]: return Response(status_code=304, headers=headers) return Response(content=body, media_type=AtlasJSONResponse.media_type, headers=headers) def is_admin_request(request: Request) -> bool: tok = request.headers.get("x-ca-admin-token") return bool(settings.admin_token and tok and hmac.compare_digest(tok, settings.admin_token)) __all__ = ["NO_STORE", "AtlasJSONResponse", "PageDep", "PageParams", "TTLCache", "cache", "cached", "cached_response", "client_ip", "is_admin_request", "owner_hash", "page_payload", "public_cache_value", "require_admin"]