scholarr/app/main.py
2026-02-17 20:24:12 +01:00

112 lines
3.8 KiB
Python

from __future__ import annotations
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
from app.api.errors import register_api_exception_handlers
from app.api.router import router as api_router
from app.api.runtime_deps import get_ingestion_service, get_scholar_source
from app.db.session import check_database
from app.db.session import close_engine
from app.http.middleware import RequestLoggingMiddleware, parse_skip_paths
from app.logging_config import configure_logging, parse_redact_fields
from app.security.csrf import CSRFMiddleware
from app.services.scheduler import SchedulerService
from app.settings import settings
configure_logging(
level=settings.log_level,
log_format=settings.log_format,
redact_fields=parse_redact_fields(settings.log_redact_fields),
include_uvicorn_access=settings.log_uvicorn_access,
)
scheduler_service = SchedulerService(
enabled=settings.scheduler_enabled,
tick_seconds=settings.scheduler_tick_seconds,
network_error_retries=settings.ingestion_network_error_retries,
retry_backoff_seconds=settings.ingestion_retry_backoff_seconds,
max_pages_per_scholar=settings.ingestion_max_pages_per_scholar,
page_size=settings.ingestion_page_size,
continuation_queue_enabled=settings.ingestion_continuation_queue_enabled,
continuation_base_delay_seconds=settings.ingestion_continuation_base_delay_seconds,
continuation_max_delay_seconds=settings.ingestion_continuation_max_delay_seconds,
continuation_max_attempts=settings.ingestion_continuation_max_attempts,
queue_batch_size=settings.scheduler_queue_batch_size,
)
@asynccontextmanager
async def lifespan(_: FastAPI):
await scheduler_service.start()
yield
await scheduler_service.stop()
await close_engine()
app = FastAPI(title=settings.app_name, lifespan=lifespan)
register_api_exception_handlers(app)
app.add_middleware(CSRFMiddleware)
app.add_middleware(
SessionMiddleware,
secret_key=settings.session_secret_key,
same_site="lax",
https_only=settings.session_cookie_secure,
)
app.add_middleware(
RequestLoggingMiddleware,
log_requests=settings.log_requests,
skip_paths=parse_skip_paths(settings.log_request_skip_paths),
)
app.include_router(api_router)
@app.get("/healthz")
async def healthz() -> dict[str, str]:
if await check_database():
return {"status": "ok"}
raise HTTPException(status_code=500, detail="database unavailable")
def _configure_frontend_routes(application: FastAPI) -> None:
if not settings.frontend_enabled:
return
dist_dir = Path(settings.frontend_dist_dir)
index_file = dist_dir / "index.html"
if not index_file.is_file():
return
assets_dir = dist_dir / "assets"
if assets_dir.is_dir():
application.mount("/assets", StaticFiles(directory=assets_dir), name="frontend-assets")
@application.get("/", include_in_schema=False)
async def frontend_index() -> FileResponse:
return FileResponse(index_file)
@application.get("/{full_path:path}", include_in_schema=False)
async def frontend_entry(full_path: str) -> FileResponse:
normalized = full_path.lstrip("/")
if normalized in {"healthz", "api"} or normalized.startswith("api/"):
raise HTTPException(status_code=404, detail="Not Found")
candidate = (dist_dir / normalized).resolve()
try:
candidate.relative_to(dist_dir.resolve())
except ValueError as error:
raise HTTPException(status_code=404, detail="Not Found") from error
if candidate.is_file():
return FileResponse(candidate)
return FileResponse(index_file)
_configure_frontend_routes(app)