"""Thin HTTP client for the dashboard. The frozen API schemas (`app/api/schemas.py`) are the only contract. The dashboard never touches the databases: everything goes over HTTP to `API_BASE_URL`. Short timeouts keep the UI responsive when the backend is down; every failure is turned into `ApiError` with a Russian message that the page shows as a banner instead of a traceback. """ from __future__ import annotations import logging import os from typing import Any, TypeVar import httpx from pydantic import BaseModel, TypeAdapter, ValidationError from app.api.schemas import ( AckIn, AlertOut, ClockOut, ConnectPatientIn, ConnectPatientOut, PatientCard, PatientListItem, ) log = logging.getLogger(__name__) DEFAULT_BASE_URL = "http://localhost:8000" DEFAULT_DOCTOR_ID = "doc-smirnova" API_PREFIX = "/api/v1" T = TypeVar("T") class ApiError(Exception): """Any API failure, with a message safe to show to the doctor (no PII, no stack traces).""" def __init__(self, message: str, status: int | None = None) -> None: super().__init__(message) self.message = message self.status = status class ApiUnavailable(ApiError): """Backend not reachable (connection refused, DNS, timeout).""" class ApiClient: def __init__( self, base_url: str | None = None, doctor_id: str | None = None, *, timeout: float = 3.0, transport: httpx.BaseTransport | None = None, ) -> None: base = (base_url or os.environ.get("API_BASE_URL") or DEFAULT_BASE_URL).rstrip("/") if not base.endswith(API_PREFIX): base += API_PREFIX self.base_url = base self.doctor_id = doctor_id or os.environ.get("DOCTOR_ID") or DEFAULT_DOCTOR_ID self._http = httpx.Client( base_url=base, timeout=httpx.Timeout(timeout, connect=min(timeout, 1.5)), transport=transport ) # ------------------------------------------------------------------ doctor endpoints def patients(self) -> list[PatientListItem]: return self._get(f"/doctors/{self.doctor_id}/patients", list[PatientListItem]) def patient_card(self, token: str) -> PatientCard: return self._get(f"/patients/{token}", PatientCard, params={"doctor_id": self.doctor_id}) def alerts(self, since_id: int = -1, *, open_only: bool = False) -> list[AlertOut]: params = {"since_id": since_id, "open_only": str(open_only).lower()} return self._get(f"/doctors/{self.doctor_id}/alerts", list[AlertOut], params=params) def ack(self, alert_id: int) -> AlertOut: return self._request("POST", f"/alerts/{alert_id}/ack", AlertOut, json=AckIn(doctor_id=self.doctor_id)) def connect(self, body: ConnectPatientIn) -> ConnectPatientOut: return self._request("POST", "/patients/connect", ConnectPatientOut, json=body) def clock(self) -> ClockOut | None: """Demo clock (ADR-005); optional decoration, so failures return None instead of raising.""" try: return self._get("/demo/clock", ClockOut) except ApiError: return None # ------------------------------------------------------------------ plumbing def _get(self, path: str, model: type[T], params: dict[str, Any] | None = None) -> T: return self._request("GET", path, model, params=params) def _request( self, method: str, path: str, model: type[T], *, params: dict[str, Any] | None = None, json: BaseModel | None = None, ) -> T: payload = json.model_dump(mode="json") if json is not None else None try: resp = self._http.request(method, path, params=params, json=payload) except (httpx.ConnectError, httpx.TimeoutException) as exc: log.warning("api unavailable: %s %s (%s)", method, _route(path), type(exc).__name__) raise ApiUnavailable("API недоступен — проверьте, что сервис запущен") from exc except httpx.HTTPError as exc: log.warning("api transport error: %s %s (%s)", method, _route(path), type(exc).__name__) raise ApiUnavailable("API недоступен — ошибка соединения") from exc if resp.status_code == 404: raise ApiError("Не найдено", 404) if resp.status_code >= 400: log.warning("api error: %s %s -> %s", method, _route(path), resp.status_code) raise ApiError(f"Ошибка API ({resp.status_code})", resp.status_code) try: return TypeAdapter(model).validate_python(resp.json()) except (ValueError, ValidationError) as exc: log.warning("api bad payload: %s %s", method, _route(path)) raise ApiError("API вернул неожиданный ответ") from exc def close(self) -> None: self._http.close() def _route(path: str) -> str: """Path without identifiers, so logs carry no patient tokens.""" parts = path.strip("/").split("/") return "/" + "/".join(p if not any(ch.isdigit() for ch in p) and not p.startswith("tok") else "*" for p in parts)