feat: autoayuno-api (FastAPI) — alimentos (OFF) y recetas
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.db
|
||||
.venv/
|
||||
venv/
|
||||
.pytest_cache/
|
||||
.env
|
||||
9
Dockerfile
Normal file
9
Dockerfile
Normal 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
28
README.md
Normal 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
36
db.py
Normal 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
141
main.py
Normal 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
49
models.py
Normal 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
46
off.py
Normal 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
7
requirements.txt
Normal 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
46
tests/test_api.py
Normal 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
|
||||
Reference in New Issue
Block a user