Skip to content

Commit 8dbe008

Browse files
authored
Add handling of nested errors (#815)
* Add handling of nested errors * Switch from syrupy to snapshottest to support Python 3.5 Once we drop support for Python 3.5 we might consider moving back to syrupy again
1 parent 3eaf07c commit 8dbe008

File tree

8 files changed

+418
-26
lines changed

8 files changed

+418
-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

example/tests/snapshots/__init__.py

Whitespace-only changes.
+121
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# -*- coding: utf-8 -*-
2+
# snapshottest: v1 - https://goo.gl/zC4yUc
3+
from __future__ import unicode_literals
4+
5+
from snapshottest import Snapshot
6+
7+
8+
snapshots = Snapshot()
9+
10+
snapshots['test_first_level_attribute_error 1'] = {
11+
'errors': [
12+
{
13+
'code': 'required',
14+
'detail': 'This field is required.',
15+
'source': {
16+
'pointer': '/data/attributes/headline'
17+
},
18+
'status': '400'
19+
}
20+
]
21+
}
22+
23+
snapshots['test_first_level_custom_attribute_error 1'] = {
24+
'errors': [
25+
{
26+
'detail': 'Too short',
27+
'source': {
28+
'pointer': '/data/attributes/body-text'
29+
},
30+
'title': 'Too Short title'
31+
}
32+
]
33+
}
34+
35+
snapshots['test_second_level_array_error 1'] = {
36+
'errors': [
37+
{
38+
'code': 'required',
39+
'detail': 'This field is required.',
40+
'source': {
41+
'pointer': '/data/attributes/comments/0/body'
42+
},
43+
'status': '400'
44+
}
45+
]
46+
}
47+
48+
snapshots['test_second_level_dict_error 1'] = {
49+
'errors': [
50+
{
51+
'code': 'required',
52+
'detail': 'This field is required.',
53+
'source': {
54+
'pointer': '/data/attributes/comment/body'
55+
},
56+
'status': '400'
57+
}
58+
]
59+
}
60+
61+
snapshots['test_third_level_array_error 1'] = {
62+
'errors': [
63+
{
64+
'code': 'required',
65+
'detail': 'This field is required.',
66+
'source': {
67+
'pointer': '/data/attributes/comments/0/attachments/0/data'
68+
},
69+
'status': '400'
70+
}
71+
]
72+
}
73+
74+
snapshots['test_third_level_custom_array_error 1'] = {
75+
'errors': [
76+
{
77+
'code': 'invalid',
78+
'detail': 'Too short data',
79+
'source': {
80+
'pointer': '/data/attributes/comments/0/attachments/0/data'
81+
},
82+
'status': '400'
83+
}
84+
]
85+
}
86+
87+
snapshots['test_third_level_dict_error 1'] = {
88+
'errors': [
89+
{
90+
'code': 'required',
91+
'detail': 'This field is required.',
92+
'source': {
93+
'pointer': '/data/attributes/comments/0/attachment/data'
94+
},
95+
'status': '400'
96+
}
97+
]
98+
}
99+
100+
snapshots['test_many_third_level_dict_errors 1'] = {
101+
'errors': [
102+
{
103+
'code': 'required',
104+
'detail': 'This field is required.',
105+
'source': {
106+
'pointer': '/data/attributes/comments/0/attachment/data'
107+
},
108+
'status': '400'
109+
},
110+
{
111+
'code': 'required',
112+
'detail': 'This field is required.',
113+
'source': {
114+
'pointer': '/data/attributes/comments/0/body'
115+
},
116+
'status': '400'
117+
}
118+
]
119+
}
120+
121+
snapshots['test_deprecation_warning 1'] = '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'

example/tests/test_errors.py

+240
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
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+
return response.json()
73+
74+
75+
def test_first_level_attribute_error(client, some_blog, snapshot):
76+
data = {
77+
'data': {
78+
'type': 'entries',
79+
'attributes': {
80+
'blog': some_blog.pk,
81+
'bodyText': 'body_text',
82+
}
83+
}
84+
}
85+
snapshot.assert_match(perform_error_test(client, data))
86+
87+
88+
def test_first_level_custom_attribute_error(client, some_blog, snapshot):
89+
data = {
90+
'data': {
91+
'type': 'entries',
92+
'attributes': {
93+
'blog': some_blog.pk,
94+
'body-text': 'body',
95+
'headline': 'headline'
96+
}
97+
}
98+
}
99+
with override_settings(JSON_API_FORMAT_FIELD_NAMES='dasherize'):
100+
snapshot.assert_match(perform_error_test(client, data))
101+
102+
103+
def test_second_level_array_error(client, some_blog, snapshot):
104+
data = {
105+
'data': {
106+
'type': 'entries',
107+
'attributes': {
108+
'blog': some_blog.pk,
109+
'bodyText': 'body_text',
110+
'headline': 'headline',
111+
'comments': [
112+
{
113+
}
114+
]
115+
}
116+
}
117+
}
118+
119+
snapshot.assert_match(perform_error_test(client, data))
120+
121+
122+
def test_second_level_dict_error(client, some_blog, snapshot):
123+
data = {
124+
'data': {
125+
'type': 'entries',
126+
'attributes': {
127+
'blog': some_blog.pk,
128+
'bodyText': 'body_text',
129+
'headline': 'headline',
130+
'comment': {}
131+
}
132+
}
133+
}
134+
135+
snapshot.assert_match(perform_error_test(client, data))
136+
137+
138+
def test_third_level_array_error(client, some_blog, snapshot):
139+
data = {
140+
'data': {
141+
'type': 'entries',
142+
'attributes': {
143+
'blog': some_blog.pk,
144+
'bodyText': 'body_text',
145+
'headline': 'headline',
146+
'comments': [
147+
{
148+
'body': 'test comment',
149+
'attachments': [
150+
{
151+
}
152+
]
153+
}
154+
]
155+
}
156+
}
157+
}
158+
159+
snapshot.assert_match(perform_error_test(client, data))
160+
161+
162+
def test_third_level_custom_array_error(client, some_blog, snapshot):
163+
data = {
164+
'data': {
165+
'type': 'entries',
166+
'attributes': {
167+
'blog': some_blog.pk,
168+
'bodyText': 'body_text',
169+
'headline': 'headline',
170+
'comments': [
171+
{
172+
'body': 'test comment',
173+
'attachments': [
174+
{
175+
'data': 'text'
176+
}
177+
]
178+
}
179+
]
180+
}
181+
}
182+
}
183+
184+
snapshot.assert_match(perform_error_test(client, data))
185+
186+
187+
def test_third_level_dict_error(client, some_blog, snapshot):
188+
data = {
189+
'data': {
190+
'type': 'entries',
191+
'attributes': {
192+
'blog': some_blog.pk,
193+
'bodyText': 'body_text',
194+
'headline': 'headline',
195+
'comments': [
196+
{
197+
'body': 'test comment',
198+
'attachment': {}
199+
}
200+
]
201+
}
202+
}
203+
}
204+
205+
snapshot.assert_match(perform_error_test(client, data))
206+
207+
208+
def test_many_third_level_dict_errors(client, some_blog, snapshot):
209+
data = {
210+
'data': {
211+
'type': 'entries',
212+
'attributes': {
213+
'blog': some_blog.pk,
214+
'bodyText': 'body_text',
215+
'headline': 'headline',
216+
'comments': [
217+
{
218+
'attachment': {}
219+
}
220+
]
221+
}
222+
}
223+
}
224+
225+
snapshot.assert_match(perform_error_test(client, data))
226+
227+
228+
@pytest.mark.filterwarnings('default::DeprecationWarning:rest_framework_json_api.serializers')
229+
def test_deprecation_warning(recwarn, settings, snapshot):
230+
settings.JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE = False
231+
232+
class DummyNestedSerializer(serializers.Serializer):
233+
field = serializers.CharField()
234+
235+
class DummySerializer(serializers.Serializer):
236+
nested = DummyNestedSerializer(many=True)
237+
238+
assert len(recwarn) == 1
239+
warning = recwarn.pop(DeprecationWarning)
240+
snapshot.assert_match(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', {

0 commit comments

Comments
 (0)