"""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()