258 lines
9.3 KiB
Python
258 lines
9.3 KiB
Python
from contextlib import asynccontextmanager
|
|
from fastapi import FastAPI
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.security import HTTPBearer
|
|
import logging
|
|
from sqlalchemy import text
|
|
from app.config.settings import settings
|
|
from app.config.database import engine
|
|
from app.routes.admin import audit_logs
|
|
import app.models.auth.user_model
|
|
import app.models.auth.role_model
|
|
import app.models.auth.tenant_model
|
|
import app.models.theme.color_palette_model
|
|
import app.models.auth.module_model
|
|
import app.models.auth.module_environment_model
|
|
import app.models.auth.tenant_module_model
|
|
import app.models.auth.sso_grant_model
|
|
import app.models.auth.access_model
|
|
import app.models.system.event_log_model
|
|
import app.models.system.audit_log
|
|
import asyncio
|
|
from app.services.auth.event_service import EventService
|
|
from app.config.database import SessionLocal
|
|
from app.core.redis import redis_client, sync_redis_client
|
|
from fastapi.concurrency import run_in_threadpool
|
|
|
|
# Configure logging
|
|
logging.basicConfig(
|
|
level=settings.LOG_LEVEL.upper(),
|
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
datefmt="%Y-%m-%d %H:%M:%S",
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
async def wait_for_db(retries: int = 10, base_delay: float = 3.0, max_delay: float = 30.0):
|
|
"""Wait for the database to become available with exponential backoff."""
|
|
for attempt in range(1, retries + 1):
|
|
try:
|
|
with engine.connect() as conn:
|
|
conn.execute(text("SELECT 1"))
|
|
logger.info("Database connection successful!")
|
|
return
|
|
except Exception as e:
|
|
if attempt == retries:
|
|
logger.error(f"Database unreachable after {retries} attempts: {e}")
|
|
raise
|
|
delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
|
|
logger.warning(
|
|
f"Database not ready (attempt {attempt}/{retries}): {e}. "
|
|
f"Retrying in {delay:.0f}s..."
|
|
)
|
|
await asyncio.sleep(delay)
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
logger.info("Starting SaaS application...")
|
|
|
|
await wait_for_db()
|
|
|
|
app.state.redis_available = False
|
|
|
|
try:
|
|
await redis_client.connect()
|
|
await run_in_threadpool(sync_redis_client.connect)
|
|
app.state.redis_available = True
|
|
logger.info("Redis connected successfully.")
|
|
except Exception as e:
|
|
logger.error(f"Redis unavailable at startup: {e}")
|
|
logger.warning("Continuing without Redis — event queue disabled.")
|
|
|
|
logger.info(
|
|
f"{settings.PROJECT_NAME} v{settings.VERSION} started ({settings.APP_ENV})"
|
|
)
|
|
|
|
async def redis_event_consumer():
|
|
logger.info("Redis Event Consumer STARTED")
|
|
while True:
|
|
try:
|
|
if not app.state.redis_available or not redis_client.client:
|
|
await asyncio.sleep(5)
|
|
continue
|
|
|
|
result = await redis_client.client.blpop("saas:events:queue", timeout=5)
|
|
|
|
if result:
|
|
_, event_id = result
|
|
try:
|
|
with SessionLocal() as db:
|
|
await run_in_threadpool(EventService.process_queue_item, db, event_id)
|
|
except Exception as e:
|
|
logger.error(f"Error processing event {event_id}: {e}")
|
|
|
|
except Exception:
|
|
await asyncio.sleep(1)
|
|
|
|
async def fallback_poller():
|
|
logger.info("Fallback Event Poller STARTED")
|
|
while True:
|
|
try:
|
|
with SessionLocal() as db:
|
|
await run_in_threadpool(EventService.process_outbox, db)
|
|
except Exception as e:
|
|
logger.error(f"Fallback poller error: {e}")
|
|
|
|
await asyncio.sleep(60)
|
|
|
|
background_tasks = [
|
|
asyncio.create_task(redis_event_consumer()),
|
|
asyncio.create_task(fallback_poller()),
|
|
]
|
|
|
|
yield
|
|
|
|
logger.info("Shutting down...")
|
|
for task in background_tasks:
|
|
task.cancel()
|
|
await asyncio.gather(*background_tasks, return_exceptions=True)
|
|
if app.state.redis_available:
|
|
try:
|
|
await redis_client.close()
|
|
await run_in_threadpool(sync_redis_client.close)
|
|
except Exception:
|
|
pass
|
|
|
|
def create_app() -> FastAPI:
|
|
app = FastAPI(
|
|
title=settings.PROJECT_NAME,
|
|
version=settings.VERSION,
|
|
description="SaaS Architecture API",
|
|
docs_url="/docs",
|
|
redoc_url="/redoc",
|
|
openapi_url="/openapi.json",
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
# === OpenAPI Security Scheme ===
|
|
from fastapi.openapi.utils import get_openapi
|
|
|
|
def custom_openapi():
|
|
if app.openapi_schema:
|
|
return app.openapi_schema
|
|
openapi_schema = get_openapi(
|
|
title=app.title,
|
|
version=app.version,
|
|
description=app.description,
|
|
routes=app.routes,
|
|
)
|
|
openapi_schema["components"]["securitySchemes"] = {
|
|
"BearerAuth": {
|
|
"type": "http",
|
|
"scheme": "bearer",
|
|
"bearerFormat": "JWT",
|
|
}
|
|
}
|
|
app.openapi_schema = openapi_schema
|
|
return app.openapi_schema
|
|
|
|
app.openapi = custom_openapi
|
|
|
|
# === CORS ===
|
|
origins = []
|
|
if settings.CORS_ALLOWED_ORIGINS:
|
|
origins = [
|
|
origin.strip()
|
|
for origin in settings.CORS_ALLOWED_ORIGINS.split(",")
|
|
if origin.strip()
|
|
]
|
|
|
|
if not origins:
|
|
raise RuntimeError(
|
|
"CORS_ALLOWED_ORIGINS must be set when allow_credentials=True"
|
|
)
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=origins,
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# === Include Routers ===
|
|
from app.routes.auth.auth import router as auth_router
|
|
from app.routes.auth.tenant import router as tenant_router
|
|
from app.routes.auth.role import router as role_router
|
|
from app.routes.auth.access import router as access_router
|
|
from app.routes.auth.user import router as user_router
|
|
from app.routes.auth.sso import public_router as sso_public_router, internal_router as sso_internal_router
|
|
from app.routes.api.module import router as module_router
|
|
from app.routes.auth.subscription_plan import router as subscription_plan_router
|
|
|
|
app.include_router(auth_router, prefix="/api/auth", tags=["Authentication"])
|
|
app.include_router(tenant_router, prefix="/api/tenant", tags=["Tenant Management"])
|
|
app.include_router(role_router, prefix="/api/role", tags=["Role Management"])
|
|
app.include_router(access_router, prefix="/api/access", tags=["Access Management"])
|
|
app.include_router(user_router, prefix="/api/user", tags=["User Management"])
|
|
app.include_router(subscription_plan_router, prefix="/api/subscription-plan", tags=["Subscription Plans"])
|
|
from app.routes.internal.module import router as internal_module_router
|
|
|
|
app.include_router(sso_public_router, prefix="/api/sso", tags=["SSO"])
|
|
app.include_router(sso_internal_router, prefix="/internal/sso", tags=["Internal SSO"])
|
|
app.include_router(module_router, prefix="/api/modules", tags=["Modules"])
|
|
app.include_router(internal_module_router, prefix="/internal/modules", tags=["Internal Modules"])
|
|
|
|
from app.routes.theme.color_palette import router as palette_router
|
|
app.include_router(palette_router, prefix="/api/theme", tags=["Theme Management"])
|
|
|
|
# === Admin Routes ===
|
|
from app.routes.admin.modules import router as admin_modules_router
|
|
from app.routes.admin.module_environments import router as admin_module_env_router
|
|
from app.routes.admin.tenant_modules import router as admin_tenant_modules_router
|
|
|
|
app.include_router(audit_logs.router, prefix="/api/admin/audit-logs", tags=["Admin - Audit Logs"])
|
|
app.include_router(admin_modules_router, prefix="/api/admin/modules", tags=["Admin - Modules"])
|
|
app.include_router(admin_module_env_router, prefix="/api/admin/modules", tags=["Admin - Module Environments"])
|
|
app.include_router(admin_tenant_modules_router, prefix="/api/admin/tenants", tags=["Admin - Tenant Modules"])
|
|
|
|
# === Basic Routes ===
|
|
@app.get("/", tags=["Root"])
|
|
def root():
|
|
"""
|
|
Root endpoint - API information.
|
|
|
|
Returns basic information about the API including version and documentation links.
|
|
"""
|
|
return {
|
|
"message": "Welcome to SaaS Architecture Backend API",
|
|
"version": settings.VERSION,
|
|
"docs": "/docs",
|
|
"redoc": "/redoc",
|
|
}
|
|
|
|
@app.get("/health", tags=["Health"])
|
|
def health():
|
|
"""
|
|
Health check endpoint.
|
|
|
|
Returns the health status of the API and database connection.
|
|
Used by monitoring tools and load balancers.
|
|
"""
|
|
db_status = "connected"
|
|
try:
|
|
with engine.connect() as conn:
|
|
conn.execute(text("SELECT 1"))
|
|
db_status = "healthy"
|
|
except Exception as e:
|
|
logger.error(f"Health check DB error: {e}")
|
|
db_status = "unhealthy"
|
|
|
|
return {
|
|
"status": "healthy" if db_status == "healthy" else "degraded",
|
|
"environment": settings.APP_ENV,
|
|
"database": db_status,
|
|
"version": settings.VERSION,
|
|
}
|
|
|
|
return app |