f67d0aa0db
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
117 lines
3.8 KiB
Python
117 lines
3.8 KiB
Python
"""FastAPI entrypoint.
|
|
|
|
Wires the §17 routers, the OAuth callbacks, the webhook receiver, and
|
|
the background reconciler. Per §4.2, single process, colocated SQLite —
|
|
no need for a separate worker.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import secrets
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import APIRouter, FastAPI, HTTPException, Request
|
|
from fastapi.responses import RedirectResponse
|
|
from starlette.middleware.sessions import SessionMiddleware
|
|
|
|
from . import api as api_routes, auth, cache, db, digest, providers as providers_mod, webhooks
|
|
from .bot import Bot
|
|
from .config import load_config
|
|
from .gitea import Gitea
|
|
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
|
log = logging.getLogger("rfc_app")
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
config = load_config()
|
|
db.run_migrations(config)
|
|
db.init(config)
|
|
gitea = Gitea(config)
|
|
bot = Bot(gitea)
|
|
reconciler = cache.Reconciler(config, gitea)
|
|
digest_sched = digest.DigestScheduler()
|
|
|
|
# §18 carryover: the multi-provider LLM abstraction. Provider
|
|
# construction can fail (missing key, wrong env value) — if it does,
|
|
# the rest of the app still serves; chat endpoints surface a clear
|
|
# 503 instead of crashing the process.
|
|
try:
|
|
providers = providers_mod.load_from_config(config)
|
|
except Exception:
|
|
log.exception("provider construction failed; chat will be disabled")
|
|
providers = {}
|
|
|
|
app.state.config = config
|
|
app.state.gitea = gitea
|
|
app.state.bot = bot
|
|
app.state.reconciler = reconciler
|
|
app.state.providers = providers
|
|
|
|
app.include_router(_oauth_router(config))
|
|
app.include_router(api_routes.make_router(config, gitea, bot, providers))
|
|
app.include_router(webhooks.make_router(config, gitea))
|
|
|
|
reconciler.start()
|
|
digest_sched.start()
|
|
log.info("RFC app started — meta repo %s/%s", config.gitea_org, config.meta_repo)
|
|
try:
|
|
yield
|
|
finally:
|
|
await digest_sched.stop()
|
|
await reconciler.stop()
|
|
await gitea.close()
|
|
|
|
|
|
def create_app() -> FastAPI:
|
|
# The secret key is required at app construction (SessionMiddleware
|
|
# is added before lifespan runs), so we read just that one value
|
|
# eagerly via load_config(). Everything else waits for lifespan.
|
|
config = load_config()
|
|
app = FastAPI(lifespan=lifespan)
|
|
app.add_middleware(
|
|
SessionMiddleware,
|
|
secret_key=config.secret_key,
|
|
session_cookie="rfc_session",
|
|
max_age=60 * 60 * 24 * 30,
|
|
https_only=False,
|
|
)
|
|
return app
|
|
|
|
|
|
app = create_app()
|
|
|
|
|
|
def _oauth_router(config) -> APIRouter:
|
|
router = APIRouter()
|
|
|
|
@router.get("/auth/login")
|
|
async def login(request: Request):
|
|
state = auth.new_state()
|
|
request.session[auth.SESSION_STATE_KEY] = state
|
|
return RedirectResponse(auth.authorization_url(config, state))
|
|
|
|
@router.get("/auth/callback")
|
|
async def callback(request: Request, code: str = "", state: str = ""):
|
|
if not code:
|
|
raise HTTPException(400, "Missing code")
|
|
stored_state = request.session.get(auth.SESSION_STATE_KEY)
|
|
if not stored_state or not secrets.compare_digest(stored_state, state):
|
|
raise HTTPException(400, "Invalid state")
|
|
token_data = await auth.exchange_code(config, code)
|
|
access_token = token_data.get("access_token")
|
|
if not access_token:
|
|
raise HTTPException(400, "Token exchange failed")
|
|
profile = await auth.fetch_user_profile(config, access_token)
|
|
user = auth.provision_user(config, profile)
|
|
auth.store_session(request, user)
|
|
return RedirectResponse("/")
|
|
|
|
@router.get("/auth/logout")
|
|
async def logout(request: Request):
|
|
request.session.clear()
|
|
return RedirectResponse("/")
|
|
|
|
return router
|