import os import time import uuid import logging from contextlib import asynccontextmanager from fastapi import FastAPI, Request, status from fastapi.middleware.cors import CORSMiddleware from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse, FileResponse from fastapi.staticfiles import StaticFiles from app.database import init_db from app.routers import auth, redact, feedback, history, health from app.services.ner_engine import get_spacy_model, preload_fallback_model logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s" ) logger = logging.getLogger("redactx") @asynccontextmanager async def lifespan(app: FastAPI): logger.info("Starting RedactX API server...") # Initialize SQLite database asynchronously await init_db() # Pre-warm Level 1 spaCy model in memory to reduce cold-start latency try: logger.info("Pre-warming NLP models...") get_spacy_model(level=1) preload_fallback_model() except Exception as e: logger.warning(f"Model pre-warm warning: {e}") yield logger.info("Shutting down RedactX API server...") app = FastAPI(title="RedactX API", version="2.0.0", lifespan=lifespan) # Restrict CORS to development ports and configured production domains allowed_origins_env = os.environ.get("ALLOWED_ORIGINS", "http://localhost:5173,http://localhost:4433,http://127.0.0.1:5173,http://127.0.0.1:4433") allowed_origins = [o.strip() for o in allowed_origins_env.split(",") if o.strip()] app.add_middleware( CORSMiddleware, allow_origins=allowed_origins, allow_origin_regex=".*", allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) @app.middleware("http") async def log_request_timing_middleware(request: Request, call_next): request_id = str(uuid.uuid4())[:8] start_time = time.time() health.REQUEST_COUNTS["total"] = health.REQUEST_COUNTS.get("total", 0) + 1 try: response = await call_next(request) duration = time.time() - start_time logger.info(f"[{request_id}] {request.method} {request.url.path} -> {response.status_code} ({duration*1000:.2f}ms)") # Track processing times for metrics endpoint path_key = request.url.path if path_key not in health.PROCESSING_TIMES: health.PROCESSING_TIMES[path_key] = [] health.PROCESSING_TIMES[path_key].append(duration) if len(health.PROCESSING_TIMES[path_key]) > 1000: health.PROCESSING_TIMES[path_key].pop(0) return response except Exception as e: health.REQUEST_COUNTS["errors"] = health.REQUEST_COUNTS.get("errors", 0) + 1 duration = time.time() - start_time logger.error(f"[{request_id}] {request.method} {request.url.path} -> ERROR: {e} ({duration*1000:.2f}ms)") raise @app.exception_handler(RequestValidationError) async def validation_exception_handler(request: Request, exc: RequestValidationError): logger.error(f"Validation error on {request.url.path}: {exc}") return JSONResponse( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, content={"detail": exc.errors()}, ) # Include modularized routers app.include_router(auth.router) app.include_router(redact.router) app.include_router(feedback.router) app.include_router(history.router) app.include_router(health.router) # Serve built React frontend if available (Docker production deployment) frontend_dist_1 = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "dist-react") frontend_dist_2 = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "dist-react") frontend_dist = frontend_dist_1 if os.path.exists(frontend_dist_1) else frontend_dist_2 if os.path.exists(frontend_dist): assets_dir = os.path.join(frontend_dist, "assets") if os.path.exists(assets_dir): app.mount("/assets", StaticFiles(directory=assets_dir), name="assets") @app.get("/{full_path:path}") async def serve_frontend(full_path: str): if full_path.startswith("api/") or full_path in ["health", "metrics", "docs", "openapi.json"]: return JSONResponse(status_code=404, content={"detail": "Not found"}) file_path = os.path.join(frontend_dist, full_path) if os.path.exists(file_path) and os.path.isfile(file_path): return FileResponse(file_path) return FileResponse(os.path.join(frontend_dist, "index.html")) else: @app.get("/") def read_root(): return {"message": "Welcome to the Redact API v2.0"}