From fcaee6e580efc62658a5b155525c55ef427c5778 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 24 May 2013 23:44:23 +0100 Subject: Clean up OPTIONS implementation --- rest_framework/fields.py | 59 +++++++++++++++-------------- rest_framework/generics.py | 43 +++++++++++++++++++-- rest_framework/serializers.py | 16 +++++--- rest_framework/tests/fields.py | 54 +++++++++++---------------- rest_framework/tests/generics.py | 74 +++++++++++++++++++------------------ rest_framework/tests/permissions.py | 15 +++----- rest_framework/tests/renderers.py | 8 ++++ rest_framework/utils/encoders.py | 7 +++- rest_framework/views.py | 64 +++++++------------------------- 9 files changed, 174 insertions(+), 166 deletions(-) (limited to 'rest_framework') diff --git a/rest_framework/fields.py b/rest_framework/fields.py index cb5f9a40..1534eeca 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -11,7 +11,6 @@ from decimal import Decimal, DecimalException import inspect import re import warnings - from django.core import validators from django.core.exceptions import ValidationError from django.conf import settings @@ -21,7 +20,6 @@ from django.forms import widgets from django.utils.encoding import is_protected_type from django.utils.translation import ugettext_lazy as _ from django.utils.datastructures import SortedDict - from rest_framework import ISO_8601 from rest_framework.compat import (timezone, parse_date, parse_datetime, parse_time) @@ -46,6 +44,7 @@ def is_simple_callable(obj): len_defaults = len(defaults) if defaults else 0 return len_args <= len_defaults + def get_component(obj, attr_name): """ Given an object, and an attribute name, @@ -72,18 +71,6 @@ def readable_date_formats(formats): return humanize_strptime(format) -def humanize_form_fields(form): - """Return a humanized description of all the fields in a form. - - :param form: A Django form. - :return: A dictionary of {field_label: humanized description} - - """ - fields = SortedDict([(name, humanize_field(field)) - for name, field in form.fields.iteritems()]) - return fields - - def readable_time_formats(formats): format = ', '.join(formats).replace(ISO_8601, 'hh:mm[:ss[.uuuuuu]]') return humanize_strptime(format) @@ -122,6 +109,7 @@ class Field(object): partial = False use_files = False form_field_class = forms.CharField + type_label = 'field' def __init__(self, source=None, label=None, help_text=None): self.parent = None @@ -207,18 +195,17 @@ class Field(object): return {'type': self.type_name} return {} - @property - def humanized(self): - humanized = { - 'type': self.type_name, - 'required': getattr(self, 'required', False), - } - optional_attrs = ['read_only', 'help_text', 'label', + def metadata(self): + metadata = SortedDict() + metadata['type'] = self.type_label + metadata['required'] = getattr(self, 'required', False) + optional_attrs = ['read_only', 'label', 'help_text', 'min_length', 'max_length'] for attr in optional_attrs: - if getattr(self, attr, None) is not None: - humanized[attr] = getattr(self, attr) - return humanized + value = getattr(self, attr, None) + if value is not None and value != '': + metadata[attr] = force_text(value, strings_only=True) + return metadata class WritableField(Field): @@ -375,6 +362,7 @@ class ModelField(WritableField): class BooleanField(WritableField): type_name = 'BooleanField' + type_label = 'boolean' form_field_class = forms.BooleanField widget = widgets.CheckboxInput default_error_messages = { @@ -397,6 +385,7 @@ class BooleanField(WritableField): class CharField(WritableField): type_name = 'CharField' + type_label = 'string' form_field_class = forms.CharField def __init__(self, max_length=None, min_length=None, *args, **kwargs): @@ -415,6 +404,7 @@ class CharField(WritableField): class URLField(CharField): type_name = 'URLField' + type_label = 'url' def __init__(self, **kwargs): kwargs['validators'] = [validators.URLValidator()] @@ -423,14 +413,15 @@ class URLField(CharField): class SlugField(CharField): type_name = 'SlugField' + type_label = 'slug' form_field_class = forms.SlugField - + default_error_messages = { 'invalid': _("Enter a valid 'slug' consisting of letters, numbers," " underscores or hyphens."), } default_validators = [validators.validate_slug] - + def __init__(self, *args, **kwargs): super(SlugField, self).__init__(*args, **kwargs) @@ -440,10 +431,11 @@ class SlugField(CharField): #result.widget = copy.deepcopy(self.widget, memo) result.validators = self.validators[:] return result - - + + class ChoiceField(WritableField): type_name = 'ChoiceField' + type_label = 'multiple choice' form_field_class = forms.ChoiceField widget = widgets.Select default_error_messages = { @@ -494,6 +486,7 @@ class ChoiceField(WritableField): class EmailField(CharField): type_name = 'EmailField' + type_label = 'email' form_field_class = forms.EmailField default_error_messages = { @@ -517,6 +510,7 @@ class EmailField(CharField): class RegexField(CharField): type_name = 'RegexField' + type_label = 'regex' form_field_class = forms.RegexField def __init__(self, regex, max_length=None, min_length=None, *args, **kwargs): @@ -546,6 +540,7 @@ class RegexField(CharField): class DateField(WritableField): type_name = 'DateField' + type_label = 'date' widget = widgets.DateInput form_field_class = forms.DateField @@ -609,6 +604,7 @@ class DateField(WritableField): class DateTimeField(WritableField): type_name = 'DateTimeField' + type_label = 'datetime' widget = widgets.DateTimeInput form_field_class = forms.DateTimeField @@ -678,6 +674,7 @@ class DateTimeField(WritableField): class TimeField(WritableField): type_name = 'TimeField' + type_label = 'time' widget = widgets.TimeInput form_field_class = forms.TimeField @@ -734,6 +731,7 @@ class TimeField(WritableField): class IntegerField(WritableField): type_name = 'IntegerField' + type_label = 'integer' form_field_class = forms.IntegerField default_error_messages = { @@ -764,6 +762,7 @@ class IntegerField(WritableField): class FloatField(WritableField): type_name = 'FloatField' + type_label = 'float' form_field_class = forms.FloatField default_error_messages = { @@ -783,6 +782,7 @@ class FloatField(WritableField): class DecimalField(WritableField): type_name = 'DecimalField' + type_label = 'decimal' form_field_class = forms.DecimalField default_error_messages = { @@ -853,6 +853,7 @@ class DecimalField(WritableField): class FileField(WritableField): use_files = True type_name = 'FileField' + type_label = 'file upload' form_field_class = forms.FileField widget = widgets.FileInput @@ -896,6 +897,8 @@ class FileField(WritableField): class ImageField(FileField): use_files = True + type_name = 'ImageField' + type_label = 'image upload' form_field_class = forms.ImageField default_error_messages = { diff --git a/rest_framework/generics.py b/rest_framework/generics.py index 05ec93d3..afcb8a9f 100644 --- a/rest_framework/generics.py +++ b/rest_framework/generics.py @@ -3,13 +3,13 @@ Generic views that provide commonly needed behaviour. """ from __future__ import unicode_literals -from django.core.exceptions import ImproperlyConfigured +from django.core.exceptions import ImproperlyConfigured, PermissionDenied from django.core.paginator import Paginator, InvalidPage from django.http import Http404 from django.shortcuts import get_object_or_404 from django.utils.translation import ugettext as _ -from rest_framework import views, mixins -from rest_framework.exceptions import ConfigurationError +from rest_framework import views, mixins, exceptions +from rest_framework.request import clone_request from rest_framework.settings import api_settings import warnings @@ -274,7 +274,7 @@ class GenericAPIView(views.APIView): ) filter_kwargs = {self.slug_field: slug} else: - raise ConfigurationError( + raise exceptions.ConfigurationError( 'Expected view %s to be called with a URL keyword argument ' 'named "%s". Fix your URL conf, or set the `.lookup_field` ' 'attribute on the view correctly.' % @@ -310,6 +310,41 @@ class GenericAPIView(views.APIView): """ pass + def metadata(self, request): + """ + Return a dictionary of metadata about the view. + Used to return responses for OPTIONS requests. + + We override the default behavior, and add some extra information + about the required request body for POST and PUT operations. + """ + ret = super(GenericAPIView, self).metadata(request) + + actions = {} + for method in ('PUT', 'POST'): + if method not in self.allowed_methods: + continue + + cloned_request = clone_request(request, method) + try: + # Test global permissions + self.check_permissions(cloned_request) + # Test object permissions + if method == 'PUT': + self.get_object() + except (exceptions.APIException, PermissionDenied, Http404): + pass + else: + # If user has appropriate permissions for the view, include + # appropriate metadata about the fields that should be supplied. + serializer = self.get_serializer() + actions[method] = serializer.metadata() + + if actions: + ret['actions'] = actions + + return ret + ########################################################## ### Concrete view classes that provide method handlers ### diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index 17da8c25..5be07fb7 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -521,12 +521,16 @@ class BaseSerializer(WritableField): return self.object - @property - def humanized(self): - humanized_fields = SortedDict( - [(name, field.humanized) - for name, field in self.fields.iteritems()]) - return humanized_fields + def metadata(self): + """ + Return a dictionary of metadata about the fields on the serializer. + Useful for things like responding to OPTIONS requests, or generating + API schemas for auto-documentation. + """ + return SortedDict( + [(field_name, field.metadata()) + for field_name, field in six.iteritems(self.fields)] + ) class Serializer(six.with_metaclass(SerializerMetaclass, BaseSerializer)): diff --git a/rest_framework/tests/fields.py b/rest_framework/tests/fields.py index 22c515a9..bff4400b 100644 --- a/rest_framework/tests/fields.py +++ b/rest_framework/tests/fields.py @@ -3,17 +3,13 @@ General serializer field tests. """ from __future__ import unicode_literals -from collections import namedtuple +import datetime from decimal import Decimal from uuid import uuid4 - -import datetime -from django import forms from django.core import validators from django.db import models from django.test import TestCase from django.utils.datastructures import SortedDict - from rest_framework import serializers from rest_framework.fields import Field, CharField from rest_framework.serializers import Serializer @@ -784,12 +780,12 @@ class SlugFieldTests(TestCase): """ class SlugFieldSerializer(serializers.ModelSerializer): slug_field = serializers.SlugField(source='slug_field', max_length=20, required=True) - + class Meta: model = self.SlugFieldModel - + s = SlugFieldSerializer(data={'slug_field': 'a b'}) - + self.assertEqual(s.is_valid(), False) self.assertEqual(s.errors, {'slug_field': ["Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens."]}) @@ -839,7 +835,7 @@ class URLFieldTests(TestCase): 'max_length'), 20) -class HumanizedField(TestCase): +class FieldMetadata(TestCase): def setUp(self): self.required_field = Field() self.required_field.label = uuid4().hex @@ -849,41 +845,35 @@ class HumanizedField(TestCase): self.optional_field.label = uuid4().hex self.optional_field.required = False - def test_type(self): - for field in (self.required_field, self.optional_field): - self.assertEqual(field.humanized['type'], field.type_name) - def test_required(self): - self.assertEqual(self.required_field.humanized['required'], True) + self.assertEqual(self.required_field.metadata()['required'], True) def test_optional(self): - self.assertEqual(self.optional_field.humanized['required'], False) + self.assertEqual(self.optional_field.metadata()['required'], False) def test_label(self): for field in (self.required_field, self.optional_field): - self.assertEqual(field.humanized['label'], field.label) + self.assertEqual(field.metadata()['label'], field.label) -class HumanizableSerializer(Serializer): +class MetadataSerializer(Serializer): field1 = CharField(3, required=True) field2 = CharField(10, required=False) -class HumanizedSerializer(TestCase): +class MetadataSerializerTestCase(TestCase): def setUp(self): - self.serializer = HumanizableSerializer() + self.serializer = MetadataSerializer() - def test_humanized(self): - humanized = self.serializer.humanized + def test_serializer_metadata(self): + metadata = self.serializer.metadata() expected = { - 'field1': {u'required': True, - u'max_length': 3, - u'type': u'CharField', - u'read_only': False}, - 'field2': {u'required': False, - u'max_length': 10, - u'type': u'CharField', - u'read_only': False}} - self.assertEqual(set(expected.keys()), set(humanized.keys())) - for k, v in humanized.iteritems(): - self.assertEqual(v, expected[k]) + 'field1': {'required': True, + 'max_length': 3, + 'type': 'string', + 'read_only': False}, + 'field2': {'required': False, + 'max_length': 10, + 'type': 'string', + 'read_only': False}} + self.assertEqual(expected, metadata) diff --git a/rest_framework/tests/generics.py b/rest_framework/tests/generics.py index a2f8fb4b..f091d0db 100644 --- a/rest_framework/tests/generics.py +++ b/rest_framework/tests/generics.py @@ -122,21 +122,24 @@ class TestRootView(TestCase): ], 'name': 'Root', 'description': 'Example description for OPTIONS.', - 'actions': {} - } - expected['actions']['GET'] = {} - expected['actions']['POST'] = { - 'text': { - 'max_length': 100, - 'read_only': False, - 'required': True, - 'type': 'String', - }, - 'id': { - 'read_only': True, - 'required': False, - 'type': 'Integer', - }, + 'actions': { + 'POST': { + 'text': { + 'max_length': 100, + 'read_only': False, + 'required': True, + 'type': 'string', + "label": "Text comes here", + "help_text": "Text description." + }, + 'id': { + 'read_only': True, + 'required': False, + 'type': 'integer', + 'label': 'ID', + }, + } + } } self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data, expected) @@ -239,9 +242,9 @@ class TestInstanceView(TestCase): """ OPTIONS requests to RetrieveUpdateDestroyAPIView should return metadata """ - request = factory.options('/') - with self.assertNumQueries(0): - response = self.view(request).render() + request = factory.options('/1') + with self.assertNumQueries(1): + response = self.view(request, pk=1).render() expected = { 'parses': [ 'application/json', @@ -254,24 +257,25 @@ class TestInstanceView(TestCase): ], 'name': 'Instance', 'description': 'Example description for OPTIONS.', - 'actions': {} - } - for method in ('GET', 'DELETE'): - expected['actions'][method] = {} - for method in ('PATCH', 'PUT'): - expected['actions'][method] = { - 'text': { - 'max_length': 100, - 'read_only': False, - 'required': True, - 'type': 'String', - }, - 'id': { - 'read_only': True, - 'required': False, - 'type': 'Integer', - }, + 'actions': { + 'PUT': { + 'text': { + 'max_length': 100, + 'read_only': False, + 'required': True, + 'type': 'string', + 'label': 'Text comes here', + 'help_text': 'Text description.' + }, + 'id': { + 'read_only': True, + 'required': False, + 'type': 'integer', + 'label': 'ID', + }, + } } + } self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data, expected) diff --git a/rest_framework/tests/permissions.py b/rest_framework/tests/permissions.py index 5a18182b..6caaf65b 100644 --- a/rest_framework/tests/permissions.py +++ b/rest_framework/tests/permissions.py @@ -114,44 +114,41 @@ class ModelPermissionsIntegrationTests(TestCase): response = root_view(request, pk='1') self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertIn('actions', response.data) - self.assertEquals(response.data['actions'].keys(), ['POST', 'GET',]) + self.assertEqual(list(response.data['actions'].keys()), ['POST']) request = factory.options('/1', content_type='application/json', HTTP_AUTHORIZATION=self.permitted_credentials) response = instance_view(request, pk='1') self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertIn('actions', response.data) - self.assertEquals(response.data['actions'].keys(), ['PUT', 'PATCH', 'DELETE', 'GET',]) + self.assertEqual(list(response.data['actions'].keys()), ['PUT']) def test_options_disallowed(self): request = factory.options('/', content_type='application/json', HTTP_AUTHORIZATION=self.disallowed_credentials) response = root_view(request, pk='1') self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertIn('actions', response.data) - self.assertEquals(response.data['actions'].keys(), ['GET',]) + self.assertNotIn('actions', response.data) request = factory.options('/1', content_type='application/json', HTTP_AUTHORIZATION=self.disallowed_credentials) response = instance_view(request, pk='1') self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertIn('actions', response.data) - self.assertEquals(response.data['actions'].keys(), ['GET',]) + self.assertNotIn('actions', response.data) def test_options_updateonly(self): request = factory.options('/', content_type='application/json', HTTP_AUTHORIZATION=self.updateonly_credentials) response = root_view(request, pk='1') self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertIn('actions', response.data) - self.assertEquals(response.data['actions'].keys(), ['GET',]) + self.assertNotIn('actions', response.data) request = factory.options('/1', content_type='application/json', HTTP_AUTHORIZATION=self.updateonly_credentials) response = instance_view(request, pk='1') self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertIn('actions', response.data) - self.assertEquals(response.data['actions'].keys(), ['PUT', 'PATCH', 'GET',]) + self.assertEqual(list(response.data['actions'].keys()), ['PUT']) class OwnerModel(models.Model): diff --git a/rest_framework/tests/renderers.py b/rest_framework/tests/renderers.py index 9096c82d..cf3f4b46 100644 --- a/rest_framework/tests/renderers.py +++ b/rest_framework/tests/renderers.py @@ -6,6 +6,7 @@ from django.core.cache import cache from django.test import TestCase from django.test.client import RequestFactory from django.utils import unittest +from django.utils.translation import ugettext_lazy as _ from rest_framework import status, permissions from rest_framework.compat import yaml, etree, patterns, url, include from rest_framework.response import Response @@ -238,6 +239,13 @@ class JSONRendererTests(TestCase): Tests specific to the JSON Renderer """ + def test_render_lazy_strings(self): + """ + JSONRenderer should deal with lazy translated strings. + """ + ret = JSONRenderer().render(_('test')) + self.assertEqual(ret, b'"test"') + def test_without_content_type_args(self): """ Test basic JSON rendering. diff --git a/rest_framework/utils/encoders.py b/rest_framework/utils/encoders.py index b6de18a8..b26a2085 100644 --- a/rest_framework/utils/encoders.py +++ b/rest_framework/utils/encoders.py @@ -3,7 +3,8 @@ Helper classes for parsers. """ from __future__ import unicode_literals from django.utils.datastructures import SortedDict -from rest_framework.compat import timezone +from django.utils.functional import Promise +from rest_framework.compat import timezone, force_text from rest_framework.serializers import DictWithMetadata, SortedDictWithMetadata import datetime import decimal @@ -19,7 +20,9 @@ class JSONEncoder(json.JSONEncoder): def default(self, o): # For Date Time string spec, see ECMA 262 # http://ecma-international.org/ecma-262/5.1/#sec-15.9.1.15 - if isinstance(o, datetime.datetime): + if isinstance(o, Promise): + return force_text(o) + elif isinstance(o, datetime.datetime): r = o.isoformat() if o.microsecond: r = r[:23] + r[26:] diff --git a/rest_framework/views.py b/rest_framework/views.py index d1afbe89..e1b6705b 100644 --- a/rest_framework/views.py +++ b/rest_framework/views.py @@ -5,12 +5,11 @@ from __future__ import unicode_literals from django.core.exceptions import PermissionDenied from django.http import Http404, HttpResponse +from django.utils.datastructures import SortedDict from django.views.decorators.csrf import csrf_exempt - from rest_framework import status, exceptions from rest_framework.compat import View -from rest_framework.fields import humanize_form_fields -from rest_framework.request import clone_request, Request +from rest_framework.request import Request from rest_framework.response import Response from rest_framework.settings import api_settings from rest_framework.utils.formatting import get_view_name, get_view_description @@ -54,53 +53,6 @@ class APIView(View): 'Vary': 'Accept' } - def metadata(self, request): - content = { - 'name': get_view_name(self.__class__), - 'description': get_view_description(self.__class__), - 'renders': [renderer.media_type for renderer in self.renderer_classes], - 'parses': [parser.media_type for parser in self.parser_classes], - } - content['actions'] = self.action_metadata(request) - - return content - - def action_metadata(self, request): - """Return a dictionary with the fields required fo reach allowed method. If no method is allowed, - return an empty dictionary. - - :param request: Request for which to return the metadata of the allowed methods. - :return: A dictionary of the form {method: {field: {field attribute: value}}} - """ - actions = {} - for method in self.allowed_methods: - # skip HEAD and OPTIONS - if method in ('HEAD', 'OPTIONS'): - continue - - cloned_request = clone_request(request, method) - try: - self.check_permissions(cloned_request) - - # TODO: discuss whether and how to expose parameters like e.g. filter or paginate - if method in ('GET', 'DELETE'): - actions[method] = {} - continue - - if not hasattr(self, 'get_serializer'): - continue - serializer = self.get_serializer() - if serializer is not None: - actions[method] = serializer.humanized - except exceptions.PermissionDenied: - # don't add this method - pass - except exceptions.NotAuthenticated: - # don't add this method - pass - - return actions if len(actions) > 0 else None - def http_method_not_allowed(self, request, *args, **kwargs): """ If `request.method` does not correspond to a handler method, @@ -383,3 +335,15 @@ class APIView(View): a less useful default implementation. """ return Response(self.metadata(request), status=status.HTTP_200_OK) + + def metadata(self, request): + """ + Return a dictionary of metadata about the view. + Used to return responses for OPTIONS requests. + """ + ret = SortedDict() + ret['name'] = get_view_name(self.__class__) + ret['description'] = get_view_description(self.__class__) + ret['renders'] = [renderer.media_type for renderer in self.renderer_classes] + ret['parses'] = [parser.media_type for parser in self.parser_classes] + return ret -- cgit v1.2.3