SPB Git forge
28commits 1branches 0releases
7.7 MBsize
maindefault branch
10 days agolast push
Python 66.3% TypeScript 22.7% JavaScript 8.6% HTML 1.4% CSS 0.7%
5.4 KB · 148 lines python
Raw Blame History
1"""Shared API helpers: JSON response class (orjson), pagination, admin/owner auth dependencies, tiny in-process TTL cache."""2from __future__ import annotations34import hashlib5import hmac6import time7from collections import OrderedDict8from typing import Annotated, Any910import orjson11from fastapi import Depends, Header, HTTPException, Query, Request, Response12from fastapi.responses import JSONResponse1314from companyatlas.config import settings151617class AtlasJSONResponse(JSONResponse):18    media_type = "application/json"1920    def render(self, content: Any) -> bytes:21        return orjson.dumps(content, option=orjson.OPT_NON_STR_KEYS | orjson.OPT_UTC_Z | orjson.OPT_SERIALIZE_NUMPY, default=_default)222324def _default(obj: Any) -> Any:25    if hasattr(obj, "isoformat"):26        return obj.isoformat()27    if hasattr(obj, "__float__"):28        return float(obj)29    if isinstance(obj, set | frozenset):30        return sorted(obj)31    raise TypeError(f"not serialisable: {type(obj)!r}")323334# ------------------------------------------------------------------------------------------------ pagination353637class PageParams:38    def __init__(self, page: int = Query(1, ge=1), per_page: int = Query(25, ge=1, le=200)):39        self.page = page40        self.per_page = per_page4142    @property43    def offset(self) -> int:44        return (self.page - 1) * self.per_page454647PageDep = Annotated[PageParams, Depends()]     # `p: PageDep` in route signatures (avoids call-in-default lint)484950def page_payload(items: list[Any], total: int, p: PageParams) -> dict[str, Any]:51    return {"items": items, "page": p.page, "per_page": p.per_page, "total": total, "pages": max(1, -(-total // p.per_page))}525354# ------------------------------------------------------------------------------------------------ auth555657def require_admin(x_ca_admin_token: str | None = Header(None, alias="X-CA-Admin-Token")) -> None:58    if not settings.admin_token or not x_ca_admin_token or not hmac.compare_digest(x_ca_admin_token, settings.admin_token):59        raise HTTPException(status_code=401, detail="admin token required")606162def owner_hash(x_ca_owner_token: str | None = Header(None, alias="X-CA-Owner-Token")) -> str:63    """Anonymous owner identity for watchlists/alerts: a client-generated random token (≥ 24 chars), stored hashed."""64    if not x_ca_owner_token or len(x_ca_owner_token) < 24 or len(x_ca_owner_token) > 200:65        raise HTTPException(status_code=401, detail="owner token required (X-CA-Owner-Token, ≥ 24 characters)")66    return hashlib.sha256(x_ca_owner_token.encode()).hexdigest()676869def client_ip(request: Request) -> str:70    fwd = request.headers.get("x-forwarded-for")71    if fwd:72        return fwd.split(",")[0].strip()73    return request.client.host if request.client else "0.0.0.0"747576# ------------------------------------------------------------------------------------------------ cache (per process)777879class TTLCache:80    def __init__(self, max_items: int = 4096):81        self._data: OrderedDict[str, tuple[float, Any]] = OrderedDict()82        self._max = max_items8384    def get(self, key: str) -> Any | None:85        item = self._data.get(key)86        if item is None:87            return None88        exp, value = item89        if exp < time.monotonic():90            self._data.pop(key, None)91            return None92        self._data.move_to_end(key)93        return value9495    def set(self, key: str, value: Any, ttl_s: float) -> None:96        self._data[key] = (time.monotonic() + ttl_s, value)97        self._data.move_to_end(key)98        while len(self._data) > self._max:99            self._data.popitem(last=False)100101    def clear(self, prefix: str | None = None) -> None:102        if prefix is None:103            self._data.clear()104        else:105            for k in [k for k in self._data if k.startswith(prefix)]:106                self._data.pop(k, None)107108109cache = TTLCache()110111112async def cached(key: str, ttl_s: float, producer):  # type: ignore[no-untyped-def]113    hit = cache.get(key)114    if hit is not None:115        return hit116    value = await producer()117    cache.set(key, value, ttl_s)118    return value119120121# ------------------------------------------------------------------------------------------------ cache headers / conditional GET122123NO_STORE = "no-store"124125126def public_cache_value(max_age_s: int) -> str:127    return f"public, max-age={max_age_s}, stale-while-revalidate={max_age_s * 2}"128129130def cached_response(request: Request, payload: Any, max_age_s: int) -> Any:131    """JSON response for a cached public aggregate: `Cache-Control: public, max-age=…` + weak ETag, `304` when `If-None-Match` matches."""132    body = AtlasJSONResponse(payload).body133    etag = 'W/"' + hashlib.sha1(body).hexdigest()[:20] + '"'134    headers = {"cache-control": public_cache_value(max_age_s), "etag": etag, "vary": "Accept-Encoding"}135    inm = request.headers.get("if-none-match")136    if inm and etag in [x.strip() for x in inm.split(",")]:137        return Response(status_code=304, headers=headers)138    return Response(content=body, media_type=AtlasJSONResponse.media_type, headers=headers)139140141def is_admin_request(request: Request) -> bool:142    tok = request.headers.get("x-ca-admin-token")143    return bool(settings.admin_token and tok and hmac.compare_digest(tok, settings.admin_token))144145146__all__ = ["NO_STORE", "AtlasJSONResponse", "PageDep", "PageParams", "TTLCache", "cache", "cached", "cached_response", "client_ip", "is_admin_request",147           "owner_hash", "page_payload", "public_cache_value", "require_admin"]148