Skip to content

Treat an empty application/json body as {} in parse_form#880

Open
linguistic76 wants to merge 2 commits into
AnswerDotAI:mainfrom
linguistic76:fix-parse-form-empty-json
Open

Treat an empty application/json body as {} in parse_form#880
linguistic76 wants to merge 2 commits into
AnswerDotAI:mainfrom
linguistic76:fix-parse-form-empty-json

Conversation

@linguistic76
Copy link
Copy Markdown

@linguistic76 linguistic76 commented May 23, 2026

Problem

parse_form runs for every route (via _wrap_req) before handlers, to build the params
dict. It already guards an empty multipart/form-data body (returns FormData()), but the
application/json branch calls await req.json() directly. 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/SDKs
that set that header by default — 500s before reaching the route, even for endpoints that take
no body.

Reproduction

from fasthtml.common import FastHTML
from starlette.testclient import TestClient

app = FastHTML()
@app.post("/x")
async def x(request): return "ok"

TestClient(app, raise_server_exceptions=False).post(
    "/x", headers={"content-type": "application/json"}
)  # 500
File ".../fasthtml/core.py", line ~165, in parse_form
    return await req.json() if ctype == 'application/json' else await req.form()
File ".../starlette/requests.py", line 259, in json
    self._json = json.loads(body)
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)

(Starlette intentionally won't tolerate this in request.json()Kludex/starlette#788 — so the
guard belongs here, alongside the existing empty-multipart one.)

Fix

Mirror the empty-multipart guard for json: an empty json body yields {} (reusing the cached
body), non-empty parses as before. Other content types are unchanged.

    body = await req.body()  # Cache body for non-multipart request types
    if ctype == 'application/json': return await req.json() if body else {}
    return await req.form()

Adds a test in nbs/api/00_core.ipynb asserting an empty application/json POST returns 200
with {} (instead of 500), and that a non-empty json body still parses to its dict (the
existing path is preserved). nbdev_test --file_glob 00_core.ipynb passes.

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
)

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>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant