"""Ten31Portal FastAPI application.""" import os from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from starlette.middleware.gzip import GZipMiddleware from starlette.middleware.sessions import SessionMiddleware from starlette.responses import FileResponse from ten31portal.config import SESSION_SECRET from ten31portal.db_init import run_migrations from ten31portal.routers.auth_router import router as auth_router from ten31portal.routers.audit_router import router as audit_router from ten31portal.routers.entity_router import router as entity_router from ten31portal.routers.holding_router import router as holding_router from ten31portal.routers.position_router import router as position_router from ten31portal.routers.round_router import router as round_router from ten31portal.routers.import_router import router as import_router from ten31portal.routers.user_router import router as user_router from ten31portal.routers.document_router import router as document_router from ten31portal.routers.capital_account_router import router as capital_account_router from ten31portal.routers.capital_import_router import router as capital_import_router @asynccontextmanager async def lifespan(app: FastAPI): run_migrations() from ten31portal.storage import ensure_docs_dir ensure_docs_dir() yield app = FastAPI(title="Ten31Portal", version="0.1.0", lifespan=lifespan) app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET) # Compress HTML/JS/CSS/JSON over the wire (the 320KB JS bundle gzips to ~80KB). Added after # SessionMiddleware so it sits outermost and compresses the final response. minimum_size skips # tiny payloads (health checks, small JSON) where compression isn't worth it. app.add_middleware(GZipMiddleware, minimum_size=500) app.include_router(auth_router) app.include_router(audit_router) app.include_router(entity_router) app.include_router(holding_router) app.include_router(position_router) app.include_router(round_router) app.include_router(import_router) app.include_router(user_router) app.include_router(document_router) app.include_router(capital_account_router) app.include_router(capital_import_router) @app.get("/api/health") def health() -> dict[str, str]: return {"status": "ok"} # Serve built frontend in production (when static/ dir exists next to the app) _static_dir = Path(__file__).resolve().parent.parent / "static" if _static_dir.is_dir(): # index.html must always be revalidated so a new build is picked up right after an # upgrade (its hashed asset references change). Hashed assets themselves are immutable. _NO_CACHE = {"Cache-Control": "no-cache, must-revalidate"} # Vite content-hashes asset filenames (index-UPbvqVP1.js), so they can cache forever. _IMMUTABLE = {"Cache-Control": "public, max-age=31536000, immutable"} _ONE_DAY = {"Cache-Control": "public, max-age=86400"} def _index() -> FileResponse: return FileResponse(_static_dir / "index.html", headers=_NO_CACHE) @app.api_route("/{path:path}", methods=["GET", "HEAD"]) async def serve_spa(path: str): file = _static_dir / path if file.is_file(): # Don't let the HTML entrypoint get cached; fingerprinted assets can cache. if file.name == "index.html": return _index() if path.startswith("assets/"): return FileResponse(file, headers=_IMMUTABLE) if path in ("ten31-logo.png", "favicon.svg", "favicon.ico"): return FileResponse(file, headers=_ONE_DAY) return FileResponse(file) return _index() def cli() -> None: import uvicorn uvicorn.run("ten31portal.main:app", host="0.0.0.0", port=8000, reload=True) if __name__ == "__main__": cli()