yet more API cleanup

This commit is contained in:
Tom Christie 2011-05-12 15:11:14 +01:00
parent 15f9e7c566
commit b5b231a874
7 changed files with 119 additions and 124 deletions

View File

@ -48,23 +48,18 @@ class RequestMixin(object):
parsers = () parsers = ()
def _get_method(self): @property
def method(self):
""" """
Returns the HTTP method for the current view. Returns the HTTP method.
""" """
if not hasattr(self, '_method'): if not hasattr(self, '_method'):
self._method = self.request.method self._method = self.request.method
return self._method return self._method
def _set_method(self, method): @property
""" def content_type(self):
Set the method for the current view.
"""
self._method = method
def _get_content_type(self):
""" """
Returns the content type header. Returns the content type header.
""" """
@ -73,11 +68,32 @@ class RequestMixin(object):
return self._content_type return self._content_type
def _set_content_type(self, content_type): @property
def DATA(self):
""" """
Set the content type header. Returns the request data.
""" """
self._content_type = content_type if not hasattr(self, '_data'):
self._load_data_and_files()
return self._data
@property
def FILES(self):
"""
Returns the request files.
"""
if not hasattr(self, '_files'):
self._load_data_and_files()
return self._files
def _load_data_and_files(self):
"""
Parse the request content into self.DATA and self.FILES.
"""
stream = self._get_stream()
(self._data, self._files) = self._parse(stream, self.content_type)
def _get_stream(self): def _get_stream(self):
@ -134,27 +150,6 @@ class RequestMixin(object):
return self._stream return self._stream
def _set_stream(self, stream):
"""
Set the stream representing the request body.
"""
self._stream = stream
def _load_data_and_files(self):
(self._data, self._files) = self._parse(self.stream, self.content_type)
def _get_data(self):
if not hasattr(self, '_data'):
self._load_data_and_files()
return self._data
def _get_files(self):
if not hasattr(self, '_files'):
self._load_data_and_files()
return self._files
# TODO: Modify this so that it happens implictly, rather than being called explicitly # TODO: Modify this so that it happens implictly, rather than being called explicitly
# ie accessing any of .DATA, .FILES, .content_type, .method will force # ie accessing any of .DATA, .FILES, .content_type, .method will force
# form overloading. # form overloading.
@ -164,7 +159,10 @@ class RequestMixin(object):
If it is then alter self.method, self.content_type, self.CONTENT to reflect that rather than simply If it is then alter self.method, self.content_type, self.CONTENT to reflect that rather than simply
delegating them to the original request. delegating them to the original request.
""" """
if not self._USE_FORM_OVERLOADING or self.method != 'POST' or not is_form_media_type(self.content_type):
# We only need to use form overloading on form POST requests
content_type = self.request.META.get('HTTP_CONTENT_TYPE', self.request.META.get('CONTENT_TYPE', ''))
if not self._USE_FORM_OVERLOADING or self.request.method != 'POST' or not not is_form_media_type(content_type):
return return
# Temporarily switch to using the form parsers, then parse the content # Temporarily switch to using the form parsers, then parse the content
@ -175,7 +173,7 @@ class RequestMixin(object):
# Method overloading - change the method and remove the param from the content # Method overloading - change the method and remove the param from the content
if self._METHOD_PARAM in content: if self._METHOD_PARAM in content:
self.method = content[self._METHOD_PARAM].upper() self._method = content[self._METHOD_PARAM].upper()
del self._data[self._METHOD_PARAM] del self._data[self._METHOD_PARAM]
# Content overloading - rewind the stream and modify the content type # Content overloading - rewind the stream and modify the content type
@ -207,28 +205,21 @@ class RequestMixin(object):
@property @property
def parsed_media_types(self): def _parsed_media_types(self):
""" """
Return an list of all the media types that this view can parse. Return a list of all the media types that this view can parse.
""" """
return [parser.media_type for parser in self.parsers] return [parser.media_type for parser in self.parsers]
@property @property
def default_parser(self): def _default_parser(self):
""" """
Return the view's most preferred parser. Return the view's default parser.
(This has no behavioral effect, but is may be used by documenting renderers)
""" """
return self.parsers[0] return self.parsers[0]
method = property(_get_method, _set_method)
content_type = property(_get_content_type, _set_content_type)
stream = property(_get_stream, _set_stream)
DATA = property(_get_data)
FILES = property(_get_files)
########## ResponseMixin ########## ########## ResponseMixin ##########
@ -240,8 +231,9 @@ class ResponseMixin(object):
Also supports overriding the content type by specifying an _accept= parameter in the URL. Also supports overriding the content type by specifying an _accept= parameter in the URL.
Ignores Accept headers from Internet Explorer user agents and uses a sensible browser Accept header instead. Ignores Accept headers from Internet Explorer user agents and uses a sensible browser Accept header instead.
""" """
ACCEPT_QUERY_PARAM = '_accept' # Allow override of Accept header in URL query params
REWRITE_IE_ACCEPT_HEADER = True _ACCEPT_QUERY_PARAM = '_accept' # Allow override of Accept header in URL query params
_IGNORE_IE_ACCEPT_HEADER = True
renderers = () renderers = ()
@ -256,7 +248,7 @@ class ResponseMixin(object):
try: try:
renderer = self._determine_renderer(self.request) renderer = self._determine_renderer(self.request)
except ErrorResponse, exc: except ErrorResponse, exc:
renderer = self.default_renderer renderer = self._default_renderer
response = exc.response response = exc.response
# Serialize the response content # Serialize the response content
@ -287,10 +279,10 @@ class ResponseMixin(object):
See: RFC 2616, Section 14 - http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html See: RFC 2616, Section 14 - http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html
""" """
if self.ACCEPT_QUERY_PARAM and request.GET.get(self.ACCEPT_QUERY_PARAM, None): if self._ACCEPT_QUERY_PARAM and request.GET.get(self._ACCEPT_QUERY_PARAM, None):
# Use _accept parameter override # Use _accept parameter override
accept_list = [request.GET.get(self.ACCEPT_QUERY_PARAM)] accept_list = [request.GET.get(self._ACCEPT_QUERY_PARAM)]
elif (self.REWRITE_IE_ACCEPT_HEADER and elif (self._IGNORE_IE_ACCEPT_HEADER and
request.META.has_key('HTTP_USER_AGENT') and request.META.has_key('HTTP_USER_AGENT') and
MSIE_USER_AGENT_REGEX.match(request.META['HTTP_USER_AGENT'])): MSIE_USER_AGENT_REGEX.match(request.META['HTTP_USER_AGENT'])):
accept_list = ['text/html', '*/*'] accept_list = ['text/html', '*/*']
@ -299,7 +291,7 @@ class ResponseMixin(object):
accept_list = request.META["HTTP_ACCEPT"].split(',') accept_list = request.META["HTTP_ACCEPT"].split(',')
else: else:
# No accept header specified # No accept header specified
return self.default_renderer return self._default_renderer
# Parse the accept header into a dict of {qvalue: set of media types} # Parse the accept header into a dict of {qvalue: set of media types}
# We ignore mietype parameters # We ignore mietype parameters
@ -340,25 +332,24 @@ class ResponseMixin(object):
# Return default # Return default
if '*/*' in accept_set: if '*/*' in accept_set:
return self.default_renderer return self._default_renderer
raise ErrorResponse(status.HTTP_406_NOT_ACCEPTABLE, raise ErrorResponse(status.HTTP_406_NOT_ACCEPTABLE,
{'detail': 'Could not satisfy the client\'s Accept header', {'detail': 'Could not satisfy the client\'s Accept header',
'available_types': self.rendered_media_types}) 'available_types': self._rendered_media_types})
@property @property
def rendered_media_types(self): def _rendered_media_types(self):
""" """
Return an list of all the media types that this resource can render. Return an list of all the media types that this view can render.
""" """
return [renderer.media_type for renderer in self.renderers] return [renderer.media_type for renderer in self.renderers]
@property @property
def default_renderer(self): def _default_renderer(self):
""" """
Return the resource's most preferred renderer. Return the view's default renderer.
(This renderer is used if the client does not send and Accept: header, or sends Accept: */*)
""" """
return self.renderers[0] return self.renderers[0]
@ -367,8 +358,7 @@ class ResponseMixin(object):
class AuthMixin(object): class AuthMixin(object):
""" """
Simple mixin class to provide authentication and permission checking, Simple mixin class to add authentication and permission checking to a ``View`` class.
by adding a set of authentication and permission classes on a ``View``.
""" """
authentication = () authentication = ()
permissions = () permissions = ()
@ -404,16 +394,16 @@ class AuthMixin(object):
########## Resource Mixin ########## ########## Resource Mixin ##########
class ResourceMixin(object): class ResourceMixin(object):
@property @property
def CONTENT(self): def CONTENT(self):
if not hasattr(self, '_content'): if not hasattr(self, '_content'):
self._content = self._get_content(self.DATA, self.FILES) self._content = self._get_content()
return self._content return self._content
def _get_content(self, data, files): def _get_content(self):
resource = self.resource(self) resource = self.resource(self)
return resource.validate(data, files) return resource.validate(self.DATA, self.FILES)
def get_bound_form(self, content=None): def get_bound_form(self, content=None):
resource = self.resource(self) resource = self.resource(self)

View File

@ -52,7 +52,7 @@ class BaseRenderer(object):
should render the output. should render the output.
EG: 'application/json; indent=4' EG: 'application/json; indent=4'
By default render simply returns the ouput as-is. By default render simply returns the output as-is.
Override this method to provide for other behavior. Override this method to provide for other behavior.
""" """
if obj is None: if obj is None:
@ -61,6 +61,41 @@ class BaseRenderer(object):
return str(obj) return str(obj)
class JSONRenderer(BaseRenderer):
"""
Renderer which serializes to JSON
"""
media_type = 'application/json'
def render(self, obj=None, media_type=None):
if obj is None:
return ''
# If the media type looks like 'application/json; indent=4', then
# pretty print the result.
indent = get_media_type_params(media_type).get('indent', None)
sort_keys = False
try:
indent = max(min(int(indent), 8), 0)
sort_keys = True
except (ValueError, TypeError):
indent = None
return json.dumps(obj, indent=indent, sort_keys=sort_keys)
class XMLRenderer(BaseRenderer):
"""
Renderer which serializes to XML.
"""
media_type = 'application/xml'
def render(self, obj=None, media_type=None):
if obj is None:
return ''
return dict2xml(obj)
class TemplateRenderer(BaseRenderer): class TemplateRenderer(BaseRenderer):
""" """
A Base class provided for convenience. A Base class provided for convenience.
@ -161,8 +196,8 @@ class DocumentingTemplateRenderer(BaseRenderer):
Add the fields dynamically.""" Add the fields dynamically."""
super(GenericContentForm, self).__init__() super(GenericContentForm, self).__init__()
contenttype_choices = [(media_type, media_type) for media_type in view.parsed_media_types] contenttype_choices = [(media_type, media_type) for media_type in view._parsed_media_types]
initial_contenttype = view.default_parser.media_type initial_contenttype = view._default_parser.media_type
self.fields[view._CONTENTTYPE_PARAM] = forms.ChoiceField(label='Content Type', self.fields[view._CONTENTTYPE_PARAM] = forms.ChoiceField(label='Content Type',
choices=contenttype_choices, choices=contenttype_choices,
@ -204,16 +239,19 @@ class DocumentingTemplateRenderer(BaseRenderer):
template = loader.get_template(self.template) template = loader.get_template(self.template)
context = RequestContext(self.view.request, { context = RequestContext(self.view.request, {
'content': content, 'content': content,
'resource': self.view, # TODO: rename to view 'view': self.view,
'request': self.view.request, # TODO: remove 'request': self.view.request, # TODO: remove
'response': self.view.response, 'response': self.view.response,
'description': description, 'description': description,
'name': name, 'name': name,
'markeddown': markeddown, 'markeddown': markeddown,
'breadcrumblist': breadcrumb_list, 'breadcrumblist': breadcrumb_list,
'available_media_types': self.view._rendered_media_types,
'form': form_instance, 'form': form_instance,
'login_url': login_url, 'login_url': login_url,
'logout_url': logout_url, 'logout_url': logout_url,
'ACCEPT_PARAM': self.view._ACCEPT_QUERY_PARAM,
'METHOD_PARAM': self.view._METHOD_PARAM,
'ADMIN_MEDIA_PREFIX': settings.ADMIN_MEDIA_PREFIX 'ADMIN_MEDIA_PREFIX': settings.ADMIN_MEDIA_PREFIX
}) })
@ -228,39 +266,6 @@ class DocumentingTemplateRenderer(BaseRenderer):
return ret return ret
class JSONRenderer(BaseRenderer):
"""
Renderer which serializes to JSON
"""
media_type = 'application/json'
def render(self, obj=None, media_type=None):
if obj is None:
return ''
indent = get_media_type_params(media_type).get('indent', None)
if indent is not None:
try:
indent = int(indent)
except ValueError:
indent = None
sort_keys = indent and True or False
return json.dumps(obj, indent=indent, sort_keys=sort_keys)
class XMLRenderer(BaseRenderer):
"""
Renderer which serializes to XML.
"""
media_type = 'application/xml'
def render(self, obj=None, media_type=None):
if obj is None:
return ''
return dict2xml(obj)
class DocumentingHTMLRenderer(DocumentingTemplateRenderer): class DocumentingHTMLRenderer(DocumentingTemplateRenderer):
""" """
Renderer which provides a browsable HTML interface for an API. Renderer which provides a browsable HTML interface for an API.

View File

@ -42,14 +42,14 @@
{% endfor %} {% endfor %}
{{ content|urlize_quoted_links }}</pre>{% endautoescape %}</div> {{ content|urlize_quoted_links }}</pre>{% endautoescape %}</div>
{% if 'GET' in resource.allowed_methods %} {% if 'GET' in view.allowed_methods %}
<form> <form>
<fieldset class='module aligned'> <fieldset class='module aligned'>
<h2>GET {{ name }}</h2> <h2>GET {{ name }}</h2>
<div class='submit-row' style='margin: 0; border: 0'> <div class='submit-row' style='margin: 0; border: 0'>
<a href='{{ request.path }}' rel="nofollow" style='float: left'>GET</a> <a href='{{ request.path }}' rel="nofollow" style='float: left'>GET</a>
{% for media_type in resource.rendered_media_types %} {% for media_type in available_media_types %}
{% with resource.ACCEPT_QUERY_PARAM|add:"="|add:media_type as param %} {% with ACCEPT_PARAM|add:"="|add:media_type as param %}
[<a href='{{ request.path|add_query_param:param }}' rel="nofollow">{{ media_type }}</a>] [<a href='{{ request.path|add_query_param:param }}' rel="nofollow">{{ media_type }}</a>]
{% endwith %} {% endwith %}
{% endfor %} {% endfor %}
@ -63,8 +63,8 @@
*** (We could display only the POST form if method tunneling is disabled, but I think *** *** (We could display only the POST form if method tunneling is disabled, but I think ***
*** the user experience would be confusing, so we simply turn all forms off. *** {% endcomment %} *** the user experience would be confusing, so we simply turn all forms off. *** {% endcomment %}
{% if resource.METHOD_PARAM and form %} {% if METHOD_PARAM and form %}
{% if 'POST' in resource.allowed_methods %} {% if 'POST' in view.allowed_methods %}
<form action="{{ request.path }}" method="post" {% if form.is_multipart %}enctype="multipart/form-data"{% endif %}> <form action="{{ request.path }}" method="post" {% if form.is_multipart %}enctype="multipart/form-data"{% endif %}>
<fieldset class='module aligned'> <fieldset class='module aligned'>
<h2>POST {{ name }}</h2> <h2>POST {{ name }}</h2>
@ -85,11 +85,11 @@
</form> </form>
{% endif %} {% endif %}
{% if 'PUT' in resource.allowed_methods %} {% if 'PUT' in view.allowed_methods %}
<form action="{{ request.path }}" method="post" {% if form.is_multipart %}enctype="multipart/form-data"{% endif %}> <form action="{{ request.path }}" method="post" {% if form.is_multipart %}enctype="multipart/form-data"{% endif %}>
<fieldset class='module aligned'> <fieldset class='module aligned'>
<h2>PUT {{ name }}</h2> <h2>PUT {{ name }}</h2>
<input type="hidden" name="{{ resource.METHOD_PARAM }}" value="PUT" /> <input type="hidden" name="{{ METHOD_PARAM }}" value="PUT" />
{% csrf_token %} {% csrf_token %}
{{ form.non_field_errors }} {{ form.non_field_errors }}
{% for field in form %} {% for field in form %}
@ -107,12 +107,12 @@
</form> </form>
{% endif %} {% endif %}
{% if 'DELETE' in resource.allowed_methods %} {% if 'DELETE' in view.allowed_methods %}
<form action="{{ request.path }}" method="post"> <form action="{{ request.path }}" method="post">
<fieldset class='module aligned'> <fieldset class='module aligned'>
<h2>DELETE {{ name }}</h2> <h2>DELETE {{ name }}</h2>
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="{{ resource.METHOD_PARAM }}" value="DELETE" /> <input type="hidden" name="{{ METHOD_PARAM }}" value="DELETE" />
<div class='submit-row' style='margin: 0; border: 0'> <div class='submit-row' style='margin: 0; border: 0'>
<input type="submit" value="DELETE" class="default" /> <input type="submit" value="DELETE" class="default" />
</div> </div>

View File

@ -40,9 +40,9 @@ class UserAgentMungingTest(TestCase):
self.assertEqual(resp['Content-Type'], 'text/html') self.assertEqual(resp['Content-Type'], 'text/html')
def test_dont_rewrite_msie_accept_header(self): def test_dont_rewrite_msie_accept_header(self):
"""Turn off REWRITE_IE_ACCEPT_HEADER, send MSIE user agent strings and ensure """Turn off _IGNORE_IE_ACCEPT_HEADER, send MSIE user agent strings and ensure
that we get a JSON response if we set a */* accept header.""" that we get a JSON response if we set a */* accept header."""
view = self.MockView.as_view(REWRITE_IE_ACCEPT_HEADER=False) view = self.MockView.as_view(_IGNORE_IE_ACCEPT_HEADER=False)
for user_agent in (MSIE_9_USER_AGENT, for user_agent in (MSIE_9_USER_AGENT,
MSIE_8_USER_AGENT, MSIE_8_USER_AGENT,

View File

@ -2,7 +2,7 @@ from django.test import TestCase
from django import forms from django import forms
from djangorestframework.compat import RequestFactory from djangorestframework.compat import RequestFactory
from djangorestframework.views import BaseView from djangorestframework.views import BaseView
from djangorestframework.resource import FormResource from djangorestframework.resources import FormResource
import StringIO import StringIO
class UploadFilesTests(TestCase): class UploadFilesTests(TestCase):

View File

@ -5,7 +5,7 @@ from djangorestframework.compat import RequestFactory
from djangorestframework.validators import BaseValidator, FormValidator, ModelFormValidator from djangorestframework.validators import BaseValidator, FormValidator, ModelFormValidator
from djangorestframework.response import ErrorResponse from djangorestframework.response import ErrorResponse
from djangorestframework.views import BaseView from djangorestframework.views import BaseView
from djangorestframework.resource import Resource from djangorestframework.resources import Resource
class TestValidatorMixinInterfaces(TestCase): class TestValidatorMixinInterfaces(TestCase):

View File

@ -4,7 +4,7 @@ from django.views.decorators.csrf import csrf_exempt
from djangorestframework.compat import View from djangorestframework.compat import View
from djangorestframework.response import Response, ErrorResponse from djangorestframework.response import Response, ErrorResponse
from djangorestframework.mixins import * from djangorestframework.mixins import *
from djangorestframework import resource, renderers, parsers, authentication, permissions, validators, status from djangorestframework import resources, renderers, parsers, authentication, permissions, status
__all__ = ( __all__ = (
@ -22,7 +22,7 @@ class BaseView(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, View):
Performs request deserialization, response serialization, authentication and input validation.""" Performs request deserialization, response serialization, authentication and input validation."""
# Use the base resource by default # Use the base resource by default
resource = resource.Resource resource = resources.Resource
# List of renderers the resource can serialize the response with, ordered by preference. # List of renderers the resource can serialize the response with, ordered by preference.
renderers = ( renderers.JSONRenderer, renderers = ( renderers.JSONRenderer,
@ -36,9 +36,6 @@ class BaseView(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, View):
parsers.FormParser, parsers.FormParser,
parsers.MultiPartParser ) parsers.MultiPartParser )
# List of validators to validate, cleanup and normalize the request content
validators = ( validators.FormValidator, )
# List of all authenticating methods to attempt. # List of all authenticating methods to attempt.
authentication = ( authentication.UserLoggedInAuthenticaton, authentication = ( authentication.UserLoggedInAuthenticaton,
authentication.BasicAuthenticaton ) authentication.BasicAuthenticaton )
@ -54,6 +51,9 @@ class BaseView(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, View):
@property @property
def allowed_methods(self): def allowed_methods(self):
"""
Return the list of allowed HTTP methods, uppercased.
"""
return [method.upper() for method in self.http_method_names if hasattr(self, method)] return [method.upper() for method in self.http_method_names if hasattr(self, method)]
def http_method_not_allowed(self, request, *args, **kwargs): def http_method_not_allowed(self, request, *args, **kwargs):
@ -61,7 +61,7 @@ class BaseView(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, View):
Return an HTTP 405 error if an operation is called which does not have a handler method. Return an HTTP 405 error if an operation is called which does not have a handler method.
""" """
raise ErrorResponse(status.HTTP_405_METHOD_NOT_ALLOWED, raise ErrorResponse(status.HTTP_405_METHOD_NOT_ALLOWED,
{'detail': 'Method \'%s\' not allowed on this resource.' % self.method}) {'detail': 'Method \'%s\' not allowed on this resource.' % self.method})
# Note: session based authentication is explicitly CSRF validated, # Note: session based authentication is explicitly CSRF validated,
@ -127,7 +127,7 @@ class BaseView(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, View):
class ModelView(BaseView): class ModelView(BaseView):
"""A RESTful view that maps to a model in the database.""" """A RESTful view that maps to a model in the database."""
validators = (validators.ModelFormValidator,) resource = resources.ModelResource
class InstanceModelView(ReadModelMixin, UpdateModelMixin, DeleteModelMixin, ModelView): class InstanceModelView(ReadModelMixin, UpdateModelMixin, DeleteModelMixin, ModelView):
"""A view which provides default operations for read/update/delete against a model instance.""" """A view which provides default operations for read/update/delete against a model instance."""