"""Client httpx vers l'API interne HelloFresh. Les URLs réelles ne sont pas codées en dur : elles viennent de `config/endpoints.json`, généré/validé via `tools/discover_api.py`. Tant que ce fichier n'est pas rempli, les appels lèvent une erreur explicite. Le token bearer est fourni par `auth.get_token()`. Les réponses (forme variable) sont mappées vers `models.Recipe` / `models.Week` de façon tolérante. """ from __future__ import annotations import json from pathlib import Path from typing import Any import httpx from . import auth from .models import Recipe, Week, _first ENDPOINTS_PATH = auth.ROOT / "config" / "endpoints.json" DEFAULT_HEADERS = { "Accept": "application/json", "Content-Type": "application/json", "Origin": auth.BASE_URL, "Referer": auth.BASE_URL + "/", "User-Agent": ( "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/124.0 Safari/537.36" ), } class EndpointsNotConfigured(RuntimeError): pass def _load_endpoints() -> dict: if not ENDPOINTS_PATH.exists(): raise EndpointsNotConfigured( f"{ENDPOINTS_PATH} absent. Lancer `python tools/discover_api.py` pour le générer." ) data = json.loads(ENDPOINTS_PATH.read_text(encoding="utf-8")) missing = [k for k in ("weeks", "menu", "set_selection") if not data.get(k)] if missing: raise EndpointsNotConfigured( f"Endpoints manquants dans {ENDPOINTS_PATH}: {missing}. " "Compléter via tools/discover_api.py." ) return data class HelloFreshClient: def __init__(self, token: str | None = None): self._endpoints = _load_endpoints() self._token = token or auth.get_token() self._client = httpx.Client( headers={**DEFAULT_HEADERS, "Authorization": f"Bearer {self._token}"}, timeout=30.0, follow_redirects=True, ) def close(self) -> None: self._client.close() def __enter__(self) -> "HelloFreshClient": return self def __exit__(self, *exc) -> None: self.close() # --- requêtes bas niveau avec re-auth transparent sur 401 --------------- def _request(self, method: str, url: str, **kwargs) -> httpx.Response: resp = self._client.request(method, url, **kwargs) if resp.status_code == 401: # Token expiré → on en capture un neuf et on rejoue une fois. self._token = auth.get_token(force=True) self._client.headers["Authorization"] = f"Bearer {self._token}" resp = self._client.request(method, url, **kwargs) resp.raise_for_status() return resp # --- API métier --------------------------------------------------------- def get_editable_weeks(self) -> list[Week]: """Liste les semaines de l'abonnement encore modifiables.""" resp = self._request("GET", self._endpoints["weeks"]) data = resp.json() raw_weeks = _extract_list(data, "weeks", "deliveries", "items", "data") weeks = [Week.from_api(w) for w in raw_weeks] editable = [w for w in weeks if w.editable] or weeks return editable def get_menu(self, week: str) -> list[Recipe]: """Recettes proposées pour une semaine donnée.""" url = self._endpoints["menu"].replace("{week}", str(week)) resp = self._request("GET", url, params=None if "{week}" in self._endpoints["menu"] else {"week": week}) data = resp.json() raw_recipes = _extract_recipes(data) return [Recipe.from_api(r) for r in raw_recipes] def set_selection(self, week: str, recipe_ids: list[str]) -> dict[str, Any]: """Enregistre la sélection de recettes pour une semaine (écriture). N'est appelé qu'après confirmation côté serveur MCP. La forme du payload dépend de l'endpoint découvert ; on envoie une structure courante, à ajuster selon discover_api.py si nécessaire. """ url = self._endpoints["set_selection"].replace("{week}", str(week)) method = self._endpoints.get("set_selection_method", "PUT").upper() payload = { "week": week, "recipes": [{"id": rid, "quantity": 1} for rid in recipe_ids], } resp = self._request(method, url, json=payload) try: return resp.json() except Exception: return {"status": resp.status_code, "ok": True} def _extract_list(data: Any, *keys: str) -> list[dict]: if isinstance(data, list): return data if isinstance(data, dict): found = _first(data, *keys, default=None) if isinstance(found, list): return found # Parfois imbriqué sous "data"/"items" for v in data.values(): if isinstance(v, list): return v return [] def _extract_recipes(data: Any) -> list[dict]: """Extrait la liste de recettes, qui peut être imbriquée dans des 'courses'.""" if isinstance(data, dict): courses = _first(data, "courses", "modules", "items", default=None) if isinstance(courses, list): recipes = [] for c in courses: if isinstance(c, dict) and "recipe" in c and isinstance(c["recipe"], dict): recipes.append(c["recipe"]) elif isinstance(c, dict): recipes.append(c) if recipes: return recipes return _extract_list(data, "recipes", "items", "data")