Files
TieMeasureFlow/src/frontend/flask_app/services/api_client.py
T
Adriano 192d4b590c Camera di stazione: acquisizione dal frontend e campi admin (#72)
Route POST /measure/api/vision/grab (proxy verso il servizio camera),
get_station_by_code, pulsante «Acquisisci dalla camera», grab() in
vision_execute.js, campi camera_url e camera_source nell'admin stazioni,
traduzioni it/en, extra_hosts host.docker.internal nel compose di sviluppo.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 09:51:07 +02:00

202 lines
7.5 KiB
Python

"""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")
def get_station_by_code(self, station_code: str) -> dict[str, Any]:
"""Return the station with the given code (camera_url / camera_source included)."""
return self.get(f"/api/stations/by-code/{station_code}")
api_client = APIClient()