"""API Client - wrapper for HTTP requests to FastAPI server.""" from typing import Any import requests from flask import g, has_request_context, request, session from config import Config class APIClient: """HTTP client for TieMeasureFlow API server.""" def __init__(self): self.base_url = Config.API_SERVER_URL.rstrip("/") self.timeout = 30 @staticmethod def _real_client_ip() -> str | None: """Best-effort tablet IP for downstream rate limiting. Behind Nginx + ProxyFix, ``request.remote_addr`` already resolves to the tablet IP. Outside a request context (background tasks, tests), return None so we don't forge a bogus header. """ if not has_request_context(): return None return request.remote_addr @property def _headers(self) -> dict[str, str]: """Build request headers with API key + real client IP from session.""" headers = {"Content-Type": "application/json"} api_key = session.get("api_key") if api_key: headers["X-API-Key"] = api_key client_ip = self._real_client_ip() if client_ip: headers["X-Forwarded-For"] = client_ip headers["X-Real-IP"] = client_ip return headers @staticmethod def _handle_session_expired() -> None: """The backend says our api_key is no longer valid (#25). One point for the whole app: whichever route made the call, the Flask session is emptied here so nothing downstream keeps acting as that user. A flag is left on ``g`` for the single after_request hook in app.py to turn into a redirect-to-login on HTML pages; JSON callers already get ``session_expired`` in the body returned below. """ if not has_request_context(): return session.clear() g.session_expired = True def _handle_response(self, response: requests.Response) -> dict[str, Any]: """Parse response and return normalized dict. Returns: - If 2xx and not 204: response JSON - If 204 No Content: empty dict - If 4xx/5xx: {"error": True, "status_code": ..., "detail": "..."} (401 also carries "session_expired": True) """ if response.ok: if response.status_code == 204: return {} return response.json() if response.status_code == 401: self._handle_session_expired() # Non-OK response (4xx/5xx) try: error_body = response.json() detail = error_body.get("detail", str(error_body)) # FastAPI 422 returns detail as a list of validation errors: # [{"type": ..., "loc": ["body", "password"], "msg": "...", ...}, ...] if isinstance(detail, list): parts = [] for e in detail: if not isinstance(e, dict): parts.append(str(e)) continue msg = e.get("msg", str(e)) loc = e.get("loc") or [] # Strip leading "body"/"query"/"path" segments; keep field path. field_path = [str(p) for p in loc if p not in ("body", "query", "path")] field = ".".join(field_path) if field_path else None parts.append(f"{field}: {msg}" if field else msg) detail = "; ".join(parts) or str(detail) except Exception: detail = response.text or f"HTTP {response.status_code}" result = { "error": True, "status_code": response.status_code, "detail": detail } if response.status_code == 401: result["session_expired"] = True return result def get(self, endpoint: str, params: dict | None = None) -> dict[str, Any]: """GET request to API server.""" try: response = requests.get( f"{self.base_url}{endpoint}", headers=self._headers, params=params, timeout=self.timeout, ) return self._handle_response(response) except (requests.ConnectionError, requests.Timeout) as e: # 502, not 0 (#37.1): a route that forwards status_code straight to # jsonify(...), status_code was answering with a made-up HTTP status # that Werkzeug doesn't know ("0 UNKNOWN"), instead of a real one a # fetch caller can branch on. return { "error": True, "status_code": 502, "detail": f"Errore di connessione al server: {str(e)}" } def post(self, endpoint: str, data: dict | None = None, files: dict | None = None) -> dict[str, Any]: """POST request to API server.""" try: if files: headers = {"X-API-Key": session.get("api_key", "")} client_ip = self._real_client_ip() if client_ip: headers["X-Forwarded-For"] = client_ip headers["X-Real-IP"] = client_ip response = requests.post( f"{self.base_url}{endpoint}", headers=headers, data=data, files=files, timeout=self.timeout, ) else: response = requests.post( f"{self.base_url}{endpoint}", headers=self._headers, json=data, timeout=self.timeout, ) return self._handle_response(response) except (requests.ConnectionError, requests.Timeout) as e: return { "error": True, "status_code": 502, "detail": f"Errore di connessione al server: {str(e)}" } def put(self, endpoint: str, data: dict | None = None) -> dict[str, Any]: """PUT request to API server.""" try: response = requests.put( f"{self.base_url}{endpoint}", headers=self._headers, json=data, timeout=self.timeout, ) return self._handle_response(response) except (requests.ConnectionError, requests.Timeout) as e: return { "error": True, "status_code": 502, "detail": f"Errore di connessione al server: {str(e)}" } def delete(self, endpoint: str) -> dict[str, Any]: """DELETE request to API server.""" try: response = requests.delete( f"{self.base_url}{endpoint}", headers=self._headers, timeout=self.timeout, ) return self._handle_response(response) except (requests.ConnectionError, requests.Timeout) as e: return { "error": True, "status_code": 502, "detail": f"Errore di connessione al server: {str(e)}" } # --- Domain helpers --- def get_station_recipes(self, station_code: str) -> dict[str, Any]: """Return the list of active recipes assigned to the given station.""" return self.get(f"/api/stations/by-code/{station_code}/recipes") api_client = APIClient()