import logging import uuid from contextlib import asynccontextmanager from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from sqlalchemy import text from starlette.middleware.sessions import SessionMiddleware from app.api.routers import ( audit, auth, bookings, customers, dashboard, data_quality, demo, integration_status, integrations, knowledge, mcp_integrations, observability, privacy, search, users, vehicles, workflows, ) from app.api.routers.auth import bootstrap_initial_admin from app.core.config import PRODUCT_NAME, get_settings from app.core.db import SessionLocal from app.core.errors import AppError, error_body from app.core.observability import ( DATABASE_READY, configure_logging, correlation_id_context, correlation_id_for, request_finished, request_started, ) from app.services.dispatcher import start_background_dispatcher, stop_background_dispatcher settings = get_settings() configure_logging(settings.log_level) request_logger = logging.getLogger("mobilityops.request") @asynccontextmanager async def lifespan(_app: FastAPI): with SessionLocal() as db: bootstrap_initial_admin(db) start_background_dispatcher() yield stop_background_dispatcher() production = settings.mobilityops_env.lower() == "production" app = FastAPI( title=f"{PRODUCT_NAME} API", version="0.1.0", lifespan=lifespan, docs_url=None if production else "/docs", redoc_url=None if production else "/redoc", openapi_url=None if production else "/openapi.json", ) app.add_middleware( SessionMiddleware, secret_key=settings.app_secret, session_cookie="mobilityops_oidc_state", max_age=600, same_site="lax", https_only=settings.session_cookie_secure, ) @app.middleware("http") async def request_observability(request: Request, call_next): correlation_id = correlation_id_for(request) request.state.correlation_id = correlation_id token = correlation_id_context.set(correlation_id) started_at = request_started() status_code = 500 try: response = await call_next(request) status_code = response.status_code response.headers["X-Correlation-Id"] = correlation_id return response finally: duration = request_finished(request, status_code, started_at) request_logger.info( "request_completed", extra={ "method": request.method, "path": request.url.path, "status_code": status_code, "duration_ms": round(duration * 1000, 2), "client_ip": request.client.host if request.client else None, }, ) correlation_id_context.reset(token) app.add_middleware( CORSMiddleware, allow_origins=[o.strip() for o in settings.cors_allow_origins.split(",")], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) @app.exception_handler(AppError) def handle_app_error(request: Request, exc: AppError) -> JSONResponse: return JSONResponse( status_code=exc.status_code, content=error_body( exc.code, exc.message, request.state.correlation_id, exc.details, ), ) @app.exception_handler(HTTPException) def handle_http_exception(request: Request, exc: HTTPException) -> JSONResponse: return JSONResponse( status_code=exc.status_code, content=error_body( code=str(exc.status_code), message=str(exc.detail), correlation_id=getattr(request.state, "correlation_id", str(uuid.uuid4())), details={}, ), headers=exc.headers, ) @app.get("/health") def health() -> dict[str, str]: return {"status": "ok", "service": "mobilityops-api"} @app.get("/health/live") def liveness() -> dict[str, str]: """Process liveness only; external dependencies deliberately do not affect it.""" return {"status": "ok", "service": "mobilityops-api"} @app.get("/health/ready") def readiness() -> JSONResponse: """Traffic readiness: the API is useful only while its canonical database responds.""" try: with SessionLocal() as db: db.execute(text("SELECT 1")) except Exception: # noqa: BLE001 -- readiness must convert infrastructure errors to 503 DATABASE_READY.set(0) return JSONResponse( status_code=503, content={"status": "not_ready", "service": "mobilityops-api", "database": "down"}, ) DATABASE_READY.set(1) return JSONResponse(content={"status": "ready", "service": "mobilityops-api", "database": "up"}) @app.get("/api/v1/system/status") def system_status() -> dict[str, object]: return { "service": "mobilityops-api", "environment": settings.mobilityops_env, "demo_mode": settings.mobilityops_demo_mode, "knowledge_provider": settings.knowledge_provider, "oidc_enabled": auth.oidc_status().enabled, "oidc_provider_name": auth.oidc_status().provider_name, } app.include_router(demo.router) app.include_router(auth.router) app.include_router(dashboard.router) app.include_router(vehicles.router) app.include_router(bookings.router) app.include_router(customers.router) app.include_router(audit.router) app.include_router(data_quality.router) app.include_router(workflows.router) app.include_router(integrations.router) app.include_router(knowledge.router) app.include_router(mcp_integrations.router) app.include_router(search.router) app.include_router(integration_status.router) app.include_router(users.router) app.include_router(observability.router) app.include_router(privacy.router)