Fastwhisper / app /main.py
Mbonea's picture
Close P1 arch-code gaps: loop summary, template-echo Rules, Move now, proof kinds.
57ed4c2
Raw
History Blame Contribute Delete
6.6 kB
"""Create and wire the Habit Journal FastAPI application.
The factory initializes durable paths, middleware, errors, and API routers.
"""
import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.middleware.sessions import SessionMiddleware
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from app.config import get_settings
from app.coach_service import CoachService
from app.models import err
from app.paths import Paths
from app.routers import agent, auth, coach, daily, debug, entries, export, health, loop, plan, stats
from app.routers import settings as settings_router
from app.schedule_reschedule import RescheduleService
from app.schedule_store import ScheduleStore
from app.store_config import ConfigStore
from app.store_daily import DailyStore
from app.store_entries import EntryStore
from app.store_traces import TraceStore
logger = logging.getLogger(__name__)
class BodyTooLarge(Exception):
"""Signal that an HTTP request exceeded the configured byte limit."""
class BodySizeMiddleware:
"""Reject HTTP request bodies larger than the configured limit."""
def __init__(self, app: ASGIApp, max_bytes: int) -> None:
self.app = app
self.max_bytes = max_bytes
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
headers = dict(scope.get("headers", []))
raw_length = headers.get(b"content-length")
if raw_length:
try:
if int(raw_length) > self.max_bytes:
await self._reject(scope, receive, send)
return
except ValueError:
pass
size = 0
async def limited_receive() -> Message:
nonlocal size
message = await receive()
if message["type"] == "http.request":
size += len(message.get("body", b""))
if size > self.max_bytes:
raise BodyTooLarge
return message
try:
await self.app(scope, limited_receive, send)
except BodyTooLarge:
await self._reject(scope, receive, send)
async def _reject(self, scope: Scope, receive: Receive, send: Send) -> None:
response = err("validation_error", "Request body too large", 413)
await response(scope, receive, send)
def create_app() -> FastAPI:
"""Build an application from environment settings."""
settings = get_settings()
paths = Paths(settings.data_root)
paths.ensure()
config_store = ConfigStore(paths)
config_store.bootstrap(settings.app_password)
entry_store = EntryStore(paths)
daily_store = DailyStore(paths)
trace_store = TraceStore(paths, trace_limit=settings.coach_trace_limit)
trace_store.ensure_brief()
coach_service = CoachService(settings, entry_store, daily_store, trace_store)
schedule_store = ScheduleStore(
paths,
shrink_k=settings.stats_shrink_k,
max_blocks=settings.schedule_max_blocks,
)
schedule_store.recompute_and_save_priors()
reschedule_service = RescheduleService(settings, schedule_store)
@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
paths.ensure()
trace_store.ensure_brief()
logger.info("Habit Journal backend ready")
yield
hidden_docs: dict[str, str | None] = {}
if settings.is_prod:
hidden_docs = {"docs_url": None, "redoc_url": None, "openapi_url": None}
app = FastAPI(title=settings.app_name, lifespan=lifespan, **hidden_docs)
app.state.settings = settings
app.state.paths = paths
app.state.config_store = config_store
app.state.entry_store = entry_store
app.state.daily_store = daily_store
app.state.trace_store = trace_store
app.state.coach_service = coach_service
app.state.schedule_store = schedule_store
app.state.reschedule_service = reschedule_service
app.add_middleware(BodySizeMiddleware, max_bytes=settings.max_body_bytes)
app.add_middleware(
SessionMiddleware,
secret_key=settings.app_secret_key,
max_age=settings.session_max_age_sec,
same_site="lax",
https_only=settings.is_prod,
)
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(
_request: Request,
exc: StarletteHTTPException,
) -> JSONResponse:
detail: Any = exc.detail
if isinstance(detail, dict):
code = detail.get("code", "internal")
message = detail.get("message", "Request failed")
else:
code = {
401: "unauthorized",
403: "forbidden",
404: "not_found",
422: "validation_error",
429: "rate_limited",
503: "setup_required",
}.get(exc.status_code, "internal")
message = "Request failed"
return err(code, message, exc.status_code)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
_request: Request,
_exc: RequestValidationError,
) -> JSONResponse:
return err("validation_error", "Invalid request", 422)
@app.exception_handler(Exception)
async def internal_exception_handler(
_request: Request,
exc: Exception,
) -> JSONResponse:
logger.exception("Unhandled API error", exc_info=exc)
return err("internal", "Internal server error", 500)
app.include_router(health.router)
app.include_router(auth.router)
app.include_router(entries.router)
app.include_router(daily.router)
app.include_router(stats.router)
app.include_router(coach.router)
app.include_router(debug.router)
app.include_router(export.router)
app.include_router(settings_router.router)
app.include_router(plan.router)
app.include_router(agent.router)
app.include_router(loop.router)
static_dir = Path("static")
if static_dir.is_dir():
app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
return app
app = create_app()