"""FastAPI application entry point.""" import logging import uuid from contextlib import asynccontextmanager from typing import AsyncGenerator from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from slowapi import Limiter, _rate_limit_exceeded_handler from slowapi.errors import RateLimitExceeded from slowapi.util import get_remote_address from sqlalchemy.exc import IntegrityError from starlette.middleware.base import BaseHTTPMiddleware from app.config import settings from app.db import engine from app.routes import ( agents_router, env_router, events_router, files_router, health_router, meta_router, playbooks_router, registration_tokens_router, tasks_router, tenants_router, ) from app.services.hub_telemetry import HubTelemetryService from app.services.local_bootstrap import LocalBootstrapService logger = logging.getLogger(__name__) # --- Middleware --- class RequestIDMiddleware(BaseHTTPMiddleware): """Middleware that adds a unique request ID to each request.""" async def dispatch(self, request: Request, call_next): request_id = str(uuid.uuid4()) request.state.request_id = request_id response = await call_next(request) response.headers["X-Request-ID"] = request_id return response # --- Lifespan --- @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: """Application lifespan handler for startup and shutdown.""" # Startup logger.info(f"Starting {settings.APP_NAME} v{settings.APP_VERSION}") logger.info(f"LOCAL_MODE={settings.LOCAL_MODE}") # Run local bootstrap if LOCAL_MODE is enabled # This is migration-safe: handles missing tables gracefully if settings.LOCAL_MODE: await LocalBootstrapService.run() # Start Hub telemetry service (if enabled via HUB_TELEMETRY_ENABLED) # This runs in background and never blocks startup await HubTelemetryService.start() yield # Shutdown logger.info("Shutting down...") await HubTelemetryService.stop() await engine.dispose() # --- Application --- app = FastAPI( title=settings.APP_NAME, version=settings.APP_VERSION, description="Control-plane backend for the LetsBe Cloud platform", lifespan=lifespan, docs_url="/docs" if settings.DEBUG else None, redoc_url="/redoc" if settings.DEBUG else None, ) # Rate limiting limiter = Limiter(key_func=get_remote_address) app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) @app.middleware("http") async def normalize_trailing_slashes(request: Request, call_next): """Strip trailing slashes from URLs to normalize routing.""" if request.url.path != "/" and request.url.path.endswith("/"): # Modify the scope to remove trailing slash request.scope["path"] = request.url.path.rstrip("/") return await call_next(request) # Add middleware app.add_middleware(RequestIDMiddleware) # Add CORS middleware if origins are configured if settings.CORS_ALLOWED_ORIGINS: origins = [o.strip() for o in settings.CORS_ALLOWED_ORIGINS.split(",")] else: origins = [] app.add_middleware( CORSMiddleware, allow_origins=origins, allow_credentials=True, allow_methods=["GET", "POST", "PATCH", "DELETE"], allow_headers=["*"], ) # --- Exception Handlers --- @app.exception_handler(IntegrityError) async def integrity_error_handler(request: Request, exc: IntegrityError) -> JSONResponse: """Handle database integrity errors (unique constraint violations, etc.).""" return JSONResponse( status_code=409, content={ "detail": "Resource conflict: a record with these values already exists", "request_id": getattr(request.state, "request_id", None), }, ) # --- Routers --- app.include_router(health_router) app.include_router(meta_router, prefix="/api/v1") app.include_router(tenants_router, prefix="/api/v1") app.include_router(tasks_router, prefix="/api/v1") app.include_router(agents_router, prefix="/api/v1") app.include_router(playbooks_router, prefix="/api/v1") app.include_router(env_router, prefix="/api/v1") app.include_router(files_router, prefix="/api/v1") app.include_router(registration_tokens_router, prefix="/api/v1") app.include_router(events_router, prefix="/api/v1") # --- Root endpoint --- @app.get("/") async def root(): """Root endpoint redirecting to docs.""" return { "message": f"Welcome to {settings.APP_NAME}", "docs": "/docs", "health": "/health", }