feat: recipe + staple API endpoints with tier gating

This commit is contained in:
pyr0ball 2026-03-31 12:49:38 -07:00
parent 3943a8c99d
commit ce61b5f422
5 changed files with 174 additions and 6 deletions

View file

@ -0,0 +1,46 @@
"""Recipe suggestion endpoints."""
from __future__ import annotations
import asyncio
from fastapi import APIRouter, Depends, HTTPException
from app.cloud_session import CloudUser, get_session
from app.db.session import get_store
from app.db.store import Store
from app.models.schemas.recipe import RecipeRequest, RecipeResult
from app.services.recipe.recipe_engine import RecipeEngine
from app.tiers import can_use
router = APIRouter()
@router.post("/suggest", response_model=RecipeResult)
async def suggest_recipes(
req: RecipeRequest,
session: CloudUser = Depends(get_session),
store: Store = Depends(get_store),
) -> RecipeResult:
if req.level == 4 and not req.wildcard_confirmed:
raise HTTPException(
status_code=400,
detail="Level 4 (Wildcard) requires wildcard_confirmed=true.",
)
if req.level in (3, 4) and not can_use("recipe_suggestions", session.tier, session.has_byok):
raise HTTPException(
status_code=403,
detail="LLM recipe levels require Paid tier or a configured LLM backend.",
)
if req.style_id and not can_use("style_picker", session.tier):
raise HTTPException(status_code=403, detail="Style picker requires Paid tier.")
req = req.model_copy(update={"tier": session.tier, "has_byok": session.has_byok})
engine = RecipeEngine(store)
return await asyncio.to_thread(engine.suggest, req)
@router.get("/{recipe_id}")
async def get_recipe(recipe_id: int, store: Store = Depends(get_store)) -> dict:
recipe = await asyncio.to_thread(store.get_recipe, recipe_id)
if not recipe:
raise HTTPException(status_code=404, detail="Recipe not found.")
return recipe

View file

@ -0,0 +1,42 @@
"""Staple library endpoints."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from app.services.recipe.staple_library import StapleLibrary
router = APIRouter()
_lib = StapleLibrary()
@router.get("/")
async def list_staples(dietary: str | None = None) -> list[dict]:
staples = _lib.filter_by_dietary(dietary) if dietary else _lib.list_all()
return [
{
"slug": s.slug,
"name": s.name,
"description": s.description,
"dietary_labels": s.dietary_labels,
"yield_formats": list(s.yield_formats.keys()),
}
for s in staples
]
@router.get("/{slug}")
async def get_staple(slug: str) -> dict:
staple = _lib.get(slug)
if not staple:
raise HTTPException(status_code=404, detail=f"Staple '{slug}' not found.")
return {
"slug": staple.slug,
"name": staple.name,
"description": staple.description,
"dietary_labels": staple.dietary_labels,
"base_ingredients": staple.base_ingredients,
"base_method": staple.base_method,
"base_time_minutes": staple.base_time_minutes,
"yield_formats": staple.yield_formats,
"compatible_styles": staple.compatible_styles,
}

View file

@ -1,10 +1,12 @@
from fastapi import APIRouter
from app.api.endpoints import health, receipts, export, inventory, ocr
from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, staples
api_router = APIRouter()
api_router.include_router(health.router, prefix="/health", tags=["health"])
api_router.include_router(receipts.router, prefix="/receipts", tags=["receipts"])
api_router.include_router(ocr.router, prefix="/receipts", tags=["ocr"]) # OCR endpoints under /receipts
api_router.include_router(export.router, tags=["export"]) # No prefix, uses /export in the router
api_router.include_router(ocr.router, prefix="/receipts", tags=["ocr"])
api_router.include_router(export.router, tags=["export"])
api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"])
api_router.include_router(recipes.router, prefix="/recipes", tags=["recipes"])
api_router.include_router(staples.router, prefix="/staples", tags=["staples"])

0
tests/api/__init__.py Normal file
View file

78
tests/api/test_recipes.py Normal file
View file

@ -0,0 +1,78 @@
import pytest
from fastapi.testclient import TestClient
from unittest.mock import MagicMock
from app.main import app
from app.cloud_session import get_session
from app.db.session import get_store
client = TestClient(app)
def _make_session(tier: str = "free", has_byok: bool = False) -> MagicMock:
mock = MagicMock()
mock.tier = tier
mock.has_byok = has_byok
return mock
def _make_store() -> MagicMock:
mock = MagicMock()
mock.search_recipes_by_ingredients.return_value = [
{
"id": 1,
"title": "Butter Pasta",
"ingredient_names": ["butter", "pasta"],
"element_coverage": {"Richness": 0.5},
"match_count": 2,
"directions": ["mix and heat"],
}
]
mock.check_and_increment_rate_limit.return_value = (True, 1)
return mock
@pytest.fixture(autouse=True)
def override_deps():
session_mock = _make_session()
store_mock = _make_store()
app.dependency_overrides[get_session] = lambda: session_mock
app.dependency_overrides[get_store] = lambda: store_mock
yield session_mock, store_mock
app.dependency_overrides.clear()
def test_suggest_returns_200():
resp = client.post("/api/v1/recipes/suggest", json={
"pantry_items": ["butter", "pasta"],
"level": 1,
"constraints": [],
})
assert resp.status_code == 200
data = resp.json()
assert "suggestions" in data
assert "element_gaps" in data
assert "grocery_list" in data
assert "grocery_links" in data
def test_suggest_level4_requires_wildcard_confirmed():
resp = client.post("/api/v1/recipes/suggest", json={
"pantry_items": ["butter"],
"level": 4,
"constraints": [],
"wildcard_confirmed": False,
})
assert resp.status_code == 400
def test_suggest_level3_requires_paid_tier(override_deps):
session_mock, _ = override_deps
session_mock.tier = "free"
session_mock.has_byok = False
resp = client.post("/api/v1/recipes/suggest", json={
"pantry_items": ["butter"],
"level": 3,
"constraints": [],
})
assert resp.status_code == 403