""" PaperJet backend — FastAPI application factory. This is the main entry point. The lifespan handler initializes the database and ensures required directories exist on startup. """ from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from typing import Any from fastapi import FastAPI, Request from fastapi.responses import JSONResponse from app.api.v1 import router as v1_router from app.config import settings from app.db import Base, SessionLocal, engine from app.services.trash_sweep import prune_auto_versions, sweep_trash @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: """Application lifespan: startup and shutdown tasks.""" # Create all tables (Alembic handles migrations in production, # but this ensures tables exist for development/first run) Base.metadata.create_all(bind=engine) # Ensure storage directories exist settings.PDF_STORAGE_PATH.mkdir(parents=True, exist_ok=True) settings.THUMBNAILS_PATH.mkdir(parents=True, exist_ok=True) settings.DATABASE_PATH.parent.mkdir(parents=True, exist_ok=True) # Run retention cleanup on every startup. This keeps the single-container # deployment self-maintaining without requiring a separate scheduler. with SessionLocal() as db: sweep_trash(db) prune_auto_versions(db) yield # Shutdown: dispose of the engine engine.dispose() app = FastAPI( title="PaperJet", description="Self-hosted PDF editor API", version=settings.APP_VERSION, docs_url="/api/v1/docs" if settings.DEBUG else None, redoc_url="/api/v1/redoc" if settings.DEBUG else None, openapi_url="/api/v1/openapi.json", lifespan=lifespan, ) # --- Error handlers --- @app.exception_handler(404) async def not_found_handler(request: Request, exc: Any) -> JSONResponse: """Consistent 404 error envelope.""" return JSONResponse( status_code=404, content={"error": {"code": "not_found", "message": "Resource not found"}}, ) @app.exception_handler(422) async def validation_error_handler(request: Request, exc: Any) -> JSONResponse: """Consistent validation error envelope.""" return JSONResponse( status_code=422, content={ "error": { "code": "validation_error", "message": "Request validation failed", "details": exc.errors() if hasattr(exc, "errors") else str(exc), } }, ) @app.exception_handler(500) async def internal_error_handler(request: Request, exc: Any) -> JSONResponse: """Consistent 500 error envelope.""" return JSONResponse( status_code=500, content={"error": {"code": "internal_error", "message": "Internal server error"}}, ) # --- Mount routers --- app.include_router(v1_router)