"""OFF normalization + HTTP calls (spec §8.1 rule 10). All OFF→foods field mapping lives here and nowhere else. Tests mock at the httpx boundary — never hit the real OFF API (spec §8.4). Normalization field mapping (OFF v2 → FoodCreate): - name: product_name (fallback: generic_name) - brand: brands (comma-separated → first entry trimmed; see _first_brand) - barcode: code - source: "openfoodfacts" - unit_type: "weight" (OFF nutrition is per-100g) - calories_per_unit: nutriments["energy-kcal_100g"], or nutriments["energy-kj_100g"] / 4.184 if kcal absent → rounded 1dp - *_per_unit: nutriments["{field}_100g"] (absent → null) - serving_size_g: serving_quantity parsed as float (grams) if present - serving_name: serving_size (human string) if present - off_data: raw product JSON serialized to string Not-found rule: returns None when the product has no usable name AND no computable calories (both missing → not a useful food). """ import json from datetime import datetime, timezone import httpx from sqlalchemy.orm import Session from models import Food from schemas import FoodRead OFF_BASE_URL = "https://world.openfoodfacts.org" OFF_USER_AGENT = "CalCount/0.1 (personal self-hosted calorie counter)" OFF_TIMEOUT = 10.0 # 1 kcal = 4.184 kJ → kJ_to_kcal = 1 / 4.184 _KJ_TO_KCAL = 1.0 / 4.184 # ── Client factory (mocked at the httpx boundary in tests) ─────────────────── def _default_client() -> httpx.Client: """Return an httpx Client with our User-Agent and timeout.""" return httpx.Client( headers={"User-Agent": OFF_USER_AGENT}, timeout=OFF_TIMEOUT, ) # ── Normalization (the ONE module — spec §8.1 rule 10) ────────────────────── def _first_brand(brands_raw: str | None) -> str | None: """OFF brands is comma-separated (e.g. "Nutella, Ferrero, Yum yum"). We split on comma, trim whitespace, and return the first non-empty entry. Returns None when the string is empty or all-whitespace.""" if not brands_raw or not brands_raw.strip(): return None parts = [p.strip() for p in brands_raw.split(",")] for p in parts: if p: return p return None def _parse_float(value: object) -> float | None: """Coerce an OFF value (number or string) to float. Returns None on failure.""" if value is None: return None try: return float(value) except (ValueError, TypeError): return None def normalize_off_product(product: dict) -> dict | None: """Map an OFF v2 product JSON object → dict matching FoodCreate. Returns None when the product is unusable: no name AND no computable calories. This is the "not-found" signal for the proxy layer. """ nutriments = product.get("nutriments") or {} # ── name ── name = (product.get("product_name") or product.get("generic_name") or "").strip() # ── calories: prefer kcal; fall back to kJ → kcal conversion ── kcal = _parse_float(nutriments.get("energy-kcal_100g")) if kcal is None: kj = _parse_float(nutriments.get("energy-kj_100g")) if kj is not None: kcal = round(kj * _KJ_TO_KCAL, 1) # ── not-found check ── if not name and kcal is None: return None # ── brand ── brand = _first_brand(product.get("brands")) # ── serving ── serving_qty = _parse_float(product.get("serving_quantity")) return { "name": name, "brand": brand, "barcode": str(product.get("code", "")), "source": "openfoodfacts", "is_meal": False, "unit_type": "weight", "calories_per_unit": kcal, "protein_per_unit": _parse_float(nutriments.get("proteins_100g")), "carbs_per_unit": _parse_float(nutriments.get("carbohydrates_100g")), "fat_per_unit": _parse_float(nutriments.get("fat_100g")), "fiber_per_unit": _parse_float(nutriments.get("fiber_100g")), "saturated_fat_per_unit": _parse_float(nutriments.get("saturated-fat_100g")), "sugars_per_unit": _parse_float(nutriments.get("sugars_100g")), "sodium_per_unit": _parse_float(nutriments.get("sodium_100g")), "serving_size_g": serving_qty, "serving_name": product.get("serving_size") or None, "off_data": json.dumps(product), } # ── OFF proxy calls ────────────────────────────────────────────────────────── def fetch_product(barcode: str, client: httpx.Client | None = None) -> dict | None: """Fetch a product from OFF by barcode and return normalized data. Returns the normalized food dict (FoodCreate shape), or None if OFF has no product / status ≠ 1 / unusable data. Upstream errors (HTTP 5xx, timeouts, connection failures) are treated as "not found" (returns None → router maps to 404) so the API degrades gracefully instead of crashing with a 500. Pass *client* with a MockTransport in tests; otherwise a default httpx.Client is created (and closed) per call. """ own = client is None if own: client = _default_client() try: resp = client.get(f"{OFF_BASE_URL}/api/v2/product/{barcode}") resp.raise_for_status() data = resp.json() if data.get("status") != 1 or not data.get("product"): return None return normalize_off_product(data["product"]) except (httpx.HTTPStatusError, httpx.RequestError): # Upstream unavailable or transport failure — degrade gracefully. return None finally: if own: client.close() def search_off(query: str, client: httpx.Client | None = None) -> list[dict]: """Search OFF by text query and return a list of normalized food dicts. Fields requested: code, product_name, generic_name, brands, nutriments, serving_quantity, serving_size. Page size is capped at 20. Returns an empty list when OFF has no matches, all results are unusable after normalization, or an upstream/transport error occurs (graceful degradation — no 500s). """ own = client is None if own: client = _default_client() try: resp = client.get( f"{OFF_BASE_URL}/api/v2/search", params={ "search_terms": query, "fields": "code,product_name,generic_name,brands," "nutriments,serving_quantity,serving_size", "page_size": 20, }, ) resp.raise_for_status() data = resp.json() results: list[dict] = [] for p in data.get("products", []): norm = normalize_off_product(p) if norm is not None: results.append(norm) return results except (httpx.HTTPStatusError, httpx.RequestError): # Upstream unavailable or transport failure — degrade gracefully. return [] finally: if own: client.close() # ── Refresh (§3.5) ─────────────────────────────────────────────────────────── def _now() -> datetime: return datetime.now(timezone.utc).replace(tzinfo=None) class RefreshError(Exception): """Errors from the refresh endpoint that map to HTTP status codes.""" def __init__(self, status_code: int, detail: str): super().__init__(detail) self.status_code = status_code self.detail = detail class NoBarcodeError(RefreshError): """Food has no barcode — can't refresh from OFF.""" def __init__(self, food_id: int): super().__init__( 400, f"Food {food_id} has no barcode — cannot refresh from OpenFoodFacts", ) def refresh_food( db: Session, food_id: int, client: httpx.Client | None = None ) -> FoodRead: """Re-fetch a food's data from OFF by its stored barcode and update the local row (nutrition, name, brand, serving, off_data; bump updated_at). Raises: RefreshError(404) — food_id not found NoBarcodeError(400) — food has no barcode RefreshError(404) — OFF no longer has the product """ food = db.get(Food, food_id) if food is None: raise RefreshError(404, f"Food {food_id} not found") barcode = food.barcode if not barcode: raise NoBarcodeError(food_id) norm = fetch_product(barcode, client=client) if norm is None: raise RefreshError(404, f"Barcode '{barcode}' no longer found on OpenFoodFacts") # Update the local row with fresh OFF data food.name = norm["name"] food.brand = norm["brand"] food.calories_per_unit = norm["calories_per_unit"] food.protein_per_unit = norm["protein_per_unit"] food.carbs_per_unit = norm["carbs_per_unit"] food.fat_per_unit = norm["fat_per_unit"] food.fiber_per_unit = norm["fiber_per_unit"] food.saturated_fat_per_unit = norm["saturated_fat_per_unit"] food.sugars_per_unit = norm["sugars_per_unit"] food.sodium_per_unit = norm["sodium_per_unit"] food.serving_size_g = norm["serving_size_g"] food.serving_name = norm["serving_name"] food.off_data = norm["off_data"] food.updated_at = _now() db.commit() db.refresh(food) return FoodRead.model_validate(food)