This commit is contained in:
Simon Charette 2016-05-22 15:22:08 +00:00
commit 069a980210
5 changed files with 440 additions and 44 deletions

View File

@ -24,7 +24,7 @@ from django.utils.dateparse import (
parse_date, parse_datetime, parse_duration, parse_time parse_date, parse_datetime, parse_duration, parse_time
) )
from django.utils.duration import duration_string 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.functional import cached_property
from django.utils.ipv6 import clean_ipv6_address from django.utils.ipv6 import clean_ipv6_address
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
@ -262,6 +262,7 @@ MISSING_ERROR_MESSAGE = (
class Field(object): class Field(object):
_creation_counter = 0 _creation_counter = 0
type = 'field'
default_error_messages = { default_error_messages = {
'required': _('This field is required.'), 'required': _('This field is required.'),
'null': _('This field may not be null.') 'null': _('This field may not be null.')
@ -597,10 +598,23 @@ class Field(object):
""" """
return unicode_to_repr(representation.field_repr(self)) 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... # Boolean types...
class BooleanField(Field): class BooleanField(Field):
type = 'boolean'
default_error_messages = { default_error_messages = {
'invalid': _('"{input}" is not a valid boolean.') 'invalid': _('"{input}" is not a valid boolean.')
} }
@ -632,6 +646,7 @@ class BooleanField(Field):
class NullBooleanField(Field): class NullBooleanField(Field):
type = 'boolean'
default_error_messages = { default_error_messages = {
'invalid': _('"{input}" is not a valid boolean.') 'invalid': _('"{input}" is not a valid boolean.')
} }
@ -667,6 +682,7 @@ class NullBooleanField(Field):
# String types... # String types...
class CharField(Field): class CharField(Field):
type = 'string'
default_error_messages = { default_error_messages = {
'blank': _('This field may not be blank.'), 'blank': _('This field may not be blank.'),
'max_length': _('Ensure this field has no more than {max_length} characters.'), 'max_length': _('Ensure this field has no more than {max_length} characters.'),
@ -704,8 +720,17 @@ class CharField(Field):
def to_representation(self, value): def to_representation(self, value):
return six.text_type(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): class EmailField(CharField):
type = 'email'
default_error_messages = { default_error_messages = {
'invalid': _('Enter a valid email address.') 'invalid': _('Enter a valid email address.')
} }
@ -717,6 +742,7 @@ class EmailField(CharField):
class RegexField(CharField): class RegexField(CharField):
type = 'regex'
default_error_messages = { default_error_messages = {
'invalid': _('This value does not match the required pattern.') 'invalid': _('This value does not match the required pattern.')
} }
@ -728,6 +754,7 @@ class RegexField(CharField):
class SlugField(CharField): class SlugField(CharField):
type = 'slug'
default_error_messages = { default_error_messages = {
'invalid': _('Enter a valid "slug" consisting of letters, numbers, underscores or hyphens.') 'invalid': _('Enter a valid "slug" consisting of letters, numbers, underscores or hyphens.')
} }
@ -740,6 +767,7 @@ class SlugField(CharField):
class URLField(CharField): class URLField(CharField):
type = 'url'
default_error_messages = { default_error_messages = {
'invalid': _('Enter a valid URL.') 'invalid': _('Enter a valid URL.')
} }
@ -814,6 +842,7 @@ class IPAddressField(CharField):
# Number types... # Number types...
class IntegerField(Field): class IntegerField(Field):
type = 'integer'
default_error_messages = { default_error_messages = {
'invalid': _('A valid integer is required.'), 'invalid': _('A valid integer is required.'),
'max_value': _('Ensure this value is less than or equal to {max_value}.'), '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): def to_representation(self, value):
return int(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): class FloatField(Field):
type = 'float'
default_error_messages = { default_error_messages = {
'invalid': _('A valid number is required.'), 'invalid': _('A valid number is required.'),
'max_value': _('Ensure this value is less than or equal to {max_value}.'), '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): def to_representation(self, value):
return float(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): class DecimalField(Field):
type = 'decimal'
default_error_messages = { default_error_messages = {
'invalid': _('A valid number is required.'), 'invalid': _('A valid number is required.'),
'max_value': _('Ensure this value is less than or equal to {max_value}.'), 'max_value': _('Ensure this value is less than or equal to {max_value}.'),
@ -1001,10 +1048,19 @@ class DecimalField(Field):
decimal.Decimal('.1') ** self.decimal_places, decimal.Decimal('.1') ** self.decimal_places,
context=context) 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... # Date & time fields...
class DateTimeField(Field): class DateTimeField(Field):
type = 'datetime'
default_error_messages = { default_error_messages = {
'invalid': _('Datetime has wrong format. Use one of these formats instead: {format}.'), 'invalid': _('Datetime has wrong format. Use one of these formats instead: {format}.'),
'date': _('Expected a datetime but got a date.'), 'date': _('Expected a datetime but got a date.'),
@ -1083,6 +1139,7 @@ class DateTimeField(Field):
class DateField(Field): class DateField(Field):
type = 'date'
default_error_messages = { default_error_messages = {
'invalid': _('Date has wrong format. Use one of these formats instead: {format}.'), 'invalid': _('Date has wrong format. Use one of these formats instead: {format}.'),
'datetime': _('Expected a date but got a datetime.'), 'datetime': _('Expected a date but got a datetime.'),
@ -1152,6 +1209,7 @@ class DateField(Field):
class TimeField(Field): class TimeField(Field):
type = 'time'
default_error_messages = { default_error_messages = {
'invalid': _('Time has wrong format. Use one of these formats instead: {format}.'), 'invalid': _('Time has wrong format. Use one of these formats instead: {format}.'),
} }
@ -1235,6 +1293,7 @@ class DurationField(Field):
# Choice types... # Choice types...
class ChoiceField(Field): class ChoiceField(Field):
type = 'choice'
default_error_messages = { default_error_messages = {
'invalid_choice': _('"{input}" is not a valid choice.') 'invalid_choice': _('"{input}" is not a valid choice.')
} }
@ -1282,8 +1341,21 @@ class ChoiceField(Field):
cutoff_text=self.html_cutoff_text 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): class MultipleChoiceField(ChoiceField):
type = 'multiple choice'
default_error_messages = { default_error_messages = {
'invalid_choice': _('"{input}" is not a valid choice.'), 'invalid_choice': _('"{input}" is not a valid choice.'),
'not_a_list': _('Expected a list of items but got type "{input_type}".'), 'not_a_list': _('Expected a list of items but got type "{input_type}".'),
@ -1342,6 +1414,7 @@ class FilePathField(ChoiceField):
# File types... # File types...
class FileField(Field): class FileField(Field):
type = 'file upload'
default_error_messages = { default_error_messages = {
'required': _('No file was submitted.'), 'required': _('No file was submitted.'),
'invalid': _('The submitted data was not a file. Check the encoding type on the form.'), 'invalid': _('The submitted data was not a file. Check the encoding type on the form.'),
@ -1391,8 +1464,15 @@ class FileField(Field):
return url return url
return value.name 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): class ImageField(FileField):
type = 'image upload'
default_error_messages = { default_error_messages = {
'invalid_image': _( 'invalid_image': _(
'Upload a valid image. The file you uploaded was either not an image or a corrupted image.' 'Upload a valid image. The file you uploaded was either not an image or a corrupted image.'
@ -1430,6 +1510,7 @@ class _UnvalidatedField(Field):
class ListField(Field): class ListField(Field):
type = 'list'
child = _UnvalidatedField() child = _UnvalidatedField()
initial = [] initial = []
default_error_messages = { default_error_messages = {
@ -1482,8 +1563,14 @@ class ListField(Field):
""" """
return [self.child.to_representation(item) if item is not None else None for item in data] return [self.child.to_representation(item) if item is not None else None 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): class DictField(Field):
type = 'nested object'
child = _UnvalidatedField() child = _UnvalidatedField()
initial = {} initial = {}
default_error_messages = { default_error_messages = {
@ -1531,6 +1618,11 @@ class DictField(Field):
for key, val in value.items() 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): class JSONField(Field):
default_error_messages = { default_error_messages = {

View File

@ -103,47 +103,11 @@ class SimpleMetadata(BaseMetadata):
Given an instance of a serializer, return a dictionary of metadata Given an instance of a serializer, return a dictionary of metadata
about its fields. about its fields.
""" """
if hasattr(serializer, 'child'): return serializer.get_metadata()
# 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()
])
def get_field_info(self, field): def get_field_info(self, field):
""" """
Given an instance of a serializer field, return a dictionary Given an instance of a serializer field, return a dictionary
of metadata about it. of metadata about it.
""" """
field_info = OrderedDict() return field.get_metadata()
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

View File

@ -10,7 +10,7 @@ from django.core.urlresolvers import (
from django.db.models import Manager from django.db.models import Manager
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
from django.utils import six 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.six.moves.urllib import parse as urlparse
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
@ -186,6 +186,18 @@ class RelatedField(Field):
def display_value(self, instance): def display_value(self, instance):
return six.text_type(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): class StringRelatedField(RelatedField):
""" """

View File

@ -322,6 +322,7 @@ def get_validation_error_detail(exc):
@six.add_metaclass(SerializerMetaclass) @six.add_metaclass(SerializerMetaclass)
class Serializer(BaseSerializer): class Serializer(BaseSerializer):
type = 'nested object'
default_error_messages = { default_error_messages = {
'invalid': _('Invalid data. Expected a dictionary, but got {datatype}.') 'invalid': _('Invalid data. Expected a dictionary, but got {datatype}.')
} }
@ -512,6 +513,17 @@ class Serializer(BaseSerializer):
ret = super(Serializer, self).errors ret = super(Serializer, self).errors
return ReturnDict(ret, serializer=self) 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, # There's some replication of `ListField` here,
# but that's probably better than obfuscating the call hierarchy. # but that's probably better than obfuscating the call hierarchy.
@ -685,6 +697,16 @@ class ListSerializer(BaseSerializer):
return ReturnDict(ret, serializer=self) return ReturnDict(ret, serializer=self)
return ReturnList(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 # ModelSerializer & HyperlinkedModelSerializer
# -------------------------------------------- # --------------------------------------------

View File

@ -1,8 +1,13 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import functools
import os
from django import forms
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.test import TestCase from django.test import TestCase
from django.utils.translation import ugettext, ugettext_lazy as _
from rest_framework import ( from rest_framework import (
exceptions, metadata, serializers, status, versioning, views exceptions, metadata, serializers, status, versioning, views
@ -261,10 +266,311 @@ class TestMetadata:
view = ExampleView.as_view(versioning_class=scheme) view = ExampleView.as_view(versioning_class=scheme)
view(request=request) view(request=request)
def test_null_boolean_field_info_type(self):
options = metadata.SimpleMetadata() class TestFieldMetadata(TestCase):
field_info = options.get_field_info(serializers.NullBooleanField()) simple_metadata = metadata.SimpleMetadata()
assert field_info['type'] == 'boolean'
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): class TestModelSerializerMetadata(TestCase):