Files
saas_backend/app/__init__.py
T

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