OPTIONS support

This commit is contained in:
Tom Christie 2014-09-24 14:09:49 +01:00
parent aa84432f9b
commit f4b1dcb167
7 changed files with 316 additions and 81 deletions

View File

@ -4,13 +4,11 @@ Generic views that provide commonly needed behaviour.
from __future__ import unicode_literals from __future__ import unicode_literals
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
from django.core.exceptions import PermissionDenied
from django.core.paginator import Paginator, InvalidPage from django.core.paginator import Paginator, InvalidPage
from django.http import Http404 from django.http import Http404
from django.shortcuts import get_object_or_404 as _get_object_or_404 from django.shortcuts import get_object_or_404 as _get_object_or_404
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from rest_framework import views, mixins, exceptions from rest_framework import views, mixins
from rest_framework.request import clone_request
from rest_framework.settings import api_settings from rest_framework.settings import api_settings
@ -249,53 +247,6 @@ class GenericAPIView(views.APIView):
return obj 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 # Concrete view classes that provide method handlers
# by composing the mixin classes with the base view. # by composing the mixin classes with the base view.

126
rest_framework/metadata.py Normal file
View File

@ -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

View File

@ -21,7 +21,7 @@ from rest_framework.utils import html, model_meta, representation
from rest_framework.utils.field_mapping import ( from rest_framework.utils.field_mapping import (
get_url_kwargs, get_field_kwargs, get_url_kwargs, get_field_kwargs,
get_relation_kwargs, get_nested_relation_kwargs, get_relation_kwargs, get_nested_relation_kwargs,
lookup_class ClassLookupDict
) )
import copy import copy
import inspect import inspect
@ -318,7 +318,7 @@ class ListSerializer(BaseSerializer):
class ModelSerializer(Serializer): class ModelSerializer(Serializer):
_field_mapping = { _field_mapping = ClassLookupDict({
models.AutoField: IntegerField, models.AutoField: IntegerField,
models.BigIntegerField: IntegerField, models.BigIntegerField: IntegerField,
models.BooleanField: BooleanField, models.BooleanField: BooleanField,
@ -341,7 +341,7 @@ class ModelSerializer(Serializer):
models.TextField: CharField, models.TextField: CharField,
models.TimeField: TimeField, models.TimeField: TimeField,
models.URLField: URLField, models.URLField: URLField,
} })
_related_class = PrimaryKeyRelatedField _related_class = PrimaryKeyRelatedField
def create(self, attrs): def create(self, attrs):
@ -400,7 +400,7 @@ class ModelSerializer(Serializer):
elif field_name in info.fields_and_pk: elif field_name in info.fields_and_pk:
# Create regular model fields. # Create regular model fields.
model_field = info.fields_and_pk[field_name] 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) kwargs = get_field_kwargs(field_name, model_field)
if 'choices' in kwargs: if 'choices' in kwargs:
# Fields with choices get coerced into `ChoiceField` # Fields with choices get coerced into `ChoiceField`

View File

@ -45,6 +45,7 @@ DEFAULTS = {
), ),
'DEFAULT_THROTTLE_CLASSES': (), 'DEFAULT_THROTTLE_CLASSES': (),
'DEFAULT_CONTENT_NEGOTIATION_CLASS': 'rest_framework.negotiation.DefaultContentNegotiation', 'DEFAULT_CONTENT_NEGOTIATION_CLASS': 'rest_framework.negotiation.DefaultContentNegotiation',
'DEFAULT_METADATA_CLASS': 'rest_framework.metadata.SimpleMetadata',
# Genric view behavior # Genric view behavior
'DEFAULT_MODEL_SERIALIZER_CLASS': 'rest_framework.serializers.ModelSerializer', 'DEFAULT_MODEL_SERIALIZER_CLASS': 'rest_framework.serializers.ModelSerializer',
@ -121,6 +122,7 @@ IMPORT_STRINGS = (
'DEFAULT_PERMISSION_CLASSES', 'DEFAULT_PERMISSION_CLASSES',
'DEFAULT_THROTTLE_CLASSES', 'DEFAULT_THROTTLE_CLASSES',
'DEFAULT_CONTENT_NEGOTIATION_CLASS', 'DEFAULT_CONTENT_NEGOTIATION_CLASS',
'DEFAULT_METADATA_CLASS',
'DEFAULT_MODEL_SERIALIZER_CLASS', 'DEFAULT_MODEL_SERIALIZER_CLASS',
'DEFAULT_PAGINATION_SERIALIZER_CLASS', 'DEFAULT_PAGINATION_SERIALIZER_CLASS',
'DEFAULT_FILTER_BACKENDS', 'DEFAULT_FILTER_BACKENDS',

View File

@ -9,17 +9,21 @@ from rest_framework.compat import clean_manytomany_helptext
import inspect import inspect
def lookup_class(mapping, instance): class ClassLookupDict(object):
""" """
Takes a dictionary with classes as keys, and an object. Takes a dictionary with classes as keys.
Traverses the object's inheritance hierarchy in method Lookups against this object will traverses the object's inheritance
resolution order, and returns the first matching value hierarchy in method resolution order, and returns the first matching value
from the dictionary or raises a KeyError if nothing matches. from the dictionary or raises a KeyError if nothing matches.
""" """
for cls in inspect.getmro(instance.__class__): def __init__(self, mapping):
if cls in mapping: self.mapping = mapping
return mapping[cls]
raise KeyError('Class %s not found in lookup.', cls.__name__) 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): def needs_label(model_field, field_name):

View File

@ -5,7 +5,6 @@ from __future__ import unicode_literals
from django.core.exceptions import PermissionDenied, ValidationError, NON_FIELD_ERRORS from django.core.exceptions import PermissionDenied, ValidationError, NON_FIELD_ERRORS
from django.http import Http404 from django.http import Http404
from django.utils.datastructures import SortedDict
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from rest_framework import status, exceptions from rest_framework import status, exceptions
from rest_framework.compat import smart_text, HttpResponseBase, View from rest_framework.compat import smart_text, HttpResponseBase, View
@ -99,6 +98,7 @@ class APIView(View):
throttle_classes = api_settings.DEFAULT_THROTTLE_CLASSES throttle_classes = api_settings.DEFAULT_THROTTLE_CLASSES
permission_classes = api_settings.DEFAULT_PERMISSION_CLASSES permission_classes = api_settings.DEFAULT_PERMISSION_CLASSES
content_negotiation_class = api_settings.DEFAULT_CONTENT_NEGOTIATION_CLASS 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. # Allow dependancy injection of other settings to make testing easier.
settings = api_settings settings = api_settings
@ -418,22 +418,8 @@ class APIView(View):
def options(self, request, *args, **kwargs): def options(self, request, *args, **kwargs):
""" """
Handler method for HTTP 'OPTIONS' request. 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) if self.metadata_class is None:
return self.http_method_not_allowed(request, *args, **kwargs)
def metadata(self, request): data = self.metadata_class().determine_metadata(request, self)
""" return Response(data, status=status.HTTP_200_OK)
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

166
tests/test_metadata.py Normal file
View File

@ -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']