Files
TieMeasureFlow/src/frontend/flask_app/services/api_client.py
T
Adriano 72f0cb1f1b fix(frontend): #37 punti 1,4,5,6 - 502 backend giù, barcode quotato, apici Alpine, paginazione ricette
1. api_client.py: ConnectionError/Timeout restituisce status_code=502 invece
   di 0 (nessuna route confrontava == 0, verificato); i proxy che inoltrano
   resp.get("status_code", 500) ereditano il fix senza altre modifiche.
4. measure.py lookup_barcode: code non stringa -> 400 (prima 500 su
   code.strip()); urllib.parse.quote(code, safe="") prima di comporre l'URL
   verso il backend - un barcode con '/' o '../' non spezza più il path né
   raggiunge altri GET con i permessi dell'utente.
5. Ogni "'{{ _(...) }}'" dentro un attributo Alpine (x-text, :title,
   :placeholder, @click) sostituito con "{{ _(...)|tojson_attr }}": la
   traduzione EN di admin/stations.html:105 ("Clear the station's recipes")
   ha un apostrofo che l'IT non ha, e rompeva quel binding e basta - non è
   un XSS, ma stessa classe di difetto di #10. File toccati: admin/stations,
   admin/users, maker/recipe_list, maker/recipe_editor, maker/task_editor,
   maker/task_drawing, components/caliper_status, measure/select_recipe.
   Le occorrenze dentro <script>...</script> (non attributi) sono fuori
   perimetro per questo punto.
6. statistics.py dashboard(): ciclo sulle pagine (per_page=100, il massimo
   del backend) invece di un'unica chiamata che troncava la tendina ricette
   alla centesima.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 23:03:06 +02:00

198 lines
7.3 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")
api_client = APIClient()