Clean up OPTIONS implementation

This commit is contained in:
Tom Christie 2013-05-24 23:44:23 +01:00
parent 760e8642bd
commit fcaee6e580
10 changed files with 175 additions and 166 deletions

View File

@ -44,6 +44,7 @@ You can determine your currently installed version using `pip freeze`:
* Serializer fields now support `label` and `help_text`.
* Added `UnicodeJSONRenderer`.
* `OPTIONS` requests now return metadata about fields for `POST` and `PUT` requests.
* Bugfix: `charset` now properly included in `Content-Type` of responses.
* Bugfix: Blank choice now added in browsable API on nullable relationships.
* Bugfix: Many to many relationships with `through` tables are now read-only.

View File

@ -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,6 +413,7 @@ class URLField(CharField):
class SlugField(CharField):
type_name = 'SlugField'
type_label = 'slug'
form_field_class = forms.SlugField
default_error_messages = {
@ -444,6 +435,7 @@ class SlugField(CharField):
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 = {

View File

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

View File

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

View File

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

View File

@ -122,22 +122,25 @@ class TestRootView(TestCase):
],
'name': 'Root',
'description': 'Example description for OPTIONS.',
'actions': {}
}
expected['actions']['GET'] = {}
expected['actions']['POST'] = {
'actions': {
'POST': {
'text': {
'max_length': 100,
'read_only': False,
'required': True,
'type': 'String',
'type': 'string',
"label": "Text comes here",
"help_text": "Text description."
},
'id': {
'read_only': True,
'required': False,
'type': 'Integer',
'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] = {
'actions': {
'PUT': {
'text': {
'max_length': 100,
'read_only': False,
'required': True,
'type': 'String',
'type': 'string',
'label': 'Text comes here',
'help_text': 'Text description.'
},
'id': {
'read_only': True,
'required': False,
'type': 'Integer',
'type': 'integer',
'label': 'ID',
},
}
}
}
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, expected)

View File

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

View File

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

View File

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

View File

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