Merge pull request #263 from tomchristie/decouple-conneg

Content negotiation logic out of response and into View
This commit is contained in:
Tom Christie 2012-09-16 14:02:18 -07:00
commit 549ebdc1c6
13 changed files with 245 additions and 495 deletions

View File

@ -31,6 +31,14 @@ class PermissionDenied(APIException):
self.detail = detail or self.default_detail
class InvalidFormat(APIException):
status_code = status.HTTP_404_NOT_FOUND
default_detail = "Format suffix '.%s' not found."
def __init__(self, format, detail=None):
self.detail = (detail or self.default_detail) % format
class MethodNotAllowed(APIException):
status_code = status.HTTP_405_METHOD_NOT_ALLOWED
default_detail = "Method '%s' not allowed."
@ -39,6 +47,15 @@ class MethodNotAllowed(APIException):
self.detail = (detail or self.default_detail) % method
class NotAcceptable(APIException):
status_code = status.HTTP_406_NOT_ACCEPTABLE
default_detail = "Could not satisfy the request's Accept header"
def __init__(self, detail=None, available_renderers=None):
self.detail = detail or self.default_detail
self.available_renderers = available_renderers
class UnsupportedMediaType(APIException):
status_code = status.HTTP_415_UNSUPPORTED_MEDIA_TYPE
default_detail = "Unsupported media type '%s' in request."

View File

@ -0,0 +1,74 @@
from djangorestframework import exceptions
from djangorestframework.settings import api_settings
from djangorestframework.utils.mediatypes import order_by_precedence
class BaseContentNegotiation(object):
def negotiate(self, request, renderers, format=None, force=False):
raise NotImplementedError('.negotiate() must be implemented')
class DefaultContentNegotiation(object):
settings = api_settings
def negotiate(self, request, renderers, format=None, force=False):
"""
Given a request and a list of renderers, return a two-tuple of:
(renderer, media type).
If force is set, then suppress exceptions, and forcibly return a
fallback renderer and media_type.
"""
try:
return self.unforced_negotiate(request, renderers, format)
except (exceptions.InvalidFormat, exceptions.NotAcceptable):
if force:
return (renderers[0], renderers[0].media_type)
raise
def unforced_negotiate(self, request, renderers, format=None):
"""
As `.negotiate()`, but does not take the optional `force` agument,
or suppress exceptions.
"""
# Allow URL style format override. eg. "?format=json
format = format or request.GET.get(self.settings.URL_FORMAT_OVERRIDE)
if format:
renderers = self.filter_renderers(renderers, format)
accepts = self.get_accept_list(request)
# Check the acceptable media types against each renderer,
# attempting more specific media types first
# NB. The inner loop here isn't as bad as it first looks :)
# Worst case is we're looping over len(accept_list) * len(self.renderers)
for media_type_set in order_by_precedence(accepts):
for renderer in renderers:
for media_type in media_type_set:
if renderer.can_handle_media_type(media_type):
return renderer, media_type
raise exceptions.NotAcceptable(available_renderers=renderers)
def filter_renderers(self, renderers, format):
"""
If there is a '.json' style format suffix, filter the renderers
so that we only negotiation against those that accept that format.
"""
renderers = [renderer for renderer in renderers
if renderer.can_handle_format(format)]
if not renderers:
raise exceptions.InvalidFormat(format)
return renderers
def get_accept_list(self, request):
"""
Given the incoming request, return a tokenised list of media
type strings.
Allows URL style accept override. eg. "?accept=application/json"
"""
header = request.META.get('HTTP_ACCEPT', '*/*')
header = request.GET.get(self.settings.URL_ACCEPT_OVERRIDE, header)
return [token.strip() for token in header.split(',')]

View File

@ -48,28 +48,22 @@ class BaseRenderer(object):
def __init__(self, view=None):
self.view = view
def can_handle_response(self, accept):
"""
Returns :const:`True` if this renderer is able to deal with the given
*accept* media type.
def can_handle_format(self, format):
return format == self.format
The default implementation for this function is to check the *accept*
argument against the :attr:`media_type` attribute set on the class to see if
def can_handle_media_type(self, media_type):
"""
Returns `True` if this renderer is able to deal with the given
media type.
The default implementation for this function is to check the media type
argument against the media_type attribute set on the class to see if
they match.
This may be overridden to provide for other behavior, but typically you'll
instead want to just set the :attr:`media_type` attribute on the class.
This may be overridden to provide for other behavior, but typically
you'll instead want to just set the `media_type` attribute on the class.
"""
# TODO: format overriding must go out of here
format = None
if self.view is not None:
format = self.view.kwargs.get(self._FORMAT_QUERY_PARAM, None)
if format is None and self.view is not None:
format = self.view.request.GET.get(self._FORMAT_QUERY_PARAM, None)
if format is not None:
return format == self.format
return media_type_matches(self.media_type, accept)
return media_type_matches(self.media_type, media_type)
def render(self, obj=None, media_type=None):
"""

View File

@ -1,97 +1,34 @@
"""
The :mod:`response` module provides :class:`Response` and :class:`ImmediateResponse` classes.
`Response` is a subclass of `HttpResponse`, and can be similarly instantiated and returned
from any view. It is a bit smarter than Django's `HttpResponse`, for it renders automatically
its content to a serial format by using a list of :mod:`renderers`.
To determine the content type to which it must render, default behaviour is to use standard
HTTP Accept header content negotiation. But `Response` also supports overriding the content type
by specifying an ``_accept=`` parameter in the URL. Also, `Response` will ignore `Accept` headers
from Internet Explorer user agents and use a sensible browser `Accept` header instead.
"""
import re
from django.template.response import SimpleTemplateResponse
from django.core.handlers.wsgi import STATUS_CODE_TEXT
from djangorestframework.settings import api_settings
from djangorestframework.utils.mediatypes import order_by_precedence
from djangorestframework import status
MSIE_USER_AGENT_REGEX = re.compile(r'^Mozilla/[0-9]+\.[0-9]+ \([^)]*; MSIE [0-9]+\.[0-9]+[a-z]?;[^)]*\)(?!.* Opera )')
class NotAcceptable(Exception):
pass
class Response(SimpleTemplateResponse):
"""
An HttpResponse that may include content that hasn't yet been serialized.
Kwargs:
- content(object). The raw content, not yet serialized.
This must be native Python data that renderers can handle.
(e.g.: `dict`, `str`, ...)
- renderer_classes(list/tuple). The renderers to use for rendering the response content.
An HttpResponse that allows it's data to be rendered into
arbitrary media types.
"""
_ACCEPT_QUERY_PARAM = api_settings.URL_ACCEPT_OVERRIDE
_IGNORE_IE_ACCEPT_HEADER = True
def __init__(self, data=None, status=None, headers=None,
renderer=None, media_type=None):
"""
Alters the init arguments slightly.
For example, drop 'template_name', and instead use 'data'.
def __init__(self, content=None, status=None, headers=None, view=None,
request=None, renderer_classes=None, format=None):
# First argument taken by `SimpleTemplateResponse.__init__` is template_name,
# which we don't need
Setting 'renderer' and 'media_type' will typically be defered,
For example being set automatically by the `APIView`.
"""
super(Response, self).__init__(None, status=status)
self.raw_content = content
self.has_content_body = content is not None
self.data = data
self.headers = headers and headers[:] or []
self.view = view
self.request = request
self.renderer_classes = renderer_classes
self.format = format
def get_renderers(self):
"""
Instantiates and returns the list of renderers the response will use.
"""
if self.renderer_classes is None:
renderer_classes = api_settings.DEFAULT_RENDERERS
else:
renderer_classes = self.renderer_classes
if self.format:
return [cls(self.view) for cls in renderer_classes
if cls.format == self.format]
return [cls(self.view) for cls in renderer_classes]
self.renderer = renderer
self.media_type = media_type
@property
def rendered_content(self):
"""
The final rendered content. Accessing this attribute triggers the
complete rendering cycle: selecting suitable renderer, setting
response's actual content type, rendering data.
"""
renderer, media_type = self._determine_renderer()
# Set the media type of the response
self['Content-Type'] = renderer.media_type
# Render the response content
if self.has_content_body:
return renderer.render(self.raw_content, media_type)
return renderer.render()
def render(self):
try:
return super(Response, self).render()
except NotAcceptable:
response = self._get_406_response()
return response.render()
self['Content-Type'] = self.renderer.media_type
if self.data is None:
return self.renderer.render()
return self.renderer.render(self.data, self.media_type)
@property
def status_text(self):
@ -100,74 +37,3 @@ class Response(SimpleTemplateResponse):
Provided for convenience.
"""
return STATUS_CODE_TEXT.get(self.status_code, '')
def _determine_accept_list(self):
"""
Returns a list of accepted media types. This list is determined from :
1. overload with `_ACCEPT_QUERY_PARAM`
2. `Accept` header of the request
If those are useless, a default value is returned instead.
"""
request = self.request
if (self._ACCEPT_QUERY_PARAM and
request.GET.get(self._ACCEPT_QUERY_PARAM, None)):
# Use _accept parameter override
return [request.GET.get(self._ACCEPT_QUERY_PARAM)]
elif (self._IGNORE_IE_ACCEPT_HEADER and
'HTTP_USER_AGENT' in request.META and
MSIE_USER_AGENT_REGEX.match(request.META['HTTP_USER_AGENT']) and
request.META.get('HTTP_X_REQUESTED_WITH', '') != 'XMLHttpRequest'):
# Ignore MSIE's broken accept behavior except for AJAX requests
# and do something sensible instead
return ['text/html', '*/*']
elif 'HTTP_ACCEPT' in request.META:
# Use standard HTTP Accept negotiation
return [token.strip() for token in request.META['HTTP_ACCEPT'].split(',')]
else:
# No accept header specified
return ['*/*']
def _determine_renderer(self):
"""
Determines the appropriate renderer for the output, given the list of
accepted media types, and the :attr:`renderer_classes` set on this class.
Returns a 2-tuple of `(renderer, media_type)`
See: RFC 2616, Section 14
http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html
"""
renderers = self.get_renderers()
accepts = self._determine_accept_list()
# Not acceptable response - Ignore accept header.
if self.status_code == 406:
return (renderers[0], renderers[0].media_type)
# Check the acceptable media types against each renderer,
# attempting more specific media types first
# NB. The inner loop here isn't as bad as it first looks :)
# Worst case is we're looping over len(accept_list) * len(self.renderers)
for media_type_set in order_by_precedence(accepts):
for renderer in renderers:
for media_type in media_type_set:
if renderer.can_handle_response(media_type):
return renderer, media_type
# No acceptable renderers were found
raise NotAcceptable
def _get_406_response(self):
renderer = self.renderer_classes[0]
return Response(
{
'detail': 'Could not satisfy the client\'s Accept header',
'available_types': [renderer.media_type
for renderer in self.renderer_classes]
},
status=status.HTTP_406_NOT_ACCEPTABLE,
view=self.view, request=self.request, renderer_classes=[renderer])

View File

@ -38,6 +38,7 @@ DEFAULTS = {
),
'DEFAULT_PERMISSIONS': (),
'DEFAULT_THROTTLES': (),
'DEFAULT_CONTENT_NEGOTIATION': 'djangorestframework.negotiation.DefaultContentNegotiation',
'UNAUTHENTICATED_USER': 'django.contrib.auth.models.AnonymousUser',
'UNAUTHENTICATED_TOKEN': None,
@ -46,6 +47,7 @@ DEFAULTS = {
'FORM_CONTENT_OVERRIDE': '_content',
'FORM_CONTENTTYPE_OVERRIDE': '_content_type',
'URL_ACCEPT_OVERRIDE': '_accept',
'URL_FORMAT_OVERRIDE': 'format',
'FORMAT_SUFFIX_KWARG': 'format'
}
@ -58,8 +60,9 @@ IMPORT_STRINGS = (
'DEFAULT_AUTHENTICATION',
'DEFAULT_PERMISSIONS',
'DEFAULT_THROTTLES',
'DEFAULT_CONTENT_NEGOTIATION',
'UNAUTHENTICATED_USER',
'UNAUTHENTICATED_TOKEN'
'UNAUTHENTICATED_TOKEN',
)
@ -68,7 +71,7 @@ def perform_import(val, setting):
If the given setting is a string import notation,
then perform the necessary import or imports.
"""
if val is None or setting not in IMPORT_STRINGS:
if val is None or not setting in IMPORT_STRINGS:
return val
if isinstance(val, basestring):
@ -88,10 +91,7 @@ def import_from_string(val, setting):
module_path, class_name = '.'.join(parts[:-1]), parts[-1]
module = importlib.import_module(module_path)
return getattr(module, class_name)
except Exception, e:
import traceback
tb = traceback.format_exc()
import pdb; pdb.set_trace()
except:
msg = "Could not import '%s' for API setting '%s'" % (val, setting)
raise ImportError(msg)

View File

@ -1,83 +0,0 @@
from django.conf.urls.defaults import patterns, url, include
from django.test import TestCase
from djangorestframework.compat import RequestFactory
from djangorestframework.views import APIView
from djangorestframework.response import Response
# See: http://www.useragentstring.com/
MSIE_9_USER_AGENT = 'Mozilla/5.0 (Windows; U; MSIE 9.0; WIndows NT 9.0; en-US))'
MSIE_8_USER_AGENT = 'Mozilla/5.0 (compatible; MSIE 8.0; Windows NT 5.2; Trident/4.0; Media Center PC 4.0; SLCC1; .NET CLR 3.0.04320)'
MSIE_7_USER_AGENT = 'Mozilla/5.0 (Windows; U; MSIE 7.0; Windows NT 6.0; en-US)'
FIREFOX_4_0_USER_AGENT = 'Mozilla/5.0 (Windows; U; Windows NT 6.1; ru; rv:1.9.2.3) Gecko/20100401 Firefox/4.0 (.NET CLR 3.5.30729)'
CHROME_11_0_USER_AGENT = 'Mozilla/5.0 (Windows; U; Windows NT 6.1; en-US) AppleWebKit/534.17 (KHTML, like Gecko) Chrome/11.0.655.0 Safari/534.17'
SAFARI_5_0_USER_AGENT = 'Mozilla/5.0 (X11; U; Linux x86_64; en-ca) AppleWebKit/531.2+ (KHTML, like Gecko) Version/5.0 Safari/531.2+'
OPERA_11_0_MSIE_USER_AGENT = 'Mozilla/4.0 (compatible; MSIE 8.0; X11; Linux x86_64; pl) Opera 11.00'
OPERA_11_0_OPERA_USER_AGENT = 'Opera/9.80 (X11; Linux x86_64; U; pl) Presto/2.7.62 Version/11.00'
urlpatterns = patterns('',
url(r'^api', include('djangorestframework.urls', namespace='djangorestframework'))
)
class UserAgentMungingTest(TestCase):
"""
We need to fake up the accept headers when we deal with MSIE. Blergh.
http://www.gethifi.com/blog/browser-rest-http-accept-headers
"""
urls = 'djangorestframework.tests.accept'
def setUp(self):
class MockView(APIView):
permissions = ()
response_class = Response
def get(self, request):
return self.response_class({'a': 1, 'b': 2, 'c': 3})
self.req = RequestFactory()
self.MockView = MockView
self.view = MockView.as_view()
def test_munge_msie_accept_header(self):
"""Send MSIE user agent strings and ensure that we get an HTML response,
even if we set a */* accept header."""
for user_agent in (MSIE_9_USER_AGENT,
MSIE_8_USER_AGENT,
MSIE_7_USER_AGENT):
req = self.req.get('/', HTTP_ACCEPT='*/*', HTTP_USER_AGENT=user_agent)
resp = self.view(req)
resp.render()
self.assertEqual(resp['Content-Type'], 'text/html')
def test_dont_rewrite_msie_accept_header(self):
"""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."""
class IgnoreIEAcceptResponse(Response):
_IGNORE_IE_ACCEPT_HEADER = False
view = self.MockView.as_view(response_class=IgnoreIEAcceptResponse)
for user_agent in (MSIE_9_USER_AGENT,
MSIE_8_USER_AGENT,
MSIE_7_USER_AGENT):
req = self.req.get('/', HTTP_ACCEPT='*/*', HTTP_USER_AGENT=user_agent)
resp = view(req)
resp.render()
self.assertEqual(resp['Content-Type'], 'application/json')
def test_dont_munge_nice_browsers_accept_header(self):
"""Send Non-MSIE user agent strings and ensure that we get a JSON response,
if we set a */* Accept header. (Other browsers will correctly set the Accept header)"""
for user_agent in (FIREFOX_4_0_USER_AGENT,
CHROME_11_0_USER_AGENT,
SAFARI_5_0_USER_AGENT,
OPERA_11_0_MSIE_USER_AGENT,
OPERA_11_0_OPERA_USER_AGENT):
req = self.req.get('/', HTTP_ACCEPT='*/*', HTTP_USER_AGENT=user_agent)
resp = self.view(req)
resp.render()
self.assertEqual(resp['Content-Type'], 'application/json')

View File

@ -169,15 +169,6 @@ class RendererEndToEndTests(TestCase):
self.assertEquals(resp.content, RENDERER_B_SERIALIZER(DUMMYCONTENT))
self.assertEquals(resp.status_code, DUMMYSTATUS)
def test_conflicting_format_query_and_accept_ignores_accept(self):
"""If a 'format' query is specified that does not match the Accept
header, we should only honor the 'format' query string."""
resp = self.client.get('/?format=%s' % RendererB.format,
HTTP_ACCEPT='dummy')
self.assertEquals(resp['Content-Type'], RendererB.media_type)
self.assertEquals(resp.content, RENDERER_B_SERIALIZER(DUMMYCONTENT))
self.assertEquals(resp.status_code, DUMMYSTATUS)
_flat_repr = '{"foo": ["bar", "baz"]}'
_indented_repr = '{\n "foo": [\n "bar",\n "baz"\n ]\n}'

View File

@ -7,7 +7,7 @@ from django.test import TestCase, Client
from djangorestframework import status
from djangorestframework.authentication import SessionAuthentication
from djangorestframework.utils import RequestFactory
from djangorestframework.compat import RequestFactory
from djangorestframework.parsers import (
FormParser,
MultiPartParser,
@ -22,33 +22,21 @@ factory = RequestFactory()
class TestMethodOverloading(TestCase):
def test_GET_method(self):
def test_method(self):
"""
GET requests identified.
Request methods should be same as underlying request.
"""
request = factory.get('/')
request = Request(factory.get('/'))
self.assertEqual(request.method, 'GET')
def test_POST_method(self):
"""
POST requests identified.
"""
request = factory.post('/')
request = Request(factory.post('/'))
self.assertEqual(request.method, 'POST')
def test_HEAD_method(self):
"""
HEAD requests identified.
"""
request = factory.head('/')
self.assertEqual(request.method, 'HEAD')
def test_overloaded_method(self):
"""
POST requests can be overloaded to another method by setting a
reserved form field
"""
request = factory.post('/', {Request._METHOD_PARAM: 'DELETE'})
request = Request(factory.post('/', {Request._METHOD_PARAM: 'DELETE'}))
self.assertEqual(request.method, 'DELETE')
@ -57,14 +45,14 @@ class TestContentParsing(TestCase):
"""
Ensure request.DATA returns None for GET request with no content.
"""
request = factory.get('/')
request = Request(factory.get('/'))
self.assertEqual(request.DATA, None)
def test_standard_behaviour_determines_no_content_HEAD(self):
"""
Ensure request.DATA returns None for HEAD request.
"""
request = factory.head('/')
request = Request(factory.head('/'))
self.assertEqual(request.DATA, None)
def test_standard_behaviour_determines_form_content_POST(self):
@ -72,8 +60,8 @@ class TestContentParsing(TestCase):
Ensure request.DATA returns content for POST request with form content.
"""
data = {'qwerty': 'uiop'}
parsers = (FormParser, MultiPartParser)
request = factory.post('/', data, parser=parsers)
request = Request(factory.post('/', data))
request.parser_classes = (FormParser, MultiPartParser)
self.assertEqual(request.DATA.items(), data.items())
def test_standard_behaviour_determines_non_form_content_POST(self):
@ -83,9 +71,8 @@ class TestContentParsing(TestCase):
"""
content = 'qwerty'
content_type = 'text/plain'
parsers = (PlainTextParser,)
request = factory.post('/', content, content_type=content_type,
parsers=parsers)
request = Request(factory.post('/', content, content_type=content_type))
request.parser_classes = (PlainTextParser,)
self.assertEqual(request.DATA, content)
def test_standard_behaviour_determines_form_content_PUT(self):
@ -93,17 +80,17 @@ class TestContentParsing(TestCase):
Ensure request.DATA returns content for PUT request with form content.
"""
data = {'qwerty': 'uiop'}
parsers = (FormParser, MultiPartParser)
from django import VERSION
if VERSION >= (1, 5):
from django.test.client import MULTIPART_CONTENT, BOUNDARY, encode_multipart
request = factory.put('/', encode_multipart(BOUNDARY, data), parsers=parsers,
content_type=MULTIPART_CONTENT)
request = Request(factory.put('/', encode_multipart(BOUNDARY, data),
content_type=MULTIPART_CONTENT))
else:
request = factory.put('/', data, parsers=parsers)
request = Request(factory.put('/', data))
request.parser_classes = (FormParser, MultiPartParser)
self.assertEqual(request.DATA.items(), data.items())
def test_standard_behaviour_determines_non_form_content_PUT(self):
@ -113,9 +100,8 @@ class TestContentParsing(TestCase):
"""
content = 'qwerty'
content_type = 'text/plain'
parsers = (PlainTextParser, )
request = factory.put('/', content, content_type=content_type,
parsers=parsers)
request = Request(factory.put('/', content, content_type=content_type))
request.parser_classes = (PlainTextParser, )
self.assertEqual(request.DATA, content)
def test_overloaded_behaviour_allows_content_tunnelling(self):
@ -128,8 +114,8 @@ class TestContentParsing(TestCase):
Request._CONTENT_PARAM: content,
Request._CONTENTTYPE_PARAM: content_type
}
parsers = (PlainTextParser, )
request = factory.post('/', data, parsers=parsers)
request = Request(factory.post('/', data))
request.parser_classes = (PlainTextParser, )
self.assertEqual(request.DATA, content)
# def test_accessing_post_after_data_form(self):

View File

@ -1,18 +1,15 @@
import json
import unittest
from django.conf.urls.defaults import patterns, url, include
from django.test import TestCase
from djangorestframework.response import Response, NotAcceptable
from djangorestframework.response import Response
from djangorestframework.views import APIView
from djangorestframework.compat import RequestFactory
from djangorestframework import status
from djangorestframework.renderers import (
BaseRenderer,
JSONRenderer,
DocumentingHTMLRenderer,
DEFAULT_RENDERERS
DocumentingHTMLRenderer
)
@ -24,126 +21,6 @@ class MockJsonRenderer(BaseRenderer):
media_type = 'application/json'
class TestResponseDetermineRenderer(TestCase):
def get_response(self, url='', accept_list=[], renderer_classes=[]):
kwargs = {}
if accept_list is not None:
kwargs['HTTP_ACCEPT'] = ','.join(accept_list)
request = RequestFactory().get(url, **kwargs)
return Response(request=request, renderer_classes=renderer_classes)
def test_determine_accept_list_accept_header(self):
"""
Test that determine_accept_list takes the Accept header.
"""
accept_list = ['application/pickle', 'application/json']
response = self.get_response(accept_list=accept_list)
self.assertEqual(response._determine_accept_list(), accept_list)
def test_determine_accept_list_default(self):
"""
Test that determine_accept_list takes the default renderer if Accept is not specified.
"""
response = self.get_response(accept_list=None)
self.assertEqual(response._determine_accept_list(), ['*/*'])
def test_determine_accept_list_overriden_header(self):
"""
Test Accept header overriding.
"""
accept_list = ['application/pickle', 'application/json']
response = self.get_response(url='?_accept=application/x-www-form-urlencoded',
accept_list=accept_list)
self.assertEqual(response._determine_accept_list(), ['application/x-www-form-urlencoded'])
def test_determine_renderer(self):
"""
Test that right renderer is chosen, in the order of Accept list.
"""
accept_list = ['application/pickle', 'application/json']
renderer_classes = (MockPickleRenderer, MockJsonRenderer)
response = self.get_response(accept_list=accept_list, renderer_classes=renderer_classes)
renderer, media_type = response._determine_renderer()
self.assertEqual(media_type, 'application/pickle')
self.assertTrue(isinstance(renderer, MockPickleRenderer))
renderer_classes = (MockJsonRenderer, )
response = self.get_response(accept_list=accept_list, renderer_classes=renderer_classes)
renderer, media_type = response._determine_renderer()
self.assertEqual(media_type, 'application/json')
self.assertTrue(isinstance(renderer, MockJsonRenderer))
def test_determine_renderer_default(self):
"""
Test determine renderer when Accept was not specified.
"""
renderer_classes = (MockPickleRenderer, )
response = self.get_response(accept_list=None, renderer_classes=renderer_classes)
renderer, media_type = response._determine_renderer()
self.assertEqual(media_type, '*/*')
self.assertTrue(isinstance(renderer, MockPickleRenderer))
def test_determine_renderer_no_renderer(self):
"""
Test determine renderer when no renderer can satisfy the Accept list.
"""
accept_list = ['application/json']
renderer_classes = (MockPickleRenderer, )
response = self.get_response(accept_list=accept_list, renderer_classes=renderer_classes)
self.assertRaises(NotAcceptable, response._determine_renderer)
class TestResponseRenderContent(TestCase):
def get_response(self, url='', accept_list=[], content=None, renderer_classes=None):
request = RequestFactory().get(url, HTTP_ACCEPT=','.join(accept_list))
return Response(request=request, content=content, renderer_classes=renderer_classes or DEFAULT_RENDERERS)
def test_render(self):
"""
Test rendering simple data to json.
"""
content = {'a': 1, 'b': [1, 2, 3]}
content_type = 'application/json'
response = self.get_response(accept_list=[content_type], content=content)
response = response.render()
self.assertEqual(json.loads(response.content), content)
self.assertEqual(response['Content-Type'], content_type)
def test_render_no_renderer(self):
"""
Test rendering response when no renderer can satisfy accept.
"""
content = 'bla'
content_type = 'weirdcontenttype'
response = self.get_response(accept_list=[content_type], content=content)
response = response.render()
self.assertEqual(response.status_code, 406)
self.assertIsNotNone(response.content)
# def test_render_renderer_raises_ImmediateResponse(self):
# """
# Test rendering response when renderer raises ImmediateResponse
# """
# class PickyJSONRenderer(BaseRenderer):
# """
# A renderer that doesn't make much sense, just to try
# out raising an ImmediateResponse
# """
# media_type = 'application/json'
# def render(self, obj=None, media_type=None):
# raise ImmediateResponse({'error': '!!!'}, status=400)
# response = self.get_response(
# accept_list=['application/json'],
# renderers=[PickyJSONRenderer, JSONRenderer]
# )
# response = response.render()
# self.assertEqual(response.status_code, 400)
# self.assertEqual(response.content, json.dumps({'error': '!!!'}))
DUMMYSTATUS = status.HTTP_200_OK
DUMMYCONTENT = 'dummycontent'
@ -280,15 +157,6 @@ class RendererIntegrationTests(TestCase):
self.assertEquals(resp.content, RENDERER_B_SERIALIZER(DUMMYCONTENT))
self.assertEquals(resp.status_code, DUMMYSTATUS)
def test_conflicting_format_query_and_accept_ignores_accept(self):
"""If a 'format' query is specified that does not match the Accept
header, we should only honor the 'format' query string."""
resp = self.client.get('/?format=%s' % RendererB.format,
HTTP_ACCEPT='dummy')
self.assertEquals(resp['Content-Type'], RendererB.media_type)
self.assertEquals(resp.content, RENDERER_B_SERIALIZER(DUMMYCONTENT))
self.assertEquals(resp.status_code, DUMMYSTATUS)
class Issue122Tests(TestCase):
"""

View File

@ -1,9 +1,6 @@
from django.utils.encoding import smart_unicode
from django.utils.xmlutils import SimplerXMLGenerator
from djangorestframework.compat import StringIO
from djangorestframework.compat import RequestFactory as DjangoRequestFactory
from djangorestframework.request import Request
import re
import xml.etree.ElementTree as ET
@ -102,38 +99,3 @@ class XMLRenderer():
def dict2xml(input):
return XMLRenderer().dict2xml(input)
class RequestFactory(DjangoRequestFactory):
"""
Replicate RequestFactory, but return Request, not HttpRequest.
"""
def get(self, *args, **kwargs):
parsers = kwargs.pop('parsers', None)
request = super(RequestFactory, self).get(*args, **kwargs)
return Request(request, parsers)
def post(self, *args, **kwargs):
parsers = kwargs.pop('parsers', None)
request = super(RequestFactory, self).post(*args, **kwargs)
return Request(request, parsers)
def put(self, *args, **kwargs):
parsers = kwargs.pop('parsers', None)
request = super(RequestFactory, self).put(*args, **kwargs)
return Request(request, parsers)
def delete(self, *args, **kwargs):
parsers = kwargs.pop('parsers', None)
request = super(RequestFactory, self).delete(*args, **kwargs)
return Request(request, parsers)
def head(self, *args, **kwargs):
parsers = kwargs.pop('parsers', None)
request = super(RequestFactory, self).head(*args, **kwargs)
return Request(request, parsers)
def options(self, *args, **kwargs):
parsers = kwargs.pop('parsers', None)
request = super(RequestFactory, self).options(*args, **kwargs)
return Request(request, parsers)

View File

@ -54,11 +54,14 @@ def _camelcase_to_spaces(content):
class APIView(_View):
settings = api_settings
renderer_classes = api_settings.DEFAULT_RENDERERS
parser_classes = api_settings.DEFAULT_PARSERS
authentication_classes = api_settings.DEFAULT_AUTHENTICATION
throttle_classes = api_settings.DEFAULT_THROTTLES
permission_classes = api_settings.DEFAULT_PERMISSIONS
content_negotiation_class = api_settings.DEFAULT_CONTENT_NEGOTIATION
@classmethod
def as_view(cls, **initkwargs):
@ -169,6 +172,19 @@ class APIView(_View):
"""
return self.renderer_classes[0]
def get_format_suffix(self, **kwargs):
"""
Determine if the request includes a '.json' style format suffix
"""
if self.settings.FORMAT_SUFFIX_KWARG:
return kwargs.get(self.settings.FORMAT_SUFFIX_KWARG)
def get_renderers(self, format=None):
"""
Instantiates and returns the list of renderers that this view can use.
"""
return [renderer(self) for renderer in self.renderer_classes]
def get_permissions(self):
"""
Instantiates and returns the list of permissions that this view requires.
@ -177,10 +193,18 @@ class APIView(_View):
def get_throttles(self):
"""
Instantiates and returns the list of thottles that this view requires.
Instantiates and returns the list of thottles that this view uses.
"""
return [throttle(self) for throttle in self.throttle_classes]
def content_negotiation(self, request, force=False):
"""
Determine which renderer and media type to use render the response.
"""
renderers = self.get_renderers()
conneg = self.content_negotiation_class()
return conneg.negotiate(request, renderers, self.format, force)
def check_permissions(self, request, obj=None):
"""
Check if request should be permitted.
@ -204,35 +228,37 @@ class APIView(_View):
return Request(request, parser_classes=self.parser_classes,
authentication_classes=self.authentication_classes)
def initial(self, request, *args, **kwargs):
"""
Runs anything that needs to occur prior to calling the method handlers.
"""
self.format = self.get_format_suffix(**kwargs)
self.check_permissions(request)
self.check_throttles(request)
self.renderer, self.media_type = self.content_negotiation(request)
def finalize_response(self, request, response, *args, **kwargs):
"""
Returns the final response object.
"""
if isinstance(response, Response):
response.view = self
response.request = request
response.renderer_classes = self.renderer_classes
if api_settings.FORMAT_SUFFIX_KWARG:
response.format = kwargs.get(api_settings.FORMAT_SUFFIX_KWARG, None)
if not getattr(self, 'renderer', None):
self.renderer, self.media_type = self.content_negotiation(request, force=True)
response.renderer = self.renderer
response.media_type = self.media_type
for key, value in self.headers.items():
response[key] = value
return response
def initial(self, request, *args, **kwargs):
"""
Runs anything that needs to occur prior to calling the method handlers.
"""
self.check_permissions(request)
self.check_throttles(request)
def handle_exception(self, exc):
"""
Handle any exception that occurs, by returning an appropriate response,
or re-raising the error.
"""
if isinstance(exc, exceptions.Throttled):
# Throttle wait header
self.headers['X-Throttle-Wait-Seconds'] = '%d' % exc.wait
if isinstance(exc, exceptions.APIException):
@ -250,14 +276,8 @@ class APIView(_View):
@csrf_exempt
def dispatch(self, request, *args, **kwargs):
"""
`APIView.dispatch()` is pretty much the same as Django's regular
`View.dispatch()`, except that it includes hooks to:
* Initialize the request object.
* Finalize the response object.
* Handle exceptions that occur in the handler method.
* An initial hook for code such as permission checking that should
occur prior to running the method handlers.
`.dispatch()` is pretty much the same as Django's regular dispatch,
but with extra hooks for startup, finalize, and exception handling.
"""
request = self.initialize_request(request, *args, **kwargs)
self.request = request
@ -270,7 +290,8 @@ class APIView(_View):
# Get the appropriate handler method
if request.method.lower() in self.http_method_names:
handler = getattr(self, request.method.lower(), self.http_method_not_allowed)
handler = getattr(self, request.method.lower(),
self.http_method_not_allowed)
else:
handler = self.http_method_not_allowed

View File

@ -1,3 +1,5 @@
<a class="github" href="negotiation.py"></a>
# Content negotiation
> HTTP has provisions for several mechanisms for "content negotiation" - the process of selecting the best representation for a given response when there are multiple representations available.

View File

@ -0,0 +1,52 @@
> You keep using that word "REST". I do not think it means what you think it means.
>
> &mdash; Mike Amundsen, [talking at REST fest 2012][cite].
# REST, Hypermedia & HATEOAS
First off, the disclaimer. The name "Django REST framework" was choosen with a view to making sure the project would be easily found by developers. Throughout the documentation we try to use the more simple and technically correct terminology of "Web APIs".
If you are serious about designing a Hypermedia APIs, you should look to resources outside of this documentation to help inform your design choices.
The following fall into the "required reading" category.
* Fielding's dissertation - [Architectural Styles and
the Design of Network-based Software Architectures][dissertation].
* Fielding's "[REST APIs must be hypertext-driven][hypertext-driven]" blog post.
* Leonard Richardson & Sam Ruby's [RESTful Web Services][restful-web-services].
* Mike Amundsen's [Building Hypermedia APIs with HTML5 and Node][building-hypermedia-apis].
* Steve Klabnik's [Designing Hypermedia APIs][designing-hypermedia-apis].
* The [Richardson Maturity Model][maturitymodel].
For a more thorough background, check out Klabnik's [Hypermedia API reading list][readinglist].
# Building Hypermedia APIs with REST framework
REST framework is an agnositic Web API toolkit. It does help guide you towards building well-connected APIs, and makes it easy to design appropriate media types, but it does not strictly enforce any particular design style.
### What REST framework *does* provide.
It is self evident that REST framework makes it possible to build Hypermedia APIs. The browseable API that it offers is built on HTML - the hypermedia language of the web.
REST framework also includes [serialization] and [parser]/[renderer] components that make it easy to build appropriate media types, [hyperlinked relations][fields] for building well-connected systems, and great support for [content negotiation][conneg].
### What REST framework *doesn't* provide.
What REST framework doesn't do is give you is machine readable hypermedia formats such as [Collection+JSON][collection] by default, or the ability to auto-magically create HATEOAS style APIs. Doing so would involve making opinionated choices about API design that should really remain outside of the framework's scope.
[cite]: http://vimeo.com/channels/restfest/page:2
[dissertation]: http://www.ics.uci.edu/~fielding/pubs/dissertation/top.htm
[hypertext-driven]: http://roy.gbiv.com/untangled/2008/rest-apis-must-be-hypertext-driven
[restful-web-services]:
[building-hypermedia-apis]: …
[designing-hypermedia-apis]: http://designinghypermediaapis.com/
[restisover]: http://blog.steveklabnik.com/posts/2012-02-23-rest-is-over
[readinglist]: http://blog.steveklabnik.com/posts/2012-02-27-hypermedia-api-reading-list
[maturitymodel]: http://martinfowler.com/articles/richardsonMaturityModel.html
[collection]: http://www.amundsen.com/media-types/collection/
[serialization]: ../api-guide/serializers.md
[parser]: ../api-guide/parsers.md
[renderer]: ../api-guide/renderers.md
[fields]: ../api-guide/fields.md
[conneg]: ../api-guide/content-negotiation.md