"""FastAPI application entrypoint. Thin by design: wire settings, routers, and error handling, and expose the OpenAPI contract. All domain logic lives in the service layer; the privacy engine is the single enforcement point for reads. """ import logging import sys from contextlib import asynccontextmanager from fastapi import FastAPI, Request from fastapi.responses import JSONResponse from app.api.health import router as health_router from app.api.v1 import api_router from app.core.config import get_settings from app.core.db import get_engine from app.core.schema_version import schema_is_current from app.services.exceptions import Conflict, Forbidden, NotFound def _configure_logging() -> None: """Emit the app's own ``provenance.*`` logs at INFO to stdout (uvicorn only configures its own loggers). The ConsoleMailer relies on this so self-hosters can read verification/reset links from the logs.""" app_logger = logging.getLogger("provenance") app_logger.setLevel(logging.INFO) if not app_logger.handlers: handler = logging.StreamHandler(sys.stdout) handler.setFormatter(logging.Formatter("%(levelname)s [%(name)s] %(message)s")) app_logger.addHandler(handler) app_logger.propagate = False async def _check_schema_drift() -> None: """On startup, shout if the DB schema is behind the code. The entrypoint runs migrations when RUN_MIGRATIONS=1; this catches the case where that didn't happen, so a half-applied deploy is obvious in the logs instead of a silent storm of 500s. Never blocks startup — purely advisory.""" logger = logging.getLogger("provenance") try: async with get_engine().connect() as conn: ok, db, expected = await schema_is_current(conn) if not ok: logger.critical( "SCHEMA DRIFT: database is at %s but this build expects %s. " "Run 'alembic upgrade head' — queries will fail until migrated.", sorted(db) or ["none"], sorted(expected), ) except Exception as exc: # noqa: BLE001 — advisory only; never block startup logger.warning("schema drift check skipped: %s", exc) @asynccontextmanager async def _lifespan(app: FastAPI): await _check_schema_drift() yield def _register_error_handlers(app: FastAPI) -> None: @app.exception_handler(NotFound) async def _not_found(request: Request, exc: NotFound) -> JSONResponse: return JSONResponse(status_code=404, content={"detail": str(exc) or "not found"}) @app.exception_handler(Forbidden) async def _forbidden(request: Request, exc: Forbidden) -> JSONResponse: return JSONResponse(status_code=403, content={"detail": str(exc) or "forbidden"}) @app.exception_handler(Conflict) async def _conflict(request: Request, exc: Conflict) -> JSONResponse: return JSONResponse(status_code=409, content={"detail": str(exc) or "conflict"}) def create_app() -> FastAPI: _configure_logging() settings = get_settings() app = FastAPI( title=settings.app_name, version=settings.version, description="Provenance API — family and land provenance.", lifespan=_lifespan, ) app.include_router(health_router) app.include_router(api_router) _register_error_handlers(app) return app app = create_app()