From 51fae73f3d565e2702c72ff9841cc072d6490804 Mon Sep 17 00:00:00 2001 From: Jamie Matthews Date: Wed, 24 Oct 2012 09:28:10 +0100 Subject: [PATCH 1/5] Implement per-field validation on Serializers --- docs/api-guide/serializers.md | 17 +++++++++++++++++ rest_framework/serializers.py | 18 ++++++++++++++++++ rest_framework/tests/serializer.py | 25 +++++++++++++++++++++++++ 3 files changed, 60 insertions(+) diff --git a/docs/api-guide/serializers.md b/docs/api-guide/serializers.md index c10a3f448..e1e12e74b 100644 --- a/docs/api-guide/serializers.md +++ b/docs/api-guide/serializers.md @@ -78,6 +78,23 @@ When deserializing data, you always need to call `is_valid()` before attempting **TODO: Describe validation in more depth** +## Custom field validation + +Like Django forms, you can specify custom field-level validation by adding `clean_()` methods to your `Serializer` subclass. This method takes a dictionary of deserialized data as a first argument, and the field name in that data as a second argument (which will be either the name of the field or the value of the `source` argument, if one was provided.) It should either return the data dictionary or raise a `ValidationError`. For example: + + class BlogPostSerializer(Serializer): + title = serializers.CharField(max_length=100) + content = serializers.CharField() + + def clean_title(self, data, source): + """ + Check that the blog post is about Django + """ + value = data[source] + if "Django" not in value: + raise ValidationError("Blog post is not about Django") + return data + ## Dealing with nested objects The previous example is fine for dealing with objects that only have simple datatypes, but sometimes we also need to be able to represent more complex objects, diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index 221cbf2fc..c9c4faa35 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -208,6 +208,23 @@ class BaseSerializer(Field): return reverted_data + def clean_fields(self, data): + """ + Run clean_ validators on the serializer + """ + fields = self.get_fields(serialize=False, data=data, nested=self.opts.nested) + + for field_name, field in fields.items(): + try: + clean_method = getattr(self, 'clean_%s' % field_name, None) + if clean_method: + source = field.source or field_name + data = clean_method(data, source) + except ValidationError as err: + self._errors[field_name] = self._errors.get(field_name, []) + list(err.messages) + + return data + def restore_object(self, attrs, instance=None): """ Deserialize a dictionary of attributes into an object instance. @@ -241,6 +258,7 @@ class BaseSerializer(Field): self._errors = {} if data is not None: attrs = self.restore_fields(data) + attrs = self.clean_fields(attrs) else: self._errors['non_field_errors'] = 'No input provided' diff --git a/rest_framework/tests/serializer.py b/rest_framework/tests/serializer.py index c614b66a0..35908449d 100644 --- a/rest_framework/tests/serializer.py +++ b/rest_framework/tests/serializer.py @@ -138,6 +138,31 @@ class ValidationTests(TestCase): self.assertEquals(serializer.is_valid(), True) self.assertEquals(serializer.errors, {}) + def test_field_validation(self): + + class CommentSerializerWithFieldValidator(CommentSerializer): + + def clean_content(self, attrs, source): + value = attrs[source] + if "test" not in value: + raise serializers.ValidationError("Test not in value") + return attrs + + data = { + 'email': 'tom@example.com', + 'content': 'A test comment', + 'created': datetime.datetime(2012, 1, 1) + } + + serializer = CommentSerializerWithFieldValidator(data) + self.assertTrue(serializer.is_valid()) + + data['content'] = 'This should not validate' + + serializer = CommentSerializerWithFieldValidator(data) + self.assertFalse(serializer.is_valid()) + self.assertEquals(serializer.errors, {'content': [u'Test not in value']}) + class MetadataTests(TestCase): def test_empty(self): From 388a807f64f60d84556288e2ade4f0fe57a8e66b Mon Sep 17 00:00:00 2001 From: Jamie Matthews Date: Wed, 24 Oct 2012 11:27:01 +0100 Subject: [PATCH 2/5] Switch from clean_ to validate_, clarify documentation --- docs/api-guide/serializers.md | 10 ++++++---- rest_framework/serializers.py | 4 ++-- rest_framework/tests/serializer.py | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/api-guide/serializers.md b/docs/api-guide/serializers.md index e1e12e74b..9011d31fa 100644 --- a/docs/api-guide/serializers.md +++ b/docs/api-guide/serializers.md @@ -80,19 +80,21 @@ When deserializing data, you always need to call `is_valid()` before attempting ## Custom field validation -Like Django forms, you can specify custom field-level validation by adding `clean_()` methods to your `Serializer` subclass. This method takes a dictionary of deserialized data as a first argument, and the field name in that data as a second argument (which will be either the name of the field or the value of the `source` argument, if one was provided.) It should either return the data dictionary or raise a `ValidationError`. For example: +You can specify custom field-level validation by adding `validate_()` methods to your `Serializer` subclass. These are analagous to `clean_` methods on Django forms, but accept slightly different arguments. They take a dictionary of deserialized data as a first argument, and the field name in that data as a second argument (which will be either the name of the field or the value of the `source` argument to the field, if one was provided). Your `validate_` methods should either just return the data dictionary or raise a `ValidationError`. For example: - class BlogPostSerializer(Serializer): + from rest_framework import serializers + + class BlogPostSerializer(serializers.Serializer): title = serializers.CharField(max_length=100) content = serializers.CharField() - def clean_title(self, data, source): + def validate_title(self, data, source): """ Check that the blog post is about Django """ value = data[source] if "Django" not in value: - raise ValidationError("Blog post is not about Django") + raise serializers.ValidationError("Blog post is not about Django") return data ## Dealing with nested objects diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index c9c4faa35..802ca55f0 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -210,13 +210,13 @@ class BaseSerializer(Field): def clean_fields(self, data): """ - Run clean_ validators on the serializer + Run validate_ methods on the serializer """ fields = self.get_fields(serialize=False, data=data, nested=self.opts.nested) for field_name, field in fields.items(): try: - clean_method = getattr(self, 'clean_%s' % field_name, None) + clean_method = getattr(self, 'validate_%s' % field_name, None) if clean_method: source = field.source or field_name data = clean_method(data, source) diff --git a/rest_framework/tests/serializer.py b/rest_framework/tests/serializer.py index 35908449d..a32de80db 100644 --- a/rest_framework/tests/serializer.py +++ b/rest_framework/tests/serializer.py @@ -142,7 +142,7 @@ class ValidationTests(TestCase): class CommentSerializerWithFieldValidator(CommentSerializer): - def clean_content(self, attrs, source): + def validate_content(self, attrs, source): value = attrs[source] if "test" not in value: raise serializers.ValidationError("Test not in value") From ac2d39892d6b3fbbe5cd53b9ef83367249ba4880 Mon Sep 17 00:00:00 2001 From: Jamie Matthews Date: Wed, 24 Oct 2012 11:39:17 +0100 Subject: [PATCH 3/5] Add cross-field validate method --- docs/api-guide/serializers.md | 8 +++++--- rest_framework/serializers.py | 13 +++++++++++++ rest_framework/tests/serializer.py | 24 ++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/docs/api-guide/serializers.md b/docs/api-guide/serializers.md index 9011d31fa..40f8a1705 100644 --- a/docs/api-guide/serializers.md +++ b/docs/api-guide/serializers.md @@ -76,9 +76,7 @@ Deserialization is similar. First we parse a stream into python native datatype When deserializing data, you always need to call `is_valid()` before attempting to access the deserialized object. If any validation errors occur, the `.errors` and `.non_field_errors` properties will contain the resulting error messages. -**TODO: Describe validation in more depth** - -## Custom field validation +### Field-level validation You can specify custom field-level validation by adding `validate_()` methods to your `Serializer` subclass. These are analagous to `clean_` methods on Django forms, but accept slightly different arguments. They take a dictionary of deserialized data as a first argument, and the field name in that data as a second argument (which will be either the name of the field or the value of the `source` argument to the field, if one was provided). Your `validate_` methods should either just return the data dictionary or raise a `ValidationError`. For example: @@ -97,6 +95,10 @@ You can specify custom field-level validation by adding `validate_()` raise serializers.ValidationError("Blog post is not about Django") return data +### Final cross-field validation + +To do any other validation that requires access to multiple fields, add a method called `validate` to your `Serializer` subclass. This method takes a single argument, which is the `attrs` dictionary. It should raise a `ValidationError` if necessary, or just return `attrs`. + ## Dealing with nested objects The previous example is fine for dealing with objects that only have simple datatypes, but sometimes we also need to be able to represent more complex objects, diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index 802ca55f0..15fe26eeb 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -225,6 +225,18 @@ class BaseSerializer(Field): return data + def clean_all(self, attrs): + """ + Run the `validate` method on the serializer, if it exists + """ + try: + validate_method = getattr(self, 'validate', None) + if validate_method: + attrs = validate_method(attrs) + except ValidationError as err: + self._errors['non_field_errors'] = err.messages + return attrs + def restore_object(self, attrs, instance=None): """ Deserialize a dictionary of attributes into an object instance. @@ -259,6 +271,7 @@ class BaseSerializer(Field): if data is not None: attrs = self.restore_fields(data) attrs = self.clean_fields(attrs) + attrs = self.clean_all(attrs) else: self._errors['non_field_errors'] = 'No input provided' diff --git a/rest_framework/tests/serializer.py b/rest_framework/tests/serializer.py index a32de80db..936f15aa6 100644 --- a/rest_framework/tests/serializer.py +++ b/rest_framework/tests/serializer.py @@ -163,6 +163,30 @@ class ValidationTests(TestCase): self.assertFalse(serializer.is_valid()) self.assertEquals(serializer.errors, {'content': [u'Test not in value']}) + def test_cross_field_validation(self): + + class CommentSerializerWithCrossFieldValidator(CommentSerializer): + + def validate(self, attrs): + if attrs["email"] not in attrs["content"]: + raise serializers.ValidationError("Email address not in content") + return attrs + + data = { + 'email': 'tom@example.com', + 'content': 'A comment from tom@example.com', + 'created': datetime.datetime(2012, 1, 1) + } + + serializer = CommentSerializerWithCrossFieldValidator(data) + self.assertTrue(serializer.is_valid()) + + data['content'] = 'A comment from foo@bar.com' + + serializer = CommentSerializerWithCrossFieldValidator(data) + self.assertFalse(serializer.is_valid()) + self.assertEquals(serializer.errors, {'non_field_errors': [u'Email address not in content']}) + class MetadataTests(TestCase): def test_empty(self): From d60d598e0255fb3d55a1213d1025447d83523658 Mon Sep 17 00:00:00 2001 From: Jamie Matthews Date: Wed, 24 Oct 2012 11:43:30 +0100 Subject: [PATCH 4/5] Clean up internal names and documentation --- docs/api-guide/serializers.md | 8 ++++---- rest_framework/serializers.py | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/api-guide/serializers.md b/docs/api-guide/serializers.md index 40f8a1705..50505d30d 100644 --- a/docs/api-guide/serializers.md +++ b/docs/api-guide/serializers.md @@ -78,7 +78,7 @@ When deserializing data, you always need to call `is_valid()` before attempting ### Field-level validation -You can specify custom field-level validation by adding `validate_()` methods to your `Serializer` subclass. These are analagous to `clean_` methods on Django forms, but accept slightly different arguments. They take a dictionary of deserialized data as a first argument, and the field name in that data as a second argument (which will be either the name of the field or the value of the `source` argument to the field, if one was provided). Your `validate_` methods should either just return the data dictionary or raise a `ValidationError`. For example: +You can specify custom field-level validation by adding `validate_()` methods to your `Serializer` subclass. These are analagous to `clean_` methods on Django forms, but accept slightly different arguments. They take a dictionary of deserialized attributes as a first argument, and the field name in that dictionary as a second argument (which will be either the name of the field or the value of the `source` argument to the field, if one was provided). Your `validate_` methods should either just return the attrs dictionary or raise a `ValidationError`. For example: from rest_framework import serializers @@ -86,14 +86,14 @@ You can specify custom field-level validation by adding `validate_()` title = serializers.CharField(max_length=100) content = serializers.CharField() - def validate_title(self, data, source): + def validate_title(self, attrs, source): """ Check that the blog post is about Django """ - value = data[source] + value = attrs[source] if "Django" not in value: raise serializers.ValidationError("Blog post is not about Django") - return data + return attrs ### Final cross-field validation diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index 15fe26eeb..2f8108d13 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -208,24 +208,24 @@ class BaseSerializer(Field): return reverted_data - def clean_fields(self, data): + def validate_fields(self, attrs): """ Run validate_ methods on the serializer """ - fields = self.get_fields(serialize=False, data=data, nested=self.opts.nested) + fields = self.get_fields(serialize=False, data=attrs, nested=self.opts.nested) for field_name, field in fields.items(): try: clean_method = getattr(self, 'validate_%s' % field_name, None) if clean_method: source = field.source or field_name - data = clean_method(data, source) + attrs = clean_method(attrs, source) except ValidationError as err: self._errors[field_name] = self._errors.get(field_name, []) + list(err.messages) - return data + return attrs - def clean_all(self, attrs): + def validate_all(self, attrs): """ Run the `validate` method on the serializer, if it exists """ @@ -270,10 +270,10 @@ class BaseSerializer(Field): self._errors = {} if data is not None: attrs = self.restore_fields(data) - attrs = self.clean_fields(attrs) - attrs = self.clean_all(attrs) + attrs = self.validate_fields(attrs) + attrs = self.validate_all(attrs) else: - self._errors['non_field_errors'] = 'No input provided' + self._errors['non_field_errors'] = ['No input provided'] if not self._errors: return self.restore_object(attrs, instance=getattr(self, 'object', None)) From 607c31c6d880501e5dc524fc5a5e1fc136b162fc Mon Sep 17 00:00:00 2001 From: Jamie Matthews Date: Wed, 24 Oct 2012 12:12:27 +0100 Subject: [PATCH 5/5] Move per-field and cross-field validation into a single method --- rest_framework/serializers.py | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index 2f8108d13..c9f025bcb 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -208,33 +208,32 @@ class BaseSerializer(Field): return reverted_data - def validate_fields(self, attrs): + def perform_validation(self, attrs): """ - Run validate_ methods on the serializer + Run `validate_()` and `validate()` methods on the serializer """ fields = self.get_fields(serialize=False, data=attrs, nested=self.opts.nested) for field_name, field in fields.items(): try: - clean_method = getattr(self, 'validate_%s' % field_name, None) - if clean_method: + validate_method = getattr(self, 'validate_%s' % field_name, None) + if validate_method: source = field.source or field_name - attrs = clean_method(attrs, source) + attrs = validate_method(attrs, source) except ValidationError as err: self._errors[field_name] = self._errors.get(field_name, []) + list(err.messages) - return attrs - - def validate_all(self, attrs): - """ - Run the `validate` method on the serializer, if it exists - """ try: - validate_method = getattr(self, 'validate', None) - if validate_method: - attrs = validate_method(attrs) + attrs = self.validate(attrs) except ValidationError as err: self._errors['non_field_errors'] = err.messages + + return attrs + + def validate(self, attrs): + """ + Stub method, to be overridden in Serializer subclasses + """ return attrs def restore_object(self, attrs, instance=None): @@ -270,8 +269,7 @@ class BaseSerializer(Field): self._errors = {} if data is not None: attrs = self.restore_fields(data) - attrs = self.validate_fields(attrs) - attrs = self.validate_all(attrs) + attrs = self.perform_validation(attrs) else: self._errors['non_field_errors'] = ['No input provided']