Coverage for src/local_deep_research/web/dependencies/json_body.py: 100%
23 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1"""The request checks and the 400 response of main's `@require_json_body`.
3Main gated 46 route handlers with ``security.decorators.require_json_body``,
4a Flask decorator that rejected any request whose parsed body was not a
5``dict``:
7 data = request.get_json(silent=True)
8 if not isinstance(data, dict):
9 return jsonify(...), 400
11The decorator has no FastAPI equivalent, so each application had to be
12re-expressed by hand during the port, and most were not. A handler that
13skipped it reads its body with ``await request.json()`` and then calls
14``data.get(...)``, which raises ``AttributeError`` on a JSON array, string,
15number or ``null`` — surfacing to the client as a **500 "Server error"**
16where main returned a clean **400**. That is a real contract change: a
17malformed client request now looks like a backend outage, in logs and in
18monitoring alike.
20``read_json_dict`` is the way to parse a JSON object body. It answers a body
21that will not parse and a body that parses to something other than an
22object with the same 400, so a call site cannot forget the parse failure
23the way the hand-written ports did:
25 data, err = await read_json_dict(request, ...)
26 if err is not None:
27 return err
29The call must not sit inside a broad ``except``, which would swallow what
30the helper lets through on purpose: the request-size limit's exception and a
31client disconnect. Its docstring has the details.
33``get_json(silent=True)`` also returned ``None`` when the Content-Type was
34not a JSON media type, so the decorator refused a JSON-looking
35``text/plain`` body as well. ``request.json()`` ignores the header, so that
36check is separate here. ``is_json_request`` is the media-type check, and
37``read_json_dict`` parses the body and checks its shape without looking at
38the media type. A route that needs both calls ``is_json_request`` first.
40``error_format`` reproduces main's three response shapes exactly, because
41the front-end branches on them: ``success`` is checked by the JS that drives
42the chat, RAG and library-search views, while ``status`` is what the
43settings and ratings pages expect. Getting the shape wrong would turn a
44handled validation error into an unhandled one in the browser, which is why
45the format is passed per call site rather than unified.
46"""
48from typing import Literal
50from fastapi.responses import JSONResponse
52ErrorFormat = Literal["simple", "status", "success"]
54DEFAULT_MESSAGE = "Request body must be valid JSON"
57def is_json_request(request) -> bool:
58 """Return whether the request's Content-Type is a JSON media type.
60 Same rule as Werkzeug's ``Request.is_json``: the media type, meaning
61 the part before any ``;``, stripped and lowercased, must be
62 ``application/json`` or ``application/*+json``. A missing header does
63 not qualify. Call it before parsing; ``read_json_dict`` does not check
64 the media type.
65 """
66 request_content_type = request.headers.get("content-type", "").split(
67 ";", 1
68 )[0]
69 request_content_type = request_content_type.strip().lower()
70 return request_content_type == "application/json" or (
71 request_content_type.startswith("application/")
72 and request_content_type.endswith("+json")
73 )
76def json_body_error(
77 error_format: ErrorFormat = "simple",
78 error_message: str = DEFAULT_MESSAGE,
79) -> JSONResponse:
80 """Build the 400 main's ``require_json_body`` returned.
82 Mirrors ``security/decorators.py`` on ``origin/main``:
84 * ``simple`` → ``{"error": msg}``
85 * ``status`` → ``{"status": "error", "message": msg}``
86 * ``success`` → ``{"success": False, "error": msg}``
87 """
88 if error_format == "status":
89 payload = {"status": "error", "message": error_message}
90 elif error_format == "success":
91 payload = {"success": False, "error": error_message}
92 else:
93 payload = {"error": error_message}
94 return JSONResponse(payload, status_code=400)
97async def read_json_dict(
98 request,
99 error_format: ErrorFormat = "simple",
100 error_message: str = DEFAULT_MESSAGE,
101):
102 """Parse the request body as a JSON object, failing with main's 400.
104 Returns ``(data, None)`` on success and ``(None, JSONResponse)`` on
105 failure, so callers stay a plain ``if err is not None: return err``.
107 It does NOT check the media type. The body is parsed whatever the
108 Content-Type says, so a JSON object sent as ``text/plain`` is accepted.
109 main's decorator refused that request as well, because
110 ``get_json(silent=True)`` returned ``None`` for a non-JSON media type.
111 A route that needs that behaviour calls ``is_json_request`` first.
113 main's ``@require_json_body`` collapsed BOTH failure modes into one 400:
114 a body that will not parse, and a body that parses to something other
115 than an object. Porting the decorator away split them — handlers kept an
116 ``isinstance(data, dict)`` check but let ``await request.json()`` raise,
117 so malformed JSON escaped to the generic handler and surfaced as a **500**
118 on 9 of the 11 news body endpoints. A 500 tells the caller (and paging
119 alerts) that the server is broken, when the client simply sent bad bytes.
121 Only a body the CLIENT got wrong is answered here, so the caller cannot
122 forget the parse error the way the hand-written ports did. That is what
123 ``request.json()`` raises for a body it cannot parse: ``ValueError``
124 (``json.JSONDecodeError`` for bad syntax, ``UnicodeDecodeError`` for
125 undecodable bytes, and the plain ``ValueError`` ``json.loads`` raises for
126 an integer literal past ``sys.get_int_max_str_digits()``) and
127 ``RecursionError`` (nesting deeper than the parser can follow). The
128 optional-body cleanup route in ``routers/settings.py`` catches the same
129 pair around its own parse.
131 Everything else propagates, on purpose. ``request.json()`` also reads the
132 request stream, so it is where transport failures surface:
133 ``BodySizeLimitMiddleware`` enforces its JSON cap on a body streamed
134 without a Content-Length by raising from the receive channel, and answers
135 413 (and logs the rejection) once that exception reaches it; a dropped
136 client surfaces as ``ClientDisconnect``, which the app-level handler in
137 ``fastapi_app.py`` logs at info level and answers 499. A broad
138 ``except Exception`` here turned an over-cap body into the route's 400
139 envelope and kept the rejection out of the log. For the same reason a
140 caller must not wrap this call in a broad ``except`` of its own, which
141 would swallow those exceptions one frame up.
142 """
143 try:
144 data = await request.json()
145 except (ValueError, RecursionError):
146 return None, json_body_error(error_format, error_message)
147 if not isinstance(data, dict):
148 return None, json_body_error(error_format, error_message)
149 return data, None