Spaces:
Sleeping
Sleeping
| """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) | |
| 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, | |
| ) | |
| 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) | |
| async def validation_exception_handler( | |
| _request: Request, | |
| _exc: RequestValidationError, | |
| ) -> JSONResponse: | |
| return err("validation_error", "Invalid request", 422) | |
| 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() | |