"""FastAPI application entry point. Wires up: - CORS middleware (whitelist, NO wildcard) - Security headers middleware (CSP, X-Frame-Options, X-Content-Type-Options, HSTS in prod) - Global exception handlers (HTTPException, RequestValidationError, SQLAlchemyError) - Startup/shutdown events (DB connection check, log level) - Versioned API router mounts under /api/v1 - Root-level /health endpoint for Coolify healthcheck """ from __future__ import annotations import logging from contextlib import asynccontextmanager from fastapi import FastAPI, Request, status from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from sqlalchemy import text from sqlalchemy.exc import SQLAlchemyError from starlette.exceptions import HTTPException as StarletteHTTPException from app import __version__ from app.api.v1 import ( accounts, activities, auth, contacts, dashboard, deals, health, notes, tags, users, ) from app.core.config import get_settings from app.core.db import AsyncSessionLocal, dispose_engine logger = logging.getLogger(__name__) # === Lifespan === @asynccontextmanager async def lifespan(app: FastAPI): """Application lifespan: startup and shutdown hooks.""" settings = get_settings() logging.basicConfig(level=settings.LOG_LEVEL) logger.info( "Starting CRM System v%s in %s mode", __version__, settings.ENVIRONMENT, ) # Verify DB connection on startup try: async with AsyncSessionLocal() as session: await session.execute(text("SELECT 1")) logger.info("Database connection OK (%s)", settings.DATABASE_URL.split("://", 1)[0]) except Exception as e: logger.error("Database connection FAILED on startup: %s", e) # Don't crash — let /health report the issue so Coolify can restart yield # Shutdown logger.info("Shutting down CRM System") await dispose_engine() # === App === def create_app() -> FastAPI: """Application factory (allows easy testing override).""" settings = get_settings() app = FastAPI( title="CRM System", version=__version__, docs_url="/docs", redoc_url="/redoc", openapi_url="/openapi.json", lifespan=lifespan, ) # === CORS === app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origins_list, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # === Security Headers === @app.middleware("http") async def security_headers_middleware(request: Request, call_next): """Inject CSP, X-Frame-Options, X-Content-Type-Options, and HSTS headers.""" response = await call_next(request) settings_local = get_settings() if settings_local.is_production: # Prod: strict CSP (no unsafe-inline for scripts; TODO v1.1 add nonce) csp = ( "default-src 'self'; " "script-src 'self' https://cdn.tailwindcss.com; " "style-src 'self' 'unsafe-inline'; " "img-src 'self' data:; " "object-src 'none';" ) else: # Dev: allow inline scripts for fast iteration (Alpine.js x-data blocks) csp = ( "default-src 'self'; " "script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com; " "style-src 'self' 'unsafe-inline'; " "img-src 'self' data:; " "object-src 'none';" ) response.headers["Content-Security-Policy"] = csp response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Frame-Options"] = "DENY" if settings_local.is_production: response.headers["Strict-Transport-Security"] = ( "max-age=31536000; includeSubDomains" ) return response # === Exception Handlers === @app.exception_handler(StarletteHTTPException) async def http_exception_handler( request: Request, exc: StarletteHTTPException ) -> JSONResponse: """Format HTTPException responses consistently.""" # Distinguish token-expired for FR-1.7 acceptance criterion if exc.status_code == 401: detail = exc.detail if detail == "Could not validate credentials": # Token invalid or expired — callers can detect this return JSONResponse( status_code=exc.status_code, content={"detail": "token_expired_or_invalid"}, headers=exc.headers, ) return JSONResponse( status_code=exc.status_code, content={"detail": exc.detail}, headers=exc.headers, ) @app.exception_handler(RequestValidationError) async def validation_exception_handler( request: Request, exc: RequestValidationError ) -> JSONResponse: """Format Pydantic validation errors consistently.""" from fastapi.encoders import jsonable_encoder return JSONResponse( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, content={"detail": jsonable_encoder(exc.errors())}, ) @app.exception_handler(SQLAlchemyError) async def sqlalchemy_exception_handler( request: Request, exc: SQLAlchemyError ) -> JSONResponse: """Log DB errors and return a 500 without leaking internals.""" logger.exception("Database error on %s %s", request.method, request.url) return JSONResponse( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"detail": "Database error"}, ) # === Routers === # Health is mounted at both /health (root) and /api/v1/health (versioned) app.include_router(health.router) app.include_router(auth.router, prefix="/api/v1") app.include_router(users.router, prefix="/api/v1") # Phase 4b business routers app.include_router(accounts.router, prefix="/api/v1") app.include_router(contacts.router, prefix="/api/v1") app.include_router(deals.router, prefix="/api/v1") app.include_router(activities.router, prefix="/api/v1") app.include_router(notes.router, prefix="/api/v1") app.include_router(tags.router, prefix="/api/v1") app.include_router(dashboard.router, prefix="/api/v1") return app app = create_app()