"""Client per il the DWH esposto via Supabase/PostgREST. Incapsula i 10 rpc verificati contro produzione. Errori sempre in italiano e azionabili (stile `vectorstore/embeddings.py`). Lettura sola: l'API ammette solo SELECT/WITH. """ import requests from tht.config import RestConfig class RestError(Exception): """Errore di accesso al DWH via REST, con messaggio leggibile per il reviewer.""" class RestClient: def __init__(self, cfg: RestConfig): self.cfg = cfg self._base = cfg.base_url.rstrip("/") # -- trasporto ----------------------------------------------------------------- def _post(self, fn: str, args: dict) -> requests.Response: url = f"{self._base}/rpc/{fn}" verify: bool | str = self.cfg.ssl_ca if self.cfg.ssl_ca else True try: return requests.post( url, json=args, headers={"X-API-Key": self.cfg.api_key}, timeout=(self.cfg.connect_timeout, self.cfg.timeout), verify=verify, ) except requests.RequestException as e: raise RestError( f"DWH REST non raggiungibile su {self.cfg.base_url} (rpc {fn}): {e}" ) from e def _error_msg(self, fn: str, resp: requests.Response) -> str: try: body = resp.json() detail = body.get("message") or body.get("details") or resp.text except (requests.exceptions.JSONDecodeError, AttributeError, TypeError): detail = resp.text return f"DWH REST rpc {fn} → HTTP {resp.status_code}: {detail}" def _call(self, fn: str, args: dict): resp = self._post(fn, args) if not resp.ok: raise RestError(self._error_msg(fn, resp)) if resp.status_code == 204 or not resp.text: return None return resp.json() # -- rpc ----------------------------------------------------------------------- def ping(self) -> dict: return self._call("ping", {}) def run_query(self, query_text: str) -> list[dict]: """Esegue una SELECT/WITH e restituisce le righe (read-only lato server).""" return self._call("run_query", {"query_text": query_text}) or [] def explain_query(self, query_text: str) -> list[str]: """EXPLAIN di una SELECT/WITH: ritorna le righe testuali del piano.""" rows = self._call("explain_query", {"query_text": query_text}) or [] return [r["line"] for r in rows] def validate_select(self, query_text: str) -> bool: """True se la query è una SELECT/WITH accettata, False se è una write.""" resp = self._post("_validate_select", {"query_text": query_text}) if resp.status_code == 204: return True if resp.status_code == 400: return False raise RestError(self._error_msg("_validate_select", resp)) def list_tables(self, schema_name: str) -> list[dict]: return self._call("list_tables", {"schema_name": schema_name}) or [] def table_columns(self, schema_name: str, table_name: str) -> list[dict]: return self._call( "table_columns", {"schema_name": schema_name, "table_name": table_name} ) or [] def table_comments(self, schema_name: str, table_name: str) -> list[dict]: return self._call( "table_comments", {"schema_name": schema_name, "table_name": table_name} ) or [] def table_foreign_keys(self, schema_name: str, table_name: str) -> list[dict]: return self._call( "table_foreign_keys", {"schema_name": schema_name, "table_name": table_name} ) or [] def top_values( self, schema_name: str, table_name: str, column_name: str, max_values: int ) -> list[dict]: return self._call( "top_values", { "schema_name": schema_name, "table_name": table_name, "column_name": column_name, "max_values": max_values, }, ) or [] def column_stats(self, schema_name: str, table_name: str, column_name: str) -> dict: return self._call( "column_stats", {"schema_name": schema_name, "table_name": table_name, "column_name": column_name}, )