"""FastAPI application entrypoint.""" from __future__ import annotations import asyncio import logging import time from contextlib import asynccontextmanager from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from sqlalchemy import text from .. import APP_NAME, APP_VERSION from ..config import CORS_ORIGINS, DATA_DIR from ..db.database import ENGINE, init_db from . import auth from .routes import ( sessions, files, processing, results, settings as settings_routes, export, ar, control, analytics, controls, payouts, accounts_summary, fx, ) logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s", ) logger = logging.getLogger(__name__) @asynccontextmanager async def lifespan(_app: FastAPI): init_db() # A restart mid-job leaves a closing stuck on "processing" forever — recover it. from ..services.jobs import recover_stale_jobs recover_stale_jobs() # Daily retention sweep (exports only; uploads are the audit source and are kept). from ..services.retention import retention_loop sweeper = asyncio.create_task(retention_loop()) yield sweeper.cancel() app = FastAPI(title=APP_NAME, version=APP_VERSION, lifespan=lifespan) @app.middleware("http") async def _request_log(request: Request, call_next): """One line per API request: method, path, status, duration.""" if not request.url.path.startswith("/api"): return await call_next(request) t0 = time.perf_counter() response = await call_next(request) ms = (time.perf_counter() - t0) * 1000 # /status is polled sub-second during processing; logging it would drown everything. if not request.url.path.endswith("/status"): logger.info("%s %s -> %d (%.0f ms)", request.method, request.url.path, response.status_code, ms) return response # Registered BEFORE CORSMiddleware so CORS stays outermost (Starlette applies middleware # in reverse registration order) and 401 responses still carry CORS headers. app.middleware("http")(auth.auth_middleware) app.add_middleware( CORSMiddleware, allow_origins=CORS_ORIGINS, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) @app.get("/api/health") def health() -> dict: """Liveness + readiness: DB reachable and the data dir writable — deep enough for a load balancer / monitor probe, fast enough to hit every few seconds.""" checks = {"db": "ok", "data_dir": "ok"} status = "ok" try: with ENGINE.connect() as conn: conn.execute(text("SELECT 1")) except Exception as e: # noqa: BLE001 checks["db"] = f"error: {type(e).__name__}" status = "degraded" try: probe = DATA_DIR / ".health-probe" probe.write_text("ok") probe.unlink() except OSError as e: checks["data_dir"] = f"error: {type(e).__name__}" status = "degraded" return {"status": status, "app": APP_NAME, "version": APP_VERSION, "checks": checks} app.include_router(auth.router) app.include_router(sessions.router) app.include_router(files.router) app.include_router(processing.router) app.include_router(results.router) app.include_router(settings_routes.router) app.include_router(settings_routes.rules_router) app.include_router(settings_routes.meta_router) app.include_router(export.router) app.include_router(ar.router) app.include_router(control.router) app.include_router(analytics.router) app.include_router(controls.router) app.include_router(payouts.router) app.include_router(accounts_summary.router) app.include_router(fx.router)