Coverage for src/local_deep_research/web/dependencies/json_body.py: 100%

19 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +0000

1"""The 400 response `@require_json_body` used to produce. 

2 

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``: 

6 

7 data = request.get_json(silent=True) 

8 if not isinstance(data, dict): 

9 return jsonify(...), 400 

10 

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. 

19 

20This module supplies the response half. Call sites keep their own 

21``await request.json()`` (and whatever ``except`` they already have for a 

22*malformed* body, which is a different failure) and add the missing shape 

23check: 

24 

25 data = await request.json() 

26 if not isinstance(data, dict): 

27 return json_body_error(...) 

28 

29``error_format`` reproduces main's three response shapes exactly, because 

30the front-end branches on them: ``success`` is checked by the JS that drives 

31the chat, RAG and library-search views, while ``status`` is what the 

32settings and ratings pages expect. Getting the shape wrong would turn a 

33handled validation error into an unhandled one in the browser, which is why 

34the format is passed per call site rather than unified. 

35""" 

36 

37from typing import Literal 

38 

39from fastapi.responses import JSONResponse 

40 

41ErrorFormat = Literal["simple", "status", "success"] 

42 

43DEFAULT_MESSAGE = "Request body must be valid JSON" 

44 

45 

46def json_body_error( 

47 error_format: ErrorFormat = "simple", 

48 error_message: str = DEFAULT_MESSAGE, 

49) -> JSONResponse: 

50 """Build the 400 main's ``require_json_body`` returned. 

51 

52 Mirrors ``security/decorators.py`` on ``origin/main``: 

53 

54 * ``simple`` → ``{"error": msg}`` 

55 * ``status`` → ``{"status": "error", "message": msg}`` 

56 * ``success`` → ``{"success": False, "error": msg}`` 

57 """ 

58 if error_format == "status": 

59 payload = {"status": "error", "message": error_message} 

60 elif error_format == "success": 

61 payload = {"success": False, "error": error_message} 

62 else: 

63 payload = {"error": error_message} 

64 return JSONResponse(payload, status_code=400) 

65 

66 

67async def read_json_dict( 

68 request, 

69 error_format: ErrorFormat = "simple", 

70 error_message: str = DEFAULT_MESSAGE, 

71): 

72 """Parse the request body as a JSON object the way main's decorator did. 

73 

74 Returns ``(data, None)`` on success and ``(None, JSONResponse)`` on 

75 failure, so callers stay a plain ``if err is not None: return err``. 

76 

77 main's ``@require_json_body`` collapsed BOTH failure modes into one 400: 

78 a body that will not parse, and a body that parses to something other 

79 than an object. Porting the decorator away split them — handlers kept an 

80 ``isinstance(data, dict)`` check but let ``await request.json()`` raise, 

81 so malformed JSON escaped to the generic handler and surfaced as a **500** 

82 on 9 of the 11 news body endpoints. A 500 tells the caller (and paging 

83 alerts) that the server is broken, when the client simply sent bad bytes. 

84 

85 Never raises: the whole point is that the caller cannot forget the parse 

86 error the way the hand-written ports did. 

87 """ 

88 try: 

89 data = await request.json() 

90 except Exception: 

91 return None, json_body_error(error_format, error_message) 

92 if not isinstance(data, dict): 

93 return None, json_body_error(error_format, error_message) 

94 return data, None