mirror of
https://github.com/encode/django-rest-framework.git
synced 2025-01-24 08:14:16 +03:00
Simplify negotiation. Drop MSIE hacks. Etc.
This commit is contained in:
parent
6543ccd244
commit
a96211d3d1
|
@ -31,6 +31,14 @@ class PermissionDenied(APIException):
|
||||||
self.detail = detail or self.default_detail
|
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):
|
class MethodNotAllowed(APIException):
|
||||||
status_code = status.HTTP_405_METHOD_NOT_ALLOWED
|
status_code = status.HTTP_405_METHOD_NOT_ALLOWED
|
||||||
default_detail = "Method '%s' not allowed."
|
default_detail = "Method '%s' not allowed."
|
||||||
|
|
|
@ -1,10 +1,6 @@
|
||||||
from djangorestframework import exceptions
|
from djangorestframework import exceptions
|
||||||
from djangorestframework.settings import api_settings
|
from djangorestframework.settings import api_settings
|
||||||
from djangorestframework.utils.mediatypes import order_by_precedence
|
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):
|
class BaseContentNegotiation(object):
|
||||||
|
@ -24,17 +20,23 @@ class DefaultContentNegotiation(object):
|
||||||
fallback renderer and media_type.
|
fallback renderer and media_type.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return self._negotiate(request, renderers, format)
|
return self.unforced_negotiate(request, renderers, format)
|
||||||
except (Http404, exceptions.NotAcceptable):
|
except (exceptions.InvalidFormat, exceptions.NotAcceptable):
|
||||||
if force:
|
if force:
|
||||||
return (renderers[0], renderers[0].media_type)
|
return (renderers[0], renderers[0].media_type)
|
||||||
raise
|
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)
|
accepts = self.get_accept_list(request)
|
||||||
|
|
||||||
# Check the acceptable media types against each renderer,
|
# Check the acceptable media types against each renderer,
|
||||||
|
@ -51,42 +53,22 @@ class DefaultContentNegotiation(object):
|
||||||
|
|
||||||
def filter_renderers(self, renderers, format):
|
def filter_renderers(self, renderers, format):
|
||||||
"""
|
"""
|
||||||
If there is a '.json' style format suffix, only use
|
If there is a '.json' style format suffix, filter the renderers
|
||||||
renderers that accept that format.
|
so that we only negotiation against those that accept that format.
|
||||||
"""
|
"""
|
||||||
if not format:
|
|
||||||
return renderers
|
|
||||||
|
|
||||||
renderers = [renderer for renderer in renderers
|
renderers = [renderer for renderer in renderers
|
||||||
if renderer.can_handle_format(format)]
|
if renderer.can_handle_format(format)]
|
||||||
if not renderers:
|
if not renderers:
|
||||||
raise Http404()
|
raise exceptions.InvalidFormat(format)
|
||||||
|
return renderers
|
||||||
|
|
||||||
def get_accept_list(self, request):
|
def get_accept_list(self, request):
|
||||||
"""
|
"""
|
||||||
Given the incoming request, return a tokenised list of
|
Given the incoming request, return a tokenised list of media
|
||||||
media type strings.
|
type strings.
|
||||||
|
|
||||||
|
Allows URL style accept override. eg. "?accept=application/json"
|
||||||
"""
|
"""
|
||||||
if self.settings.URL_ACCEPT_OVERRIDE:
|
header = request.META.get('HTTP_ACCEPT', '*/*')
|
||||||
# URL style accept override. eg. "?accept=application/json"
|
header = request.GET.get(self.settings.URL_ACCEPT_OVERRIDE, header)
|
||||||
override = request.GET.get(self.settings.URL_ACCEPT_OVERRIDE)
|
return [token.strip() for token in header.split(',')]
|
||||||
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 ['*/*']
|
|
|
@ -25,7 +25,7 @@ class Response(SimpleTemplateResponse):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def rendered_content(self):
|
def rendered_content(self):
|
||||||
self['Content-Type'] = self.media_type
|
self['Content-Type'] = self.renderer.media_type
|
||||||
if self.data is None:
|
if self.data is None:
|
||||||
return self.renderer.render()
|
return self.renderer.render()
|
||||||
return self.renderer.render(self.data, self.media_type)
|
return self.renderer.render(self.data, self.media_type)
|
||||||
|
|
|
@ -38,7 +38,7 @@ DEFAULTS = {
|
||||||
),
|
),
|
||||||
'DEFAULT_PERMISSIONS': (),
|
'DEFAULT_PERMISSIONS': (),
|
||||||
'DEFAULT_THROTTLES': (),
|
'DEFAULT_THROTTLES': (),
|
||||||
'DEFAULT_CONTENT_NEGOTIATION': 'djangorestframework.contentnegotiation.DefaultContentNegotiation',
|
'DEFAULT_CONTENT_NEGOTIATION': 'djangorestframework.negotiation.DefaultContentNegotiation',
|
||||||
|
|
||||||
'UNAUTHENTICATED_USER': 'django.contrib.auth.models.AnonymousUser',
|
'UNAUTHENTICATED_USER': 'django.contrib.auth.models.AnonymousUser',
|
||||||
'UNAUTHENTICATED_TOKEN': None,
|
'UNAUTHENTICATED_TOKEN': None,
|
||||||
|
@ -46,8 +46,8 @@ DEFAULTS = {
|
||||||
'FORM_METHOD_OVERRIDE': '_method',
|
'FORM_METHOD_OVERRIDE': '_method',
|
||||||
'FORM_CONTENT_OVERRIDE': '_content',
|
'FORM_CONTENT_OVERRIDE': '_content',
|
||||||
'FORM_CONTENTTYPE_OVERRIDE': '_content_type',
|
'FORM_CONTENTTYPE_OVERRIDE': '_content_type',
|
||||||
'URL_ACCEPT_OVERRIDE': 'accept',
|
'URL_ACCEPT_OVERRIDE': '_accept',
|
||||||
'IGNORE_MSIE_ACCEPT_HEADER': True,
|
'URL_FORMAT_OVERRIDE': 'format',
|
||||||
|
|
||||||
'FORMAT_SUFFIX_KWARG': 'format'
|
'FORMAT_SUFFIX_KWARG': 'format'
|
||||||
}
|
}
|
||||||
|
|
|
@ -169,15 +169,6 @@ class RendererEndToEndTests(TestCase):
|
||||||
self.assertEquals(resp.content, RENDERER_B_SERIALIZER(DUMMYCONTENT))
|
self.assertEquals(resp.content, RENDERER_B_SERIALIZER(DUMMYCONTENT))
|
||||||
self.assertEquals(resp.status_code, DUMMYSTATUS)
|
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"]}'
|
_flat_repr = '{"foo": ["bar", "baz"]}'
|
||||||
_indented_repr = '{\n "foo": [\n "bar",\n "baz"\n ]\n}'
|
_indented_repr = '{\n "foo": [\n "bar",\n "baz"\n ]\n}'
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import json
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from django.conf.urls.defaults import patterns, url, include
|
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.response import Response
|
||||||
from djangorestframework.views import APIView
|
from djangorestframework.views import APIView
|
||||||
from djangorestframework.compat import RequestFactory
|
from djangorestframework import status
|
||||||
from djangorestframework import status, exceptions
|
|
||||||
from djangorestframework.renderers import (
|
from djangorestframework.renderers import (
|
||||||
BaseRenderer,
|
BaseRenderer,
|
||||||
JSONRenderer,
|
JSONRenderer,
|
||||||
DocumentingHTMLRenderer,
|
DocumentingHTMLRenderer
|
||||||
DEFAULT_RENDERERS
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -24,126 +21,6 @@ class MockJsonRenderer(BaseRenderer):
|
||||||
media_type = 'application/json'
|
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
|
DUMMYSTATUS = status.HTTP_200_OK
|
||||||
DUMMYCONTENT = 'dummycontent'
|
DUMMYCONTENT = 'dummycontent'
|
||||||
|
|
||||||
|
@ -280,15 +157,6 @@ class RendererIntegrationTests(TestCase):
|
||||||
self.assertEquals(resp.content, RENDERER_B_SERIALIZER(DUMMYCONTENT))
|
self.assertEquals(resp.content, RENDERER_B_SERIALIZER(DUMMYCONTENT))
|
||||||
self.assertEquals(resp.status_code, DUMMYSTATUS)
|
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):
|
class Issue122Tests(TestCase):
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
<a class="github" href="negotiation.py"></a>
|
||||||
|
|
||||||
# Content negotiation
|
# 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.
|
> 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.
|
||||||
|
|
52
docs/topics/rest-hypermedia-hateoas.md
Normal file
52
docs/topics/rest-hypermedia-hateoas.md
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
> You keep using that word "REST". I do not think it means what you think it means.
|
||||||
|
>
|
||||||
|
> — 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
|
Loading…
Reference in New Issue
Block a user