diff --git a/docs/api-guide/fields.md b/docs/api-guide/fields.md index e05c03061..be41a2c79 100644 --- a/docs/api-guide/fields.md +++ b/docs/api-guide/fields.md @@ -297,6 +297,35 @@ Django's regular [FILE_UPLOAD_HANDLERS] are used for handling uploaded files. --- +# Compound Fields + +These fields represent compound datatypes, which build on other fields with some additional aspect such collecting multiple elements. + +## ListField + +A list representation, whose elements are described by a given item field. This means that all elements must meet the definition of +that field. The item field can be another field type (e.g., CharField) or a serializer. If `item_field` is not given, then the +list-items are passed through as-is, and can be anything. Note that in this case, any non-native list elements wouldn't be properly +prepared for data rendering. + +**Signature:** `ListField(item_field=None)` + +## DictField + +A dictionary representation, whose values are described by a given value field. This means that all values must meet the definition of +that field. The value field can be another field type (e.g., CharField) or a serializer. If `value_field` is not given, then the `dict` +values are passed through-as-is, and can be anything. Note that in this case, any non-native `dict` values wouldn't be properly +prepared for data rendering. + +Dictionary keys are presumed to be character strings or convertible to such, and so during processing are casted to `unicode`. If +necessary, options for unicode conversion (such as the encoding, or error processing) can be provided to a `DictField`. For more info, +see [py_unicode]. + +**Signature:** `DictField(value_field=None, unicode_options=None)` + +If given, unicode_options must be a dict providing options per the [unicode](http://docs.python.org/2/library/functions.html#unicode) +function. + # Custom fields If you want to create a custom field, you'll probably want to override either one or both of the `.to_native()` and `.from_native()` methods. These two methods are used to convert between the initial datatype, and a primitive, serializable datatype. Primitive datatypes may be any of a number, string, date/time/datetime or None. They may also be any list or dictionary like object that only contains other primitive objects. @@ -345,3 +374,4 @@ As an example, let's create a field that can be used represent the class name of [ecma262]: http://ecma-international.org/ecma-262/5.1/#sec-15.9.1.15 [strftime]: http://docs.python.org/2/library/datetime.html#strftime-and-strptime-behavior [iso8601]: http://www.w3.org/TR/NOTE-datetime +[py_unicode]: http://docs.python.org/2/howto/unicode.html diff --git a/rest_framework/compound_fields.py b/rest_framework/compound_fields.py new file mode 100644 index 000000000..9084464f4 --- /dev/null +++ b/rest_framework/compound_fields.py @@ -0,0 +1,108 @@ +""" +Compound fields for processing values that are lists and dicts of values described by embedded +fields. +""" +from django.core.exceptions import ValidationError +from django.utils.translation import ugettext_lazy as _ + +from .fields import WritableField +from rest_framework.compat import six + + +class ListField(WritableField): + """ + A field whose values are lists of items described by the given item field. The item field can + be another field type (e.g., CharField) or a serializer. + """ + + default_error_messages = { + 'invalid_type': _('%(value)s is not a list.'), + } + + def __init__(self, item_field=None, *args, **kwargs): + super(ListField, self).__init__(*args, **kwargs) + self.item_field = item_field + + def to_native(self, obj): + if self.item_field and obj: + return [ + self.item_field.to_native(item) + for item in obj + ] + return obj + + def from_native(self, data): + if self.item_field and data: + return [ + self.item_field.from_native(item_data) + for item_data in data + ] + return data + + def validate(self, value): + super(ListField, self).validate(value) + + if not isinstance(value, list): + raise ValidationError(self.error_messages['invalid_type'] % {'value': value}) + + if self.item_field: + errors = {} + for index, item in enumerate(value): + try: + self.item_field.validate(item) + self.item_field.run_validators(item) + except ValidationError as e: + errors[index] = [e] + + if errors: + raise ValidationError(errors) + + +class DictField(WritableField): + """ + A field whose values are dicts of values described by the given value field. The value field + can be another field type (e.g., CharField) or a serializer. + """ + + default_error_messages = { + 'invalid_type': _('%(value)s is not a dict.'), + } + + def __init__(self, value_field=None, unicode_options=None, *args, **kwargs): + super(DictField, self).__init__(*args, **kwargs) + self.value_field = value_field + self.unicode_options = unicode_options or {} + + def to_native(self, obj): + if self.value_field and obj: + return dict( + (six.text_type(key, **self.unicode_options), self.value_field.to_native(value)) + for key, value in obj.items() + ) + return obj + + def from_native(self, data): + if self.value_field and data: + return dict( + (six.text_type(key, **self.unicode_options), self.value_field.from_native(value)) + for key, value in data.items() + ) + return data + + def validate(self, value): + super(DictField, self).validate(value) + + if not isinstance(value, dict): + raise ValidationError(self.error_messages['invalid_type'] % {'value': value}) + + if self.value_field: + errors = {} + for k, v in six.iteritems(value): + try: + self.value_field.validate(v) + self.value_field.run_validators(v) + except ValidationError as e: + errors[k] = [e] + + if errors: + raise ValidationError(errors) diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index c0c810ab9..b22ca5783 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -331,7 +331,7 @@ class BaseSerializer(WritableField): return ret - def from_native(self, data, files): + def from_native(self, data, files=None): """ Deserialize primitives -> objects. """ diff --git a/rest_framework/tests/test_compound_fields.py b/rest_framework/tests/test_compound_fields.py new file mode 100644 index 000000000..c069c12a1 --- /dev/null +++ b/rest_framework/tests/test_compound_fields.py @@ -0,0 +1,195 @@ +""" +General serializer field tests. +""" + + +from datetime import date +import unittest + +from django.core.exceptions import ValidationError + +from rest_framework import ISO_8601 +from rest_framework.compound_fields import DictField +from rest_framework.compound_fields import ListField +from rest_framework.fields import CharField +from rest_framework.fields import DateField + + +class ListFieldTests(unittest.TestCase): + """ + Tests for the ListField behavior + """ + + def test_from_native_no_item_field(self): + """ + When a ListField has no item-field, from_native should return the data it was given + un-processed. + """ + field = ListField() + data = range(5) + obj = field.from_native(data) + self.assertEqual(data, obj) + + def test_to_native_no_item_field(self): + """ + When a ListField has no item-field, to_native should return the data it was given + un-processed. + """ + field = ListField() + obj = range(5) + data = field.to_native(obj) + self.assertEqual(obj, data) + + def test_from_native_with_item_field(self): + """ + When a ListField has an item-field, from_native should return a list of elements resulting + from the application of the item-field's from_native method to each element of the input + data list. + """ + field = ListField(DateField()) + data = ["2000-01-01", "2000-01-02"] + obj = field.from_native(data) + self.assertEqual([date(2000, 1, 1), date(2000, 1, 2)], obj) + + def test_to_native_with_item_field(self): + """ + When a ListField has an item-field, to_native should return a list of elements resulting + from the application of the item-field's to_native method to each element of the input + object list. + """ + field = ListField(DateField(format=ISO_8601)) + obj = [date(2000, 1, 1), date(2000, 1, 2)] + data = field.to_native(obj) + self.assertEqual(["2000-01-01", "2000-01-02"], data) + + def test_missing_required_list(self): + """ + When a ListField requires a value, then validate will raise a ValidationError on a missing + (None) value. + """ + field = ListField() + self.assertRaises(ValidationError, field.validate, None) + + def test_validate_non_list(self): + """ + When a ListField is given a non-list value, then validate will raise a ValidationError. + """ + field = ListField() + self.assertRaises(ValidationError, field.validate, 'notAList') + + def test_validate_empty_list(self): + """ + When a ListField requires a value, then validate will raise a ValidationError on an empty + value. + """ + field = ListField() + self.assertRaises(ValidationError, field.validate, []) + + def test_validate_elements_valid(self): + """ + When a ListField is given a list whose elements are valid for the item-field, then validate + will not raise a ValidationError. + """ + field = ListField(CharField(max_length=5)) + try: + field.validate(["a", "b", "c"]) + except ValidationError: + self.fail("ValidationError was raised") + + def test_validate_elements_invalid(self): + """ + When a ListField is given a list containing elements that are invalid for the item-field, + then validate will raise a ValidationError. + """ + field = ListField(CharField(max_length=5)) + self.assertRaises(ValidationError, field.validate, ["012345", "012345"]) + + +class DictFieldTests(unittest.TestCase): + """ + Tests for the DictField behavior + """ + + def test_from_native_no_value_field(self): + """ + When a DictField has no value-field, from_native should return the data it was given + un-processed. + """ + field = DictField() + data = {"a": 1, "b": 2} + obj = field.from_native(data) + self.assertEqual(data, obj) + + def test_to_native_no_value_field(self): + """ + When a DictField has no value-field, to_native should return the data it was given + un-processed. + """ + field = DictField() + obj = {"a": 1, "b": 2} + data = field.to_native(obj) + self.assertEqual(obj, data) + + def test_from_native_with_value_field(self): + """ + When a DictField has an value-field, from_native should return a dict of elements resulting + from the application of the value-field's from_native method to each value of the input + data dict. + """ + field = DictField(DateField()) + data = {"a": "2000-01-01", "b": "2000-01-02"} + obj = field.from_native(data) + self.assertEqual({"a": date(2000, 1, 1), "b": date(2000, 1, 2)}, obj) + + def test_to_native_with_value_field(self): + """ + When a DictField has an value-field, to_native should return a dict of elements resulting + from the application of the value-field's to_native method to each value of the input + object dict. + """ + field = DictField(DateField(format=ISO_8601)) + obj = {"a": date(2000, 1, 1), "b": date(2000, 1, 2)} + data = field.to_native(obj) + self.assertEqual({"a": "2000-01-01", "b": "2000-01-02"}, data) + + def test_missing_required_dict(self): + """ + When a DictField requires a value, then validate will raise a ValidationError on a missing + (None) value. + """ + field = DictField() + self.assertRaises(ValidationError, field.validate, None) + + def test_validate_non_dict(self): + """ + When a DictField is given a non-dict value, then validate will raise a ValidationError. + """ + field = DictField() + self.assertRaises(ValidationError, field.validate, 'notADict') + + def test_validate_empty_dict(self): + """ + When a DictField requires a value, then validate will raise a ValidationError on an empty + value. + """ + field = DictField() + self.assertRaises(ValidationError, field.validate, {}) + + def test_validate_elements_valid(self): + """ + When a DictField is given a dict whose values are valid for the value-field, then validate + will not raise a ValidationError. + """ + field = DictField(CharField(max_length=5)) + try: + field.validate({"a": "a", "b": "b", "c": "c"}) + except ValidationError: + self.fail("ValidationError was raised") + + def test_validate_elements_invalid(self): + """ + When a DictField is given a dict containing values that are invalid for the value-field, + then validate will raise a ValidationError. + """ + field = DictField(CharField(max_length=5)) + self.assertRaises(ValidationError, field.validate, {"a": "012345", "b": "012345"})