LetsBeBiz-Redesign/letsbe-orchestrator/app/main.py

164 lines
4.6 KiB
Python

"""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",
}