248 lines
8.4 KiB
Python
248 lines
8.4 KiB
Python
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"])
|