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.auth.subscription_plan_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