From 26d34f0e8305dde00d481c7f8483d1b94d2c14e9 Mon Sep 17 00:00:00 2001 From: William Barnhart Date: Sun, 19 Jul 2026 04:54:39 +0000 Subject: [PATCH] Add charset=utf-8 to JSON responses on the orjson (bytes) path Web.json() serializes with faust.utils.json.dumps, which returns str with stdlib json but bytes with orjson. The str path goes through Response(text=...), which makes aiohttp append "; charset=utf-8" to the content-type; the bytes path went through Response(body=...), which does not -- so with orjson installed every JSON response was served as "application/json" with no charset, while the same app on stdlib json served "application/json; charset=utf-8". Build the bytes-path Response directly with an explicit charset="utf-8" so JSON responses are consistent regardless of the installed JSON backend. The generic bytes() helper is left unchanged (binary responses should not be forced to utf-8). Fixes #557. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HHPL4VFWQRQPpjR1gXSKyL --- faust/web/drivers/aiohttp.py | 13 ++++-- tests/unit/web/drivers/test_aiohttp.py | 55 +++++++++++++++++--------- 2 files changed, 46 insertions(+), 22 deletions(-) diff --git a/faust/web/drivers/aiohttp.py b/faust/web/drivers/aiohttp.py index 16c0d4fde..f58d9a562 100644 --- a/faust/web/drivers/aiohttp.py +++ b/faust/web/drivers/aiohttp.py @@ -180,15 +180,22 @@ def json( """ ctype = content_type or "application/json" payload: Any = _json.dumps(value) - # normal json returns str, orjson returns bytes + # normal json returns str, orjson returns bytes. + # The str path goes through Response(text=...), which makes aiohttp + # append "; charset=utf-8" to the content-type; the bytes path uses + # Response(body=...), which does not -- so set the charset explicitly + # to keep JSON responses consistent regardless of the JSON backend + # (see #557). if isinstance(payload, bytes): - return self.bytes( - payload, + response = Response( + body=payload, content_type=ctype, + charset="utf-8", status=status, reason=reason, headers=headers, ) + return cast(base.Response, response) else: return self.text( payload, diff --git a/tests/unit/web/drivers/test_aiohttp.py b/tests/unit/web/drivers/test_aiohttp.py index 1748f1363..f555ca2c8 100644 --- a/tests/unit/web/drivers/test_aiohttp.py +++ b/tests/unit/web/drivers/test_aiohttp.py @@ -171,26 +171,43 @@ def test_json__from_str(self, *, web): assert response is web.text.return_value def test_json__from_bytes(self, *, web): - web.bytes = Mock(name="web.bytes") + # orjson returns bytes: build a Response directly with an explicit + # charset=utf-8 (Response(body=...) does not add one on its own), + # so the content-type matches the str path -- see #557. + with patch("faust.web.drivers.aiohttp.Response") as Response: + with patch("faust.utils.json.dumps") as dumps: + dumps.return_value = b"foo" + payload = {"foo": "bar"} + response = web.json( + payload, + content_type="application/x-json", + status=101, + reason="bar", + headers={"k": "v"}, + ) + dumps.assert_called_once_with(payload) + Response.assert_called_once_with( + body=b"foo", + content_type="application/x-json", + charset="utf-8", + status=101, + reason="bar", + headers={"k": "v"}, + ) + assert response is Response() + + def test_json_content_type_has_charset_for_both_backends(self, *, web): + # Regression for #557: whether the JSON backend returns str (stdlib + # json) or bytes (orjson), the response content-type must carry + # charset=utf-8. with patch("faust.utils.json.dumps") as dumps: - dumps.return_value = b"foo" - payload = {"foo": "bar"} - response = web.json( - payload, - content_type="application/x-json", - status=101, - reason="bar", - headers={"k": "v"}, - ) - dumps.assert_called_once_with(payload) - web.bytes.assert_called_once_with( - dumps.return_value, - content_type="application/x-json", - status=101, - reason="bar", - headers={"k": "v"}, - ) - assert response is web.bytes.return_value + dumps.return_value = b'{"a": 1}' # orjson-style bytes + resp = web.json({"a": 1}) + assert resp.headers["Content-Type"] == "application/json; charset=utf-8" + + dumps.return_value = '{"a": 1}' # stdlib-style str + resp = web.json({"a": 1}) + assert resp.headers["Content-Type"] == "application/json; charset=utf-8" def test_html(self, *, web): with patch("faust.web.drivers.aiohttp.Response") as Response: