From f4b1dcb167be0bbdaae2cc2a92f651536896dc16 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 24 Sep 2014 14:09:49 +0100 Subject: [PATCH] OPTIONS support --- rest_framework/generics.py | 51 +------- rest_framework/metadata.py | 126 +++++++++++++++++++ rest_framework/serializers.py | 8 +- rest_framework/settings.py | 2 + rest_framework/utils/field_mapping.py | 20 ++-- rest_framework/views.py | 24 +--- tests/test_metadata.py | 166 ++++++++++++++++++++++++++ 7 files changed, 316 insertions(+), 81 deletions(-) create mode 100644 rest_framework/metadata.py create mode 100644 tests/test_metadata.py diff --git a/rest_framework/generics.py b/rest_framework/generics.py index eb6b64efa..f49b0a439 100644 --- a/rest_framework/generics.py +++ b/rest_framework/generics.py @@ -4,13 +4,11 @@ Generic views that provide commonly needed behaviour. from __future__ import unicode_literals from django.db.models.query import QuerySet -from django.core.exceptions import PermissionDenied from django.core.paginator import Paginator, InvalidPage from django.http import Http404 from django.shortcuts import get_object_or_404 as _get_object_or_404 from django.utils.translation import ugettext as _ -from rest_framework import views, mixins, exceptions -from rest_framework.request import clone_request +from rest_framework import views, mixins from rest_framework.settings import api_settings @@ -249,53 +247,6 @@ class GenericAPIView(views.APIView): return obj - # The following are placeholder methods, - # and are intended to be overridden. - # - # The are not called by GenericAPIView directly, - # but are used by the mixin methods. - 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': - try: - self.get_object() - except Http404: - # Http404 should be acceptable and the serializer - # metadata should be populated. Except this so the - # outer "else" clause of the try-except-else block - # will be executed. - pass - except (exceptions.APIException, PermissionDenied): - 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 # by composing the mixin classes with the base view. diff --git a/rest_framework/metadata.py b/rest_framework/metadata.py new file mode 100644 index 000000000..580259de1 --- /dev/null +++ b/rest_framework/metadata.py @@ -0,0 +1,126 @@ +""" +The metadata API is used to allow cusomization of how `OPTIONS` requests +are handled. We currently provide a single default implementation that returns +some fairly ad-hoc information about the view. + +Future implementations might use JSON schema or other definations in order +to return this information in a more standardized way. +""" +from __future__ import unicode_literals + +from django.core.exceptions import PermissionDenied +from django.http import Http404 +from django.utils import six +from django.utils.datastructures import SortedDict +from rest_framework import exceptions, serializers +from rest_framework.compat import force_text +from rest_framework.request import clone_request +from rest_framework.utils.field_mapping import ClassLookupDict + + +class BaseMetadata(object): + def determine_metadata(self, request, view): + """ + Return a dictionary of metadata about the view. + Used to return responses for OPTIONS requests. + """ + raise NotImplementedError(".determine_metadata() must be overridden.") + + +class SimpleMetadata(BaseMetadata): + """ + This is the default metadata implementation. + It returns an ad-hoc set of information about the view. + There are not any formalized standards for `OPTIONS` responses + for us to base this on. + """ + label_lookup = ClassLookupDict({ + serializers.Field: 'field', + serializers.BooleanField: 'boolean', + serializers.CharField: 'string', + serializers.URLField: 'url', + serializers.EmailField: 'email', + serializers.RegexField: 'regex', + serializers.SlugField: 'slug', + serializers.IntegerField: 'integer', + serializers.FloatField: 'float', + serializers.DecimalField: 'decimal', + serializers.DateField: 'date', + serializers.DateTimeField: 'datetime', + serializers.TimeField: 'time', + serializers.ChoiceField: 'choice', + serializers.MultipleChoiceField: 'multiple choice', + serializers.FileField: 'file upload', + serializers.ImageField: 'image upload', + }) + + def determine_metadata(self, request, view): + metadata = SortedDict() + metadata['name'] = view.get_view_name() + metadata['description'] = view.get_view_description() + metadata['renders'] = [renderer.media_type for renderer in view.renderer_classes] + metadata['parses'] = [parser.media_type for parser in view.parser_classes] + if hasattr(view, 'get_serializer'): + actions = self.determine_actions(request, view) + if actions: + metadata['actions'] = actions + return metadata + + def determine_actions(self, request, view): + """ + For generic class based views we return information about + the fields that are accepted for 'PUT' and 'POST' methods. + """ + actions = {} + for method in set(['PUT', 'POST']) & set(view.allowed_methods): + view.request = clone_request(request, method) + try: + # Test global permissions + if hasattr(view, 'check_permissions'): + view.check_permissions(view.request) + # Test object permissions + if method == 'PUT' and hasattr(view, 'get_object'): + view.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 = view.get_serializer() + actions[method] = self.get_serializer_info(serializer) + finally: + view.request = request + + return actions + + def get_serializer_info(self, serializer): + """ + Given an instance of a serializer, return a dictionary of metadata + about its fields. + """ + return SortedDict([ + (field_name, self.get_field_info(field)) + for field_name, field in six.iteritems(serializer.fields) + ]) + + def get_field_info(self, field): + """ + Given an instance of a serializer field, return a dictionary + of metadata about it. + """ + field_info = SortedDict() + field_info['type'] = self.label_lookup[field] + field_info['required'] = getattr(field, 'required', False) + + for attr in ['read_only', 'label', 'help_text', 'min_length', 'max_length']: + value = getattr(field, attr, None) + if value is not None and value != '': + field_info[attr] = force_text(value, strings_only=True) + + if hasattr(field, 'choices'): + field_info['choices'] = [ + {'value': choice_value, 'display_name': choice_name} + for choice_value, choice_name in field.choices.items() + ] + + return field_info diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index d8d72a4c6..8902294bc 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -21,7 +21,7 @@ from rest_framework.utils import html, model_meta, representation from rest_framework.utils.field_mapping import ( get_url_kwargs, get_field_kwargs, get_relation_kwargs, get_nested_relation_kwargs, - lookup_class + ClassLookupDict ) import copy import inspect @@ -318,7 +318,7 @@ class ListSerializer(BaseSerializer): class ModelSerializer(Serializer): - _field_mapping = { + _field_mapping = ClassLookupDict({ models.AutoField: IntegerField, models.BigIntegerField: IntegerField, models.BooleanField: BooleanField, @@ -341,7 +341,7 @@ class ModelSerializer(Serializer): models.TextField: CharField, models.TimeField: TimeField, models.URLField: URLField, - } + }) _related_class = PrimaryKeyRelatedField def create(self, attrs): @@ -400,7 +400,7 @@ class ModelSerializer(Serializer): elif field_name in info.fields_and_pk: # Create regular model fields. model_field = info.fields_and_pk[field_name] - field_cls = lookup_class(self._field_mapping, model_field) + field_cls = self._field_mapping[model_field] kwargs = get_field_kwargs(field_name, model_field) if 'choices' in kwargs: # Fields with choices get coerced into `ChoiceField` diff --git a/rest_framework/settings.py b/rest_framework/settings.py index 421e146c8..d7fb0a436 100644 --- a/rest_framework/settings.py +++ b/rest_framework/settings.py @@ -45,6 +45,7 @@ DEFAULTS = { ), 'DEFAULT_THROTTLE_CLASSES': (), 'DEFAULT_CONTENT_NEGOTIATION_CLASS': 'rest_framework.negotiation.DefaultContentNegotiation', + 'DEFAULT_METADATA_CLASS': 'rest_framework.metadata.SimpleMetadata', # Genric view behavior 'DEFAULT_MODEL_SERIALIZER_CLASS': 'rest_framework.serializers.ModelSerializer', @@ -121,6 +122,7 @@ IMPORT_STRINGS = ( 'DEFAULT_PERMISSION_CLASSES', 'DEFAULT_THROTTLE_CLASSES', 'DEFAULT_CONTENT_NEGOTIATION_CLASS', + 'DEFAULT_METADATA_CLASS', 'DEFAULT_MODEL_SERIALIZER_CLASS', 'DEFAULT_PAGINATION_SERIALIZER_CLASS', 'DEFAULT_FILTER_BACKENDS', diff --git a/rest_framework/utils/field_mapping.py b/rest_framework/utils/field_mapping.py index c208afdc5..c37940832 100644 --- a/rest_framework/utils/field_mapping.py +++ b/rest_framework/utils/field_mapping.py @@ -9,17 +9,21 @@ from rest_framework.compat import clean_manytomany_helptext import inspect -def lookup_class(mapping, instance): +class ClassLookupDict(object): """ - Takes a dictionary with classes as keys, and an object. - Traverses the object's inheritance hierarchy in method - resolution order, and returns the first matching value + Takes a dictionary with classes as keys. + Lookups against this object will traverses the object's inheritance + hierarchy in method resolution order, and returns the first matching value from the dictionary or raises a KeyError if nothing matches. """ - for cls in inspect.getmro(instance.__class__): - if cls in mapping: - return mapping[cls] - raise KeyError('Class %s not found in lookup.', cls.__name__) + def __init__(self, mapping): + self.mapping = mapping + + def __getitem__(self, key): + for cls in inspect.getmro(key.__class__): + if cls in self.mapping: + return self.mapping[cls] + raise KeyError('Class %s not found in lookup.', cls.__name__) def needs_label(model_field, field_name): diff --git a/rest_framework/views.py b/rest_framework/views.py index 9f08a4ad5..835e223a7 100644 --- a/rest_framework/views.py +++ b/rest_framework/views.py @@ -5,7 +5,6 @@ from __future__ import unicode_literals from django.core.exceptions import PermissionDenied, ValidationError, NON_FIELD_ERRORS from django.http import Http404 -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 smart_text, HttpResponseBase, View @@ -99,6 +98,7 @@ class APIView(View): throttle_classes = api_settings.DEFAULT_THROTTLE_CLASSES permission_classes = api_settings.DEFAULT_PERMISSION_CLASSES content_negotiation_class = api_settings.DEFAULT_CONTENT_NEGOTIATION_CLASS + metadata_class = api_settings.DEFAULT_METADATA_CLASS # Allow dependancy injection of other settings to make testing easier. settings = api_settings @@ -418,22 +418,8 @@ class APIView(View): def options(self, request, *args, **kwargs): """ Handler method for HTTP 'OPTIONS' request. - We may as well implement this as Django will otherwise provide - 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. - """ - # By default we can't provide any form-like information, however the - # generic views override this implementation and add additional - # information for POST and PUT methods, based on the serializer. - ret = SortedDict() - ret['name'] = self.get_view_name() - ret['description'] = self.get_view_description() - ret['renders'] = [renderer.media_type for renderer in self.renderer_classes] - ret['parses'] = [parser.media_type for parser in self.parser_classes] - return ret + if self.metadata_class is None: + return self.http_method_not_allowed(request, *args, **kwargs) + data = self.metadata_class().determine_metadata(request, self) + return Response(data, status=status.HTTP_200_OK) diff --git a/tests/test_metadata.py b/tests/test_metadata.py new file mode 100644 index 000000000..0ebea935f --- /dev/null +++ b/tests/test_metadata.py @@ -0,0 +1,166 @@ +from __future__ import unicode_literals + +from rest_framework import exceptions, serializers, views +from rest_framework.request import Request +from rest_framework.test import APIRequestFactory +import pytest + +request = Request(APIRequestFactory().options('/')) + + +class TestMetadata: + def test_metadata(self): + """ + OPTIONS requests to views should return a valid 200 response. + """ + class ExampleView(views.APIView): + """Example view.""" + pass + + response = ExampleView().options(request=request) + expected = { + 'name': 'Example', + 'description': 'Example view.', + 'renders': [ + 'application/json', + 'text/html' + ], + 'parses': [ + 'application/json', + 'application/x-www-form-urlencoded', + 'multipart/form-data' + ] + } + assert response.status_code == 200 + assert response.data == expected + + def test_none_metadata(self): + """ + OPTIONS requests to views where `metadata_class = None` should raise + a MethodNotAllowed exception, which will result in an HTTP 405 response. + """ + class ExampleView(views.APIView): + metadata_class = None + + with pytest.raises(exceptions.MethodNotAllowed): + ExampleView().options(request=request) + + def test_actions(self): + """ + On generic views OPTIONS should return an 'actions' key with metadata + on the fields that may be supplied to PUT and POST requests. + """ + class ExampleSerializer(serializers.Serializer): + choice_field = serializers.ChoiceField(['red', 'green', 'blue']) + integer_field = serializers.IntegerField(max_value=10) + char_field = serializers.CharField(required=False) + + class ExampleView(views.APIView): + """Example view.""" + def post(self, request): + pass + + def get_serializer(self): + return ExampleSerializer() + + response = ExampleView().options(request=request) + expected = { + 'name': 'Example', + 'description': 'Example view.', + 'renders': [ + 'application/json', + 'text/html' + ], + 'parses': [ + 'application/json', + 'application/x-www-form-urlencoded', + 'multipart/form-data' + ], + 'actions': { + 'POST': { + 'choice_field': { + 'type': 'choice', + 'required': True, + 'read_only': False, + 'label': 'Choice field', + 'choices': [ + {'display_name': 'blue', 'value': 'blue'}, + {'display_name': 'green', 'value': 'green'}, + {'display_name': 'red', 'value': 'red'} + ] + }, + 'integer_field': { + 'type': 'integer', + 'required': True, + 'read_only': False, + 'label': 'Integer field' + }, + 'char_field': { + 'type': 'string', + 'required': False, + 'read_only': False, + 'label': 'Char field' + } + } + } + } + assert response.status_code == 200 + assert response.data == expected + + def test_global_permissions(self): + """ + If a user does not have global permissions on an action, then any + metadata associated with it should not be included in OPTION responses. + """ + class ExampleSerializer(serializers.Serializer): + choice_field = serializers.ChoiceField(['red', 'green', 'blue']) + integer_field = serializers.IntegerField(max_value=10) + char_field = serializers.CharField(required=False) + + class ExampleView(views.APIView): + """Example view.""" + def post(self, request): + pass + + def put(self, request): + pass + + def get_serializer(self): + return ExampleSerializer() + + def check_permissions(self, request): + if request.method == 'POST': + raise exceptions.PermissionDenied() + + response = ExampleView().options(request=request) + assert response.status_code == 200 + assert list(response.data['actions'].keys()) == ['PUT'] + + def test_object_permissions(self): + """ + If a user does not have object permissions on an action, then any + metadata associated with it should not be included in OPTION responses. + """ + class ExampleSerializer(serializers.Serializer): + choice_field = serializers.ChoiceField(['red', 'green', 'blue']) + integer_field = serializers.IntegerField(max_value=10) + char_field = serializers.CharField(required=False) + + class ExampleView(views.APIView): + """Example view.""" + def post(self, request): + pass + + def put(self, request): + pass + + def get_serializer(self): + return ExampleSerializer() + + def get_object(self): + if self.request.method == 'PUT': + raise exceptions.PermissionDenied() + + response = ExampleView().options(request=request) + assert response.status_code == 200 + assert list(response.data['actions'].keys()) == ['POST']