Skip to content

Commit b5c995a

Browse files
authored
Merge pull request #214 from novafloss/errors-apiv2
Change errors format in the builder
2 parents 4c65888 + 2d5871e commit b5c995a

7 files changed

+235
-27
lines changed

CHANGELOG.rst

+2-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ ChangeLog
55
master (unreleased)
66
===================
77

8-
nothing yet.
8+
- Change errors format returned in the builder in order to have something
9+
more constistant (#214)
910

1011
Release 0.9.1 (2017-04-24)
1112
==========================

demo/tests/test_post_save_callbacks.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ def test_create_error_post(self):
5858
reverse('formidable:form_create'), form_data_without_items,
5959
format='json'
6060
)
61-
self.assertEquals(res.status_code, 400)
61+
self.assertEquals(res.status_code, 422)
6262
self.assertEqual(patched_callback.call_count, 1)
6363

6464
@override_settings(
@@ -137,7 +137,7 @@ def test_update_error_post(self):
137137
reverse('formidable:form_detail', args=[self.form.id]),
138138
form_data_without_items, format='json'
139139
)
140-
self.assertEquals(res.status_code, 400)
140+
self.assertEquals(res.status_code, 422)
141141
self.assertEqual(patched_callback.call_count, 1)
142142

143143
@override_settings(

demo/tests/test_post_save_callbacks_regression_tests.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ def test_create_error(self):
3232
reverse('formidable:form_create'), form_data_without_items,
3333
format='json'
3434
)
35-
self.assertEquals(res.status_code, 400)
35+
self.assertEquals(res.status_code, 422)
3636

3737
@override_settings()
3838
def test_create_no_settings_no_error(self):
@@ -52,7 +52,7 @@ def test_create_no_settings_error(self):
5252
reverse('formidable:form_create'), form_data_without_items,
5353
format='json'
5454
)
55-
self.assertEquals(res.status_code, 400)
55+
self.assertEquals(res.status_code, 422)
5656

5757

5858
class UpdateFormTestCase(APITestCase):
@@ -80,7 +80,7 @@ def test_update_error(self):
8080
reverse('formidable:form_detail', args=[self.form.id]),
8181
form_data_without_items, format='json'
8282
)
83-
self.assertEquals(res.status_code, 400)
83+
self.assertEquals(res.status_code, 422)
8484

8585
@override_settings()
8686
def test_update_no_settings_no_error(self):
@@ -101,4 +101,4 @@ def test_update_no_settings_error(self):
101101
reverse('formidable:form_detail', args=[self.form.id]),
102102
form_data_without_items, format='json'
103103
)
104-
self.assertEquals(res.status_code, 400)
104+
self.assertEquals(res.status_code, 422)

demo/tests/tests_integration.py

+31-12
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,21 @@
2727
from mock import patch
2828

2929

30+
class FormidableAPITestCase(APITestCase):
31+
32+
def check_errors_format(self, data):
33+
# keys must be in {'code', 'message', 'errors'}
34+
# 'errors' is optional
35+
self.assertFalse(set(data.keys()) - {'code', 'message', 'errors'})
36+
self.assertIn('code', data)
37+
self.assertIn('message', data)
38+
for error in data.get('errors', []):
39+
self.assertFalse(set(error.keys()) - {'code', 'message', 'field'})
40+
# 'field' is optional
41+
self.assertIn('code', error)
42+
self.assertIn('message', error)
43+
44+
3045
class MyTestForm(FormidableForm):
3146

3247
name = fields.CharField(label='Name', accesses={'jedi': 'REQUIRED'})
@@ -70,7 +85,7 @@ class Meta:
7085
]
7186

7287

73-
class CreateFormTestCase(APITestCase):
88+
class CreateFormTestCase(FormidableAPITestCase):
7489

7590
def test_simple(self):
7691
initial_count = Formidable.objects.count()
@@ -99,7 +114,8 @@ def test_fields_slug(self):
99114
res = self.client.post(
100115
reverse('formidable:form_create'), data, format='json'
101116
)
102-
self.assertEquals(res.status_code, 400)
117+
self.assertEquals(res.status_code, 422)
118+
self.check_errors_format(res.data)
103119

104120
def test_with_items_in_fields(self):
105121
initial_count = Formidable.objects.count()
@@ -120,7 +136,8 @@ def test_forgotten_items_fields(self):
120136
reverse('formidable:form_create'), form_data_without_items,
121137
format='json'
122138
)
123-
self.assertEquals(res.status_code, 400)
139+
self.assertEquals(res.status_code, 422)
140+
self.check_errors_format(res.data)
124141

125142
def test_with_unknown_accesses(self):
126143
form_data_copy = deepcopy(form_data)
@@ -130,10 +147,11 @@ def test_with_unknown_accesses(self):
130147
reverse('formidable:form_create'), form_data_copy,
131148
format='json'
132149
)
133-
self.assertEquals(res.status_code, 400)
150+
self.assertEquals(res.status_code, 422)
151+
self.check_errors_format(res.data)
134152

135153

136-
class UpdateFormTestCase(APITestCase):
154+
class UpdateFormTestCase(FormidableAPITestCase):
137155

138156
def setUp(self):
139157
super(UpdateFormTestCase, self).setUp()
@@ -203,7 +221,8 @@ def test_duplicate_items_update(self):
203221
data['fields'] *= 2
204222
res = self.client.put(self.edit_url, data, format='json')
205223
# expect validation error
206-
self.assertEquals(res.status_code, 400)
224+
self.assertEquals(res.status_code, 422)
225+
self.check_errors_format(res.data)
207226

208227
def test_delete_field_on_update(self):
209228
self.form.fields.create(
@@ -270,7 +289,7 @@ def order_by(self, *fields):
270289
self.assertEquals(res.status_code, 200, res)
271290

272291

273-
class TestAccess(APITestCase):
292+
class TestAccess(FormidableAPITestCase):
274293

275294
def test_get(self):
276295
response = self.client.get(reverse('formidable:accesses_list'))
@@ -292,7 +311,7 @@ def test_get(self):
292311
self.assertEqual(access['preview_as'], 'FORM')
293312

294313

295-
class TestPresetsList(APITestCase):
314+
class TestPresetsList(FormidableAPITestCase):
296315

297316
def test_get(self):
298317
response = self.client.get(reverse('formidable:presets_list'))
@@ -306,10 +325,10 @@ def test_get(self):
306325
self.assertIn('arguments', preset)
307326

308327

309-
class TestChain(APITestCase):
328+
class TestChain(FormidableAPITestCase):
310329

311330
def setUp(self):
312-
super(APITestCase, self).setUp()
331+
super(FormidableAPITestCase, self).setUp()
313332
self.form = MyTestForm.to_formidable(label='Jedi Form')
314333
self.assertTrue(self.form.pk)
315334

@@ -337,7 +356,7 @@ def test_jedi_form_form_invalid(self):
337356
)
338357

339358

340-
class TestContextFormEndPoint(APITestCase):
359+
class TestContextFormEndPoint(FormidableAPITestCase):
341360

342361
@classmethod
343362
def setUpClass(cls):
@@ -378,7 +397,7 @@ class Meta:
378397
]
379398

380399

381-
class TestValidationEndPoint(APITestCase):
400+
class TestValidationEndPoint(FormidableAPITestCase):
382401

383402
def setUp(self):
384403
super(TestValidationEndPoint, self).setUp()

formidable/exception_handler.py

+178
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
# -*- coding: utf-8 -*-
2+
from __future__ import unicode_literals
3+
4+
from django import forms
5+
from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied
6+
from django.forms.utils import ErrorDict, ErrorList
7+
from django.http import Http404
8+
9+
import six
10+
11+
from rest_framework import exceptions
12+
from rest_framework.response import Response
13+
from rest_framework.settings import api_settings
14+
15+
16+
def _reformat_drf_errors(errors, detail, path=None):
17+
if path is None:
18+
path = []
19+
if isinstance(detail, dict):
20+
for k, v in detail.items():
21+
if k == api_settings.NON_FIELD_ERRORS_KEY:
22+
new_path = path
23+
else:
24+
new_path = path + [k]
25+
_reformat_drf_errors(errors, v, new_path)
26+
elif isinstance(detail, list):
27+
for idx, v in enumerate(detail):
28+
if v:
29+
_reformat_drf_errors(errors, v, path + ['{}'.format(idx)])
30+
else:
31+
# errors are always coerced to a list so we
32+
# can skip the last level in our path
33+
path.pop()
34+
message = six.text_type(detail)
35+
error = {
36+
'message': message,
37+
'code': getattr(detail, 'code', 'invalid') or 'invalid',
38+
}
39+
# do not put a field attribute on global validaiton errors
40+
if path:
41+
error['field'] = '.'.join(path)
42+
errors.append(error)
43+
44+
45+
def format_validation_error(detail):
46+
errors = []
47+
_reformat_drf_errors(errors, detail)
48+
return {
49+
'code': 'validation_error',
50+
'message': 'Validation failed',
51+
'errors': errors,
52+
}
53+
54+
55+
def _reformat_forms_errors(errors, error, path=None):
56+
if path is None:
57+
path = []
58+
if isinstance(error, ErrorDict):
59+
for k, v in error.items():
60+
if k == NON_FIELD_ERRORS:
61+
new_path = path
62+
else:
63+
new_path = path + [k]
64+
_reformat_forms_errors(errors, v, new_path)
65+
elif isinstance(error, ErrorList):
66+
for item in error.as_data():
67+
_reformat_forms_errors(errors, item, path)
68+
elif isinstance(error, list):
69+
for item in error:
70+
_reformat_forms_errors(errors, item, path)
71+
else:
72+
# django.core.exceptions.Exception
73+
item = {
74+
'message': six.text_type(error.message),
75+
'code': error.code or 'invalid',
76+
}
77+
if path:
78+
item['field'] = '.'.join(path)
79+
errors.append(item)
80+
81+
82+
def format_forms_error(error):
83+
errors = []
84+
_reformat_forms_errors(errors, error)
85+
return {
86+
'code': 'validation_error',
87+
'message': 'Validation failed',
88+
'errors': errors,
89+
}
90+
91+
92+
def exception_handler(exc, context):
93+
"""
94+
Returns the response that should be used for any given exception.
95+
96+
By default we handle the REST framework `APIException`, and also
97+
Django's built-in `Http404` and `PermissionDenied` exceptions.
98+
99+
Any unhandled exceptions may return `None`, which will cause a 500 error
100+
to be raised.
101+
"""
102+
headers = None
103+
if isinstance(exc, exceptions.ValidationError):
104+
data = format_validation_error(exc.detail)
105+
# change default 400 status code from DRF to Unprocessable Entity
106+
status_code = 422
107+
# status_code = exc.status_code
108+
elif isinstance(exc, exceptions.APIException):
109+
headers = {}
110+
if getattr(exc, 'auth_header', None):
111+
headers['WWW-Authenticate'] = exc.auth_header
112+
if getattr(exc, 'wait', None):
113+
headers['Retry-After'] = '%d' % exc.wait
114+
115+
data = {
116+
'code': getattr(exc.detail, 'code', 'error'),
117+
'message': six.text_type(exc.detail),
118+
}
119+
status_code = exc.status_code
120+
elif isinstance(exc, Http404):
121+
data = {
122+
'code': 'not_found',
123+
'message': six.text_type(exceptions.NotFound.default_detail),
124+
}
125+
status_code = 404
126+
elif isinstance(exc, PermissionDenied):
127+
data = {
128+
'code': 'permission_denied',
129+
'message': six.text_type(
130+
exceptions.PermissionDenied.default_detail),
131+
}
132+
status_code = 403
133+
elif isinstance(exc, forms.ValidationError):
134+
data = format_forms_error(exc.error_list)
135+
status_code = 422
136+
else:
137+
# unhandled exception, return generic error
138+
# TODO add logs ?
139+
data = {
140+
'code': 'error',
141+
'message': six.text_type(
142+
exceptions.APIException.default_detail),
143+
}
144+
status_code = 500
145+
146+
return Response(data, status=status_code, headers=headers)
147+
148+
149+
class ExceptionHandlerMixin(object):
150+
"""
151+
this mixin replaces the exception handler from the APIView
152+
153+
Warning: must be set *after* `CallbackMixin`.
154+
"""
155+
156+
def handle_exception(self, exc):
157+
"""
158+
Handle any exception that occurs, by returning an appropriate response,
159+
or re-raising the error.
160+
"""
161+
if isinstance(exc, (exceptions.NotAuthenticated,
162+
exceptions.AuthenticationFailed)):
163+
# WWW-Authenticate header for 401 responses, else coerce to 403
164+
auth_header = self.get_authenticate_header(self.request)
165+
166+
if auth_header:
167+
exc.auth_header = auth_header
168+
else:
169+
exc.status_code = 403
170+
171+
context = self.get_exception_handler_context()
172+
response = exception_handler(exc, context)
173+
174+
if response is None:
175+
raise
176+
177+
response.exception = True
178+
return response

formidable/serializers/forms.py

+1-2
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,13 @@
22

33
from __future__ import unicode_literals
44

5-
from django.core.exceptions import ValidationError
65
from django.db import transaction
7-
86
from formidable.models import Formidable
97
from formidable.serializers import fields
108
from formidable.serializers.common import WithNestedSerializer
119
from formidable.serializers.presets import PresetModelSerializer
1210
from rest_framework import serializers
11+
from rest_framework.exceptions import ValidationError
1312

1413

1514
class FormidableSerializer(WithNestedSerializer):

0 commit comments

Comments
 (0)