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