2025-12-03 11:02:31 +01:00
|
|
|
"""FastAPI application entry point."""
|
|
|
|
|
|
|
|
|
|
import uuid
|
|
|
|
|
from contextlib import asynccontextmanager
|
|
|
|
|
from typing import AsyncGenerator
|
|
|
|
|
|
|
|
|
|
from fastapi import FastAPI, Request
|
|
|
|
|
from fastapi.responses import JSONResponse
|
|
|
|
|
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,
|
2025-12-04 00:30:37 +01:00
|
|
|
env_router,
|
|
|
|
|
files_router,
|
2025-12-03 11:02:31 +01:00
|
|
|
health_router,
|
|
|
|
|
playbooks_router,
|
2025-12-07 11:11:32 +01:00
|
|
|
registration_tokens_router,
|
2025-12-03 11:02:31 +01:00
|
|
|
tasks_router,
|
|
|
|
|
tenants_router,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- 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
|
|
|
|
|
yield
|
|
|
|
|
# Shutdown
|
|
|
|
|
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,
|
|
|
|
|
)
|
|
|
|
|
|
2025-12-08 12:37:28 +01:00
|
|
|
|
|
|
|
|
@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)
|
|
|
|
|
|
2025-12-03 11:02:31 +01:00
|
|
|
# Add middleware
|
|
|
|
|
app.add_middleware(RequestIDMiddleware)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- 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(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")
|
2025-12-04 00:30:37 +01:00
|
|
|
app.include_router(env_router, prefix="/api/v1")
|
|
|
|
|
app.include_router(files_router, prefix="/api/v1")
|
2025-12-07 11:11:32 +01:00
|
|
|
app.include_router(registration_tokens_router, prefix="/api/v1")
|
2025-12-03 11:02:31 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Root endpoint ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/")
|
|
|
|
|
async def root():
|
|
|
|
|
"""Root endpoint redirecting to docs."""
|
|
|
|
|
return {
|
|
|
|
|
"message": f"Welcome to {settings.APP_NAME}",
|
|
|
|
|
"docs": "/docs",
|
|
|
|
|
"health": "/health",
|
|
|
|
|
}
|