Repository: hikari-py/hikari
Pull request #714 (2021-08-03 13:14:43)

Improve 50035 400 error reprs
### Summary
This adds the loosely documented "errors" mapping on some error responses (see https://discord.com/developers/docs/reference#error-messages for ref) to BadRequestError

### Checklist
<!-- Make sure to tick all the following boxes by putting an `x` in between (like this `[x]`) -->
- [x] I have run `nox` and all the pipelines have passed.
- [x] I have made unittests according to the code I have added/modified/deleted.

### Related issues
<!--
To mention an issue use `#issue-id` and to mention a merge request use `!merge-request-id`
To close/fix an issue use `Close #issue-id` or `Fix #issue-id` (depending on the merge request)
-->



Patch:
diff --git a/hikari/errors.py b/hikari/errors.py
index 864660ee..feee3ddf 100644
--- a/hikari/errors.py
+++ b/hikari/errors.py
@@ -57,13 +57,13 @@
 import attr
 
 from hikari.internal import attr_extensions
+from hikari.internal import data_binding
 from hikari.internal import enums
 
 if typing.TYPE_CHECKING:
     from hikari import intents as intents_
     from hikari import messages
     from hikari import snowflakes
-    from hikari.internal import data_binding
     from hikari.internal import routes
 
 
@@ -460,6 +460,27 @@ class BadRequestError(ClientHTTPResponseError):
     status: http.HTTPStatus = attr.field(default=http.HTTPStatus.BAD_REQUEST, init=False)
     """The HTTP status code for the response."""
 
+    errors: typing.Optional[typing.Dict[str, data_binding.JSONObject]] = attr.field(default=None, kw_only=True)
+    """Dict of top level field names to field specific error paths.
+
+    For more information, this error format is loosely defined at
+    https://discord.com/developers/docs/reference#error-messages and is commonly
+    returned for 50035 errors.
+    """
+
+    _cached_str: str = attr.field(default=None, init=False)
+
+    def __str__(self) -> str:
+        if self._cached_str:
+            return self._cached_str
+
+        value = super().__str__()
+        if self.errors:
+            value += "\n" + data_binding.dump_json(self.errors, indent=2)
+
+        self._cached_str = value
+        return value
+
 
 @attr.define(auto_exc=True, repr=False, weakref_slot=False)
 class UnauthorizedError(ClientHTTPResponseError):
diff --git a/hikari/internal/data_binding.py b/hikari/internal/data_binding.py
index 762e75ce..b78d72be 100644
--- a/hikari/internal/data_binding.py
+++ b/hikari/internal/data_binding.py
@@ -83,7 +83,7 @@
     JSONDecodeError: typing.Type[Exception] = Exception
     """Exception raised when loading an invalid JSON string"""
 
-    def dump_json(_: typing.Union[JSONArray, JSONObject], /) -> str:
+    def dump_json(_: typing.Union[JSONArray, JSONObject], /, *, indent: int = ...) -> str:
         """Convert a Python type to a JSON string."""
 
     def load_json(_: typing.AnyStr, /) -> typing.Union[JSONArray, JSONObject]:
diff --git a/hikari/internal/net.py b/hikari/internal/net.py
index f4fd6dc3..1dfc2283 100644
--- a/hikari/internal/net.py
+++ b/hikari/internal/net.py
@@ -35,6 +35,7 @@
 
 if typing.TYPE_CHECKING:
     from hikari import config
+    from hikari.internal import data_binding
 
 
 async def generate_error_response(response: aiohttp.ClientResponse) -> errors.HTTPError:
@@ -48,11 +49,12 @@ async def generate_error_response(response: aiohttp.ClientResponse) -> errors.HT
         json_body = await response.json()
         args.append(json_body.get("message", ""))
         args.append(errors.RESTErrorCode(json_body.get("code", 0)))
+        raw_error_array: typing.Optional[data_binding.JSONObject] = json_body.get("errors")
     except aiohttp.ContentTypeError:
-        pass
+        raw_error_array = None
 
     if response.status == http.HTTPStatus.BAD_REQUEST:
-        return errors.BadRequestError(*args)
+        return errors.BadRequestError(*args, errors=raw_error_array)
     if response.status == http.HTTPStatus.UNAUTHORIZED:
         return errors.UnauthorizedError(*args)
     if response.status == http.HTTPStatus.FORBIDDEN:


Test patch:
diff --git a/tests/hikari/internal/test_net.py b/tests/hikari/internal/test_net.py
index 83d205d6..5a3a5e6a 100644
--- a/tests/hikari/internal/test_net.py
+++ b/tests/hikari/internal/test_net.py
@@ -33,7 +33,6 @@
 @pytest.mark.parametrize(
     ("status_", "expected_error"),
     [
-        (http.HTTPStatus.BAD_REQUEST, "BadRequestError"),
         (http.HTTPStatus.UNAUTHORIZED, "UnauthorizedError"),
         (http.HTTPStatus.FORBIDDEN, "ForbiddenError"),
         (http.HTTPStatus.NOT_FOUND, "NotFoundError"),
@@ -74,14 +73,13 @@ async def json(self):
 @pytest.mark.parametrize(
     ("status_", "expected_error"),
     [
-        (http.HTTPStatus.BAD_REQUEST, "BadRequestError"),
         (http.HTTPStatus.UNAUTHORIZED, "UnauthorizedError"),
         (http.HTTPStatus.FORBIDDEN, "ForbiddenError"),
         (http.HTTPStatus.NOT_FOUND, "NotFoundError"),
     ],
 )
 @pytest.mark.asyncio()
-async def test_generate_error_when_error_with_json(status_, expected_error):
+async def test_generate_error_when_error_without_json(status_, expected_error):
     json_response = aiohttp.ContentTypeError(None, None)
 
     class StubResponse:
@@ -98,3 +96,50 @@ async def read(self):
 
     error.assert_called_once_with("https://some.url", {}, "some raw body")
     assert returned is error()
+
+
+@pytest.mark.asyncio()
+async def test_generate_bad_request_error_without_json_response():
+    class StubResponse:
+        real_url = "https://some.url"
+        status = http.HTTPStatus.BAD_REQUEST
+        headers = {}
+        json = mock.AsyncMock(side_effect=aiohttp.ContentTypeError(None, None))
+
+        async def read(self):
+            return "some raw body"
+
+    with mock.patch.object(errors, "BadRequestError", errors=None) as error:
+        returned = await net.generate_error_response(StubResponse())
+
+    error.assert_called_once_with("https://some.url", {}, "some raw body", errors=None)
+    assert returned is error()
+
+
+@pytest.mark.parametrize(
+    ("json_method", "expected_errors"),
+    [
+        (
+            mock.AsyncMock(return_value={"message": "raw message", "code": 123, "errors": {"component": []}}),
+            {"component": []},
+        ),
+        (mock.AsyncMock(return_value={"message": "raw message", "code": 123, "errors": {}}), {}),
+        (mock.AsyncMock(return_value={"message": "raw message", "code": 123}), None),
+    ],
+)
+@pytest.mark.asyncio()
+async def test_generate_bad_request_error_with_json_response(json_method, expected_errors):
+    class StubResponse:
+        real_url = "https://some.url"
+        status = http.HTTPStatus.BAD_REQUEST
+        headers = {}
+        json = json_method
+
+        async def read(self):
+            return "some raw body"
+
+    with mock.patch.object(errors, "BadRequestError", errors=None) as error:
+        returned = await net.generate_error_response(StubResponse())
+
+    error.assert_called_once_with("https://some.url", {}, "some raw body", "raw message", 123, errors=expected_errors)
+    assert returned is error()
diff --git a/tests/hikari/test_errors.py b/tests/hikari/test_errors.py
index ab0a5b27..0c067efe 100644
--- a/tests/hikari/test_errors.py
+++ b/tests/hikari/test_errors.py
@@ -22,10 +22,12 @@
 
 import http
 
+import mock
 import pytest
 
 from hikari import errors
 from hikari import intents
+from hikari.internal import data_binding
 
 
 class TestShardCloseCode:
@@ -88,6 +90,35 @@ def test_str_when_message_is_not_None(self, error):
         assert str(error) == "Some Status: 'Some message' for https://some.url"
 
 
+class TestBadRequestError:
+    @pytest.fixture()
+    def error(self):
+        return errors.BadRequestError(
+            "https://some.url", http.HTTPStatus.BAD_REQUEST, {}, "raw body", errors={"components": {"0": "ok"}}
+        )
+
+    def test_str(self, error):
+        assert (
+            str(error)
+            == 'Bad Request 400: \'raw body\' for https://some.url\n{\n  "components": {\n    "0": "ok"\n  }\n}'
+        )
+
+    def test_str_when_cached(self, error):
+        error._cached_str = "ok"
+
+        with mock.patch.object(data_binding, "dump_json") as dump_json:
+            assert str(error) == "ok"
+
+        dump_json.assert_not_called()
+
+    def test_str_when_no_errors(self, error):
+        error.errors = None
+        with mock.patch.object(data_binding, "dump_json") as dump_json:
+            assert str(error) == "Bad Request 400: 'raw body' for https://some.url"
+
+        dump_json.assert_not_called()
+
+
 class TestRateLimitTooLongError:
     @pytest.fixture()
     def error(self):