Treat an empty application/json body as {} in parse_form#880
Open
linguistic76 wants to merge 2 commits into
Open
Treat an empty application/json body as {} in parse_form#880linguistic76 wants to merge 2 commits into
linguistic76 wants to merge 2 commits into
Conversation
parse_form() runs for every route (via _wrap_req) before handlers, and already
guards an empty multipart/form-data body (returns FormData()). The application/json
branch did not: it calls `await req.json()`, and Starlette's request.json() does
json.loads(b"") on an empty body -> JSONDecodeError, which is uncaught -> 500. So a
bodyless POST sent with `Content-Type: application/json` (common from HTTP clients)
500s before reaching the route, even for endpoints that take no body.
Mirror the existing empty-multipart guard: an empty json body yields {} (the cached
body is reused). Adds a test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
linguistic76
pushed a commit
to linguistic76/skuel
that referenced
this pull request
May 23, 2026
Runs the original (pre-shim) parse_form; while FastHTML still raises on an empty application/json body the test passes, but once a release carrying AnswerDotAI/fasthtml#880 is pinned it returns {} and the test fails — the automated signal to remove the interim shim + bootstrap call + this test + the upstream doc. Keeps the temporary monkeypatch from silently outliving its purpose. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
linguistic76
added a commit
to linguistic76/skuel
that referenced
this pull request
May 23, 2026
) * fix: tolerate empty application/json body (FastHTML parse_form gap) FastHTML's parse_form runs for every route via _wrap_req before any handler, and guards an empty multipart/form-data body but not an empty application/json body — it calls `await req.json()`, and Starlette's request.json() does json.loads(b"") → JSONDecodeError → uncaught 500. So any bodyless POST sent with Content-Type: application/json (common from HTTP clients) 500s framework- side, e.g. POST /api/ps/{uid}/engage which takes no body. Not fixed in latest FastHTML; no route-style change avoids it (parse_form is unconditional). The aligned long-term fix is upstream in FastHTML (mirror its own empty- multipart guard for json) — drafted in docs/upstream/FASTHTML_EMPTY_JSON_PARSE_FORM.md for submission. Until a fixed release is pinned, this carries a minimal interim shim applying the identical guard: - adapters/inbound/fasthtml_empty_json_patch.py: overrides fasthtml.core.parse_form to return {} for an empty application/json body, delegating otherwise. - scripts/dev/bootstrap.py: applies it at Step 0 (before any request is served). - tests/unit/test_fasthtml_empty_json_patch.py: drives a real FastHTML app via TestClient, so a FastHTML upgrade that changes the internal fails loudly rather than silently reintroducing the 500. Interim monkeypatch of a dependency internal, deliberately temporary — remove the shim + bootstrap call + test when python-fasthtml is bumped to a release carrying the upstream fix (the test stays green either way). Verified live: a no-auth empty-json POST to /api/ps/{uid}/engage now returns 403 CSRF (flows past parse_form) instead of a 500 JSONDecodeError. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: point upstream draft at the submitted PR (AnswerDotAI/fasthtml#880) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: add tripwire that fails when FastHTML fixes empty-json natively Runs the original (pre-shim) parse_form; while FastHTML still raises on an empty application/json body the test passes, but once a release carrying AnswerDotAI/fasthtml#880 is pinned it returns {} and the test fails — the automated signal to remove the interim shim + bootstrap call + this test + the upstream doc. Keeps the temporary monkeypatch from silently outliving its purpose. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: document the tripwire + exact removal procedure for the shim Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Mike <mike@skuel.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced May 23, 2026
Pair the empty-json assertion with a non-empty case so the test proves the
change preserves the existing application/json path (body -> parsed dict),
not only that an empty body now yields {}.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
parse_formruns for every route (via_wrap_req) before handlers, to build the paramsdict. It already guards an empty
multipart/form-databody (returnsFormData()), but theapplication/jsonbranch callsawait req.json()directly. Starlette'srequest.json()doesjson.loads(b"")on an empty body →JSONDecodeError, which is uncaught → 500.So a bodyless
POSTsent withContent-Type: application/json— common from HTTP clients/SDKsthat set that header by default — 500s before reaching the route, even for endpoints that take
no body.
Reproduction
(Starlette intentionally won't tolerate this in
request.json()— Kludex/starlette#788 — so theguard belongs here, alongside the existing empty-multipart one.)
Fix
Mirror the empty-multipart guard for json: an empty json body yields
{}(reusing the cachedbody), non-empty parses as before. Other content types are unchanged.
Adds a test in
nbs/api/00_core.ipynbasserting an emptyapplication/jsonPOST returns 200with
{}(instead of 500), and that a non-empty json body still parses to its dict (theexisting path is preserved).
nbdev_test --file_glob 00_core.ipynbpasses.