import logging
from contextlib import asynccontextmanager
from pathlib import Path
import structlog
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from src.config import settings
STATIC_DIR = Path("src/static")
def _ensure_static_dir() -> None:
"""Create src/static with a stub index.html if the frontend hasn't been built yet."""
STATIC_DIR.mkdir(parents=True, exist_ok=True)
idx = STATIC_DIR / "index.html"
if not idx.exists():
idx.write_text(
"
Frontend not built. Run: cd web && npm run build
"
)
from src.middleware.logging import LoggingMiddleware
from src.routers import admin, auth, dashboard, events, ingest, keys, projects
from src.routers import calendar, tasks, manual_entries, budgets, tags, devops, exports, reports, omg
from src.services.scheduler import scheduler, setup_scheduler
BASE = settings.BASE_PATH
def _configure_logging() -> None:
shared_processors = [
structlog.contextvars.merge_contextvars,
structlog.stdlib.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
]
if settings.LOG_FORMAT == "json":
renderer = structlog.processors.JSONRenderer()
else:
renderer = structlog.dev.ConsoleRenderer(colors=True)
structlog.configure(
processors=shared_processors + [renderer],
wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
context_class=dict,
logger_factory=structlog.PrintLoggerFactory(),
cache_logger_on_first_use=True,
)
@asynccontextmanager
async def lifespan(app: FastAPI):
_configure_logging()
_ensure_static_dir()
setup_scheduler()
scheduler.start()
yield
scheduler.shutdown()
app = FastAPI(
title=settings.APP_TITLE,
docs_url=f"{BASE}/docs" if settings.DEBUG else None,
redoc_url=None,
root_path=BASE,
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(LoggingMiddleware)
for router in [
auth.router,
keys.router,
admin.router,
ingest.router,
dashboard.router,
events.router,
projects.router,
calendar.router,
tasks.router,
manual_entries.router,
budgets.router,
tags.router,
devops.router,
exports.router,
reports.router,
omg.router,
]:
app.include_router(router)
@app.get("/healthz", include_in_schema=False)
async def health():
return {"status": "ok"}
app.mount("/static", StaticFiles(directory="src/static"), name="static")
_NO_CACHE = {"Cache-Control": "no-cache, no-store, must-revalidate"}
@app.get("/", include_in_schema=False)
@app.get("", include_in_schema=False)
async def spa_root():
return FileResponse("src/static/index.html", headers=_NO_CACHE)
@app.get("/{path:path}", include_in_schema=False)
async def spa_fallback(path: str, request: Request):
if path.startswith("api/") or path.startswith("static/"):
from fastapi import HTTPException
raise HTTPException(status_code=404)
return FileResponse("src/static/index.html", headers=_NO_CACHE)