Skip to content

Commit 8c29efe

Browse files
committed
Introduce error code for validation errors.
This patch is meant to fix encode#3111, regarding comments made to encode#3137 and encode#3169. The `ValidationError` will now contain a `code` attribute. Before this patch, `ValidationError.detail` only contained a `dict` with values equal to a `list` of string error messages or directly a `list` containing string error messages. Now, the string error messages are replaced with `ValidationError`. This means that, depending on the case, you will not only get a string back but a all object containing both the error message and the error code, respectively `ValidationError.detail` and `ValidationError.code`. It is important to note that the `code` attribute is not relevant when the `ValidationError` represents a combination of errors and hence is `None` in such cases. The main benefit of this change is that the error message and error code are now accessible the custom exception handler and can be used to format the error response. An custom exception handler example is available in the `TestValidationErrorWithCode` test.
1 parent e831753 commit 8c29efe

21 files changed

+287
-75
lines changed

rest_framework/authtoken/serializers.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,22 @@ def validate(self, attrs):
1818
if user:
1919
if not user.is_active:
2020
msg = _('User account is disabled.')
21-
raise serializers.ValidationError(msg)
21+
raise serializers.ValidationError(
22+
msg,
23+
code='authorization'
24+
)
2225
else:
2326
msg = _('Unable to log in with provided credentials.')
24-
raise serializers.ValidationError(msg)
27+
raise serializers.ValidationError(
28+
msg,
29+
code='authorization'
30+
)
2531
else:
2632
msg = _('Must include "username" and "password".')
27-
raise serializers.ValidationError(msg)
33+
raise serializers.ValidationError(
34+
msg,
35+
code='authorization'
36+
)
2837

2938
attrs['user'] = user
3039
return attrs

rest_framework/exceptions.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,14 @@ def __str__(self):
5858
return self.detail
5959

6060

61+
def build_error_from_django_validation_error(exc_info):
62+
code = getattr(exc_info, 'code', None) or 'invalid'
63+
return [
64+
ValidationError(msg, code=code)
65+
for msg in exc_info.messages
66+
]
67+
68+
6169
# The recommended style for using `ValidationError` is to keep it namespaced
6270
# under `serializers`, in order to minimize potential confusion with Django's
6371
# built in `ValidationError`. For example:
@@ -68,12 +76,17 @@ def __str__(self):
6876
class ValidationError(APIException):
6977
status_code = status.HTTP_400_BAD_REQUEST
7078

71-
def __init__(self, detail):
79+
def __init__(self, detail, code=None):
7280
# For validation errors the 'detail' key is always required.
7381
# The details should always be coerced to a list if not already.
7482
if not isinstance(detail, dict) and not isinstance(detail, list):
7583
detail = [detail]
76-
self.detail = _force_text_recursive(detail)
84+
elif isinstance(detail, dict) or (detail and isinstance(detail[0], ValidationError)):
85+
assert code is None, (
86+
'The `code` argument must not be set for compound errors.')
87+
88+
self.detail = detail
89+
self.code = code
7790

7891
def __str__(self):
7992
return six.text_type(self.detail)

rest_framework/fields.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@
3131
MinValueValidator, duration_string, parse_duration, unicode_repr,
3232
unicode_to_repr
3333
)
34-
from rest_framework.exceptions import ValidationError
34+
from rest_framework.exceptions import (
35+
ValidationError, build_error_from_django_validation_error
36+
)
3537
from rest_framework.settings import api_settings
3638
from rest_framework.utils import html, humanize_datetime, representation
3739

@@ -501,9 +503,11 @@ def run_validators(self, value):
501503
# attempting to accumulate a list of errors.
502504
if isinstance(exc.detail, dict):
503505
raise
504-
errors.extend(exc.detail)
506+
errors.append(ValidationError(exc.detail, code=exc.code))
505507
except DjangoValidationError as exc:
506-
errors.extend(exc.messages)
508+
errors.extend(
509+
build_error_from_django_validation_error(exc)
510+
)
507511
if errors:
508512
raise ValidationError(errors)
509513

@@ -541,7 +545,7 @@ def fail(self, key, **kwargs):
541545
msg = MISSING_ERROR_MESSAGE.format(class_name=class_name, key=key)
542546
raise AssertionError(msg)
543547
message_string = msg.format(**kwargs)
544-
raise ValidationError(message_string)
548+
raise ValidationError(message_string, code=key)
545549

546550
@cached_property
547551
def root(self):

rest_framework/renderers.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,21 @@ def get_rendered_html_form(self, data, view, method, request):
493493
if hasattr(serializer, 'initial_data'):
494494
serializer.is_valid()
495495

496+
# Convert ValidationError to unicode string
497+
# This is mainly a hack to monkey patch the errors and make the form renderer happy...
498+
errors = OrderedDict()
499+
for field_name, values in serializer.errors.items():
500+
if isinstance(values, list):
501+
errors[field_name] = values
502+
continue
503+
504+
errors[field_name] = []
505+
for value in values.detail:
506+
for message in value.detail:
507+
errors[field_name].append(message)
508+
509+
serializer._errors = errors
510+
496511
form_renderer = self.form_renderer_class()
497512
return form_renderer.render(
498513
serializer.data,

rest_framework/response.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ def __init__(self, data=None, status=None,
3838
'`.error`. representation.'
3939
)
4040
raise AssertionError(msg)
41-
4241
self.data = data
4342
self.template_name = template_name
4443
self.exception = exception

rest_framework/serializers.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from django.utils.functional import cached_property
2121
from django.utils.translation import ugettext_lazy as _
2222

23+
from rest_framework import exceptions
2324
from rest_framework.compat import DurationField as ModelDurationField
2425
from rest_framework.compat import JSONField as ModelJSONField
2526
from rest_framework.compat import postgres_fields, unicode_to_repr
@@ -300,7 +301,8 @@ def get_validation_error_detail(exc):
300301
# exception class as well for simpler compat.
301302
# Eg. Calling Model.clean() explicitly inside Serializer.validate()
302303
return {
303-
api_settings.NON_FIELD_ERRORS_KEY: list(exc.messages)
304+
api_settings.NON_FIELD_ERRORS_KEY:
305+
exceptions.build_error_from_django_validation_error(exc)
304306
}
305307
elif isinstance(exc.detail, dict):
306308
# If errors may be a dict we use the standard {key: list of values}.
@@ -422,8 +424,9 @@ def to_internal_value(self, data):
422424
message = self.error_messages['invalid'].format(
423425
datatype=type(data).__name__
424426
)
427+
error = ValidationError(message, code='invalid')
425428
raise ValidationError({
426-
api_settings.NON_FIELD_ERRORS_KEY: [message]
429+
api_settings.NON_FIELD_ERRORS_KEY: [error]
427430
})
428431

429432
ret = OrderedDict()
@@ -438,9 +441,11 @@ def to_internal_value(self, data):
438441
if validate_method is not None:
439442
validated_value = validate_method(validated_value)
440443
except ValidationError as exc:
441-
errors[field.field_name] = exc.detail
444+
errors[field.field_name] = exc
442445
except DjangoValidationError as exc:
443-
errors[field.field_name] = list(exc.messages)
446+
errors[field.field_name] = (
447+
exceptions.build_error_from_django_validation_error(exc)
448+
)
444449
except SkipField:
445450
pass
446451
else:
@@ -575,14 +580,18 @@ def to_internal_value(self, data):
575580
message = self.error_messages['not_a_list'].format(
576581
input_type=type(data).__name__
577582
)
583+
error = ValidationError(
584+
message,
585+
code='not_a_list'
586+
)
578587
raise ValidationError({
579-
api_settings.NON_FIELD_ERRORS_KEY: [message]
588+
api_settings.NON_FIELD_ERRORS_KEY: [error]
580589
})
581590

582591
if not self.allow_empty and len(data) == 0:
583592
message = self.error_messages['empty']
584593
raise ValidationError({
585-
api_settings.NON_FIELD_ERRORS_KEY: [message]
594+
api_settings.NON_FIELD_ERRORS_KEY: [ValidationError(message, code='empty_not_allowed')]
586595
})
587596

588597
ret = []

rest_framework/validators.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def __call__(self, value):
6060
queryset = self.filter_queryset(value, queryset)
6161
queryset = self.exclude_current_instance(queryset)
6262
if queryset.exists():
63-
raise ValidationError(self.message)
63+
raise ValidationError(self.message, code='unique')
6464

6565
def __repr__(self):
6666
return unicode_to_repr('<%s(queryset=%s)>' % (
@@ -101,7 +101,9 @@ def enforce_required_fields(self, attrs):
101101
return
102102

103103
missing = {
104-
field_name: self.missing_message
104+
field_name: ValidationError(
105+
self.missing_message,
106+
code='required')
105107
for field_name in self.fields
106108
if field_name not in attrs
107109
}
@@ -147,7 +149,8 @@ def __call__(self, attrs):
147149
]
148150
if None not in checked_values and queryset.exists():
149151
field_names = ', '.join(self.fields)
150-
raise ValidationError(self.message.format(field_names=field_names))
152+
raise ValidationError(self.message.format(field_names=field_names),
153+
code='unique')
151154

152155
def __repr__(self):
153156
return unicode_to_repr('<%s(queryset=%s, fields=%s)>' % (
@@ -185,7 +188,9 @@ def enforce_required_fields(self, attrs):
185188
'required' state on the fields they are applied to.
186189
"""
187190
missing = {
188-
field_name: self.missing_message
191+
field_name: ValidationError(
192+
self.missing_message,
193+
code='required')
189194
for field_name in [self.field, self.date_field]
190195
if field_name not in attrs
191196
}
@@ -211,7 +216,8 @@ def __call__(self, attrs):
211216
queryset = self.exclude_current_instance(attrs, queryset)
212217
if queryset.exists():
213218
message = self.message.format(date_field=self.date_field)
214-
raise ValidationError({self.field: message})
219+
error = ValidationError(message, code='unique')
220+
raise ValidationError({self.field: error})
215221

216222
def __repr__(self):
217223
return unicode_to_repr('<%s(queryset=%s, field=%s, date_field=%s)>' % (

rest_framework/views.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
from rest_framework import exceptions, status
1717
from rest_framework.compat import set_rollback
18+
from rest_framework.exceptions import ValidationError, _force_text_recursive
1819
from rest_framework.request import Request
1920
from rest_framework.response import Response
2021
from rest_framework.settings import api_settings
@@ -69,7 +70,17 @@ def exception_handler(exc, context):
6970
if getattr(exc, 'wait', None):
7071
headers['Retry-After'] = '%d' % exc.wait
7172

72-
if isinstance(exc.detail, (list, dict)):
73+
if isinstance(exc.detail, list):
74+
data = _force_text_recursive([item.detail if isinstance(item, ValidationError) else item
75+
for item in exc.detai])
76+
elif isinstance(exc.detail, dict):
77+
for field_name, e in exc.detail.items():
78+
if hasattr(e, 'detail') and isinstance(e.detail[0], ValidationError):
79+
exc.detail[field_name] = e.detail[0].detail
80+
elif isinstance(e, ValidationError):
81+
exc.detail[field_name] = e.detail
82+
else:
83+
exc.detail[field_name] = e
7384
data = exc.detail
7485
else:
7586
data = {'detail': exc.detail}

tests/test_bound_fields.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ class ExampleSerializer(serializers.Serializer):
3939
serializer.is_valid()
4040

4141
assert serializer['text'].value == 'x' * 1000
42-
assert serializer['text'].errors == ['Ensure this field has no more than 100 characters.']
42+
assert serializer['text'].errors.detail[0].detail == ['Ensure this field has no more than 100 characters.']
43+
assert serializer['text'].errors.detail[0].code == 'max_length'
4344
assert serializer['text'].name == 'text'
4445
assert serializer['amount'].value is 123
4546
assert serializer['amount'].errors is None

tests/test_fields.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import rest_framework
1212
from rest_framework import serializers
13+
from rest_framework.exceptions import ValidationError
1314

1415

1516
# Tests for field keyword arguments and core functionality.
@@ -426,7 +427,13 @@ def test_invalid_inputs(self):
426427
for input_value, expected_failure in get_items(self.invalid_inputs):
427428
with pytest.raises(serializers.ValidationError) as exc_info:
428429
self.field.run_validation(input_value)
429-
assert exc_info.value.detail == expected_failure
430+
431+
if isinstance(exc_info.value.detail[0], ValidationError):
432+
failure = exc_info.value.detail[0].detail
433+
else:
434+
failure = exc_info.value.detail
435+
436+
assert failure == expected_failure
430437

431438
def test_outputs(self):
432439
for output_value, expected_output in get_items(self.outputs):
@@ -1393,7 +1400,10 @@ class TestFieldFieldWithName(FieldValues):
13931400
# call into it's regular validation, or require PIL for testing.
13941401
class FailImageValidation(object):
13951402
def to_python(self, value):
1396-
raise serializers.ValidationError(self.error_messages['invalid_image'])
1403+
raise serializers.ValidationError(
1404+
self.error_messages['invalid_image'],
1405+
code='invalid_image'
1406+
)
13971407

13981408

13991409
class PassImageValidation(object):

tests/test_model_serializer.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,7 @@ class Meta:
374374

375375
s = TestSerializer(data={'address': 'not an ip address'})
376376
self.assertFalse(s.is_valid())
377-
self.assertEquals(1, len(s.errors['address']),
377+
self.assertEquals(1, len(s.errors['address'].detail),
378378
'Unexpected number of validation errors: '
379379
'{0}'.format(s.errors))
380380

tests/test_relations_hyperlink.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,8 @@ def test_foreign_key_update_incorrect_type(self):
244244
instance = ForeignKeySource.objects.get(pk=1)
245245
serializer = ForeignKeySourceSerializer(instance, data=data, context={'request': request})
246246
self.assertFalse(serializer.is_valid())
247-
self.assertEqual(serializer.errors, {'target': ['Incorrect type. Expected URL string, received int.']})
247+
self.assertEqual(serializer.errors['target'].detail, ['Incorrect type. Expected URL string, received int.'])
248+
self.assertEqual(serializer.errors['target'].code, 'incorrect_type')
248249

249250
def test_reverse_foreign_key_update(self):
250251
data = {'url': 'http://testserver/foreignkeytarget/2/', 'name': 'target-2', 'sources': ['http://testserver/foreignkeysource/1/', 'http://testserver/foreignkeysource/3/']}
@@ -315,7 +316,8 @@ def test_foreign_key_update_with_invalid_null(self):
315316
instance = ForeignKeySource.objects.get(pk=1)
316317
serializer = ForeignKeySourceSerializer(instance, data=data, context={'request': request})
317318
self.assertFalse(serializer.is_valid())
318-
self.assertEqual(serializer.errors, {'target': ['This field may not be null.']})
319+
self.assertEqual(serializer.errors['target'].detail, ['This field may not be null.'])
320+
self.assertEqual(serializer.errors['target'].code, 'null')
319321

320322

321323
class HyperlinkedNullableForeignKeyTests(TestCase):

tests/test_relations_pk.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,9 @@ def test_foreign_key_update_incorrect_type(self):
235235
instance = ForeignKeySource.objects.get(pk=1)
236236
serializer = ForeignKeySourceSerializer(instance, data=data)
237237
self.assertFalse(serializer.is_valid())
238-
self.assertEqual(serializer.errors, {'target': ['Incorrect type. Expected pk value, received %s.' % six.text_type.__name__]})
238+
self.assertEqual(serializer.errors['target'].detail,
239+
['Incorrect type. Expected pk value, received %s.' % six.text_type.__name__])
240+
self.assertEqual(serializer.errors['target'].code, 'incorrect_type')
239241

240242
def test_reverse_foreign_key_update(self):
241243
data = {'id': 2, 'name': 'target-2', 'sources': [1, 3]}
@@ -306,7 +308,8 @@ def test_foreign_key_update_with_invalid_null(self):
306308
instance = ForeignKeySource.objects.get(pk=1)
307309
serializer = ForeignKeySourceSerializer(instance, data=data)
308310
self.assertFalse(serializer.is_valid())
309-
self.assertEqual(serializer.errors, {'target': ['This field may not be null.']})
311+
self.assertEqual(serializer.errors['target'].detail, ['This field may not be null.'])
312+
self.assertEqual(serializer.errors['target'].code, 'null')
310313

311314
def test_foreign_key_with_unsaved(self):
312315
source = ForeignKeySource(name='source-unsaved')

tests/test_relations_slug.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,8 @@ def test_foreign_key_update_incorrect_type(self):
104104
instance = ForeignKeySource.objects.get(pk=1)
105105
serializer = ForeignKeySourceSerializer(instance, data=data)
106106
self.assertFalse(serializer.is_valid())
107-
self.assertEqual(serializer.errors, {'target': ['Object with name=123 does not exist.']})
107+
self.assertEqual(serializer.errors['target'].detail, ['Object with name=123 does not exist.'])
108+
self.assertEqual(serializer.errors['target'].code, 'does_not_exist')
108109

109110
def test_reverse_foreign_key_update(self):
110111
data = {'id': 2, 'name': 'target-2', 'sources': ['source-1', 'source-3']}
@@ -176,7 +177,8 @@ def test_foreign_key_update_with_invalid_null(self):
176177
instance = ForeignKeySource.objects.get(pk=1)
177178
serializer = ForeignKeySourceSerializer(instance, data=data)
178179
self.assertFalse(serializer.is_valid())
179-
self.assertEqual(serializer.errors, {'target': ['This field may not be null.']})
180+
self.assertEqual(serializer.errors['target'].detail, ['This field may not be null.'])
181+
self.assertEqual(serializer.errors['target'].code, 'null')
180182

181183

182184
class SlugNullableForeignKeyTests(TestCase):

0 commit comments

Comments
 (0)