Scaffolding

This commit is contained in:
jableader 2025-10-18 16:50:43 +11:00
parent 1b76ea44b6
commit 3d7afa3765
8 changed files with 316 additions and 0 deletions

2
api/__init__.py Normal file
View file

@ -0,0 +1,2 @@
# API package for FastAPI routers.
# Routers will be split by feature: recipes, meals, persons, shopping, auth.

25
api/auth.py Normal file
View file

@ -0,0 +1,25 @@
from __future__ import annotations
from typing import Any
import aiosqlite
from fastapi import APIRouter, Depends, Request
import persons
from common import ProblemDetails
from main import get_db, cookie_person # temporary during extraction
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login", operation_id="login", summary="Login and set user_id cookie",
responses={404: {"model": ProblemDetails, "description": "Person not found", "content": {"application/problem+json": {}}}})
async def login(
data: Any, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None
) -> persons.Person:
raise NotImplementedError("login extraction pending")
@router.post("/refresh", operation_id="refresh", summary="Refresh current user from cookie")
async def current_user(user: persons.Person = Depends(cookie_person)) -> persons.Person:
raise NotImplementedError("current_user extraction pending")

77
api/meals.py Normal file
View file

@ -0,0 +1,77 @@
from __future__ import annotations
from typing import List, Optional
import datetime
import aiosqlite
from fastapi import APIRouter, Depends, Query, Request
import meals
import persons
import shopping
from common import ProblemDetails
from main import get_db, cookie_person # temporary imports during extraction
router = APIRouter(prefix="/meals", tags=["meals"])
@router.get("/upcoming", operation_id="getUpcomingMeals", summary="List upcoming meals in a date range")
async def get_upcoming_meals(
date_from: datetime.datetime = Query(..., alias="from"),
to: datetime.datetime = Query(...),
conn: aiosqlite.Connection = Depends(get_db),
) -> List[meals.Meal]:
raise NotImplementedError("get_upcoming_meals extraction pending")
@router.get("/{meal_id}", response_model=meals.Meal, operation_id="getMeal", summary="Get a meal by id",
responses={404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}})
async def get_meal(
meal_id: int, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None
) -> meals.Meal:
raise NotImplementedError("get_meal extraction pending")
@router.post("", response_model=meals.Meal, operation_id="createMeal", summary="Create a new meal",
responses={400: {"model": ProblemDetails, "description": "Validation error", "content": {"application/problem+json": {}}}})
async def create_meal(
meal: meals.Meal, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None
) -> meals.Meal:
raise NotImplementedError("create_meal extraction pending")
@router.put("/{meal_id}", response_model=meals.Meal, operation_id="updateMeal", summary="Update an existing meal",
responses={
400: {"model": ProblemDetails, "description": "Validation error", "content": {"application/problem+json": {}}},
404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}},
})
async def update_meal(
meal_id: int, meal: meals.Meal, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None
) -> meals.Meal:
raise NotImplementedError("update_meal extraction pending")
@router.post("/{meal_id}/consumed", response_model=meals.Meal, operation_id="markMealConsumed", summary="Mark a meal as consumed",
responses={
400: {"model": ProblemDetails, "description": "Validation error", "content": {"application/problem+json": {}}},
404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}},
})
async def mark_consumed(
meal_id: int,
consumed_date: Optional[datetime.datetime] = None,
conn: aiosqlite.Connection = Depends(get_db),
person: persons.Person = Depends(cookie_person),
request: Request | None = None,
) -> meals.Meal:
raise NotImplementedError("mark_consumed extraction pending")
@router.delete("/{meal_id}", response_model=meals.Meal, operation_id="deleteMeal", summary="Delete a meal",
responses={404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}})
async def delete_meal(
meal_id: int,
conn: aiosqlite.Connection = Depends(get_db),
person: persons.Person = Depends(cookie_person),
request: Request | None = None,
) -> meals.Meal:
raise NotImplementedError("delete_meal extraction pending")

30
api/persons.py Normal file
View file

@ -0,0 +1,30 @@
from __future__ import annotations
from typing import List, Optional
import aiosqlite
from fastapi import APIRouter, Depends, Query, Request
import persons
from common import Page, ProblemDetails
from main import get_db # temporary during extraction
router = APIRouter(prefix="/persons", tags=["persons"])
@router.get("", response_model=Page[persons.Person], operation_id="listPersons", summary="List persons (paginated)")
async def list_persons(
q: Optional[str] = Query(default=None, description="Optional case-insensitive name filter (applied after page read; may be pushed to SQL in the future)."),
cursor: Optional[str] = Query(default=None, description="Opaque cursor for pagination. Pass the value returned in nextCursor to fetch the next page."),
limit: int = Query(50, ge=1, le=200, description="Maximum number of items to return (1-200)."),
conn: aiosqlite.Connection = Depends(get_db),
request: Request | None = None,
) -> Page[persons.Person]:
raise NotImplementedError("list_persons extraction pending")
@router.post("", response_model=persons.Person, operation_id="createPerson", summary="Create a person")
async def create_person(
person: persons.Person, conn: aiosqlite.Connection = Depends(get_db)
) -> persons.Person:
raise NotImplementedError("create_person extraction pending")

77
api/recipes.py Normal file
View file

@ -0,0 +1,77 @@
from __future__ import annotations
from typing import List, Optional
import aiosqlite
from fastapi import APIRouter, Depends, Query, Request
import ingredients
import recipes
import persons
from common import Page, ProblemDetails
from main import get_db, cookie_person, error_response # temporary imports during extraction
router = APIRouter(prefix="/recipes", tags=["recipes"])
@router.get("", response_model=Page[recipes.Recipe], operation_id="listRecipes", summary="List recipes (paginated)")
async def list_recipes(
q: Optional[str] = Query(default=None, description="Optional case-insensitive name filter (matches recipe name with SQL LIKE)."),
cursor: Optional[str] = Query(default=None, description="Opaque cursor for pagination. Pass the value returned in nextCursor to fetch the next page."),
limit: int = Query(50, ge=1, le=200, description="Maximum number of items to return (1-200)."),
conn: aiosqlite.Connection = Depends(get_db),
request: Request | None = None,
) -> Page[recipes.Recipe]:
# Placeholder: implementation will be moved from main.get_recipes in a later step.
raise NotImplementedError("list_recipes extraction pending")
@router.get("/{recipe_id}", response_model=recipes.Recipe, operation_id="getRecipe", summary="Get a single recipe",
responses={404: {"model": ProblemDetails, "description": "Recipe not found", "content": {"application/problem+json": {}}}})
async def get_recipe(
recipe_id: int, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None
) -> recipes.Recipe:
# Placeholder: implementation will be moved from main.get_recipe in a later step.
raise NotImplementedError("get_recipe extraction pending")
@router.post("", response_model=recipes.Recipe, operation_id="createRecipe", summary="Create a new recipe (versioning semantics applied)",
responses={400: {"model": ProblemDetails, "description": "Validation error", "content": {"application/problem+json": {}}}})
async def create_recipe(
recipe: recipes.Recipe,
conn: aiosqlite.Connection = Depends(get_db),
user: persons.Person = Depends(cookie_person),
request: Request | None = None,
) -> recipes.Recipe:
# Placeholder: implementation will be moved from main.create_recipe in a later step.
raise NotImplementedError("create_recipe extraction pending")
@router.delete("/{recipe_id}", response_model=recipes.Recipe, operation_id="deleteRecipe", summary="Soft-delete (hide) a recipe",
responses={404: {"model": ProblemDetails, "description": "Recipe not found", "content": {"application/problem+json": {}}}})
async def delete_recipe(
recipe_id: int,
conn: aiosqlite.Connection = Depends(get_db),
user: persons.Person = Depends(cookie_person),
request: Request | None = None,
) -> recipes.Recipe:
# Placeholder: implementation will be moved from main.delete_recipe in a later step.
raise NotImplementedError("delete_recipe extraction pending")
@router.get("/parse", response_model=None, operation_id="parseRecipe", summary="Parse a recipe from a URL",
responses={400: {"model": ProblemDetails, "description": "Recipe not found", "content": {"application/problem+json": {}}}})
async def parse_recipe_handler(
url: str, conn: aiosqlite.Connection = Depends(get_db), person=Depends(cookie_person), request: Request | None = None
) -> recipes.Recipe:
# Placeholder: implementation will be moved from main.parse_recipe_handler in a later step.
raise NotImplementedError("parse_recipe_handler extraction pending")
@router.get("/ingredients/parse", operation_id="parseIngredients", summary="Parse raw ingredient lines")
async def parse_ingredients(
lines: List[str] = Query(alias="ingredients", title="Array of ingredients to parse"),
conn: aiosqlite.Connection = Depends(get_db),
) -> List[ingredients.Ingredient]:
# Placeholder: implementation will be moved from main.parse_ingredients in a later step.
raise NotImplementedError("parse_ingredients extraction pending")

78
api/shopping.py Normal file
View file

@ -0,0 +1,78 @@
from __future__ import annotations
from typing import Dict, List
import aiosqlite
from fastapi import APIRouter, Depends, Request
import ingredients
import meals
import persons
import recipes
import shopping
from common import ProblemDetails
from main import get_db, cookie_person # temporary during extraction
router = APIRouter(prefix="/shopping", tags=["shopping"])
@router.get("/current", operation_id="getCurrentShoppingList", summary="Get the current aggregated shopping list")
async def get_current_shopping_list(
conn: aiosqlite.Connection = Depends(get_db),
):
raise NotImplementedError("get_current_shopping_list extraction pending")
@router.get("/{list_id}", operation_id="getShoppingList", summary="Get a purchased shopping list by id",
responses={404: {"model": ProblemDetails, "description": "Shopping list not found", "content": {"application/problem+json": {}}}})
async def get_shopping_list(
list_id: int, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None
):
raise NotImplementedError("get_shopping_list extraction pending")
@router.post("", operation_id="purchaseIngredients", summary="Purchase ingredients for a shopping list")
async def purchase_ingredients(
shopping_list: shopping.ShoppingList,
conn: aiosqlite.Connection = Depends(get_db),
person: persons.Person = Depends(cookie_person),
):
raise NotImplementedError("purchase_ingredients extraction pending")
@router.get("/current/me/ingredients", operation_id="getMyShoppingList", summary="Get my outstanding ingredient requests")
async def get_my_shopping_list(
conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)
) -> List[ingredients.Ingredient]:
raise NotImplementedError("get_my_shopping_list extraction pending")
@router.post("/current/me/ingredients", operation_id="syncMyShoppingList", summary="Sync my outstanding ingredient requests")
async def sync_my_shopping_list(
requests: List[ingredients.Ingredient],
conn: aiosqlite.Connection = Depends(get_db),
person: persons.Person = Depends(cookie_person),
) -> List[ingredients.Ingredient]:
raise NotImplementedError("sync_my_shopping_list extraction pending")
@router.post("/current/meals/{meal_id}", operation_id="requestMeal", summary="Request a meal for shopping",
responses={404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}})
async def request_meal(
meal_id: int,
conn: aiosqlite.Connection = Depends(get_db),
person: persons.Person = Depends(cookie_person),
request: Request | None = None,
):
raise NotImplementedError("request_meal extraction pending")
@router.delete("/current/meals/{meal_id}", operation_id="unrequestMeal", summary="Remove a meal request",
responses={404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}})
async def unrequest_meal(
meal_id: int,
conn: aiosqlite.Connection = Depends(get_db),
person: persons.Person = Depends(cookie_person),
request: Request | None = None,
):
raise NotImplementedError("unrequest_meal extraction pending")

View file

@ -142,6 +142,7 @@ Note: We can adopt this structure gradually without moving DB code immediately;
## Progress log ## Progress log
- 2025-10-18: Created strategy document and added settings.py (not yet wired) - 2025-10-18: Created strategy document and added settings.py (not yet wired)
- 2025-10-18: Finalized router layout and health endpoint plan — Phase 0 complete - 2025-10-18: Finalized router layout and health endpoint plan — Phase 0 complete
- 2025-10-18: Created api package and scaffolded routers (recipes, meals, persons, shopping, auth) with placeholders
--- ---

26
settings.py Normal file
View file

@ -0,0 +1,26 @@
"""
Centralized runtime settings for the Doof backend.
No external dependencies; reads from environment only so it can be imported
anywhere (including tests) without side effects.
"""
from __future__ import annotations
from dataclasses import dataclass
import os
@dataclass(frozen=True)
class Settings:
# Database
database_path: str = os.environ.get("DOOF_DB", "./data/doof.sqlite")
# Environment flags
prod: bool = os.environ.get("DOOF_PROD", "false").lower() in {"1", "true", "yes"}
# Frontend dev server for reverse proxy in non-prod
frontend_dev_url: str = os.environ.get("FRONTEND_DEV_URL", "http://localhost:8080/")
# A module-level singleton for convenience imports
settings = Settings()