98 lines
3.4 KiB
Python
98 lines
3.4 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 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"}
|