aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorTom Christie2012-09-16 14:02:18 -0700
committerTom Christie2012-09-16 14:02:18 -0700
commit549ebdc1c67c20bdff39a2f4a59012dd010213a1 (patch)
treeef7b3626e602ecaa1761cb4e780501f0b8db9463
parentb7b8cd11b1aad3fcf4bad221d164bb55e0bf5859 (diff)
parentd8ede0355c32455989ca5f955d555ffaf827b296 (diff)
downloaddjango-rest-framework-549ebdc1c67c20bdff39a2f4a59012dd010213a1.tar.bz2
Merge pull request #263 from tomchristie/decouple-conneg
Content negotiation logic out of response and into View
-rw-r--r--djangorestframework/exceptions.py17
-rw-r--r--djangorestframework/negotiation.py74
-rw-r--r--djangorestframework/renderers.py28
-rw-r--r--djangorestframework/response.py168
-rw-r--r--djangorestframework/settings.py12
-rw-r--r--djangorestframework/tests/accept.py83
-rw-r--r--djangorestframework/tests/renderers.py9
-rw-r--r--djangorestframework/tests/request.py54
-rw-r--r--djangorestframework/tests/response.py136
-rw-r--r--djangorestframework/utils/__init__.py38
-rw-r--r--djangorestframework/views.py65
-rw-r--r--docs/api-guide/content-negotiation.md2
-rw-r--r--docs/topics/rest-hypermedia-hateoas.md52
13 files changed, 244 insertions, 494 deletions
diff --git a/djangorestframework/exceptions.py b/djangorestframework/exceptions.py
index 3f5b23f6..a405a885 100644
--- a/djangorestframework/exceptions.py
+++ b/djangorestframework/exceptions.py
@@ -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."
diff --git a/djangorestframework/negotiation.py b/djangorestframework/negotiation.py
new file mode 100644
index 00000000..201d32ad
--- /dev/null
+++ b/djangorestframework/negotiation.py
@@ -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(',')]
diff --git a/djangorestframework/renderers.py b/djangorestframework/renderers.py
index 26e8cba1..729f8111 100644
--- a/djangorestframework/renderers.py
+++ b/djangorestframework/renderers.py
@@ -48,28 +48,22 @@ class BaseRenderer(object):
def __init__(self, view=None):
self.view = view
- def can_handle_response(self, accept):
+ def can_handle_format(self, format):
+ return format == self.format
+
+ def can_handle_media_type(self, media_type):
"""
- Returns :const:`True` if this renderer is able to deal with the given
- *accept* 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 *accept*
- argument against the :attr:`media_type` attribute set on the class to see if
+ 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):
"""
diff --git a/djangorestframework/response.py b/djangorestframework/response.py
index e1366bdb..29034e25 100644
--- a/djangorestframework/response.py
+++ b/djangorestframework/response.py
@@ -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])
diff --git a/djangorestframework/settings.py b/djangorestframework/settings.py
index e5181f4b..4dec1a4d 100644
--- a/djangorestframework/settings.py
+++ b/djangorestframework/settings.py
@@ -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)
diff --git a/djangorestframework/tests/accept.py b/djangorestframework/tests/accept.py
deleted file mode 100644
index 7258f461..00000000
--- a/djangorestframework/tests/accept.py
+++ /dev/null
@@ -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')
diff --git a/djangorestframework/tests/renderers.py b/djangorestframework/tests/renderers.py
index 718c903f..650798de 100644
--- a/djangorestframework/tests/renderers.py
+++ b/djangorestframework/tests/renderers.py
@@ -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}'
diff --git a/djangorestframework/tests/request.py b/djangorestframework/tests/request.py
index 51e3660c..74eae438 100644
--- a/djangorestframework/tests/request.py
+++ b/djangorestframework/tests/request.py
@@ -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):
diff --git a/djangorestframework/tests/response.py b/djangorestframework/tests/response.py
index 0483d826..5083f6d2 100644
--- a/djangorestframework/tests/response.py
+++ b/djangorestframework/tests/response.py
@@ -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):
"""
diff --git a/djangorestframework/utils/__init__.py b/djangorestframework/utils/__init__.py
index bb5bb6d7..f53ac0b8 100644
--- a/djangorestframework/utils/__init__.py
+++ b/djangorestframework/utils/__init__.py
@@ -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)
diff --git a/djangorestframework/views.py b/djangorestframework/views.py
index 9debee19..32d403ea 100644
--- a/djangorestframework/views.py
+++ b/djangorestframework/views.py
@@ -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
diff --git a/docs/api-guide/content-negotiation.md b/docs/api-guide/content-negotiation.md
index 01895a4b..ad98de3b 100644
--- a/docs/api-guide/content-negotiation.md
+++ b/docs/api-guide/content-negotiation.md
@@ -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.
diff --git a/docs/topics/rest-hypermedia-hateoas.md b/docs/topics/rest-hypermedia-hateoas.md
new file mode 100644
index 00000000..2bca2ab8
--- /dev/null
+++ b/docs/topics/rest-hypermedia-hateoas.md
@@ -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 \ No newline at end of file