Skip to content

Commit 158a42d

Browse files
Fix Nested Serializer Validation Error (#10)
Co-authored-by: bedilbek <[email protected]>
1 parent 7547549 commit 158a42d

File tree

7 files changed

+120
-13
lines changed

7 files changed

+120
-13
lines changed

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,19 @@ REST_FRAMEWORK={
5959
}
6060
```
6161

62+
Optionally set additional configuration for the package.
63+
```python
64+
EXCEPTIONS_HOG = {
65+
"EXCEPTION_REPORTING": "exceptions_hog.handler.exception_reporter",
66+
"ENABLE_IN_DEBUG": False,
67+
"NESTED_KEY_SEPARATOR": "__",
68+
}
69+
```
70+
71+
- `EXCEPTION_REPORTING`: specify a method to call after an exception occurs. Particularly useful to report errors (e.g. through Sentry, NewRelic, ...). Default: `exceptions_hog.handler.exception_reporter`
72+
- `ENABLE_IN_DEBUG`: whether exceptions-hog should run when `DEBUG = 1`. It's useful to turn this off in debugging to get full error stack traces when developing. Defaut: `False`.
73+
- `NESTED_KEY_SEPARATOR`: customize the separator used for obtaining the `attr` name if the exception comes from nested objects (e.g. nested serializers). Default: `__`.
74+
6275
## 📑 Documentation
6376

6477
We're working on more comprehensive documentation. Feel free to open a PR to contribute to this. In the meantime, you will find the most relevant information for this package here.

exceptions_hog/handler.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,18 @@ def override_or_return(code: str) -> str:
9494
# Only one exception, return
9595
return (codes, None)
9696
elif isinstance(codes, dict):
97-
key = next(iter(codes)) # Get first key
98-
code = codes[key] if isinstance(codes[key], str) else codes[key][0]
97+
# If object is a dict or nested dict, return the key of the very first error
98+
iterating_key = next(iter(codes)) # Get initial key
99+
key = iterating_key
100+
while isinstance(codes[iterating_key], dict):
101+
codes = codes[iterating_key]
102+
iterating_key = next(iter(codes))
103+
key = f"{key}{api_settings.NESTED_KEY_SEPARATOR}{iterating_key}"
104+
code = (
105+
codes[iterating_key]
106+
if isinstance(codes[iterating_key], str)
107+
else codes[iterating_key][0]
108+
)
99109
return (override_or_return(code), key)
100110
elif isinstance(codes, list):
101111
return (override_or_return(str(codes[0])), None)
@@ -115,11 +125,10 @@ def _get_detail(exc, exception_key: str = "") -> str:
115125
exc.detail
116126
) # We do str() to get the actual error string on ErrorDetail instances
117127
elif isinstance(exc.detail, dict):
118-
return str(
119-
exc.detail[exception_key][0]
120-
if isinstance(exc.detail[exception_key], str)
121-
else exc.detail[exception_key][0]
122-
)
128+
value = exc.detail
129+
for key in exception_key.split(api_settings.NESTED_KEY_SEPARATOR):
130+
value = value[key]
131+
return str(value if isinstance(value, str) else value[0])
123132
elif isinstance(exc.detail, list) and len(exc.detail) > 0:
124133
return exc.detail[0]
125134

exceptions_hog/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
DEFAULTS: Dict = {
99
"EXCEPTION_REPORTING": "exceptions_hog.handler.exception_reporter",
1010
"ENABLE_IN_DEBUG": False,
11+
"NESTED_KEY_SEPARATOR": "__",
1112
}
1213

1314
# List of settings that may be in string import notation.

requirements-test.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ isort==5.5.*
44
mypy==0.782
55
pytest==6.0.*
66
pytest-cov==2.10.1
7-
pytest-django==3.10.*
7+
pytest-django==3.10.*
8+
six==1.15.0

test_project/test_app/apps.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +0,0 @@
1-
from django.apps import AppConfig
2-
3-
4-
class TestAppConfig(AppConfig):
5-
name = "test_app"

tests/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ def pytest_configure():
99
from django.conf import settings
1010

1111
settings.configure(
12+
SECRET_KEY="#mk0y8q%rh!ieekh5h#39b@a99u3eg$93kc9oq#z1kpzvg+k2_",
1213
INSTALLED_APPS=[
1314
"django.contrib.contenttypes",
1415
"django.contrib.auth",

tests/test_handler.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from django.db.models import ProtectedError
33
from django.http import Http404
44
from rest_framework import exceptions, status
5+
from rest_framework.exceptions import ErrorDetail
56

67
from exceptions_hog.handler import exception_handler
78
from exceptions_hog.settings import api_settings
@@ -73,6 +74,92 @@ def test_validation_error() -> None:
7374
}
7475

7576

77+
def test_validation_error_serializer_field() -> None:
78+
response = exception_handler(
79+
exceptions.ValidationError(
80+
{
81+
"phone_number": [
82+
ErrorDetail(string="This field is required.", code="required")
83+
]
84+
}
85+
)
86+
)
87+
assert response is not None
88+
assert response.status_code == status.HTTP_400_BAD_REQUEST
89+
assert response.data == {
90+
"type": "validation_error",
91+
"code": "required",
92+
"detail": "This field is required.",
93+
"attr": "phone_number",
94+
}
95+
96+
97+
def test_validation_error_with_simple_nested_serializer_field() -> None:
98+
response = exception_handler(
99+
exceptions.ValidationError(
100+
{
101+
"parent": {
102+
"children_attr": [
103+
ErrorDetail(string="This field is required.", code="required")
104+
],
105+
"second_children_attr": [
106+
ErrorDetail(
107+
string="This field is also invalid.", code="invalid_too"
108+
)
109+
],
110+
}
111+
}
112+
)
113+
)
114+
assert response is not None
115+
assert response.status_code == status.HTTP_400_BAD_REQUEST
116+
assert response.data == {
117+
"type": "validation_error",
118+
"code": "required",
119+
"detail": "This field is required.",
120+
"attr": "parent__children_attr",
121+
}
122+
123+
124+
def test_validation_error_with_complex_nested_serializer_field() -> None:
125+
response = exception_handler(
126+
exceptions.ValidationError(
127+
{
128+
"parent": {
129+
"l1_attr": {
130+
"l2_attr": {
131+
"l3_attr": ErrorDetail(
132+
string="Focus on this error.", code="focus"
133+
),
134+
},
135+
"l2_attr_2": {
136+
"l3_attr_2": [
137+
ErrorDetail(
138+
string="This field is also invalid.",
139+
code="invalid_too",
140+
)
141+
]
142+
},
143+
},
144+
"l1_attr_2": [
145+
ErrorDetail(
146+
string="This field is also invalid.", code="invalid_too"
147+
)
148+
],
149+
}
150+
}
151+
)
152+
)
153+
assert response is not None
154+
assert response.status_code == status.HTTP_400_BAD_REQUEST
155+
assert response.data == {
156+
"type": "validation_error",
157+
"code": "focus",
158+
"detail": "Focus on this error.",
159+
"attr": "parent__l1_attr__l2_attr__l3_attr",
160+
}
161+
162+
76163
# Django & DRF exceptions
77164

78165

0 commit comments

Comments
 (0)