From 79abb8147d97f7d39cbb022789597bfd6499dbf9 Mon Sep 17 00:00:00 2001 From: Simon Charette Date: Wed, 30 Mar 2016 15:44:35 -0400 Subject: [PATCH 1/2] Added regression tests for field metadata. --- tests/test_metadata.py | 314 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 310 insertions(+), 4 deletions(-) diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 6819f1504..97e61cbf2 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -1,8 +1,13 @@ from __future__ import unicode_literals +import functools +import os + +from django import forms from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.test import TestCase +from django.utils.translation import ugettext, ugettext_lazy as _ from rest_framework import ( exceptions, metadata, serializers, status, versioning, views @@ -261,10 +266,311 @@ class TestMetadata: view = ExampleView.as_view(versioning_class=scheme) view(request=request) - def test_null_boolean_field_info_type(self): - options = metadata.SimpleMetadata() - field_info = options.get_field_info(serializers.NullBooleanField()) - assert field_info['type'] == 'boolean' + +class TestFieldMetadata(TestCase): + simple_metadata = metadata.SimpleMetadata() + + def get_field_metadata(self, field): + return self.simple_metadata.get_field_info(field) + + def get_serializer_metadata(self, serializer): + return self.simple_metadata.get_serializer_info(serializer) + + def assertMetadataEqual(self, field, expected_metadata): + if isinstance(field, serializers.BaseSerializer): + metadata = self.get_serializer_metadata(field) + else: + metadata = self.get_field_metadata(field) + self.assertEqual(metadata, expected_metadata) + + def test_field(self, field_factory=serializers.Field, expected_type='field', **expected_metadata): + field = field_factory() + self.assertMetadataEqual(field, dict({ + 'type': expected_type, + 'required': True, + 'read_only': False, + }, **expected_metadata)) + field = field_factory( + required=False, + label=_('label'), + help_text=_('help text'), + ) + self.assertMetadataEqual(field, dict({ + 'type': expected_type, + 'required': False, + 'read_only': False, + 'label': ugettext('label'), + 'help_text': ugettext('help text'), + }, **expected_metadata)) + # Empty string label and help_text should be ignored. + field = field_factory( + label='', + help_text='', + ) + self.assertMetadataEqual(field, dict({ + 'type': expected_type, + 'required': True, + 'read_only': False, + }, **expected_metadata)) + + def test_read_only_field(self, field_factory=serializers.ReadOnlyField, expected_type='field', + **expected_metadata): + field = field_factory(read_only=True) + self.assertMetadataEqual(field, dict({ + 'type': expected_type, + 'required': False, + 'read_only': True, + }, **expected_metadata)) + + def test_boolean_field(self): + self.test_field(serializers.BooleanField, 'boolean') + self.test_read_only_field(serializers.BooleanField, 'boolean') + + def test_null_boolean_field(self): + self.test_field(serializers.NullBooleanField, 'boolean') + self.test_read_only_field(serializers.NullBooleanField, 'boolean') + + def test_char_field(self, field_factory=serializers.CharField, expected_type='string'): + self.test_field(field_factory, expected_type) + self.test_read_only_field(field_factory, expected_type) + field = field_factory(min_length=0, max_length=0) + self.assertMetadataEqual(field, { + 'type': expected_type, + 'required': True, + 'read_only': False, + 'min_length': 0, + 'max_length': 0, + }) + + def test_email_field(self): + self.test_char_field(serializers.EmailField, 'email') + + def test_url_field(self): + self.test_char_field(serializers.URLField, 'url') + + def test_slug_field(self): + self.test_char_field(serializers.SlugField, 'slug') + + def test_regex_field(self): + self.test_char_field(functools.partial(serializers.RegexField, regex='regex'), 'regex') + + def test_ip_address_field(self): + self.test_char_field(serializers.IPAddressField) + + def test_uuid_field(self): + self.test_field(serializers.UUIDField) + self.test_read_only_field(serializers.UUIDField) + + def test_integer_field(self, field_factory=serializers.IntegerField, expected_type='integer'): + self.test_field(field_factory, expected_type) + self.test_read_only_field(field_factory, expected_type) + field = field_factory(min_value=0, max_value=0) + self.assertMetadataEqual(field, { + 'type': expected_type, + 'required': True, + 'read_only': False, + 'min_value': 0, + 'max_value': 0, + }) + + def test_float_field(self): + self.test_integer_field(serializers.FloatField, 'float') + + def test_decimal_field(self): + decimal_field_factory = functools.partial(serializers.DecimalField, max_digits=5, decimal_places=2) + self.test_integer_field(decimal_field_factory, 'decimal') + + def test_date_time_field(self): + self.test_field(serializers.DateTimeField, 'datetime') + self.test_read_only_field(serializers.DateTimeField, 'datetime') + + def test_date_field(self): + self.test_field(serializers.DateField, 'date') + self.test_read_only_field(serializers.DateField, 'date') + + def test_time_field(self): + self.test_field(serializers.TimeField, 'time') + self.test_read_only_field(serializers.TimeField, 'time') + + def test_duration_field(self): + self.test_field(serializers.DurationField) + self.test_read_only_field(serializers.DurationField) + + def test_choice_field(self, field_factory=serializers.ChoiceField, expected_type='choice'): + choice_field_factory = functools.partial(field_factory, choices=[]) + self.test_field(choice_field_factory, expected_type, choices=[]) + self.test_read_only_field(choice_field_factory, expected_type) + field = field_factory([('value', _('label'))]) + self.assertMetadataEqual(field, { + 'type': expected_type, + 'required': True, + 'read_only': False, + 'choices': [{'value': 'value', 'display_name': ugettext('label')}] + }) + + def test_multiple_choice_field(self): + self.test_choice_field(serializers.MultipleChoiceField, 'multiple choice') + + def test_file_path_field(self): + # We have to special case FilePathField as it deals differently with + # the `required` argument by changing the choices values to include + # an empty string choice instead. + path = os.path.dirname(__file__) + choices = [ + {'value': value, 'display_name': display_name} + for value, display_name in forms.FilePathField(path, required=False).choices + ] + file_path_field_factory = functools.partial(serializers.FilePathField, path=path) + field = file_path_field_factory() + self.assertMetadataEqual(field, { + 'type': 'choice', + 'required': True, + 'read_only': False, + 'choices': choices, + }) + field = file_path_field_factory( + required=False, + label=_('label'), + help_text=_('help text'), + ) + self.assertMetadataEqual(field, { + 'type': 'choice', + 'required': True, + 'read_only': False, + 'label': ugettext('label'), + 'help_text': ugettext('help text'), + 'choices': choices, + }) + # Empty string label and help_text should be ignored. + field = file_path_field_factory( + label='', + help_text='', + ) + self.assertMetadataEqual(field, { + 'type': 'choice', + 'required': True, + 'read_only': False, + 'choices': choices, + }) + self.test_read_only_field(file_path_field_factory, 'choice') + + def test_file_field(self, field_factory=serializers.FileField, expected_type='file upload'): + self.test_field(field_factory, expected_type) + self.test_read_only_field(field_factory, expected_type) + field = field_factory(max_length=0) + self.assertMetadataEqual(field, { + 'type': expected_type, + 'required': True, + 'read_only': False, + 'max_length': 0, + }) + + def test_image_field(self): + self.test_file_field(serializers.ImageField, expected_type='image upload') + + def test_list_field(self): + def list_field_factory(*args, **kwargs): + return serializers.ListField(child=serializers.Field(), *args, **kwargs) + child_metadata = self.get_field_metadata(serializers.Field()) + self.test_field(list_field_factory, 'list', child=child_metadata) + self.test_read_only_field(list_field_factory, 'list', child=child_metadata) + + def test_dict_field(self): + def dict_field_factory(*args, **kwargs): + return serializers.DictField(child=serializers.Field(), *args, **kwargs) + child_metadata = self.get_field_metadata(serializers.Field()) + self.test_field(dict_field_factory, 'nested object', child=child_metadata) + self.test_read_only_field(dict_field_factory, 'nested object', child=child_metadata) + + def test_json_field(self): + self.test_field(serializers.JSONField) + self.test_read_only_field(serializers.JSONField) + + def test_serializer_method_field(self): + self.test_read_only_field(functools.partial(serializers.SerializerMethodField, method_name='method')) + + def test_model_field(self): + model_field_factory = functools.partial(serializers.ModelField, model_field=models.Field()) + self.test_field(model_field_factory) + self.test_read_only_field(model_field_factory) + + def test_serializer(self): + class TestSerializer(serializers.Serializer): + field = serializers.Field() + + serializer = TestSerializer() + self.assertMetadataEqual(serializer, { + 'field': { + 'type': 'field', + 'required': True, + 'read_only': False, + 'label': 'Field', + } + }) + + list_serializer = TestSerializer(many=True) + self.assertMetadataEqual(list_serializer, { + 'field': { + 'type': 'field', + 'required': True, + 'read_only': False, + 'label': 'Field', + } + }) + + def test_nested_serializer(self): + class TestNestedSerializer(serializers.Serializer): + field = serializers.Field() + + class TestSerializer(serializers.Serializer): + serializer = TestNestedSerializer( + label=_('label'), help_text=_('help text'), required=True + ) + + serializer = TestSerializer() + self.assertMetadataEqual(serializer, { + 'serializer': { + 'type': 'nested object', + 'required': True, + 'read_only': False, + 'label': ugettext('label'), + 'help_text': ugettext('help text'), + 'children': { + 'field': { + 'type': 'field', + 'required': True, + 'read_only': False, + 'label': 'Field', + }, + }, + }, + }) + + class TestListSerializer(serializers.Serializer): + serializer = TestNestedSerializer(many=True) + + list_serializer = TestListSerializer() + self.assertMetadataEqual(list_serializer, { + 'serializer': { + 'type': 'field', + 'required': True, + 'read_only': False, + 'label': 'Serializer', + 'child': { + 'type': 'nested object', + 'required': True, + 'read_only': False, + 'children': { + 'field': { + 'type': 'field', + 'required': True, + 'read_only': False, + 'label': 'Field', + }, + }, + }, + }, + }) class TestModelSerializerMetadata(TestCase): From 6bbb9165a59b94ba2e02cee4a9ecaf1ccb2c19b8 Mon Sep 17 00:00:00 2001 From: Simon Charette Date: Tue, 29 Mar 2016 17:31:09 -0400 Subject: [PATCH 2/2] Allowed fields to specify their own metadata. --- rest_framework/fields.py | 94 ++++++++++++++++++++++++++++++++++- rest_framework/metadata.py | 40 +-------------- rest_framework/relations.py | 14 +++++- rest_framework/serializers.py | 22 ++++++++ 4 files changed, 130 insertions(+), 40 deletions(-) diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 2a08e09ff..3debf333c 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -24,7 +24,7 @@ from django.utils.dateparse import ( parse_date, parse_datetime, parse_duration, parse_time ) from django.utils.duration import duration_string -from django.utils.encoding import is_protected_type, smart_text +from django.utils.encoding import force_text, is_protected_type, smart_text from django.utils.functional import cached_property from django.utils.ipv6 import clean_ipv6_address from django.utils.translation import ugettext_lazy as _ @@ -262,6 +262,7 @@ MISSING_ERROR_MESSAGE = ( class Field(object): _creation_counter = 0 + type = 'field' default_error_messages = { 'required': _('This field is required.'), 'null': _('This field may not be null.') @@ -597,10 +598,23 @@ class Field(object): """ return unicode_to_repr(representation.field_repr(self)) + def get_metadata(self): + metadata = OrderedDict([ + ('type', self.type), + ('required', self.required), + ('read_only', self.read_only), + ]) + for attr in ['label', 'help_text']: + value = getattr(self, attr) + if value is not None and value != '': + metadata[attr] = force_text(value, strings_only=True) + return metadata + # Boolean types... class BooleanField(Field): + type = 'boolean' default_error_messages = { 'invalid': _('"{input}" is not a valid boolean.') } @@ -632,6 +646,7 @@ class BooleanField(Field): class NullBooleanField(Field): + type = 'boolean' default_error_messages = { 'invalid': _('"{input}" is not a valid boolean.') } @@ -667,6 +682,7 @@ class NullBooleanField(Field): # String types... class CharField(Field): + type = 'string' default_error_messages = { 'blank': _('This field may not be blank.'), 'max_length': _('Ensure this field has no more than {max_length} characters.'), @@ -704,8 +720,17 @@ class CharField(Field): def to_representation(self, value): return six.text_type(value) + def get_metadata(self): + metadata = super(CharField, self).get_metadata() + for attr in ['min_length', 'max_length']: + value = getattr(self, attr) + if value is not None: + metadata[attr] = value + return metadata + class EmailField(CharField): + type = 'email' default_error_messages = { 'invalid': _('Enter a valid email address.') } @@ -717,6 +742,7 @@ class EmailField(CharField): class RegexField(CharField): + type = 'regex' default_error_messages = { 'invalid': _('This value does not match the required pattern.') } @@ -728,6 +754,7 @@ class RegexField(CharField): class SlugField(CharField): + type = 'slug' default_error_messages = { 'invalid': _('Enter a valid "slug" consisting of letters, numbers, underscores or hyphens.') } @@ -740,6 +767,7 @@ class SlugField(CharField): class URLField(CharField): + type = 'url' default_error_messages = { 'invalid': _('Enter a valid URL.') } @@ -814,6 +842,7 @@ class IPAddressField(CharField): # Number types... class IntegerField(Field): + type = 'integer' default_error_messages = { 'invalid': _('A valid integer is required.'), 'max_value': _('Ensure this value is less than or equal to {max_value}.'), @@ -847,8 +876,17 @@ class IntegerField(Field): def to_representation(self, value): return int(value) + def get_metadata(self): + metadata = super(IntegerField, self).get_metadata() + for attr in ['max_value', 'min_value']: + value = getattr(self, attr) + if value is not None: + metadata[attr] = value + return metadata + class FloatField(Field): + type = 'float' default_error_messages = { 'invalid': _('A valid number is required.'), 'max_value': _('Ensure this value is less than or equal to {max_value}.'), @@ -880,8 +918,17 @@ class FloatField(Field): def to_representation(self, value): return float(value) + def get_metadata(self): + metadata = super(FloatField, self).get_metadata() + for attr in ['max_value', 'min_value']: + value = getattr(self, attr) + if value is not None: + metadata[attr] = value + return metadata + class DecimalField(Field): + type = 'decimal' default_error_messages = { 'invalid': _('A valid number is required.'), 'max_value': _('Ensure this value is less than or equal to {max_value}.'), @@ -998,10 +1045,19 @@ class DecimalField(Field): decimal.Decimal('.1') ** self.decimal_places, context=context) + def get_metadata(self): + metadata = super(DecimalField, self).get_metadata() + for attr in ['max_value', 'min_value']: + value = getattr(self, attr) + if value is not None: + metadata[attr] = value + return metadata + # Date & time fields... class DateTimeField(Field): + type = 'datetime' default_error_messages = { 'invalid': _('Datetime has wrong format. Use one of these formats instead: {format}.'), 'date': _('Expected a datetime but got a date.'), @@ -1080,6 +1136,7 @@ class DateTimeField(Field): class DateField(Field): + type = 'date' default_error_messages = { 'invalid': _('Date has wrong format. Use one of these formats instead: {format}.'), 'datetime': _('Expected a date but got a datetime.'), @@ -1149,6 +1206,7 @@ class DateField(Field): class TimeField(Field): + type = 'time' default_error_messages = { 'invalid': _('Time has wrong format. Use one of these formats instead: {format}.'), } @@ -1232,6 +1290,7 @@ class DurationField(Field): # Choice types... class ChoiceField(Field): + type = 'choice' default_error_messages = { 'invalid_choice': _('"{input}" is not a valid choice.') } @@ -1279,8 +1338,21 @@ class ChoiceField(Field): cutoff_text=self.html_cutoff_text ) + def get_metadata(self): + metadata = super(ChoiceField, self).get_metadata() + if not self.read_only: + metadata['choices'] = [ + { + 'value': choice_value, + 'display_name': force_text(choice_name, strings_only=True) + } + for choice_value, choice_name in self.choices.items() + ] + return metadata + class MultipleChoiceField(ChoiceField): + type = 'multiple choice' default_error_messages = { 'invalid_choice': _('"{input}" is not a valid choice.'), 'not_a_list': _('Expected a list of items but got type "{input_type}".'), @@ -1339,6 +1411,7 @@ class FilePathField(ChoiceField): # File types... class FileField(Field): + type = 'file upload' default_error_messages = { 'required': _('No file was submitted.'), 'invalid': _('The submitted data was not a file. Check the encoding type on the form.'), @@ -1388,8 +1461,15 @@ class FileField(Field): return url return value.name + def get_metadata(self): + metadata = super(FileField, self).get_metadata() + if self.max_length is not None: + metadata['max_length'] = self.max_length + return metadata + class ImageField(FileField): + type = 'image upload' default_error_messages = { 'invalid_image': _( 'Upload a valid image. The file you uploaded was either not an image or a corrupted image.' @@ -1427,6 +1507,7 @@ class _UnvalidatedField(Field): class ListField(Field): + type = 'list' child = _UnvalidatedField() initial = [] default_error_messages = { @@ -1479,8 +1560,14 @@ class ListField(Field): """ return [self.child.to_representation(item) for item in data] + def get_metadata(self): + metadata = super(ListField, self).get_metadata() + metadata['child'] = self.child.get_metadata() + return metadata + class DictField(Field): + type = 'nested object' child = _UnvalidatedField() initial = {} default_error_messages = { @@ -1528,6 +1615,11 @@ class DictField(Field): for key, val in value.items() } + def get_metadata(self): + metadata = super(DictField, self).get_metadata() + metadata['child'] = self.child.get_metadata() + return metadata + class JSONField(Field): default_error_messages = { diff --git a/rest_framework/metadata.py b/rest_framework/metadata.py index 6c4f17692..5c8aa9fb4 100644 --- a/rest_framework/metadata.py +++ b/rest_framework/metadata.py @@ -103,47 +103,11 @@ class SimpleMetadata(BaseMetadata): Given an instance of a serializer, return a dictionary of metadata about its fields. """ - if hasattr(serializer, 'child'): - # If this is a `ListSerializer` then we want to examine the - # underlying child serializer instance instead. - serializer = serializer.child - return OrderedDict([ - (field_name, self.get_field_info(field)) - for field_name, field in serializer.fields.items() - ]) + return serializer.get_metadata() def get_field_info(self, field): """ Given an instance of a serializer field, return a dictionary of metadata about it. """ - field_info = OrderedDict() - field_info['type'] = self.label_lookup[field] - field_info['required'] = getattr(field, 'required', False) - - attrs = [ - 'read_only', 'label', 'help_text', - 'min_length', 'max_length', - 'min_value', 'max_value' - ] - - for attr in attrs: - value = getattr(field, attr, None) - if value is not None and value != '': - field_info[attr] = force_text(value, strings_only=True) - - if getattr(field, 'child', None): - field_info['child'] = self.get_field_info(field.child) - elif getattr(field, 'fields', None): - field_info['children'] = self.get_serializer_info(field) - - if not field_info.get('read_only') and hasattr(field, 'choices'): - field_info['choices'] = [ - { - 'value': choice_value, - 'display_name': force_text(choice_name, strings_only=True) - } - for choice_value, choice_name in field.choices.items() - ] - - return field_info + return field.get_metadata() diff --git a/rest_framework/relations.py b/rest_framework/relations.py index 572b69170..5f821e26a 100644 --- a/rest_framework/relations.py +++ b/rest_framework/relations.py @@ -10,7 +10,7 @@ from django.core.urlresolvers import ( from django.db.models import Manager from django.db.models.query import QuerySet from django.utils import six -from django.utils.encoding import smart_text +from django.utils.encoding import force_text, smart_text from django.utils.six.moves.urllib import parse as urlparse from django.utils.translation import ugettext_lazy as _ @@ -186,6 +186,18 @@ class RelatedField(Field): def display_value(self, instance): return six.text_type(instance) + def get_metadata(self): + metadata = super(RelatedField, self).get_metadata() + if not self.read_only: + metadata['choices'] = [ + { + 'value': choice_value, + 'display_name': force_text(choice_name, strings_only=True) + } + for choice_value, choice_name in self.choices.items() + ] + return metadata + class StringRelatedField(RelatedField): """ diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index 8c475e91c..4d1a4ba90 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -322,6 +322,7 @@ def get_validation_error_detail(exc): @six.add_metaclass(SerializerMetaclass) class Serializer(BaseSerializer): + type = 'nested object' default_error_messages = { 'invalid': _('Invalid data. Expected a dictionary, but got {datatype}.') } @@ -512,6 +513,17 @@ class Serializer(BaseSerializer): ret = super(Serializer, self).errors return ReturnDict(ret, serializer=self) + def get_metadata(self): + fields_metadata = OrderedDict([ + (field_name, field.get_metadata()) + for field_name, field in self.fields.items() + ]) + if self.root is self: + return fields_metadata + metadata = super(Serializer, self).get_metadata() + metadata['children'] = fields_metadata + return metadata + # There's some replication of `ListField` here, # but that's probably better than obfuscating the call hierarchy. @@ -685,6 +697,16 @@ class ListSerializer(BaseSerializer): return ReturnDict(ret, serializer=self) return ReturnList(ret, serializer=self) + def get_metadata(self): + if self.root is self: + return OrderedDict([ + (field_name, field.get_metadata()) + for field_name, field in self.child.fields.items() + ]) + metadata = super(ListSerializer, self).get_metadata() + metadata['child'] = self.child.get_metadata() + return metadata + # ModelSerializer & HyperlinkedModelSerializer # --------------------------------------------