112 lines
3.6 KiB
Python
112 lines
3.6 KiB
Python
"""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, audit,
|
|
)
|
|
|
|
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)
|
|
app.include_router(audit.router)
|