Spaces:
Sleeping
Sleeping
File size: 6,601 Bytes
990895d 57ed4c2 990895d c6253b2 990895d c6253b2 990895d c6253b2 990895d c6253b2 57ed4c2 990895d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 | """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()
|