Add error codes to ValidationError

This change addresses use cases that require more information about reported
validation errors. Currently for each error that REST Framework reports users
get only that error's message string. The message can be translated so there's
no good way to recognize programmatically what sort of an error it is.

When building an API that is supposed to return error codes, I've found it very
limiting. For example, I was supposed to differentiate between missing fields
and invalid arguments.

This commit introduces proper error codes handling to the ValidationError.

ValidationError can hold a single error itself (text), a list of those, or a
dictionary mapping errors to fields. Error code is only meaningful for a single
error, and I've added assertions to check for proper usage.

To help with my development, I've added a setting that makes error code a
mandatory argument. Thanks to this, I was able to correct all uses of
ValidationError across the code.

To maintain backward compatibility, I'm not passing error codes when building
compound errors (e.g. a dictionary with all validation errors). However, users
(me) can now monkey patch ValidationError.build_detail method, to store the
codes.
This commit is contained in:
Tomasz Rydzyński 2015-07-17 13:42:42 +02:00
parent af5474f9b3
commit 4e634ca068
11 changed files with 161 additions and 22 deletions

View File

@ -18,13 +18,22 @@ class AuthTokenSerializer(serializers.Serializer):
if user:
if not user.is_active:
msg = _('User account is disabled.')
raise serializers.ValidationError(msg)
raise serializers.ValidationError(
msg,
code='authorization'
)
else:
msg = _('Unable to log in with provided credentials.')
raise serializers.ValidationError(msg)
raise serializers.ValidationError(
msg,
code='authorization'
)
else:
msg = _('Must include "username" and "password".')
raise serializers.ValidationError(msg)
raise serializers.ValidationError(
msg,
code='authorization'
)
attrs['user'] = user
return attrs

View File

@ -14,6 +14,7 @@ from django.utils.translation import ugettext_lazy as _
from django.utils.translation import ungettext
from rest_framework import status
from rest_framework.settings import api_settings
from rest_framework.utils.serializer_helpers import ReturnDict, ReturnList
@ -58,6 +59,14 @@ class APIException(Exception):
return self.detail
def build_error_from_django_validation_error(exc_info):
code = getattr(exc_info, 'code', None) or 'invalid'
return [
ValidationError.build_detail(msg, code=code)
for msg in exc_info.messages
]
# The recommended style for using `ValidationError` is to keep it namespaced
# under `serializers`, in order to minimize potential confusion with Django's
# built in `ValidationError`. For example:
@ -68,12 +77,40 @@ class APIException(Exception):
class ValidationError(APIException):
status_code = status.HTTP_400_BAD_REQUEST
def __init__(self, detail):
@staticmethod
def build_detail(detail, code=None):
"""
Create error's representation.
This method is a helper that should be used when building compound
ValidationErrors directly (i.e. a whole list at once). Thanks to that
extra call, users have a customization point where they can tune how
much information about an error they want to see in the final output.
"""
if api_settings.REQUIRE_ERROR_CODES:
assert code is not None, (
'The `code` argument is required for single errors. '
'Strict checking of `code` is enabled with '
'REQUIRE_ERROR_CODES settings key.'
)
return detail
def __init__(self, detail, code=None):
# For validation errors the 'detail' key is always required.
# The details should always be coerced to a list if not already.
if not isinstance(detail, dict) and not isinstance(detail, list):
detail = [detail]
detail = [self.build_detail(detail, code)]
else:
if api_settings.REQUIRE_ERROR_CODES:
assert code is None, (
'The `code` argument must not be set for compound '
'errors. Strict checking of `code` is enabled with '
'REQUIRE_ERROR_CODES settings key.'
)
self.detail = _force_text_recursive(detail)
self.code = code
def __str__(self):
return six.text_type(self.detail)

View File

@ -31,7 +31,9 @@ from rest_framework.compat import (
MinValueValidator, duration_string, parse_duration, unicode_repr,
unicode_to_repr
)
from rest_framework.exceptions import ValidationError
from rest_framework.exceptions import (
ValidationError, build_error_from_django_validation_error
)
from rest_framework.settings import api_settings
from rest_framework.utils import html, humanize_datetime, representation
@ -503,7 +505,9 @@ class Field(object):
raise
errors.extend(exc.detail)
except DjangoValidationError as exc:
errors.extend(exc.messages)
errors.extend(
build_error_from_django_validation_error(exc)
)
if errors:
raise ValidationError(errors)
@ -541,7 +545,7 @@ class Field(object):
msg = MISSING_ERROR_MESSAGE.format(class_name=class_name, key=key)
raise AssertionError(msg)
message_string = msg.format(**kwargs)
raise ValidationError(message_string)
raise ValidationError(message_string, code=key)
@cached_property
def root(self):

View File

@ -20,6 +20,7 @@ from django.db.models.fields import FieldDoesNotExist
from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _
from rest_framework import exceptions
from rest_framework.compat import DurationField as ModelDurationField
from rest_framework.compat import JSONField as ModelJSONField
from rest_framework.compat import postgres_fields, unicode_to_repr
@ -300,7 +301,8 @@ def get_validation_error_detail(exc):
# exception class as well for simpler compat.
# Eg. Calling Model.clean() explicitly inside Serializer.validate()
return {
api_settings.NON_FIELD_ERRORS_KEY: list(exc.messages)
api_settings.NON_FIELD_ERRORS_KEY:
exceptions.build_error_from_django_validation_error(exc)
}
elif isinstance(exc.detail, dict):
# If errors may be a dict we use the standard {key: list of values}.
@ -422,8 +424,9 @@ class Serializer(BaseSerializer):
message = self.error_messages['invalid'].format(
datatype=type(data).__name__
)
error = ValidationError.build_detail(message, code='invalid')
raise ValidationError({
api_settings.NON_FIELD_ERRORS_KEY: [message]
api_settings.NON_FIELD_ERRORS_KEY: [error]
})
ret = OrderedDict()
@ -440,7 +443,9 @@ class Serializer(BaseSerializer):
except ValidationError as exc:
errors[field.field_name] = exc.detail
except DjangoValidationError as exc:
errors[field.field_name] = list(exc.messages)
errors[field.field_name] = (
exceptions.build_error_from_django_validation_error(exc)
)
except SkipField:
pass
else:
@ -560,7 +565,9 @@ class ListSerializer(BaseSerializer):
value = self.validate(value)
assert value is not None, '.validate() should return the validated data'
except (ValidationError, DjangoValidationError) as exc:
raise ValidationError(detail=get_validation_error_detail(exc))
raise ValidationError(
detail=get_validation_error_detail(exc)
)
return value
@ -575,8 +582,12 @@ class ListSerializer(BaseSerializer):
message = self.error_messages['not_a_list'].format(
input_type=type(data).__name__
)
error = ValidationError.build_detail(
message,
code='not_a_list'
)
raise ValidationError({
api_settings.NON_FIELD_ERRORS_KEY: [message]
api_settings.NON_FIELD_ERRORS_KEY: [error]
})
if not self.allow_empty and len(data) == 0:

View File

@ -83,6 +83,7 @@ DEFAULTS = {
# Exception handling
'EXCEPTION_HANDLER': 'rest_framework.views.exception_handler',
'NON_FIELD_ERRORS_KEY': 'non_field_errors',
'REQUIRE_ERROR_CODES': False,
# Testing
'TEST_REQUEST_RENDERER_CLASSES': (

View File

@ -60,7 +60,7 @@ class UniqueValidator(object):
queryset = self.filter_queryset(value, queryset)
queryset = self.exclude_current_instance(queryset)
if queryset.exists():
raise ValidationError(self.message)
raise ValidationError(self.message, code='unique')
def __repr__(self):
return unicode_to_repr('<%s(queryset=%s)>' % (
@ -101,7 +101,9 @@ class UniqueTogetherValidator(object):
return
missing = {
field_name: self.missing_message
field_name: ValidationError.build_detail(
self.missing_message,
code='required')
for field_name in self.fields
if field_name not in attrs
}
@ -147,7 +149,8 @@ class UniqueTogetherValidator(object):
]
if None not in checked_values and queryset.exists():
field_names = ', '.join(self.fields)
raise ValidationError(self.message.format(field_names=field_names))
raise ValidationError(self.message.format(field_names=field_names),
code='unique')
def __repr__(self):
return unicode_to_repr('<%s(queryset=%s, fields=%s)>' % (
@ -185,7 +188,9 @@ class BaseUniqueForValidator(object):
'required' state on the fields they are applied to.
"""
missing = {
field_name: self.missing_message
field_name: ValidationError.build_detail(
self.missing_message,
code='required')
for field_name in [self.field, self.date_field]
if field_name not in attrs
}
@ -211,7 +216,8 @@ class BaseUniqueForValidator(object):
queryset = self.exclude_current_instance(attrs, queryset)
if queryset.exists():
message = self.message.format(date_field=self.date_field)
raise ValidationError({self.field: message})
error = ValidationError.build_detail(message, code='unique')
raise ValidationError({self.field: error})
def __repr__(self):
return unicode_to_repr('<%s(queryset=%s, field=%s, date_field=%s)>' % (

View File

@ -1392,7 +1392,10 @@ class TestFieldFieldWithName(FieldValues):
# call into it's regular validation, or require PIL for testing.
class FailImageValidation(object):
def to_python(self, value):
raise serializers.ValidationError(self.error_messages['invalid_image'])
raise serializers.ValidationError(
self.error_messages['invalid_image'],
code='invalid_image'
)
class PassImageValidation(object):

View File

@ -7,6 +7,7 @@ import pytest
from rest_framework import serializers
from rest_framework.compat import unicode_repr
from rest_framework.fields import DjangoValidationError
from .utils import MockObject
@ -69,7 +70,10 @@ class TestValidateMethod:
integer = serializers.IntegerField()
def validate(self, attrs):
raise serializers.ValidationError('Non field error')
raise serializers.ValidationError(
'Non field error',
code='test'
)
serializer = ExampleSerializer(data={'char': 'abc', 'integer': 123})
assert not serializer.is_valid()
@ -309,3 +313,25 @@ class TestCacheSerializerData:
pickled = pickle.dumps(serializer.data)
data = pickle.loads(pickled)
assert data == {'field1': 'a', 'field2': 'b'}
class TestGetValidationErrorDetail:
def test_get_validation_error_detail_converts_django_errors(self):
exc = DjangoValidationError("Missing field.", code='required')
detail = serializers.get_validation_error_detail(exc)
assert detail == {'non_field_errors': ['Missing field.']}
class TestCapturingDjangoValidationError:
def test_django_validation_error_on_a_field_is_converted(self):
class ExampleSerializer(serializers.Serializer):
field = serializers.CharField()
def validate_field(self, value):
raise DjangoValidationError(
'validation failed'
)
serializer = ExampleSerializer(data={'field': 'a'})
assert not serializer.is_valid()
assert serializer.errors == {'field': ['validation failed']}

View File

@ -280,7 +280,10 @@ class TestListSerializerClass:
def test_list_serializer_class_validate(self):
class CustomListSerializer(serializers.ListSerializer):
def validate(self, attrs):
raise serializers.ValidationError('Non field error')
raise serializers.ValidationError(
'Non field error',
code='test'
)
class TestSerializer(serializers.Serializer):
class Meta:

View File

@ -41,7 +41,8 @@ class ShouldValidateModelSerializer(serializers.ModelSerializer):
def validate_renamed(self, value):
if len(value) < 3:
raise serializers.ValidationError('Minimum 3 characters.')
raise serializers.ValidationError('Minimum 3 characters.',
code='min_length')
return value
class Meta:

View File

@ -0,0 +1,38 @@
import pytest
from django.test import TestCase
from rest_framework import serializers
from rest_framework.settings import api_settings
class TestMandatoryErrorCodeArgument(TestCase):
"""
If mandatory error code is enabled in settings, it should prevent throwing
ValidationError without the code set.
"""
def setUp(self):
self.DEFAULT_REQUIRE_ERROR_CODES = api_settings.REQUIRE_ERROR_CODES
api_settings.REQUIRE_ERROR_CODES = True
def tearDown(self):
api_settings.REQUIRE_ERROR_CODES = self.DEFAULT_REQUIRE_ERROR_CODES
def test_validation_error_requires_code_for_simple_messages(self):
with pytest.raises(AssertionError):
serializers.ValidationError("")
def test_validation_error_requires_no_code_for_structured_errors(self):
"""
ValidationError can hold a list or dictionary of simple errors, in
which case the code is no longer meaningful and should not be
specified.
"""
with pytest.raises(AssertionError):
serializers.ValidationError([], code='min_value')
with pytest.raises(AssertionError):
serializers.ValidationError({}, code='min_value')
def test_validation_error_stores_error_code(self):
exception = serializers.ValidationError("", code='min_value')
assert exception.code == 'min_value'