"""FastAPI application factory and lifecycle. Startup runs migrations and opens the database engine; shutdown disposes it so no connection or engine resource leaks. The engine, session factory, and config live on ``app.state`` for dependencies to use. The app binds to 127.0.0.1 by default and exposes the versioned ``/api/v1`` surface; US01-02 ships only health. """ from __future__ import annotations import logging from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI, Request from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles from starlette.exceptions import HTTPException as StarletteHTTPException from photo_pipeline.api.routes import ( albums, analysis, archives, duplicates, health, inventory, jobs, library, operations, renames, safety, session as session_routes, thumbnails, uploads, workflow, ) from photo_pipeline.api.security import DEFAULT_HEADERS, SecurityMiddleware, Session # Registers the safety_score / analysis job handlers on import. import photo_pipeline.jobs.domain_handlers # noqa: F401 from photo_pipeline.config import Config from photo_pipeline.db import create_db_engine, create_session_factory from photo_pipeline.services.backup import migrate_with_backup from photo_pipeline.logging import configure_logging from photo_pipeline.services.thumbnails import ThumbnailService from photo_pipeline.services.upload_batches import UploadBatchService FRONTEND_DIR = Path(__file__).resolve().parents[2] / "frontend" log = logging.getLogger(__name__) def _envelope(status: int, code: str, message: str) -> JSONResponse: return JSONResponse( status_code=status, content={"error": {"code": code, "message": message}}, headers=DEFAULT_HEADERS, ) def _install_error_handlers(app: FastAPI) -> None: """One JSON error envelope everywhere, and nothing behind it. An unhandled exception carries the library's absolute paths, SQL, and sometimes a credential in its text; the client gets a code, the operator gets the traceback in the server log (US07-02). """ @app.exception_handler(StarletteHTTPException) async def _http_error(request: Request, exc: StarletteHTTPException): return _envelope(exc.status_code, "http_error", str(exc.detail)) @app.exception_handler(RequestValidationError) async def _validation_error(request: Request, exc: RequestValidationError): # Field locations only: the echoed input can be the caller's own data, but it # is also what ends up in shared logs and screenshots. fields = sorted(".".join(str(part) for part in error["loc"]) for error in exc.errors()) return _envelope(422, "invalid_request", f"invalid request fields: {', '.join(fields)}") @app.exception_handler(Exception) async def _unhandled(request: Request, exc: Exception): log.exception("unhandled error serving %s", request.url.path) return _envelope(500, "internal_error", "internal error") def create_app(config: Config | None = None) -> FastAPI: config = config or Config.from_env() configure_logging(config.log_level, config.log_format) @asynccontextmanager async def lifespan(app: FastAPI): config.database_path.parent.mkdir(parents=True, exist_ok=True) # A schema upgrade is snapshotted first, so a migration that fails halfway # leaves a restorable database behind rather than a damaged one (US07-05). migrate_with_backup(config) engine = create_db_engine(config.database_url) app.state.config = config app.state.engine = engine app.state.session_factory = create_session_factory(engine) # An upload whose process died left no outcome behind; resolve it now so the # uploader lane is free and the uncertain batch is visible (US05-02). UploadBatchService(app.state.session_factory, config=config).recover() # A render killed mid-write leaves its temporary beside the cache entry; # remove those recognized leftovers, and only those (US07-03). ThumbnailService(app.state.session_factory, config).cleanup_temp_files() try: yield finally: engine.dispose() app.state.engine = None app = FastAPI(title="Photo Pipeline", version="0.1.0", lifespan=lifespan) # One session per process: the browser exchanges it for a cookie + CSRF token, # and every other origin is refused before a route ever runs (US07-02). app.state.session = Session.create() app.add_middleware(SecurityMiddleware, session=app.state.session, config=config) _install_error_handlers(app) app.include_router(session_routes.router, prefix="/api/v1") app.include_router(health.router, prefix="/api/v1") app.include_router(inventory.router, prefix="/api/v1") app.include_router(duplicates.router, prefix="/api/v1") app.include_router(jobs.router, prefix="/api/v1") app.include_router(thumbnails.router, prefix="/api/v1") app.include_router(workflow.router, prefix="/api/v1") app.include_router(safety.router, prefix="/api/v1") app.include_router(analysis.router, prefix="/api/v1") app.include_router(library.router, prefix="/api/v1") app.include_router(albums.router, prefix="/api/v1") app.include_router(renames.router, prefix="/api/v1") app.include_router(uploads.router, prefix="/api/v1") app.include_router(archives.router, prefix="/api/v1") app.include_router(operations.router, prefix="/api/v1") # Static single-page app (hash-routed). Mounted last so /api/v1 wins. if FRONTEND_DIR.is_dir(): app.mount("/app", StaticFiles(directory=FRONTEND_DIR, html=True), name="app") return app