import asyncio import logging from collections.abc import AsyncIterator from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI, Request from fastapi.responses import JSONResponse from slowapi.errors import RateLimitExceeded from slowapi.middleware import SlowAPIMiddleware from starlette.middleware.body_limit import RequestBodyLimitMiddleware from .auth.api import access_router, auth_router from .flows.api import approvals_router, flows_router, runs_router from .flows.sync import format_report, run_sync_blocking, watch_forever from .middleware import SecurityHeadersMiddleware, limiter from .settings import settings logger = logging.getLogger(__name__) def _blocking_startup_sync() -> None: """One-shot reconciliation at startup (fresh session, never fatal).""" try: report = run_sync_blocking(Path(settings.flows_dir)) logger.info("startup flow sync: %s", format_report(report)) for filename, reason in report.errors.items(): logger.warning("startup flow sync: %s: %s", filename, reason) except FileNotFoundError as exc: logger.error("startup flow sync skipped: %s", exc) except Exception: logger.exception("startup flow sync failed") # never fatal at boot @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: task: asyncio.Task | None = None stop: asyncio.Event | None = None if settings.flows_watch_enabled: await asyncio.to_thread(_blocking_startup_sync) stop = asyncio.Event() task = asyncio.create_task( watch_forever(stop, Path(settings.flows_dir)), name="flows-watcher" ) yield if task is not None and stop is not None: stop.set() task.cancel() try: await task except asyncio.CancelledError: pass app = FastAPI( title="Kirche Einfach API", description="KI-gestützte Verwaltungsplattform für Kirche", version="0.1.0", # Interactive docs only in debug: /api/docs maps the whole surface. docs_url="/api/docs" if settings.debug else None, redoc_url="/api/redoc" if settings.debug else None, openapi_url="/api/openapi.json" if settings.debug else None, lifespan=lifespan, ) # TLS terminates at the reverse proxy: uvicorn --proxy-headers (see # entrypoint.sh) trusts its forwarding headers so Secure cookies, client # IPs (throttle) and absolute URLs are correct. The API is not directly # internet-reachable (proxy-only), so trusting them is safe. app.state.limiter = limiter app.add_middleware(RequestBodyLimitMiddleware, max_body_size=settings.max_body_bytes) app.add_middleware(SlowAPIMiddleware) app.add_middleware(SecurityHeadersMiddleware) @app.exception_handler(RateLimitExceeded) async def _throttle_handler(request: Request, exc: RateLimitExceeded) -> JSONResponse: response = JSONResponse({"detail": "too many runs, try again later"}, status_code=429) retry_after = getattr(exc, "retry_after", None) or "60" response.headers["Retry-After"] = str(retry_after) return response app.include_router(flows_router) app.include_router(approvals_router) app.include_router(runs_router) app.include_router(access_router) app.include_router(auth_router) @app.get("/") async def root(): return {"message": "Kirche Einfach API", "version": settings.version} @app.get("/health") async def health(): return {"status": "ok"}