Repository: encode/httpx
Pull request #3367 (2024-10-26 16:45:18)

Ensure JSON representation is compact. #3363
<!-- Thanks for contributing to HTTPX! 💚
Given this is a project maintained by volunteers, please read this template to not waste your time, or ours! 😁 -->

# Summary

this PR solves the issues mentionned here #3363

> The following related issues could all be resolved together...
> 
> We should use ensure_ascii=False for more compact text representations.
> We should use separators = (',', ':') for more compact list and object representations.
> We should use allow_nan=False to disallow invalid Infinity and NaN representations.

# Checklist

- [x] I understand that this PR may be closed in case there was no previous discussion. (This doesn't apply to typos!)
- [x] I've added a test for each change that was introduced, and I tried as much as possible to make a single atomic change.
- [x] I've updated the documentation accordingly.



Patch:
diff --git a/httpx/_content.py b/httpx/_content.py
index 6e8ad98..6f479a0 100644
--- a/httpx/_content.py
+++ b/httpx/_content.py
@@ -174,7 +174,9 @@ def encode_html(html: str) -> tuple[dict[str, str], ByteStream]:
 
 
 def encode_json(json: Any) -> tuple[dict[str, str], ByteStream]:
-    body = json_dumps(json).encode("utf-8")
+    body = json_dumps(
+        json, ensure_ascii=False, separators=(",", ":"), allow_nan=False
+    ).encode("utf-8")
     content_length = str(len(body))
     content_type = "application/json"
     headers = {"Content-Length": content_length, "Content-Type": content_type}


Test patch:
diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py
index 5776fc3..b3aeaf4 100644
--- a/tests/client/test_auth.py
+++ b/tests/client/test_auth.py
@@ -743,7 +743,7 @@ async def test_async_auth_reads_response_body() -> None:
         response = await client.get(url, auth=auth)
 
     assert response.status_code == 200
-    assert response.json() == {"auth": '{"auth": "xyz"}'}
+    assert response.json() == {"auth": '{"auth":"xyz"}'}
 
 
 def test_sync_auth_reads_response_body() -> None:
@@ -759,7 +759,7 @@ def test_sync_auth_reads_response_body() -> None:
         response = client.get(url, auth=auth)
 
     assert response.status_code == 200
-    assert response.json() == {"auth": '{"auth": "xyz"}'}
+    assert response.json() == {"auth": '{"auth":"xyz"}'}
 
 
 @pytest.mark.anyio
diff --git a/tests/models/test_requests.py b/tests/models/test_requests.py
index ad6d670..d2a458d 100644
--- a/tests/models/test_requests.py
+++ b/tests/models/test_requests.py
@@ -62,7 +62,7 @@ def test_json_encoded_data():
     request.read()
 
     assert request.headers["Content-Type"] == "application/json"
-    assert request.content == b'{"test": 123}'
+    assert request.content == b'{"test":123}'
 
 
 def test_headers():
@@ -71,7 +71,7 @@ def test_headers():
     assert request.headers == {
         "Host": "example.org",
         "Content-Type": "application/json",
-        "Content-Length": "13",
+        "Content-Length": "12",
     }
 
 
@@ -183,12 +183,12 @@ def test_request_picklable():
     assert pickle_request.method == "POST"
     assert pickle_request.url.path == "/"
     assert pickle_request.headers["Content-Type"] == "application/json"
-    assert pickle_request.content == b'{"test": 123}'
+    assert pickle_request.content == b'{"test":123}'
     assert pickle_request.stream is not None
     assert request.headers == {
         "Host": "example.org",
         "Content-Type": "application/json",
-        "content-length": "13",
+        "content-length": "12",
     }
 
 
diff --git a/tests/models/test_responses.py b/tests/models/test_responses.py
index d639625..06c28e1 100644
--- a/tests/models/test_responses.py
+++ b/tests/models/test_responses.py
@@ -81,9 +81,9 @@ def test_response_json():
 
     assert response.status_code == 200
     assert response.reason_phrase == "OK"
-    assert response.json() == {"hello": "world"}
+    assert str(response.json()) == "{'hello': 'world'}"
     assert response.headers == {
-        "Content-Length": "18",
+        "Content-Length": "17",
         "Content-Type": "application/json",
     }
 
diff --git a/tests/models/test_whatwg.py b/tests/models/test_whatwg.py
index 6e00a92..14af682 100644
--- a/tests/models/test_whatwg.py
+++ b/tests/models/test_whatwg.py
@@ -10,7 +10,7 @@ from httpx._urlparse import urlparse
 
 # URL test cases from...
 # https://github.com/web-platform-tests/wpt/blob/master/url/resources/urltestdata.json
-with open("tests/models/whatwg.json", "r") as input:
+with open("tests/models/whatwg.json", "r", encoding="utf-8") as input:
     test_cases = json.load(input)
     test_cases = [
         item
diff --git a/tests/test_content.py b/tests/test_content.py
index 21c92dd..053f52e 100644
--- a/tests/test_content.py
+++ b/tests/test_content.py
@@ -4,6 +4,7 @@ import typing
 import pytest
 
 import httpx
+from httpx._content import encode_json
 
 method = "POST"
 url = "https://www.example.com"
@@ -173,11 +174,11 @@ async def test_json_content():
 
     assert request.headers == {
         "Host": "www.example.com",
-        "Content-Length": "19",
+        "Content-Length": "18",
         "Content-Type": "application/json",
     }
-    assert sync_content == b'{"Hello": "world!"}'
-    assert async_content == b'{"Hello": "world!"}'
+    assert sync_content == b'{"Hello":"world!"}'
+    assert async_content == b'{"Hello":"world!"}'
 
 
 @pytest.mark.anyio
@@ -484,3 +485,39 @@ async def test_response_aiterator_content():
 def test_response_invalid_argument():
     with pytest.raises(TypeError):
         httpx.Response(200, content=123)  # type: ignore
+
+
+def test_ensure_ascii_false_with_french_characters():
+    data = {"greeting": "Bonjour, ça va ?"}
+    headers, byte_stream = encode_json(data)
+    json_output = b"".join(byte_stream).decode("utf-8")
+
+    assert (
+        "ça va" in json_output
+    ), "ensure_ascii=False should preserve French accented characters"
+    assert headers["Content-Type"] == "application/json"
+
+
+def test_separators_for_compact_json():
+    data = {"clé": "valeur", "liste": [1, 2, 3]}
+    headers, byte_stream = encode_json(data)
+    json_output = b"".join(byte_stream).decode("utf-8")
+
+    assert (
+        json_output == '{"clé":"valeur","liste":[1,2,3]}'
+    ), "separators=(',', ':') should produce a compact representation"
+    assert headers["Content-Type"] == "application/json"
+
+
+def test_allow_nan_false():
+    data_with_nan = {"nombre": float("nan")}
+    data_with_inf = {"nombre": float("inf")}
+
+    with pytest.raises(
+        ValueError, match="Out of range float values are not JSON compliant"
+    ):
+        encode_json(data_with_nan)
+    with pytest.raises(
+        ValueError, match="Out of range float values are not JSON compliant"
+    ):
+        encode_json(data_with_inf)
diff --git a/tests/test_main.py b/tests/test_main.py
index feb796e..b1a77d4 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -114,7 +114,7 @@ def test_post(server):
         "content-type: text/plain",
         "Transfer-Encoding: chunked",
         "",
-        '{"hello": "world"}',
+        '{"hello":"world"}',
     ]