Brent Bolleman
initial build
085f6c4
Raw History Blame Contribute Delete
3.59 kB
"""FastAPI application factory.
No CORS middleware is registered: same-origin only by default (FR-016).
All error responses use the contract's error envelope (contracts/api.md).
"""
from __future__ import annotations
import asyncio
import os
from contextlib import asynccontextmanager, suppress
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
from cadmorph.api.routes import max_upload_bytes, router
from cadmorph.observability import configure_logging
from cadmorph.pipeline import JobStore, cleanup_loop
def error_envelope(code: str, message: str, comparison_id: str | None = None) -> dict:
error: dict = {"code": code, "message": message}
if comparison_id is not None:
error["comparison_id"] = comparison_id
return {"error": error}
def create_app(data_dir: str | Path | None = None) -> FastAPI:
configure_logging()
@asynccontextmanager
async def lifespan(app: FastAPI):
# TTL cleanup job (T045): sweep on startup, then hourly by default
interval = float(os.environ.get("CADMORPH_CLEANUP_INTERVAL_S", "3600"))
sweeper = asyncio.create_task(cleanup_loop(app.state.store, interval))
try:
yield
finally:
sweeper.cancel()
with suppress(asyncio.CancelledError):
await sweeper
app = FastAPI(title="CADMorph", version="0.1.0", lifespan=lifespan)
app.state.store = JobStore(data_dir or os.environ.get("CADMORPH_DATA_DIR", "data"))
app.include_router(router, prefix="/api/v1")
@app.middleware("http")
async def upload_size_guard(request: Request, call_next):
"""Fast-fail oversized uploads on declared Content-Length before the
body is buffered (T046). The per-file check in routes still guards
undeclared-length bodies."""
declared = request.headers.get("content-length", "")
cap = 2 * max_upload_bytes() + 1_048_576 # two files + form overhead
if request.method == "POST" and declared.isdigit() and int(declared) > cap:
return JSONResponse(
status_code=413,
content=error_envelope(
"file_too_large",
f"request body exceeds {cap // 1_048_576} MiB "
f"(per-file limit {max_upload_bytes() // 1_048_576} MiB)",
),
)
return await call_next(request)
@app.exception_handler(RequestValidationError)
async def validation_handler(_: Request, exc: RequestValidationError) -> JSONResponse:
detail = str(exc.errors()[:1])
return JSONResponse(status_code=400, content=error_envelope("missing_file", detail))
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(_: Request, exc: StarletteHTTPException) -> JSONResponse:
"""Route/method misses etc. must use the contract envelope too
(contracts/api.md: error envelope on ALL non-2xx)."""
code = {404: "not_found", 405: "method_not_allowed"}.get(exc.status_code, "error")
return JSONResponse(
status_code=exc.status_code, content=error_envelope(code, str(exc.detail))
)
@app.exception_handler(Exception)
async def internal_handler(_: Request, exc: Exception) -> JSONResponse:
return JSONResponse(status_code=500, content=error_envelope("internal", "internal error"))
return app
app = create_app()