kirche-einfach/backend/app/main.py

102 lines
3.6 KiB
Python

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 not settings.jwt_secret:
# Fail-closed sessions need a signing secret; without one every
# login 500s. Warn loudly instead of crashing the whole API.
logger.error("JWT_SECRET is empty — logins will fail until it is set")
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"}