researchradar / src /api /app.py
unknown
ResearchRadar: RAG-powered NLP research explorer
65dfa4b
"""FastAPI application factory.
Creates the app, registers routers, and wires up lifespan events.
Serves the React frontend in production (static files + SPA fallback).
"""
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from src.api.deps import get_db, init_components, is_initialized
from src.api.models import HealthResponse
from src.api.routes_analytics import router as analytics_router
from src.api.routes_papers import router as papers_router
from src.api.routes_search import router as search_router
logger = logging.getLogger(__name__)
# Frontend build directory β€” check both dev and Docker locations
_FRONTEND_DIST = Path(__file__).parent.parent.parent / "frontend" / "dist"
_DOCKER_FRONTEND = Path("/app/frontend/dist")
def _get_frontend_dir() -> Path | None:
"""Return the frontend dist directory if it exists."""
if _DOCKER_FRONTEND.is_dir():
return _DOCKER_FRONTEND
if _FRONTEND_DIST.is_dir():
return _FRONTEND_DIST
return None
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Initialize heavy components on startup, cleanup on shutdown."""
if not is_initialized():
logger.info("Starting ResearchRadar API β€” loading models...")
init_components()
logger.info("Startup complete")
yield
logger.info("Shutting down")
def create_app() -> FastAPI:
"""Build and return the FastAPI application."""
app = FastAPI(
title="ResearchRadar",
description="RAG-powered research paper search, analytics, and trend tracking.",
version="0.6.0",
lifespan=lifespan,
)
# CORS β€” allow frontend dev server during development
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://localhost:3000"],
allow_methods=["*"],
allow_headers=["*"],
)
# API routers
app.include_router(search_router, prefix="/api")
app.include_router(papers_router, prefix="/api")
app.include_router(analytics_router, prefix="/api")
@app.get("/api/health", response_model=HealthResponse, tags=["health"])
def health_check():
db = get_db()
return HealthResponse(
status="ok",
paper_count=db.get_paper_count(),
chunk_count=db.get_chunk_count(),
)
# ── Static file serving for the React SPA ────────────────────────
frontend_dir = _get_frontend_dir()
if frontend_dir is not None:
logger.info("Serving frontend from %s", frontend_dir)
# Mount /assets (hashed JS/CSS bundles)
assets_dir = frontend_dir / "assets"
if assets_dir.is_dir():
app.mount(
"/assets",
StaticFiles(directory=str(assets_dir)),
name="assets",
)
# SPA fallback: any non-API route returns index.html
index_html = frontend_dir / "index.html"
@app.get("/{full_path:path}", include_in_schema=False)
async def spa_fallback(request: Request, full_path: str):
# Serve static file if it exists (favicon.svg, etc.)
static_file = frontend_dir / full_path
if full_path and static_file.is_file():
return FileResponse(str(static_file))
# Otherwise serve index.html for client-side routing
return FileResponse(str(index_html))
else:
logger.info("No frontend build found β€” API-only mode")
return app
app = create_app()