""" Imago — Application principale FastAPI """ import logging from contextlib import asynccontextmanager from fastapi import FastAPI, Request, APIRouter from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from slowapi import _rate_limit_exceeded_handler from slowapi.errors import RateLimitExceeded from app.config import settings from app.database import init_db from app.logging_config import configure_logging from app.routers import images_router, ai_router, auth_router, files_router, ws_router, admin_router from app.middleware import limiter from app.middleware.logging_middleware import LoggingMiddleware from app.middleware.versioning import APIVersioningMiddleware from app.workers.redis_client import get_redis_pool, close_redis_pool, parse_redis_url from app.workers.arq_fallback import _FallbackArqPool # Configure le logging structuré dès l'import configure_logging(debug=settings.DEBUG) logger = logging.getLogger(__name__) try: from arq import create_pool from arq.connections import RedisSettings _arq_available = True except ImportError: _arq_available = False try: from prometheus_fastapi_instrumentator import Instrumentator _prometheus_available = True except ImportError: _prometheus_available = False def _arq_redis_settings() -> "RedisSettings": """Parse REDIS_URL en RedisSettings ARQ via le parser centralisé.""" host, port, password, database = parse_redis_url() return RedisSettings( host=host, port=port, password=password, database=database, ) # ───────────────────────────────────────────────────────────── # Lifespan — initialisation au démarrage # ───────────────────────────────────────────────────────────── @asynccontextmanager async def lifespan(app: FastAPI): settings.upload_path settings.thumbnails_path await init_db() active_model = settings.OPENROUTER_MODEL if settings.AI_PROVIDER == "openrouter" else settings.GEMINI_MODEL logger.info("startup.db_initialized", extra={"upload_dir": settings.UPLOAD_DIR}) logger.info("startup.ai_config", extra={ "provider": settings.AI_PROVIDER, "model": active_model, "ocr_enabled": settings.OCR_ENABLED, }) try: app.state.redis = await get_redis_pool() logger.info("startup.redis_connected", extra={"url": settings.REDIS_URL}) except Exception as e: app.state.redis = None logger.warning("startup.redis_unavailable", extra={"error": str(e)}) if _arq_available: try: app.state.arq_pool = await create_pool(_arq_redis_settings()) logger.info("startup.arq_pool_created") except Exception as e: app.state.arq_pool = _FallbackArqPool() logger.warning("startup.arq_fallback", extra={"error": str(e)}) else: app.state.arq_pool = _FallbackArqPool() logger.warning("startup.arq_not_installed") yield if hasattr(app.state, "arq_pool") and hasattr(app.state.arq_pool, "close"): await app.state.arq_pool.close() await close_redis_pool() logger.info("shutdown.complete") # ───────────────────────────────────────────────────────────── # Application # ───────────────────────────────────────────────────────────── app = FastAPI( title=f"{settings.APP_NAME} API", version=settings.APP_VERSION, description=""" ## Imago API (v1) Backend de gestion d'images et fonctionnalités AI pour l'interface Shaarli. > **Note de versionnement :** L'API principale est désormais servie sous le préfixe `/api/v1/`. ### Fonctionnalités - Upload et stockage d'images avec génération de thumbnails - Extraction EXIF automatique (appareil, GPS, paramètres de prise de vue) - OCR — extraction de texte depuis les images (Tesseract) - Vision AI — description et classification par tags - Résumé d'URL — scraping + résumé AI de pages web - Rédaction de tâches — génération structurée via AI - File de tâches ARQ — pipeline persistant avec retry automatique - Métriques Prometheus — /metrics endpoint """, lifespan=lifespan, docs_url="/docs", redoc_url="/redoc", ) # ── Middleware CORS ───────────────────────────────────────── app.add_middleware( CORSMiddleware, allow_origins=settings.CORS_ORIGINS, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # ── Request ID (trace_id) ─────────────────────────────────── from app.middleware.request_id import RequestIDMiddleware app.add_middleware(RequestIDMiddleware) # ── API Versioning ────────────────────────────────────────── app.add_middleware(APIVersioningMiddleware) # ── Logging HTTP ──────────────────────────────────────────── app.add_middleware(LoggingMiddleware) # ── Rate Limiting ─────────────────────────────────────────── app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) # ── Prometheus Metrics ────────────────────────────────────── if _prometheus_available: Instrumentator( should_group_status_codes=True, should_ignore_untemplated=True, excluded_handlers=["/health", "/health/detailed", "/metrics"], ).instrument(app).expose(app, endpoint="/metrics", tags=["Observabilité"]) # ── Fichiers statiques ────────────────────────────────────── if settings.STORAGE_BACKEND == "local": app.include_router(files_router) app.mount("/static/uploads", StaticFiles(directory=str(settings.upload_path)), name="uploads") app.mount("/static/thumbnails", StaticFiles(directory=str(settings.thumbnails_path)), name="thumbnails") # ── Routers versionnés (/api/v1/) ─────────────────────────── api_v1_router = APIRouter(prefix="/api/v1") api_v1_router.include_router(images_router) api_v1_router.include_router(ai_router) api_v1_router.include_router(auth_router) app.include_router(api_v1_router) # ── Routers non-versionnés ────────────────────────────────── app.include_router(ws_router) app.include_router(admin_router) # ── Routes utilitaires ────────────────────────────────────── @app.get("/", tags=["Santé"]) async def root(): return { "app": settings.APP_NAME, "version": settings.APP_VERSION, "status": "running", "docs": "/docs", } @app.get("/health", tags=["Santé"]) async def health(): ai_configured = ( (settings.AI_PROVIDER == "gemini" and bool(settings.GEMINI_API_KEY)) or (settings.AI_PROVIDER == "openrouter" and bool(settings.OPENROUTER_API_KEY)) ) active_model = settings.OPENROUTER_MODEL if settings.AI_PROVIDER == "openrouter" else settings.GEMINI_MODEL return { "status": "healthy", "ai_enabled": settings.AI_ENABLED, "ai_provider": settings.AI_PROVIDER, "ai_configured": ai_configured, "ocr_enabled": settings.OCR_ENABLED, "model": active_model, } @app.get("/health/detailed", tags=["Santé"]) async def health_detailed(request: Request): """Endpoint de santé détaillé pour monitoring avancé.""" checks = {} checks["backend"] = { "status": "ok", "version": settings.APP_VERSION, "uptime": "N/A", } try: from app.database import AsyncSessionLocal from sqlalchemy import text async with AsyncSessionLocal() as session: await session.execute(text("SELECT 1")) checks["database"] = {"status": "ok"} except Exception as e: checks["database"] = {"status": "error", "error": str(e)} redis = getattr(request.app.state, "redis", None) if redis: try: await redis.ping() checks["redis"] = {"status": "ok"} except Exception as e: checks["redis"] = {"status": "error", "error": str(e)} else: checks["redis"] = {"status": "not_configured"} arq_pool = getattr(request.app.state, "arq_pool", None) if arq_pool and not isinstance(arq_pool, _FallbackArqPool): try: queued_jobs = await arq_pool.zcard("arq:queue") checks["queue"] = {"status": "ok", "pending_jobs": queued_jobs} hc_key = "arq:queue:health-check" worker_active = await redis.exists(hc_key) if redis else False checks["worker"] = { "status": "ok" if worker_active else "error", "active_workers": 1 if worker_active else 0, "detail": "Worker opérationnel" if worker_active else "Aucun worker détecté", } except Exception as e: checks["queue"] = {"status": "error", "error": str(e)} checks["worker"] = {"status": "error", "error": str(e)} else: checks["queue"] = {"status": "fallback"} checks["worker"] = {"status": "error", "detail": "ARQ non disponible"} from app.services.storage_backend import get_storage_backend, S3Storage storage = get_storage_backend() try: if isinstance(storage, S3Storage): await storage.exists(".health-check") checks["minio"] = {"status": "ok", "backend": "s3", "bucket": settings.S3_BUCKET} else: checks["minio"] = {"status": "ok", "backend": "local", "path": settings.UPLOAD_DIR} except Exception as e: checks["minio"] = {"status": "error", "error": str(e)} checks["tesseract"] = {"status": "ok" if settings.OCR_ENABLED else "disabled"} ai_configured = ( (settings.AI_PROVIDER == "gemini" and bool(settings.GEMINI_API_KEY)) or (settings.AI_PROVIDER == "openrouter" and bool(settings.OPENROUTER_API_KEY)) ) checks["ai"] = { "status": "ok" if ai_configured else "warning", "provider": settings.AI_PROVIDER, "configured": ai_configured, } overall = "healthy" if all( c.get("status") in ("ok", "enabled", "disabled", "not_configured", "fallback") for k, c in checks.items() if k != "ai" ) else "degraded" return {"status": overall, "checks": checks, "version": settings.APP_VERSION}