Files
beavergram/backend/src/api/app.py
T

137 lines
3.8 KiB
Python

import asyncio
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from pathlib import Path
import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka, setup_dishka
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, RedirectResponse
from fastmcp.utilities.lifespan import combine_lifespans
from starlette.applications import Starlette
from api.access_log import hide_mcp_query
from api.auth import BearerAuthMiddleware
from api.mcp.server import mcp
from api.realtime import hub
from api.routers import (
accounts,
analytics,
annotations,
avatars,
backfill,
bots,
chats,
custom_emoji,
discover,
events,
files,
folders,
media,
peers,
policy,
presence,
profile,
search,
shares,
social,
storage,
stories,
watches,
)
from dependencies.container import container
from utils.cache import DAY_HEADERS, IMMUTABLE_HEADERS, NO_STORE_HEADERS
from utils.env import env
from utils.storage import ContentAddressedStorage
from utils.trash import purge_loop
if env.auth.token is None:
msg = "AUTH__TOKEN is required for the api process"
raise RuntimeError(msg)
_token = env.auth.token.get_secret_value()
hide_mcp_query()
mcp_app = mcp.http_app(path="/")
@asynccontextmanager
async def lifespan(app_: Starlette) -> AsyncGenerator[None]:
pool = await container.get(asyncpg.Pool)
await hub.start(pool)
purger = asyncio.create_task(
purge_loop(pool, await container.get(ContentAddressedStorage))
)
yield
purger.cancel()
await hub.stop()
await app_.state.dishka_container.close()
app = FastAPI(
title="beavergram API", lifespan=combine_lifespans(lifespan, mcp_app.lifespan)
)
app.router.route_class = DishkaRoute
@app.get("/health")
async def health(pool: FromDishka[asyncpg.Pool]) -> dict[str, bool]:
db_ok = await pool.fetchval("SELECT 1") == 1
timescale_ok = await pool.fetchval(
"SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb')"
)
return {"db": db_ok, "timescaledb": bool(timescale_ok)}
app.include_router(accounts.router)
app.include_router(analytics.router)
app.include_router(bots.router)
app.include_router(policy.router)
app.include_router(folders.router)
app.include_router(backfill.router)
app.include_router(search.router)
app.include_router(chats.router)
app.include_router(media.router)
app.include_router(avatars.router)
app.include_router(custom_emoji.router)
app.include_router(social.router)
app.include_router(presence.router)
app.include_router(stories.router)
app.include_router(profile.router)
app.include_router(events.router)
app.include_router(peers.router)
app.include_router(discover.router)
app.include_router(annotations.router)
app.include_router(watches.router)
app.include_router(shares.router)
app.include_router(storage.router)
app.include_router(files.router)
app.mount("/mcp", mcp_app)
@app.api_route("/mcp", methods=["GET", "POST", "DELETE"])
async def mcp_trailing_slash(request: Request) -> RedirectResponse:
query = request.url.query
return RedirectResponse(f"/mcp/?{query}" if query else "/mcp/", status_code=307)
_spa_dir = Path(env.api.static_dir).resolve()
if _spa_dir.is_dir():
_spa_index = _spa_dir / "index.html"
@app.get("/{spa_path:path}")
async def serve_spa(spa_path: str) -> FileResponse:
candidate = (_spa_dir / spa_path).resolve()
if spa_path and candidate.is_relative_to(_spa_dir) and candidate.is_file():
immutable = spa_path.startswith("_app/immutable/")
return FileResponse(
candidate, headers=IMMUTABLE_HEADERS if immutable else DAY_HEADERS
)
return FileResponse(_spa_index, headers=NO_STORE_HEADERS)
app.add_middleware(BearerAuthMiddleware, token=_token)
setup_dishka(container, app)