import datetime import os from typing import Annotated, Dict, List, Optional, Any from contextlib import asynccontextmanager import aiosqlite from fastapi import Depends, FastAPI, APIRouter, Request from fastapi.encoders import jsonable_encoder from fastapi.responses import JSONResponse from pydantic import Field import db import products from fastapi.routing import APIRoute from common import ProblemDetails, Page, ApiModel from settings import settings from api.deps import get_db, cookie_person, error_response class CamelCaseRoute(APIRoute): def __init__(self, *args, **kwargs): kwargs.setdefault("response_model_by_alias", True) kwargs.setdefault("response_model_exclude_none", True) super().__init__(*args, **kwargs) @asynccontextmanager async def app_lifespan(app: FastAPI): client = None if not settings.prod: import httpx client = httpx.AsyncClient(base_url=settings.frontend_dev_url) app.state.proxy_client = client try: yield finally: if client is not None: await client.aclose() app = FastAPI(title="Doof API", version="1.0.0", description="Doof Backend API", lifespan=app_lifespan) api_v1 = APIRouter(route_class=CamelCaseRoute) DATABASE_PATH = settings.database_path # get_db, cookie_person, and error_response are imported from api.deps # OpenAPI reusable responses for ProblemDetails def _extend_openapi_with_problem_responses(app: FastAPI) -> None: # Attach a custom openapi generation that injects reusable responses original_openapi = app.openapi def custom_openapi(): spec = original_openapi() components = spec.setdefault("components", {}) responses = components.setdefault("responses", {}) # Standard ProblemDetails responses responses.setdefault( "Problem400", { "description": "Bad Request", "content": { "application/problem+json": {}, "application/json": {"schema": {"$ref": "#/components/schemas/ProblemDetails"}}, }, }, ) responses.setdefault( "Problem404", { "description": "Not Found", "content": { "application/problem+json": {}, "application/json": {"schema": {"$ref": "#/components/schemas/ProblemDetails"}}, }, }, ) responses.setdefault( "Problem422", { "description": "Validation Error", "content": { "application/problem+json": { "schema": {"$ref": "#/components/schemas/ProblemDetails"} }, # Some clients may still expect FastAPI's default error; keep schema available "application/json": { "schema": {"$ref": "#/components/schemas/ProblemDetails"} }, }, }, ) # Normalize v1 responses to reference reusable ProblemDetails where appropriate paths = spec.get("paths", {}) for path, ops in paths.items(): if not isinstance(path, str) or not path.startswith("/api/v1/"): continue if not isinstance(ops, dict): continue for method, op in ops.items(): if not isinstance(op, dict): continue resp = op.get("responses") if not isinstance(resp, dict): continue # Map 400/404 to reusable references; ensure 422 exists if "400" in resp: resp["400"] = {"$ref": "#/components/responses/Problem400"} if "404" in resp: resp["404"] = {"$ref": "#/components/responses/Problem404"} # Only add 422 if not already present if "422" not in resp: resp["422"] = {"$ref": "#/components/responses/Problem422"} return spec app.openapi = custom_openapi # type: ignore[assignment] _extend_openapi_with_problem_responses(app) class ProductUrl(ApiModel): url: str tags: List[str] = Field(default_factory=list) @api_v1.post( "/products", operation_id="createProduct", tags=["products"], summary="Create or fetch a product from a URL", ) async def create_product( url: ProductUrl, conn: aiosqlite.Connection = Depends(get_db) ) -> Optional[products.Product]: return await products.get_or_create(conn, url.url, url.tags) from api import recipes as recipes_router # type: ignore from api import meals as meals_router # type: ignore from api import shopping as shopping_router # type: ignore from api import persons as persons_router # type: ignore from api import auth as auth_router # type: ignore # RFC7807 Problem Details handlers from starlette.exceptions import HTTPException as StarletteHTTPException from pydantic import ValidationError from fastapi.exceptions import RequestValidationError @app.exception_handler(StarletteHTTPException) async def http_exc_handler(request: Request, exc: StarletteHTTPException): body = ProblemDetails( title=str(exc.detail) if exc.detail else "HTTP Error", status=exc.status_code, type=f"https://httpstatuses.com/{exc.status_code}", instance=str(request.url), ) return JSONResponse( content=body.model_dump(by_alias=True), status_code=exc.status_code, media_type="application/problem+json", ) @app.exception_handler(ValidationError) async def validation_exc_handler(request: Request, exc: ValidationError): errors: Dict[str, Any] = {} for e in exc.errors(): loc = ".".join([str(p) for p in e.get("loc", [])]) errors.setdefault(loc, []).append(e.get("msg")) body = ProblemDetails( title="Validation Error", status=422, type="https://datatracker.ietf.org/doc/html/rfc7807", instance=str(request.url), errors=errors, ) return JSONResponse( content=body.model_dump(by_alias=True), status_code=422, media_type="application/problem+json" ) @app.exception_handler(RequestValidationError) async def request_validation_exc_handler(request: Request, exc: RequestValidationError): errors: Dict[str, Any] = {} for e in exc.errors(): loc = ".".join([str(p) for p in e.get("loc", [])]) errors.setdefault(loc, []).append(e.get("msg")) body = ProblemDetails( title="Validation Error", status=422, type="https://datatracker.ietf.org/doc/html/rfc7807", instance=str(request.url), errors=errors, ) return JSONResponse( content=body.model_dump(by_alias=True), status_code=422, media_type="application/problem+json" ) # Mount versioned API router app.include_router(api_v1, prefix="/api/v1", tags=["v1"]) app.include_router(recipes_router.router, prefix="/api/v1", tags=["v1"]) # extracted app.include_router(meals_router.router, prefix="/api/v1", tags=["v1"]) # extracted app.include_router(shopping_router.router, prefix="/api/v1", tags=["v1"]) # extracted app.include_router(persons_router.router, prefix="/api/v1", tags=["v1"]) # extracted app.include_router(auth_router.router, prefix="/api/v1", tags=["v1"]) # extracted @app.get("/healthz") async def healthz(): return {"status": "ok"} if settings.prod: from fastapi.staticfiles import StaticFiles app.mount("/", StaticFiles(directory="./front-dist", html=True), name="front-dist") else: # Proxy the request to the frontend development server from starlette.background import BackgroundTask from starlette.requests import Request from starlette.responses import StreamingResponse async def _reverse_proxy(request: Request): import httpx url = httpx.URL(path=request.url.path, query=request.url.query.encode("utf-8")) client = app.state.proxy_client rp_req = client.build_request( request.method, url, headers=request.headers.raw, content=request.stream() ) rp_resp = await client.send(rp_req, stream=True) return StreamingResponse( rp_resp.aiter_raw(), status_code=rp_resp.status_code, headers=rp_resp.headers, background=BackgroundTask(rp_resp.aclose), ) app.add_route("/{path:path}", _reverse_proxy, ["GET", "POST"])