Repository: kpn/combadge (language: python)

Issue:
Handling malformed responses
If the server returns plain text instead of JSON, the HTTPX backend fails with `JSONDecodeError`. We need to:

- Handle this
- Implement error matching for such responses (add a pseudo-field?)


Discussion / hints:

Fix patch:
diff --git a/.github/release.yml b/.github/release.yml
index 3fcea05..0a2020c 100644
--- a/.github/release.yml
+++ b/.github/release.yml
@@ -3,12 +3,9 @@ changelog:
     - title: 💥 Breaking changes
       labels:
         - breaking-change
-    - title: ✨ New features
-      labels:
-        - enhancement
     - title: ⚡️Improvements
       labels:
-        - improvement
+        - enhancement
         - documentation
     - title: 🎨 Other changes
       labels:
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
index dda922e..fcf66ec 100644
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -4,10 +4,11 @@
     <inspection_tool class="PyCompatibilityInspection" enabled="true" level="WARNING" enabled_by_default="true">
       <option name="ourVersions">
         <value>
-          <list size="3">
+          <list size="4">
             <item index="0" class="java.lang.String" itemvalue="3.9" />
             <item index="1" class="java.lang.String" itemvalue="3.10" />
             <item index="2" class="java.lang.String" itemvalue="3.11" />
+            <item index="3" class="java.lang.String" itemvalue="3.12" />
           </list>
         </value>
       </option>
diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml
index 105ce2d..dd4c951 100644
--- a/.idea/inspectionProfiles/profiles_settings.xml
+++ b/.idea/inspectionProfiles/profiles_settings.xml
@@ -1,5 +1,6 @@
 <component name="InspectionProjectProfileManager">
   <settings>
+    <option name="PROJECT_PROFILE" value="Default" />
     <option name="USE_PROJECT_PROFILE" value="false" />
     <version value="1.0" />
   </settings>
diff --git a/.idea/misc.xml b/.idea/misc.xml
index a059370..1f359f7 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,4 +1,7 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <project version="4">
   <component name="ProjectRootManager" version="2" project-jdk-name="Poetry (combadge)" project-jdk-type="Python SDK" />
+  <component name="PythonCompatibilityInspectionAdvertiser">
+    <option name="version" value="3" />
+  </component>
 </project>
\ No newline at end of file
diff --git a/combadge/support/http/aliases.py b/combadge/support/http/aliases.py
index c179dd0..3aca7f9 100644
--- a/combadge/support/http/aliases.py
+++ b/combadge/support/http/aliases.py
@@ -13,3 +13,8 @@
 REASON_ALIAS = "__reason__"
 Reason: TypeAlias = Annotated[_ReasonT, Field(alias=REASON_ALIAS)]
 """HTTP reason phrase. Alias for the `__reason__` pseudo-field."""
+
+_ContentT = TypeVar("_ContentT", bound=bytes)
+CONTENT_ALIAS = "__content__"
+Content: TypeAlias = Annotated[_ContentT, Field(alias=CONTENT_ALIAS)]
+"""Original response content. Alias for the `__content__` pseudo-field."""
diff --git a/combadge/support/httpx/backends/base.py b/combadge/support/httpx/backends/base.py
index 007c0fa..71a7dfb 100644
--- a/combadge/support/httpx/backends/base.py
+++ b/combadge/support/httpx/backends/base.py
@@ -7,7 +7,7 @@
 
 from combadge.core.interfaces import ProvidesBinder
 from combadge.core.typevars import ResponseT
-from combadge.support.http.aliases import REASON_ALIAS, STATUS_CODE_ALIAS
+from combadge.support.http.aliases import CONTENT_ALIAS, REASON_ALIAS, STATUS_CODE_ALIAS
 
 _ClientT = TypeVar("_ClientT", Client, AsyncClient)
 
@@ -18,6 +18,7 @@ class BaseHttpxBackend(ProvidesBinder, Generic[_ClientT]):
 
     # Available response aliases
 
+    - [`Content`][combadge.support.http.aliases.Content]: HTTP response content
     - [`StatusCode`][combadge.support.http.aliases.StatusCode]: HTTP response status code
     - [`Reason`][combadge.support.http.aliases.Reason]: HTTP reason phrase
     """
@@ -30,11 +31,16 @@ def __init__(self, client: _ClientT, *, raise_for_status: bool = True) -> None:
 
     @classmethod
     def _parse_response(cls, from_response: Response, to_type: Type[ResponseT]) -> ResponseT:
+        try:
+            json_fields = from_response.json()
+        except ValueError:
+            json_fields = {}
         return parse_obj_as(
             to_type,
             {
                 STATUS_CODE_ALIAS: from_response.status_code,
                 REASON_ALIAS: from_response.reason_phrase,
-                **from_response.json(),
+                CONTENT_ALIAS: from_response.content,
+                **json_fields,
             },
         )


Test patch:
diff --git a/tests/support/httpx/__init__.py b/tests/support/httpx/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/support/httpx/backends/__init__.py b/tests/support/httpx/backends/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/support/httpx/backends/test_base.py b/tests/support/httpx/backends/test_base.py
new file mode 100644
index 0000000..0ede39c
--- /dev/null
+++ b/tests/support/httpx/backends/test_base.py
@@ -0,0 +1,20 @@
+from http import HTTPStatus
+
+from httpx import Response
+from pydantic import BaseModel
+
+from combadge.support.http.aliases import Content, Reason, StatusCode
+from combadge.support.httpx.backends.base import BaseHttpxBackend
+
+
+def test_aliases() -> None:
+    class Model(BaseModel):
+        status_code: StatusCode[HTTPStatus]
+        reason: Reason[str]
+        content: Content[bytes]
+
+    model = BaseHttpxBackend._parse_response(Response(200, text="hello world"), Model)
+
+    assert model.status_code == HTTPStatus.OK
+    assert model.reason == "OK"
+    assert model.content == b"hello world"