Simplify negotiation. Drop MSIE hacks. Etc.

This commit is contained in:
Tom Christie 2012-09-16 21:48:55 +01:00
parent 6543ccd244
commit a96211d3d1
8 changed files with 90 additions and 187 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."

View File

@ -1,10 +1,6 @@
from djangorestframework import exceptions
from djangorestframework.settings import api_settings
from djangorestframework.utils.mediatypes import order_by_precedence
from django.http import Http404
import re
MSIE_USER_AGENT_REGEX = re.compile(r'^Mozilla/[0-9]+\.[0-9]+ \([^)]*; MSIE [0-9]+\.[0-9]+[a-z]?;[^)]*\)(?!.* Opera )')
class BaseContentNegotiation(object):
@ -24,17 +20,23 @@ class DefaultContentNegotiation(object):
fallback renderer and media_type.
"""
try:
return self._negotiate(request, renderers, format)
except (Http404, exceptions.NotAcceptable):
return self.unforced_negotiate(request, renderers, format)
except (exceptions.InvalidFormat, exceptions.NotAcceptable):
if force:
return (renderers[0], renderers[0].media_type)
raise
def _negotiate(self, request, renderers, format=None):
def unforced_negotiate(self, request, renderers, format=None):
"""
Actual implementation of negotiate, inside the 'force' wrapper.
As `.negotiate()`, but does not take the optional `force` agument,
or suppress exceptions.
"""
renderers = self.filter_renderers(renderers, format)
# 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,
@ -51,42 +53,22 @@ class DefaultContentNegotiation(object):
def filter_renderers(self, renderers, format):
"""
If there is a '.json' style format suffix, only use
renderers that accept that format.
If there is a '.json' style format suffix, filter the renderers
so that we only negotiation against those that accept that format.
"""
if not format:
return renderers
renderers = [renderer for renderer in renderers
if renderer.can_handle_format(format)]
if not renderers:
raise Http404()
raise exceptions.InvalidFormat(format)
return renderers
def get_accept_list(self, request):
"""
Given the incoming request, return a tokenised list of
media type strings.
Given the incoming request, return a tokenised list of media
type strings.
Allows URL style accept override. eg. "?accept=application/json"
"""
if self.settings.URL_ACCEPT_OVERRIDE:
# URL style accept override. eg. "?accept=application/json"
override = request.GET.get(self.settings.URL_ACCEPT_OVERRIDE)
if override:
return [override]
if (self.settings.IGNORE_MSIE_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', '').lower() != 'xmlhttprequest'):
# Ignore MSIE's broken accept behavior except for AJAX requests
# and do something sensible instead
return ['text/html', '*/*']
if 'HTTP_ACCEPT' in request.META:
# Standard HTTP Accept negotiation
# Accept header specified
tokens = request.META['HTTP_ACCEPT'].split(',')
return [token.strip() for token in tokens]
# Standard HTTP Accept negotiation
# No accept header specified
return ['*/*']
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

@ -25,7 +25,7 @@ class Response(SimpleTemplateResponse):
@property
def rendered_content(self):
self['Content-Type'] = self.media_type
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)

View File

@ -38,7 +38,7 @@ DEFAULTS = {
),
'DEFAULT_PERMISSIONS': (),
'DEFAULT_THROTTLES': (),
'DEFAULT_CONTENT_NEGOTIATION': 'djangorestframework.contentnegotiation.DefaultContentNegotiation',
'DEFAULT_CONTENT_NEGOTIATION': 'djangorestframework.negotiation.DefaultContentNegotiation',
'UNAUTHENTICATED_USER': 'django.contrib.auth.models.AnonymousUser',
'UNAUTHENTICATED_TOKEN': None,
@ -46,8 +46,8 @@ DEFAULTS = {
'FORM_METHOD_OVERRIDE': '_method',
'FORM_CONTENT_OVERRIDE': '_content',
'FORM_CONTENTTYPE_OVERRIDE': '_content_type',
'URL_ACCEPT_OVERRIDE': 'accept',
'IGNORE_MSIE_ACCEPT_HEADER': True,
'URL_ACCEPT_OVERRIDE': '_accept',
'URL_FORMAT_OVERRIDE': 'format',
'FORMAT_SUFFIX_KWARG': 'format'
}

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

@ -1,4 +1,3 @@
import json
import unittest
from django.conf.urls.defaults import patterns, url, include
@ -6,13 +5,11 @@ from django.test import TestCase
from djangorestframework.response import Response
from djangorestframework.views import APIView
from djangorestframework.compat import RequestFactory
from djangorestframework import status, exceptions
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(exceptions.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,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