300 lines
12 KiB
Python
300 lines
12 KiB
Python
from contextlib import asynccontextmanager
|
|
|
|
import sentry_sdk
|
|
from fastapi import FastAPI, HTTPException, Request
|
|
from fastapi.exceptions import RequestValidationError
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import JSONResponse
|
|
from sentry_sdk.integrations.celery import CeleryIntegration
|
|
from sentry_sdk.integrations.fastapi import FastApiIntegration
|
|
from sentry_sdk.integrations.pymongo import PyMongoIntegration
|
|
from sentry_sdk.integrations.redis import RedisIntegration
|
|
|
|
from .api.v1.routes_admin import router as admin_router
|
|
from .api.v1.routes_admin_production import router as admin_production_router
|
|
from .api.v1.routes_auth import router as auth_router
|
|
from .api.v1.routes_briefs import router as briefs_router
|
|
from .api.v1.routes_clients import router as clients_router
|
|
from .api.v1.routes_files import router as files_router
|
|
from .api.v1.routes_glossaries import router as glossaries_router
|
|
from .api.v1.routes_invitations import org_router as invitations_org_router
|
|
from .api.v1.routes_invitations import router as invitations_router
|
|
from .api.v1.routes_jobs import router as jobs_router
|
|
from .api.v1.routes_language_qc import router as language_qc_router
|
|
from .api.v1.routes_organizations import router as organizations_router
|
|
from .api.v1.routes_review_notes import router as review_notes_router
|
|
from .api.v1.routes_share import router as share_router
|
|
from .api.v1.routes_tts import router as tts_router
|
|
from .api.v1.routes_vtt_versions import router as vtt_versions_router
|
|
from .api.v1.routes_websockets import router as websockets_router
|
|
from .core.config import settings
|
|
from .core.database import (
|
|
close_mongo_connection,
|
|
connect_to_mongo,
|
|
get_database,
|
|
)
|
|
from .core.logging import setup_logging
|
|
from .core.redis import close_redis_connection, connect_to_redis, get_redis_client
|
|
from .core.secrets_config import initialize_config
|
|
from .core.seed import seed_default_admin
|
|
from .middleware import create_rate_limit_middleware, create_validation_middleware
|
|
from .services.language_qc import seed_language_qc_for_job
|
|
from .services.websocket import connection_manager
|
|
from .telemetry import (
|
|
app_metrics,
|
|
)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
# Startup
|
|
setup_logging()
|
|
|
|
# Initialize configuration with secrets
|
|
if settings.app_env == "prod":
|
|
try:
|
|
await initialize_config()
|
|
print("✅ Configuration initialized with Secret Manager")
|
|
except Exception as e:
|
|
print(f"⚠️ Failed to load secrets from Secret Manager: {e}")
|
|
print("⚠️ Falling back to environment variables")
|
|
|
|
# Initialize Sentry error tracking
|
|
if settings.sentry_dsn and settings.sentry_dsn.startswith(('http', 'https')):
|
|
sentry_sdk.init(
|
|
dsn=settings.sentry_dsn,
|
|
integrations=[
|
|
FastApiIntegration(),
|
|
RedisIntegration(),
|
|
PyMongoIntegration(),
|
|
CeleryIntegration(monitor_beat_tasks=True),
|
|
],
|
|
traces_sample_rate=0.1 if settings.app_env == "prod" else 1.0,
|
|
environment=settings.app_env,
|
|
release="1.0.0",
|
|
attach_stacktrace=True,
|
|
send_default_pii=False, # Don't send PII for privacy
|
|
)
|
|
|
|
# Initialize telemetry (disabled for local development)
|
|
# setup_tracing("accessible-video-api", "1.0.0")
|
|
# instrument_dependencies()
|
|
|
|
# Start Prometheus metrics server in production
|
|
if settings.app_env == "prod":
|
|
app_metrics.start_prometheus_server(port=8001)
|
|
|
|
await connect_to_mongo()
|
|
await connect_to_redis()
|
|
|
|
try:
|
|
db = await get_database()
|
|
await seed_default_admin(db)
|
|
except Exception as e:
|
|
print(f"⚠️ Could not seed default admin: {e}")
|
|
# await create_indexes() # Temporarily disabled for debugging
|
|
|
|
# T-16: Seed language_qc only for jobs that still lack it (idempotent, skips on subsequent starts)
|
|
try:
|
|
db = await get_database()
|
|
pending_count = await db.jobs.count_documents({"language_qc": {"$exists": False}})
|
|
if pending_count > 0:
|
|
async for job_doc in db.jobs.find(
|
|
{"language_qc": {"$exists": False}},
|
|
{"_id": 1, "status": 1, "outputs": 1, "source": 1, "review": 1, "updated_at": 1, "requested_outputs": 1},
|
|
):
|
|
await seed_language_qc_for_job(db, job_doc)
|
|
print(f"✅ language_qc migration complete ({pending_count} jobs seeded)")
|
|
except Exception as e:
|
|
print(f"⚠️ language_qc migration failed: {e}")
|
|
|
|
# Start WebSocket connection manager
|
|
await connection_manager.start()
|
|
|
|
# Initialize middleware with Redis client
|
|
redis_client = get_redis_client()
|
|
if redis_client:
|
|
rate_limit_middleware = await create_rate_limit_middleware(redis_client)
|
|
validation_middleware = await create_validation_middleware()
|
|
|
|
# Store middleware in app state for access
|
|
app.state.rate_limit_middleware = rate_limit_middleware
|
|
app.state.validation_middleware = validation_middleware
|
|
elif settings.redis_url:
|
|
# T-13: REDIS_URL is configured but client unavailable — rate limiting is disabled
|
|
print(f"⚠️ Redis configured at {settings.redis_url!r} but connection failed — rate limiting disabled")
|
|
|
|
yield
|
|
# Shutdown
|
|
await connection_manager.stop()
|
|
await close_mongo_connection()
|
|
await close_redis_connection()
|
|
|
|
|
|
app = FastAPI(
|
|
title="Accessible Video API",
|
|
description="API for accessible video processing platform",
|
|
version="1.0.0",
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
# CORS middleware
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=settings.cors_origins_list,
|
|
allow_credentials=True,
|
|
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# Custom CORS error handler middleware to ensure CORS headers are added to all error responses
|
|
# This must be added BEFORE CORSMiddleware (which will be applied after due to reverse order)
|
|
@app.middleware("http")
|
|
async def cors_error_handler(request, call_next):
|
|
"""Ensure CORS headers are added to all responses, including errors."""
|
|
try:
|
|
response = await call_next(request)
|
|
except Exception as e:
|
|
import traceback
|
|
|
|
from .core.logging import get_logger as _get_logger
|
|
_get_logger(__name__).exception("🚨 CORS middleware caught: %s\n%s", e, traceback.format_exc())
|
|
|
|
from fastapi.responses import JSONResponse
|
|
response = JSONResponse(
|
|
status_code=500,
|
|
content={"detail": "Internal server error"},
|
|
)
|
|
|
|
# Always add CORS headers for allowed origins
|
|
origin = request.headers.get("origin")
|
|
if origin and origin in settings.cors_origins_list:
|
|
response.headers["access-control-allow-origin"] = origin
|
|
response.headers["access-control-allow-credentials"] = "true"
|
|
# Add other necessary CORS headers for error responses
|
|
if response.status_code >= 400:
|
|
response.headers["access-control-allow-methods"] = "GET, POST, PUT, PATCH, DELETE"
|
|
response.headers["access-control-allow-headers"] = "*"
|
|
|
|
return response
|
|
|
|
# Global exception handler to ensure CORS headers on all errors
|
|
@app.exception_handler(HTTPException)
|
|
async def http_exception_handler(request: Request, exc: HTTPException):
|
|
"""Handle HTTP exceptions with CORS headers"""
|
|
response = JSONResponse(
|
|
status_code=exc.status_code,
|
|
content={"detail": exc.detail}
|
|
)
|
|
|
|
# Add CORS headers
|
|
origin = request.headers.get("origin")
|
|
if origin and origin in settings.cors_origins_list:
|
|
response.headers["access-control-allow-origin"] = origin
|
|
response.headers["access-control-allow-credentials"] = "true"
|
|
response.headers["access-control-allow-methods"] = "GET, POST, PUT, PATCH, DELETE"
|
|
response.headers["access-control-allow-headers"] = "*"
|
|
|
|
return response
|
|
|
|
# Global exception handler for validation errors
|
|
@app.exception_handler(RequestValidationError)
|
|
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
|
"""Handle request validation errors with CORS headers"""
|
|
response = JSONResponse(
|
|
status_code=422,
|
|
content={"detail": exc.errors(), "body": exc.body}
|
|
)
|
|
|
|
# Add CORS headers
|
|
origin = request.headers.get("origin")
|
|
if origin and origin in settings.cors_origins_list:
|
|
response.headers["access-control-allow-origin"] = origin
|
|
response.headers["access-control-allow-credentials"] = "true"
|
|
response.headers["access-control-allow-methods"] = "GET, POST, PUT, PATCH, DELETE"
|
|
response.headers["access-control-allow-headers"] = "*"
|
|
|
|
return response
|
|
|
|
# Global exception handler for all other exceptions
|
|
@app.exception_handler(Exception)
|
|
async def general_exception_handler(request: Request, exc: Exception):
|
|
"""Handle all uncaught exceptions with logging"""
|
|
import traceback
|
|
|
|
from .core.logging import get_logger
|
|
|
|
logger = get_logger(__name__)
|
|
logger.exception(
|
|
"🚨 Unhandled %s %s: %s\n%s",
|
|
request.method, request.url.path, exc, traceback.format_exc(),
|
|
)
|
|
|
|
response = JSONResponse(
|
|
status_code=500,
|
|
content={"detail": "Internal server error"},
|
|
)
|
|
|
|
# Add CORS headers
|
|
origin = request.headers.get("origin")
|
|
if origin and origin in settings.cors_origins_list:
|
|
response.headers["access-control-allow-origin"] = origin
|
|
response.headers["access-control-allow-credentials"] = "true"
|
|
|
|
return response
|
|
|
|
# Add custom middleware (order matters - applied in reverse order)
|
|
@app.middleware("http")
|
|
async def rate_limiting_middleware(request, call_next):
|
|
"""Apply rate limiting middleware."""
|
|
if hasattr(app.state, 'rate_limit_middleware'):
|
|
return await app.state.rate_limit_middleware(request, call_next)
|
|
return await call_next(request)
|
|
|
|
@app.middleware("http")
|
|
async def validation_middleware(request, call_next):
|
|
"""Apply request validation middleware."""
|
|
if request.url.path in ["/health", "/metrics", "/api/v1/auth/login", "/api/v1/auth/refresh"]:
|
|
return await call_next(request)
|
|
if hasattr(app.state, 'validation_middleware'):
|
|
return await app.state.validation_middleware(request, call_next)
|
|
return await call_next(request)
|
|
|
|
# Instrument FastAPI app for tracing (disabled for local development)
|
|
# instrument_fastapi_app(app)
|
|
|
|
# Include routers
|
|
app.include_router(auth_router, prefix="/api/v1")
|
|
app.include_router(clients_router, prefix="/api/v1")
|
|
app.include_router(organizations_router, prefix="/api/v1")
|
|
app.include_router(invitations_org_router, prefix="/api/v1")
|
|
app.include_router(invitations_router, prefix="/api/v1")
|
|
app.include_router(files_router, prefix="/api/v1")
|
|
app.include_router(jobs_router, prefix="/api/v1")
|
|
app.include_router(review_notes_router, prefix="/api/v1")
|
|
app.include_router(vtt_versions_router, prefix="/api/v1")
|
|
app.include_router(language_qc_router, prefix="/api/v1")
|
|
app.include_router(glossaries_router, prefix="/api/v1")
|
|
app.include_router(tts_router, prefix="/api/v1")
|
|
app.include_router(admin_router, prefix="/api/v1")
|
|
app.include_router(admin_production_router, prefix="/api/v1")
|
|
app.include_router(briefs_router, prefix="/api/v1")
|
|
app.include_router(share_router, prefix="/api/v1")
|
|
app.include_router(websockets_router, prefix="/api/v1")
|
|
|
|
|
|
@app.get("/health")
|
|
async def health_check():
|
|
return {"status": "healthy", "version": "1.0.0"}
|
|
|
|
|
|
@app.get("/metrics")
|
|
async def metrics():
|
|
"""Prometheus metrics endpoint"""
|
|
from fastapi import Response
|
|
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
|
|
|
|
return Response(
|
|
content=generate_latest(),
|
|
media_type=CONTENT_TYPE_LATEST
|
|
)
|