feat: autoayuno-api (FastAPI) — alimentos (OFF) y recetas

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-07-01 19:35:02 +02:00
commit 150a323a37
9 changed files with 369 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
__pycache__/
*.pyc
*.db
.venv/
venv/
.pytest_cache/
.env

9
Dockerfile Normal file
View File

@@ -0,0 +1,9 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

28
README.md Normal file
View File

@@ -0,0 +1,28 @@
# autoayuno-api
Catálogo compartido de **alimentos** (alimentado de Open Food Facts bajo demanda)
y **recetas** publicables para la app [autoayuno]. Sin cuentas: publicar es
anónimo con un `deviceToken`. Lo personal del usuario vive en su dispositivo;
aquí solo lo compartido.
## Stack
FastAPI + SQLAlchemy + Postgres (SQLite como fallback en dev). Docker → Dokploy.
Dominio: `api-autoayuno.carlosnarro.com`.
## Endpoints
- `GET /health`
- `GET /foods?q=` — busca en la BD; si hay pocos, importa de Open Food Facts y guarda.
- `POST /foods` — alta anónima de un alimento (`source=user`).
- `GET /recipes?q=`, `GET /recipes/{id}`, `POST /recipes` — recetas públicas.
Las respuestas usan la forma que espera la app (`kcalPer100g`, `zone`, `portions`, `origin`).
## Desarrollo
```bash
pip install -r requirements.txt
uvicorn main:app --reload # SQLite local por defecto
pytest # tests con SQLite en memoria
```
## Producción (Dokploy)
Variable `DATABASE_URL` con el Postgres. Build por Dockerfile, puerto 8000.

36
db.py Normal file
View File

@@ -0,0 +1,36 @@
"""Conexión a la base de datos. Postgres en producción (DATABASE_URL); SQLite
como fallback para desarrollo/tests."""
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
from sqlalchemy.pool import StaticPool
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./autoayuno.db")
# psycopg2 usa 'postgresql://'; algunos proveedores dan 'postgres://'.
if DATABASE_URL.startswith("postgres://"):
DATABASE_URL = DATABASE_URL.replace("postgres://", "postgresql://", 1)
if DATABASE_URL.startswith("sqlite"):
connect_args = {"check_same_thread": False}
# SQLite en memoria: una sola conexión compartida (StaticPool) para que las
# tablas creadas al arrancar sean visibles por todas las sesiones.
is_memory = ":memory:" in DATABASE_URL or DATABASE_URL == "sqlite://"
engine = create_engine(
DATABASE_URL,
connect_args=connect_args,
poolclass=StaticPool if is_memory else None,
)
else:
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

141
main.py Normal file
View File

@@ -0,0 +1,141 @@
"""API de autoayuno: catálogo compartido de alimentos (alimentado de Open Food
Facts bajo demanda) y recetas publicables. Sin cuentas: publicar es anónimo con
un deviceToken."""
import uuid
from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from sqlalchemy import func
from sqlalchemy.orm import Session
import off
from db import Base, engine, get_db
from models import Food, Recipe
Base.metadata.create_all(bind=engine)
app = FastAPI(title="autoayuno-api")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health")
def health():
return {"status": "ok"}
# ---------------------------------------------------------------------------
# Alimentos
# ---------------------------------------------------------------------------
class FoodIn(BaseModel):
name: str
kcalPer100g: float
zone: str = "otros"
portions: list = []
barcode: str | None = None
deviceToken: str | None = None
@app.get("/foods")
def search_foods(q: str = "", limit: int = 20, db: Session = Depends(get_db)):
q = (q or "").strip()
if len(q) < 2:
return []
local = (
db.query(Food)
.filter(func.lower(Food.name).like(f"%{q.lower()}%"))
.limit(limit)
.all()
)
results = [f.to_app() for f in local]
# Si hay pocos, enriquecemos con Open Food Facts y guardamos.
if len(local) < 5:
seen_off = {f.off_id for f in local if f.off_id}
seen_names = {f.name.lower() for f in local}
for item in off.search(q):
if item["off_id"] and item["off_id"] in seen_off:
continue
if item["name"].lower() in seen_names:
continue
# Dedupe contra la BD por off_id.
if item["off_id"] and db.query(Food).filter(Food.off_id == item["off_id"]).first():
continue
food = Food(
id=str(uuid.uuid4()),
name=item["name"],
kcal_100g=item["kcal_100g"],
zone="otros",
portions=[],
barcode=item.get("barcode"),
source="off",
off_id=item.get("off_id"),
)
db.add(food)
seen_names.add(item["name"].lower())
results.append(food.to_app())
db.commit()
return results[:limit]
@app.post("/foods")
def add_food(body: FoodIn, db: Session = Depends(get_db)):
food = Food(
id=str(uuid.uuid4()),
name=body.name.strip()[:120],
kcal_100g=body.kcalPer100g,
zone=body.zone,
portions=body.portions or [],
barcode=body.barcode,
source="user",
author_token=body.deviceToken,
)
db.add(food)
db.commit()
return food.to_app()
# ---------------------------------------------------------------------------
# Recetas
# ---------------------------------------------------------------------------
class RecipeIn(BaseModel):
name: str
servings: int = 1
items: list = []
deviceToken: str | None = None
@app.get("/recipes")
def search_recipes(q: str = "", limit: int = 20, db: Session = Depends(get_db)):
query = db.query(Recipe)
q = (q or "").strip()
if q:
query = query.filter(func.lower(Recipe.name).like(f"%{q.lower()}%"))
return [r.to_app() for r in query.limit(limit).all()]
@app.get("/recipes/{recipe_id}")
def get_recipe(recipe_id: str, db: Session = Depends(get_db)):
r = db.query(Recipe).filter(Recipe.id == recipe_id).first()
return r.to_app() if r else {}
@app.post("/recipes")
def add_recipe(body: RecipeIn, db: Session = Depends(get_db)):
r = Recipe(
id=str(uuid.uuid4()),
name=body.name.strip()[:140],
servings=body.servings,
items=body.items or [],
author_token=body.deviceToken,
)
db.add(r)
db.commit()
return r.to_app()

49
models.py Normal file
View File

@@ -0,0 +1,49 @@
"""Modelos de la base de datos: alimentos y recetas del catálogo compartido."""
from sqlalchemy import Column, Float, Integer, String, JSON, DateTime, func
from db import Base
class Food(Base):
__tablename__ = "foods"
id = Column(String, primary_key=True)
name = Column(String, nullable=False, index=True)
kcal_100g = Column(Float, nullable=False)
zone = Column(String, default="otros")
portions = Column(JSON, default=list) # [{"name","grams"}]
barcode = Column(String, nullable=True)
source = Column(String, default="user") # off | user
off_id = Column(String, nullable=True, index=True)
author_token = Column(String, nullable=True)
created_at = Column(DateTime, server_default=func.now())
def to_app(self) -> dict:
"""Serializa a la forma que espera la app (Food.fromJson)."""
return {
"id": self.id,
"name": self.name,
"kcalPer100g": self.kcal_100g,
"zone": self.zone or "otros",
"portions": self.portions or [],
"origin": "remote",
}
class Recipe(Base):
__tablename__ = "recipes"
id = Column(String, primary_key=True)
name = Column(String, nullable=False, index=True)
servings = Column(Integer, default=1)
items = Column(JSON, default=list) # [{"food": {...}, "grams", "portionLabel"}]
author_token = Column(String, nullable=True)
created_at = Column(DateTime, server_default=func.now())
def to_app(self) -> dict:
return {
"id": self.id,
"name": self.name,
"servings": self.servings,
"items": self.items or [],
}

46
off.py Normal file
View File

@@ -0,0 +1,46 @@
"""Cliente de Open Food Facts: busca productos y los normaliza a la forma de
alimento de la app. Tolerante a fallos: devuelve [] si algo va mal."""
import httpx
_SEARCH = "https://world.openfoodfacts.org/cgi/search.pl"
def search(q: str, limit: int = 12) -> list[dict]:
"""Devuelve alimentos normalizados: {name, kcal_100g, off_id, barcode}."""
try:
resp = httpx.get(
_SEARCH,
params={
"search_terms": q,
"search_simple": 1,
"action": "process",
"json": 1,
"page_size": limit,
"fields": "product_name,nutriments,code",
},
timeout=6.0,
headers={"User-Agent": "autoayuno/1.0 (self-hosted)"},
)
if resp.status_code != 200:
return []
out = []
for p in resp.json().get("products", []):
name = (p.get("product_name") or "").strip()
kcal = p.get("nutriments", {}).get("energy-kcal_100g")
if not name or kcal is None:
continue
try:
kcal = float(kcal)
except (TypeError, ValueError):
continue
if kcal <= 0:
continue
out.append({
"name": name[:120],
"kcal_100g": kcal,
"off_id": p.get("code"),
"barcode": p.get("code"),
})
return out
except Exception:
return []

7
requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy==2.0.36
psycopg2-binary==2.9.10
httpx==0.28.1
pydantic==2.10.4
pytest==8.3.4

46
tests/test_api.py Normal file
View File

@@ -0,0 +1,46 @@
import os
import sys
# BD en memoria para tests, antes de importar la app.
os.environ["DATABASE_URL"] = "sqlite://"
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
import off # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from main import app # noqa: E402
client = TestClient(app)
def test_health():
assert client.get("/health").json() == {"status": "ok"}
def test_add_and_search_food():
r = client.post("/foods", json={"name": "Mi salsa", "kcalPer100g": 120})
assert r.status_code == 200
assert r.json()["kcalPer100g"] == 120
res = client.get("/foods", params={"q": "salsa"}).json()
assert any(f["name"] == "Mi salsa" for f in res)
def test_search_imports_from_off(monkeypatch):
monkeypatch.setattr(
off, "search",
lambda q, limit=12: [{"name": "Cosa OFF", "kcal_100g": 90, "off_id": "111", "barcode": "111"}],
)
res = client.get("/foods", params={"q": "cosarara"}).json()
assert any(f["name"] == "Cosa OFF" for f in res)
def test_publish_and_get_recipe():
r = client.post("/recipes", json={
"name": "Ensalada",
"servings": 2,
"items": [{"food": {"name": "Lechuga", "kcalPer100g": 15}, "grams": 100}],
})
assert r.status_code == 200
rid = r.json()["id"]
got = client.get(f"/recipes/{rid}").json()
assert got["name"] == "Ensalada"
assert got["servings"] == 2