Rename mixins into Mixin class, rename ResponseException to ErrorResponse, remove NoContent

This commit is contained in:
Tom Christie 2011-04-11 16:38:00 +01:00
parent a1ed565081
commit 349ffcaf5f
11 changed files with 64 additions and 191 deletions

View File

@ -4,11 +4,10 @@ by serializing the output along with documentation regarding the Resource, outpu
and providing forms and links depending on the allowed methods, emitters and parsers on the Resource.
"""
from django.conf import settings
from django.http import HttpResponse
from django.template import RequestContext, loader
from django import forms
from djangorestframework.response import NoContent, ResponseException
from djangorestframework.response import ErrorResponse
from djangorestframework.utils import dict2xml, url_resolves
from djangorestframework.markdownwrapper import apply_markdown
from djangorestframework.breadcrumbs import get_breadcrumbs
@ -18,7 +17,6 @@ from djangorestframework import status
from urllib import quote_plus
import string
import re
from decimal import Decimal
try:
import json
@ -26,132 +24,9 @@ except ImportError:
import simplejson as json
_MSIE_USER_AGENT = re.compile(r'^Mozilla/[0-9]+\.[0-9]+ \([^)]*; MSIE [0-9]+\.[0-9]+[a-z]?;[^)]*\)(?!.* Opera )')
class EmitterMixin(object):
"""Adds behaviour for pluggable Emitters to a :class:`.Resource` or Django :class:`View`. class.
Default behaviour is to use standard HTTP Accept header content negotiation.
Also supports overidding 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."""
ACCEPT_QUERY_PARAM = '_accept' # Allow override of Accept header in URL query params
REWRITE_IE_ACCEPT_HEADER = True
request = None
response = None
emitters = ()
def emit(self, response):
"""Takes a :class:`Response` object and returns a Django :class:`HttpResponse`."""
self.response = response
try:
emitter = self._determine_emitter(self.request)
except ResponseException, exc:
emitter = self.default_emitter
response = exc.response
# Serialize the response content
if response.has_content_body:
content = emitter(self).emit(output=response.cleaned_content)
else:
content = emitter(self).emit()
# Munge DELETE Response code to allow us to return content
# (Do this *after* we've rendered the template so that we include the normal deletion response code in the output)
if response.status == 204:
response.status = 200
# Build the HTTP Response
# TODO: Check if emitter.mimetype is underspecified, or if a content-type header has been set
resp = HttpResponse(content, mimetype=emitter.media_type, status=response.status)
for (key, val) in response.headers.items():
resp[key] = val
return resp
def _determine_emitter(self, request):
"""Return the appropriate emitter for the output, given the client's 'Accept' header,
and the content types that this Resource knows how to serve.
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):
# Use _accept parameter override
accept_list = [request.GET.get(self.ACCEPT_QUERY_PARAM)]
elif self.REWRITE_IE_ACCEPT_HEADER and request.META.has_key('HTTP_USER_AGENT') and _MSIE_USER_AGENT.match(request.META['HTTP_USER_AGENT']):
accept_list = ['text/html', '*/*']
elif request.META.has_key('HTTP_ACCEPT'):
# Use standard HTTP Accept negotiation
accept_list = request.META["HTTP_ACCEPT"].split(',')
else:
# No accept header specified
return self.default_emitter
# Parse the accept header into a dict of {qvalue: set of media types}
# We ignore mietype parameters
accept_dict = {}
for token in accept_list:
components = token.split(';')
mimetype = components[0].strip()
qvalue = Decimal('1.0')
if len(components) > 1:
# Parse items that have a qvalue eg text/html;q=0.9
try:
(q, num) = components[-1].split('=')
if q == 'q':
qvalue = Decimal(num)
except:
# Skip malformed entries
continue
if accept_dict.has_key(qvalue):
accept_dict[qvalue].add(mimetype)
else:
accept_dict[qvalue] = set((mimetype,))
# Convert to a list of sets ordered by qvalue (highest first)
accept_sets = [accept_dict[qvalue] for qvalue in sorted(accept_dict.keys(), reverse=True)]
for accept_set in accept_sets:
# Return any exact match
for emitter in self.emitters:
if emitter.media_type in accept_set:
return emitter
# Return any subtype match
for emitter in self.emitters:
if emitter.media_type.split('/')[0] + '/*' in accept_set:
return emitter
# Return default
if '*/*' in accept_set:
return self.default_emitter
raise ResponseException(status.HTTP_406_NOT_ACCEPTABLE,
{'detail': 'Could not statisfy the client\'s Accept header',
'available_types': self.emitted_media_types})
@property
def emitted_media_types(self):
"""Return an list of all the media types that this resource can emit."""
return [emitter.media_type for emitter in self.emitters]
@property
def default_emitter(self):
"""Return the resource's most prefered emitter.
(This emitter is used if the client does not send and Accept: header, or sends Accept: */*)"""
return self.emitters[0]
# TODO: Rename verbose to something more appropriate
# TODO: NoContent could be handled more cleanly. It'd be nice if it was handled by default,
# TODO: Maybe None could be handled more cleanly. It'd be nice if it was handled by default,
# and only have an emitter output anything if it explicitly provides support for that.
class BaseEmitter(object):
@ -162,10 +37,10 @@ class BaseEmitter(object):
def __init__(self, resource):
self.resource = resource
def emit(self, output=NoContent, verbose=False):
def emit(self, output=None, verbose=False):
"""By default emit simply returns the ouput as-is.
Override this method to provide for other behaviour."""
if output is NoContent:
if output is None:
return ''
return output
@ -177,8 +52,8 @@ class TemplateEmitter(BaseEmitter):
media_type = None
template = None
def emit(self, output=NoContent, verbose=False):
if output is NoContent:
def emit(self, output=None, verbose=False):
if output is None:
return ''
context = RequestContext(self.request, output)
@ -276,7 +151,7 @@ class DocumentingTemplateEmitter(BaseEmitter):
return GenericContentForm(resource)
def emit(self, output=NoContent):
def emit(self, output=None):
content = self._get_content(self.resource, self.resource.request, output)
form_instance = self._get_form_instance(self.resource)
@ -324,8 +199,8 @@ class JSONEmitter(BaseEmitter):
"""Emitter which serializes to JSON"""
media_type = 'application/json'
def emit(self, output=NoContent, verbose=False):
if output is NoContent:
def emit(self, output=None, verbose=False):
if output is None:
return ''
if verbose:
return json.dumps(output, indent=4, sort_keys=True)
@ -336,8 +211,8 @@ class XMLEmitter(BaseEmitter):
"""Emitter which serializes to XML."""
media_type = 'application/xml'
def emit(self, output=NoContent, verbose=False):
if output is NoContent:
def emit(self, output=None, verbose=False):
if output is None:
return ''
return dict2xml(output)

View File

@ -3,7 +3,7 @@ from django.db.models import Model
from django.db.models.query import QuerySet
from django.db.models.fields.related import RelatedField
from djangorestframework.response import Response, ResponseException
from djangorestframework.response import Response, ErrorResponse
from djangorestframework.resource import Resource
from djangorestframework import status, validators
@ -370,7 +370,7 @@ class ModelResource(Resource):
# Otherwise assume the kwargs uniquely identify the model
instance = self.model.objects.get(**kwargs)
except self.model.DoesNotExist:
raise ResponseException(status.HTTP_404_NOT_FOUND)
raise ErrorResponse(status.HTTP_404_NOT_FOUND)
return instance
@ -402,7 +402,7 @@ class ModelResource(Resource):
# Otherwise assume the kwargs uniquely identify the model
instance = self.model.objects.get(**kwargs)
except self.model.DoesNotExist:
raise ResponseException(status.HTTP_404_NOT_FOUND, None, {})
raise ErrorResponse(status.HTTP_404_NOT_FOUND, None, {})
instance.delete()
return

View File

@ -9,7 +9,7 @@ We need a method to be able to:
and multipart/form-data. (eg also handle multipart/json)
"""
from django.http.multipartparser import MultiPartParser as DjangoMPParser
from djangorestframework.response import ResponseException
from djangorestframework.response import ErrorResponse
from djangorestframework import status
from djangorestframework.utils import as_tuple
from djangorestframework.mediatypes import MediaType
@ -59,7 +59,7 @@ class JSONParser(BaseParser):
try:
return json.load(stream)
except ValueError, exc:
raise ResponseException(status.HTTP_400_BAD_REQUEST, {'detail': 'JSON parse error - %s' % str(exc)})
raise ErrorResponse(status.HTTP_400_BAD_REQUEST, {'detail': 'JSON parse error - %s' % str(exc)})
class DataFlatener(object):

View File

@ -2,9 +2,8 @@ from django.core.urlresolvers import set_script_prefix
from django.views.decorators.csrf import csrf_exempt
from djangorestframework.compat import View
from djangorestframework.emitters import EmitterMixin
from djangorestframework.response import Response, ResponseException
from djangorestframework.request import RequestMixin, AuthMixin
from djangorestframework.response import Response, ErrorResponse
from djangorestframework.mixins import RequestMixin, ResponseMixin, AuthMixin
from djangorestframework import emitters, parsers, authenticators, validators, status
@ -16,7 +15,7 @@ from djangorestframework import emitters, parsers, authenticators, validators, s
__all__ = ['Resource']
class Resource(EmitterMixin, AuthMixin, RequestMixin, View):
class Resource(RequestMixin, ResponseMixin, AuthMixin, View):
"""Handles incoming requests and maps them to REST operations,
performing authentication, input deserialization, input validation, output serialization."""
@ -81,7 +80,7 @@ class Resource(EmitterMixin, AuthMixin, RequestMixin, View):
def not_implemented(self, operation):
"""Return an HTTP 500 server error if an operation is called which has been allowed by
allowed_methods, but which has not been implemented."""
raise ResponseException(status.HTTP_500_INTERNAL_SERVER_ERROR,
raise ErrorResponse(status.HTTP_500_INTERNAL_SERVER_ERROR,
{'detail': '%s operation on this resource has not been implemented' % (operation, )})
@ -89,15 +88,15 @@ class Resource(EmitterMixin, AuthMixin, RequestMixin, View):
"""Ensure the request method is permitted for this resource, raising a ResourceException if it is not."""
if not method in self.callmap.keys():
raise ResponseException(status.HTTP_501_NOT_IMPLEMENTED,
raise ErrorResponse(status.HTTP_501_NOT_IMPLEMENTED,
{'detail': 'Unknown or unsupported method \'%s\'' % method})
if not method in self.allowed_methods:
raise ResponseException(status.HTTP_405_METHOD_NOT_ALLOWED,
raise ErrorResponse(status.HTTP_405_METHOD_NOT_ALLOWED,
{'detail': 'Method \'%s\' not allowed on this resource.' % method})
if auth is None and not method in self.anon_allowed_methods:
raise ResponseException(status.HTTP_403_FORBIDDEN,
raise ErrorResponse(status.HTTP_403_FORBIDDEN,
{'detail': 'You do not have permission to access this resource. ' +
'You may need to login or otherwise authenticate the request.'})
@ -172,7 +171,7 @@ class Resource(EmitterMixin, AuthMixin, RequestMixin, View):
# Pre-serialize filtering (eg filter complex objects into natively serializable types)
response.cleaned_content = self.cleanup_response(response.raw_content)
except ResponseException, exc:
except ErrorResponse, exc:
response = exc.response
except:
@ -183,8 +182,12 @@ class Resource(EmitterMixin, AuthMixin, RequestMixin, View):
#
# TODO - this isn't actually the correct way to set the vary header,
# also it's currently sub-obtimal for HTTP caching - need to sort that out.
response.headers['Allow'] = ', '.join(self.allowed_methods)
response.headers['Vary'] = 'Authenticate, Accept'
try:
response.headers['Allow'] = ', '.join(self.allowed_methods)
response.headers['Vary'] = 'Authenticate, Accept'
return self.emit(response)
return self.emit(response)
except:
import traceback
traceback.print_exc()

View File

@ -1,24 +1,16 @@
from django.core.handlers.wsgi import STATUS_CODE_TEXT
__all__ =['NoContent', 'Response', ]
class NoContent(object):
"""Used to indicate no body in http response.
(We cannot just use None, as that is a valid, serializable response object.)
TODO: On reflection I'm going to get rid of this and just not support serialized 'None' responses.
"""
pass
__all__ =['Response', 'ErrorResponse']
# TODO: remove raw_content/cleaned_content and just use content?
class Response(object):
def __init__(self, status=200, content=NoContent, headers={}):
"""An HttpResponse that may include content that hasn't yet been serialized."""
def __init__(self, status=200, content=None, headers={}):
self.status = status
self.has_content_body = not content is NoContent # TODO: remove and just use content
self.raw_content = content # content prior to filtering - TODO: remove and just use content
self.cleaned_content = content # content after filtering TODO: remove and just use content
self.has_content_body = content is not None
self.raw_content = content # content prior to filtering
self.cleaned_content = content # content after filtering
self.headers = headers
@property
@ -28,6 +20,7 @@ class Response(object):
return STATUS_CODE_TEXT.get(self.status, '')
class ResponseException(BaseException):
def __init__(self, status, content=NoContent, headers={}):
class ErrorResponse(BaseException):
"""An exception representing an HttpResponse that should be returned immediatley."""
def __init__(self, status, content=None, headers={}):
self.response = Response(status, content=content, headers=headers)

View File

@ -3,7 +3,7 @@ Tests for content parsing, and form-overloaded content parsing.
"""
from django.test import TestCase
from djangorestframework.compat import RequestFactory
from djangorestframework.request import RequestMixin
from djangorestframework.mixins import RequestMixin
from djangorestframework.parsers import FormParser, MultipartParser, PlainTextParser

View File

@ -2,7 +2,8 @@ from django.conf.urls.defaults import patterns, url
from django import http
from django.test import TestCase
from djangorestframework.compat import View
from djangorestframework.emitters import EmitterMixin, BaseEmitter
from djangorestframework.emitters import BaseEmitter
from djangorestframework.mixins import ResponseMixin
from djangorestframework.response import Response
DUMMYSTATUS = 200
@ -11,7 +12,7 @@ DUMMYCONTENT = 'dummycontent'
EMITTER_A_SERIALIZER = lambda x: 'Emitter A: %s' % x
EMITTER_B_SERIALIZER = lambda x: 'Emitter B: %s' % x
class MockView(EmitterMixin, View):
class MockView(ResponseMixin, View):
def get(self, request):
response = Response(DUMMYSTATUS, DUMMYCONTENT)
return self.emit(response)

View File

@ -1,6 +1,6 @@
from django.test import TestCase
from djangorestframework.compat import RequestFactory
from djangorestframework.request import RequestMixin
from djangorestframework.mixins import RequestMixin
class TestMethodOverloading(TestCase):

View File

@ -3,7 +3,7 @@ from django.db import models
from django.test import TestCase
from djangorestframework.compat import RequestFactory
from djangorestframework.validators import BaseValidator, FormValidator, ModelFormValidator
from djangorestframework.response import ResponseException
from djangorestframework.response import ErrorResponse
class TestValidatorMixinInterfaces(TestCase):
@ -81,7 +81,7 @@ class TestNonFieldErrors(TestCase):
content = {'field1': 'example1', 'field2': 'example2'}
try:
FormValidator(view).validate(content)
except ResponseException, exc:
except ErrorResponse, exc:
self.assertEqual(exc.response.raw_content, {'errors': [MockForm.ERROR_TEXT]})
else:
self.fail('ResourceException was not raised') #pragma: no cover
@ -115,14 +115,14 @@ class TestFormValidation(TestCase):
def validation_failure_raises_response_exception(self, validator):
"""If form validation fails a ResourceException 400 (Bad Request) should be raised."""
content = {}
self.assertRaises(ResponseException, validator.validate, content)
self.assertRaises(ErrorResponse, validator.validate, content)
def validation_does_not_allow_extra_fields_by_default(self, validator):
"""If some (otherwise valid) content includes fields that are not in the form then validation should fail.
It might be okay on normal form submission, but for Web APIs we oughta get strict, as it'll help show up
broken clients more easily (eg submitting content with a misnamed field)"""
content = {'qwerty': 'uiop', 'extra': 'extra'}
self.assertRaises(ResponseException, validator.validate, content)
self.assertRaises(ErrorResponse, validator.validate, content)
def validation_allows_extra_fields_if_explicitly_set(self, validator):
"""If we include an allowed_extra_fields paramater on _validate, then allow fields with those names."""
@ -139,7 +139,7 @@ class TestFormValidation(TestCase):
content = {}
try:
validator.validate(content)
except ResponseException, exc:
except ErrorResponse, exc:
self.assertEqual(exc.response.raw_content, {'field-errors': {'qwerty': ['This field is required.']}})
else:
self.fail('ResourceException was not raised') #pragma: no cover
@ -149,7 +149,7 @@ class TestFormValidation(TestCase):
content = {'qwerty': ''}
try:
validator.validate(content)
except ResponseException, exc:
except ErrorResponse, exc:
self.assertEqual(exc.response.raw_content, {'field-errors': {'qwerty': ['This field is required.']}})
else:
self.fail('ResourceException was not raised') #pragma: no cover
@ -159,7 +159,7 @@ class TestFormValidation(TestCase):
content = {'qwerty': 'uiop', 'extra': 'extra'}
try:
validator.validate(content)
except ResponseException, exc:
except ErrorResponse, exc:
self.assertEqual(exc.response.raw_content, {'field-errors': {'extra': ['This field does not exist.']}})
else:
self.fail('ResourceException was not raised') #pragma: no cover
@ -169,7 +169,7 @@ class TestFormValidation(TestCase):
content = {'qwerty': '', 'extra': 'extra'}
try:
validator.validate(content)
except ResponseException, exc:
except ErrorResponse, exc:
self.assertEqual(exc.response.raw_content, {'field-errors': {'qwerty': ['This field is required.'],
'extra': ['This field does not exist.']}})
else:
@ -286,14 +286,14 @@ class TestModelFormValidator(TestCase):
It might be okay on normal form submission, but for Web APIs we oughta get strict, as it'll help show up
broken clients more easily (eg submitting content with a misnamed field)"""
content = {'qwerty': 'example', 'uiop':'example', 'readonly': 'read only', 'extra': 'extra'}
self.assertRaises(ResponseException, self.validator.validate, content)
self.assertRaises(ErrorResponse, self.validator.validate, content)
def test_validate_requires_fields_on_model_forms(self):
"""If some (otherwise valid) content includes fields that are not in the form then validation should fail.
It might be okay on normal form submission, but for Web APIs we oughta get strict, as it'll help show up
broken clients more easily (eg submitting content with a misnamed field)"""
content = {'readonly': 'read only'}
self.assertRaises(ResponseException, self.validator.validate, content)
self.assertRaises(ErrorResponse, self.validator.validate, content)
def test_validate_does_not_require_blankable_fields_on_model_forms(self):
"""Test standard ModelForm validation behaviour - fields with blank=True are not required."""

View File

@ -14,6 +14,7 @@ except ImportError:
# """Adds the ADMIN_MEDIA_PREFIX to the request context."""
# return {'ADMIN_MEDIA_PREFIX': settings.ADMIN_MEDIA_PREFIX}
MSIE_USER_AGENT_REGEX = re.compile(r'^Mozilla/[0-9]+\.[0-9]+ \([^)]*; MSIE [0-9]+\.[0-9]+[a-z]?;[^)]*\)(?!.* Opera )')
def as_tuple(obj):
"""Given obj return a tuple"""

View File

@ -1,7 +1,7 @@
"""Mixin classes that provide a validate(content) function to validate and cleanup request content"""
from django import forms
from django.db import models
from djangorestframework.response import ResponseException
from djangorestframework.response import ErrorResponse
from djangorestframework.utils import as_tuple
@ -13,7 +13,7 @@ class BaseValidator(object):
def validate(self, content):
"""Given some content as input return some cleaned, validated content.
Typically raises a ResponseException with status code 400 (Bad Request) on failure.
Typically raises a ErrorResponse with status code 400 (Bad Request) on failure.
Must be overridden to be implemented."""
raise NotImplementedError()
@ -32,11 +32,11 @@ class FormValidator(BaseValidator):
def validate(self, content):
"""Given some content as input return some cleaned, validated content.
Raises a ResponseException with status code 400 (Bad Request) on failure.
Raises a ErrorResponse with status code 400 (Bad Request) on failure.
Validation is standard form validation, with an additional constraint that no extra unknown fields may be supplied.
On failure the ResponseException content is a dict which may contain 'errors' and 'field-errors' keys.
On failure the ErrorResponse content is a dict which may contain 'errors' and 'field-errors' keys.
If the 'errors' key exists it is a list of strings of non-field errors.
If the 'field-errors' key exists it is a dict of {field name as string: list of errors as strings}."""
return self._validate(content)
@ -97,7 +97,7 @@ class FormValidator(BaseValidator):
detail[u'field-errors'] = field_errors
# Return HTTP 400 response (BAD REQUEST)
raise ResponseException(400, detail)
raise ErrorResponse(400, detail)
def get_bound_form(self, content=None):
@ -139,14 +139,14 @@ class ModelFormValidator(FormValidator):
# TODO: be really strict on fields - check they match in the handler methods. (this isn't a validator thing tho.)
def validate(self, content):
"""Given some content as input return some cleaned, validated content.
Raises a ResponseException with status code 400 (Bad Request) on failure.
Raises a ErrorResponse with status code 400 (Bad Request) on failure.
Validation is standard form or model form validation,
with an additional constraint that no extra unknown fields may be supplied,
and that all fields specified by the fields class attribute must be supplied,
even if they are not validated by the form/model form.
On failure the ResponseException content is a dict which may contain 'errors' and 'field-errors' keys.
On failure the ErrorResponse content is a dict which may contain 'errors' and 'field-errors' keys.
If the 'errors' key exists it is a list of strings of non-field errors.
If the 'field-errors' key exists it is a dict of {field name as string: list of errors as strings}."""
return self._validate(content, allowed_extra_fields=self._property_fields_set)