Skip to content

Commit 0712f9f

Browse files
committed
Add handling of nested errors
1 parent 3eaf07c commit 0712f9f

File tree

6 files changed

+403
-26
lines changed

6 files changed

+403
-26
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ any parts of the framework not mentioned in the documentation should generally b
1919
* Avoid `AttributeError` for PUT and PATCH methods when using `APIView`
2020
* Clear many-to-many relationships instead of deleting related objects during PATCH on `RelationshipView`
2121
* Allow POST, PATCH, DELETE for actions in `ReadOnlyModelViewSet`. It was problematic since 2.8.0.
22+
* Properly format nested errors
2223

2324
### Changed
2425

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# name: test_deprecation_warning
2+
'Rendering nested serializer as relationship is deprecated. Use `ResourceRelatedField` instead if DummyNestedSerializer in serializer example.tests.test_errors.test_deprecation_warning.<locals>.DummySerializer should remain a relationship. Otherwise set JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE to True to render nested serializer as nested json attribute'
3+
---
4+
# name: test_first_level_attribute_error
5+
<class 'list'> [
6+
<class 'dict'> {
7+
'code': 'required',
8+
'detail': ErrorDetail(string='This field is required.', code='required'),
9+
'source': <class 'dict'> {
10+
'pointer': '/data/attributes/headline',
11+
},
12+
'status': '400',
13+
},
14+
]
15+
---
16+
# name: test_first_level_custom_attribute_error
17+
<class 'list'> [
18+
<class 'dict'> {
19+
'detail': ErrorDetail(string='Too short', code='invalid'),
20+
'source': <class 'dict'> {
21+
'pointer': '/data/attributes/body-text',
22+
},
23+
'title': ErrorDetail(string='Too Short title', code='invalid'),
24+
},
25+
]
26+
---
27+
# name: test_many_third_level_dict_errors
28+
<class 'list'> [
29+
<class 'dict'> {
30+
'code': 'required',
31+
'detail': ErrorDetail(string='This field is required.', code='required'),
32+
'source': <class 'dict'> {
33+
'pointer': '/data/attributes/comments/0/attachment/data',
34+
},
35+
'status': '400',
36+
},
37+
<class 'dict'> {
38+
'code': 'required',
39+
'detail': ErrorDetail(string='This field is required.', code='required'),
40+
'source': <class 'dict'> {
41+
'pointer': '/data/attributes/comments/0/body',
42+
},
43+
'status': '400',
44+
},
45+
]
46+
---
47+
# name: test_second_level_array_error
48+
<class 'list'> [
49+
<class 'dict'> {
50+
'code': 'required',
51+
'detail': ErrorDetail(string='This field is required.', code='required'),
52+
'source': <class 'dict'> {
53+
'pointer': '/data/attributes/comments/0/body',
54+
},
55+
'status': '400',
56+
},
57+
]
58+
---
59+
# name: test_second_level_dict_error
60+
<class 'list'> [
61+
<class 'dict'> {
62+
'code': 'required',
63+
'detail': ErrorDetail(string='This field is required.', code='required'),
64+
'source': <class 'dict'> {
65+
'pointer': '/data/attributes/comment/body',
66+
},
67+
'status': '400',
68+
},
69+
]
70+
---
71+
# name: test_third_level_array_error
72+
<class 'list'> [
73+
<class 'dict'> {
74+
'code': 'required',
75+
'detail': ErrorDetail(string='This field is required.', code='required'),
76+
'source': <class 'dict'> {
77+
'pointer': '/data/attributes/comments/0/attachments/0/data',
78+
},
79+
'status': '400',
80+
},
81+
]
82+
---
83+
# name: test_third_level_custom_array_error
84+
<class 'list'> [
85+
<class 'dict'> {
86+
'code': 'invalid',
87+
'detail': ErrorDetail(string='Too short data', code='invalid'),
88+
'source': <class 'dict'> {
89+
'pointer': '/data/attributes/comments/0/attachments/0/data',
90+
},
91+
'status': '400',
92+
},
93+
]
94+
---
95+
# name: test_third_level_dict_error
96+
<class 'list'> [
97+
<class 'dict'> {
98+
'code': 'required',
99+
'detail': ErrorDetail(string='This field is required.', code='required'),
100+
'source': <class 'dict'> {
101+
'pointer': '/data/attributes/comments/0/attachment/data',
102+
},
103+
'status': '400',
104+
},
105+
]
106+
---

example/tests/test_errors.py

+241
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
import pytest
2+
from django.conf.urls import url
3+
from django.test import override_settings
4+
from django.urls import reverse
5+
from rest_framework import views
6+
7+
from rest_framework_json_api import serializers
8+
9+
from example.models import Blog
10+
11+
12+
# serializers
13+
class CommentAttachmentSerializer(serializers.Serializer):
14+
data = serializers.CharField(allow_null=False, required=True)
15+
16+
def validate_data(self, value):
17+
if value and len(value) < 10:
18+
raise serializers.ValidationError('Too short data')
19+
20+
21+
class CommentSerializer(serializers.Serializer):
22+
attachments = CommentAttachmentSerializer(many=True, required=False)
23+
attachment = CommentAttachmentSerializer(required=False)
24+
one_more_attachment = CommentAttachmentSerializer(required=False)
25+
body = serializers.CharField(allow_null=False, required=True)
26+
27+
28+
class EntrySerializer(serializers.Serializer):
29+
blog = serializers.IntegerField()
30+
comments = CommentSerializer(many=True, required=False)
31+
comment = CommentSerializer(required=False)
32+
headline = serializers.CharField(allow_null=True, required=True)
33+
body_text = serializers.CharField()
34+
35+
def validate(self, attrs):
36+
body_text = attrs['body_text']
37+
if len(body_text) < 5:
38+
raise serializers.ValidationError({'body_text': {
39+
'title': 'Too Short title', 'detail': 'Too short'}
40+
})
41+
42+
43+
# view
44+
class DummyTestView(views.APIView):
45+
serializer_class = EntrySerializer
46+
resource_name = 'entries'
47+
48+
def post(self, request, *args, **kwargs):
49+
serializer = self.serializer_class(data=request.data)
50+
serializer.is_valid(raise_exception=True)
51+
52+
53+
urlpatterns = [
54+
url('entries-nested', DummyTestView.as_view(),
55+
name='entries-nested-list')
56+
]
57+
58+
59+
@pytest.fixture(scope='function')
60+
def some_blog(db):
61+
return Blog.objects.create(name='Some Blog', tagline="It's a blog")
62+
63+
64+
def perform_error_test(client, data):
65+
with override_settings(
66+
JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE=True,
67+
ROOT_URLCONF=__name__
68+
):
69+
url = reverse('entries-nested-list')
70+
response = client.post(url, data=data)
71+
72+
errors = response.data
73+
return errors
74+
75+
76+
def test_first_level_attribute_error(client, some_blog, snapshot):
77+
data = {
78+
'data': {
79+
'type': 'entries',
80+
'attributes': {
81+
'blog': some_blog.pk,
82+
'bodyText': 'body_text',
83+
}
84+
}
85+
}
86+
assert snapshot == perform_error_test(client, data)
87+
88+
89+
def test_first_level_custom_attribute_error(client, some_blog, snapshot):
90+
data = {
91+
'data': {
92+
'type': 'entries',
93+
'attributes': {
94+
'blog': some_blog.pk,
95+
'body-text': 'body',
96+
'headline': 'headline'
97+
}
98+
}
99+
}
100+
with override_settings(JSON_API_FORMAT_FIELD_NAMES='dasherize'):
101+
assert snapshot == perform_error_test(client, data)
102+
103+
104+
def test_second_level_array_error(client, some_blog, snapshot):
105+
data = {
106+
'data': {
107+
'type': 'entries',
108+
'attributes': {
109+
'blog': some_blog.pk,
110+
'bodyText': 'body_text',
111+
'headline': 'headline',
112+
'comments': [
113+
{
114+
}
115+
]
116+
}
117+
}
118+
}
119+
120+
assert snapshot == perform_error_test(client, data)
121+
122+
123+
def test_second_level_dict_error(client, some_blog, snapshot):
124+
data = {
125+
'data': {
126+
'type': 'entries',
127+
'attributes': {
128+
'blog': some_blog.pk,
129+
'bodyText': 'body_text',
130+
'headline': 'headline',
131+
'comment': {}
132+
}
133+
}
134+
}
135+
136+
assert snapshot == perform_error_test(client, data)
137+
138+
139+
def test_third_level_array_error(client, some_blog, snapshot):
140+
data = {
141+
'data': {
142+
'type': 'entries',
143+
'attributes': {
144+
'blog': some_blog.pk,
145+
'bodyText': 'body_text',
146+
'headline': 'headline',
147+
'comments': [
148+
{
149+
'body': 'test comment',
150+
'attachments': [
151+
{
152+
}
153+
]
154+
}
155+
]
156+
}
157+
}
158+
}
159+
160+
assert snapshot == perform_error_test(client, data)
161+
162+
163+
def test_third_level_custom_array_error(client, some_blog, snapshot):
164+
data = {
165+
'data': {
166+
'type': 'entries',
167+
'attributes': {
168+
'blog': some_blog.pk,
169+
'bodyText': 'body_text',
170+
'headline': 'headline',
171+
'comments': [
172+
{
173+
'body': 'test comment',
174+
'attachments': [
175+
{
176+
'data': 'text'
177+
}
178+
]
179+
}
180+
]
181+
}
182+
}
183+
}
184+
185+
assert snapshot == perform_error_test(client, data)
186+
187+
188+
def test_third_level_dict_error(client, some_blog, snapshot):
189+
data = {
190+
'data': {
191+
'type': 'entries',
192+
'attributes': {
193+
'blog': some_blog.pk,
194+
'bodyText': 'body_text',
195+
'headline': 'headline',
196+
'comments': [
197+
{
198+
'body': 'test comment',
199+
'attachment': {}
200+
}
201+
]
202+
}
203+
}
204+
}
205+
206+
assert snapshot == perform_error_test(client, data)
207+
208+
209+
def test_many_third_level_dict_errors(client, some_blog, snapshot):
210+
data = {
211+
'data': {
212+
'type': 'entries',
213+
'attributes': {
214+
'blog': some_blog.pk,
215+
'bodyText': 'body_text',
216+
'headline': 'headline',
217+
'comments': [
218+
{
219+
'attachment': {}
220+
}
221+
]
222+
}
223+
}
224+
}
225+
226+
assert snapshot == perform_error_test(client, data)
227+
228+
229+
@pytest.mark.filterwarnings('default::DeprecationWarning:rest_framework_json_api.serializers')
230+
def test_deprecation_warning(recwarn, settings, snapshot):
231+
settings.JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE = False
232+
233+
class DummyNestedSerializer(serializers.Serializer):
234+
field = serializers.CharField()
235+
236+
class DummySerializer(serializers.Serializer):
237+
nested = DummyNestedSerializer(many=True)
238+
239+
assert len(recwarn) == 1
240+
warning = recwarn.pop(DeprecationWarning)
241+
assert snapshot == str(warning.message)

example/tests/test_generic_viewset.py

+6-5
Original file line numberDiff line numberDiff line change
@@ -95,11 +95,6 @@ def test_custom_validation_exceptions(self):
9595
"""
9696
expected = {
9797
'errors': [
98-
{
99-
'id': 'armageddon101',
100-
'detail': 'Hey! You need a last name!',
101-
'meta': 'something',
102-
},
10398
{
10499
'status': '400',
105100
'source': {
@@ -108,6 +103,12 @@ def test_custom_validation_exceptions(self):
108103
'detail': 'Enter a valid email address.',
109104
'code': 'invalid',
110105
},
106+
{
107+
'id': 'armageddon101',
108+
'detail': 'Hey! You need a last name!',
109+
'meta': 'something',
110+
'source': {'pointer': '/data/attributes/lastName'}
111+
},
111112
]
112113
}
113114
response = self.client.post('/identities', {

requirements/requirements-testing.txt

+1
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ pytest==6.0.1
55
pytest-cov==2.10.1
66
pytest-django==3.9.0
77
pytest-factoryboy==2.0.3
8+
syrupy==0.6.1

0 commit comments

Comments
 (0)