Allow .json .html .xml style urls and also allow these formats to be specified in a "?format=..." query string.

This commit is contained in:
Michael Fötsch 2011-06-26 16:03:36 +02:00
parent d3024ff181
commit d8bec115ad
6 changed files with 146 additions and 64 deletions

View File

@ -11,6 +11,7 @@ from django.http.multipartparser import LimitBytes
from djangorestframework import status from djangorestframework import status
from djangorestframework.parsers import FormParser, MultiPartParser from djangorestframework.parsers import FormParser, MultiPartParser
from djangorestframework.renderers import BaseRenderer
from djangorestframework.resources import Resource, FormResource, ModelResource from djangorestframework.resources import Resource, FormResource, ModelResource
from djangorestframework.response import Response, ErrorResponse from djangorestframework.response import Response, ErrorResponse
from djangorestframework.utils import as_tuple, MSIE_USER_AGENT_REGEX from djangorestframework.utils import as_tuple, MSIE_USER_AGENT_REGEX
@ -290,7 +291,7 @@ class ResponseMixin(object):
accept_list = [token.strip() for token in request.META["HTTP_ACCEPT"].split(',')] accept_list = [token.strip() for token in request.META["HTTP_ACCEPT"].split(',')]
else: else:
# No accept header specified # No accept header specified
return (self._default_renderer(self), self._default_renderer.media_type) accept_list = ['*/*']
# Check the acceptable media types against each renderer, # Check the acceptable media types against each renderer,
# attempting more specific media types first # attempting more specific media types first
@ -298,11 +299,11 @@ class ResponseMixin(object):
# Worst case is we're looping over len(accept_list) * len(self.renderers) # Worst case is we're looping over len(accept_list) * len(self.renderers)
renderers = [renderer_cls(self) for renderer_cls in self.renderers] renderers = [renderer_cls(self) for renderer_cls in self.renderers]
for media_type_lst in order_by_precedence(accept_list): for accepted_media_type_lst in order_by_precedence(accept_list):
for renderer in renderers: for renderer in renderers:
for media_type in media_type_lst: for accepted_media_type in accepted_media_type_lst:
if renderer.can_handle_response(media_type): if renderer.can_handle_response(accepted_media_type):
return renderer, media_type return renderer, accepted_media_type
# No acceptable renderers were found # No acceptable renderers were found
raise ErrorResponse(status.HTTP_406_NOT_ACCEPTABLE, raise ErrorResponse(status.HTTP_406_NOT_ACCEPTABLE,
@ -317,6 +318,13 @@ class ResponseMixin(object):
""" """
return [renderer.media_type for renderer in self.renderers] return [renderer.media_type for renderer in self.renderers]
@property
def _rendered_formats(self):
"""
Return a list of all the formats that this view can render.
"""
return [renderer.format for renderer in self.renderers]
@property @property
def _default_renderer(self): def _default_renderer(self):
""" """
@ -486,7 +494,10 @@ class ReadModelMixin(object):
instance = model.objects.get(pk=args[-1], **kwargs) instance = model.objects.get(pk=args[-1], **kwargs)
else: else:
# Otherwise assume the kwargs uniquely identify the model # Otherwise assume the kwargs uniquely identify the model
instance = model.objects.get(**kwargs) filtered_keywords = kwargs.copy()
if BaseRenderer._FORMAT_QUERY_PARAM in filtered_keywords:
del filtered_keywords[BaseRenderer._FORMAT_QUERY_PARAM]
instance = model.objects.get(**filtered_keywords)
except model.DoesNotExist: except model.DoesNotExist:
raise ErrorResponse(status.HTTP_404_NOT_FOUND) raise ErrorResponse(status.HTTP_404_NOT_FOUND)

View File

@ -41,7 +41,10 @@ class BaseRenderer(object):
and override the :meth:`render` method. and override the :meth:`render` method.
""" """
_FORMAT_QUERY_PARAM = 'format'
media_type = None media_type = None
format = None
def __init__(self, view): def __init__(self, view):
self.view = view self.view = view
@ -58,6 +61,11 @@ class BaseRenderer(object):
This may be overridden to provide for other behavior, but typically you'll 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. instead want to just set the :attr:`media_type` attribute on the class.
""" """
format = self.view.kwargs.get(self._FORMAT_QUERY_PARAM, None)
if format is 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, accept)
def render(self, obj=None, media_type=None): def render(self, obj=None, media_type=None):
@ -84,6 +92,7 @@ class JSONRenderer(BaseRenderer):
""" """
media_type = 'application/json' media_type = 'application/json'
format = 'json'
def render(self, obj=None, media_type=None): def render(self, obj=None, media_type=None):
""" """
@ -111,6 +120,7 @@ class XMLRenderer(BaseRenderer):
""" """
media_type = 'application/xml' media_type = 'application/xml'
format = 'xml'
def render(self, obj=None, media_type=None): def render(self, obj=None, media_type=None):
""" """
@ -289,12 +299,12 @@ class DocumentingTemplateRenderer(BaseRenderer):
'version': VERSION, 'version': VERSION,
'markeddown': markeddown, 'markeddown': markeddown,
'breadcrumblist': breadcrumb_list, 'breadcrumblist': breadcrumb_list,
'available_media_types': self.view._rendered_media_types, 'available_formats': self.view._rendered_formats,
'put_form': put_form_instance, 'put_form': put_form_instance,
'post_form': post_form_instance, 'post_form': post_form_instance,
'login_url': login_url, 'login_url': login_url,
'logout_url': logout_url, 'logout_url': logout_url,
'ACCEPT_PARAM': getattr(self.view, '_ACCEPT_QUERY_PARAM', None), 'FORMAT_PARAM': self._FORMAT_QUERY_PARAM,
'METHOD_PARAM': getattr(self.view, '_METHOD_PARAM', None), 'METHOD_PARAM': getattr(self.view, '_METHOD_PARAM', None),
'ADMIN_MEDIA_PREFIX': settings.ADMIN_MEDIA_PREFIX 'ADMIN_MEDIA_PREFIX': settings.ADMIN_MEDIA_PREFIX
}) })
@ -317,6 +327,7 @@ class DocumentingHTMLRenderer(DocumentingTemplateRenderer):
""" """
media_type = 'text/html' media_type = 'text/html'
format = 'html'
template = 'renderer.html' template = 'renderer.html'
@ -328,6 +339,7 @@ class DocumentingXHTMLRenderer(DocumentingTemplateRenderer):
""" """
media_type = 'application/xhtml+xml' media_type = 'application/xhtml+xml'
format = 'xhtml'
template = 'renderer.html' template = 'renderer.html'
@ -339,6 +351,7 @@ class DocumentingPlainTextRenderer(DocumentingTemplateRenderer):
""" """
media_type = 'text/plain' media_type = 'text/plain'
format = 'txt'
template = 'renderer.txt' template = 'renderer.txt'

View File

@ -2,6 +2,7 @@
DEBUG = True DEBUG = True
TEMPLATE_DEBUG = DEBUG TEMPLATE_DEBUG = DEBUG
DEBUG_PROPAGATE_EXCEPTIONS = True
ADMINS = ( ADMINS = (
# ('Your Name', 'your_email@domain.com'), # ('Your Name', 'your_email@domain.com'),

View File

@ -48,9 +48,9 @@
<h2>GET {{ name }}</h2> <h2>GET {{ name }}</h2>
<div class='submit-row' style='margin: 0; border: 0'> <div class='submit-row' style='margin: 0; border: 0'>
<a href='{{ request.get_full_path }}' rel="nofollow" style='float: left'>GET</a> <a href='{{ request.get_full_path }}' rel="nofollow" style='float: left'>GET</a>
{% for media_type in available_media_types %} {% for format in available_formats %}
{% with ACCEPT_PARAM|add:"="|add:media_type as param %} {% with FORMAT_PARAM|add:"="|add:format as param %}
[<a href='{{ request.get_full_path|add_query_param:param }}' rel="nofollow">{{ media_type }}</a>] [<a href='{{ request.get_full_path|add_query_param:param }}' rel="nofollow">{{ format }}</a>]
{% endwith %} {% endwith %}
{% endfor %} {% endfor %}
</div> </div>

View File

@ -2,6 +2,7 @@ from django.conf.urls.defaults import patterns, url
from django import http from django import http
from django.test import TestCase from django.test import TestCase
from djangorestframework import status
from djangorestframework.compat import View as DjangoView from djangorestframework.compat import View as DjangoView
from djangorestframework.renderers import BaseRenderer, JSONRenderer from djangorestframework.renderers import BaseRenderer, JSONRenderer
from djangorestframework.parsers import JSONParser from djangorestframework.parsers import JSONParser
@ -11,7 +12,7 @@ from djangorestframework.utils.mediatypes import add_media_type_param
from StringIO import StringIO from StringIO import StringIO
DUMMYSTATUS = 200 DUMMYSTATUS = status.HTTP_200_OK
DUMMYCONTENT = 'dummycontent' DUMMYCONTENT = 'dummycontent'
RENDERER_A_SERIALIZER = lambda x: 'Renderer A: %s' % x RENDERER_A_SERIALIZER = lambda x: 'Renderer A: %s' % x
@ -19,12 +20,14 @@ RENDERER_B_SERIALIZER = lambda x: 'Renderer B: %s' % x
class RendererA(BaseRenderer): class RendererA(BaseRenderer):
media_type = 'mock/renderera' media_type = 'mock/renderera'
format="formata"
def render(self, obj=None, media_type=None): def render(self, obj=None, media_type=None):
return RENDERER_A_SERIALIZER(obj) return RENDERER_A_SERIALIZER(obj)
class RendererB(BaseRenderer): class RendererB(BaseRenderer):
media_type = 'mock/rendererb' media_type = 'mock/rendererb'
format="formatb"
def render(self, obj=None, media_type=None): def render(self, obj=None, media_type=None):
return RENDERER_B_SERIALIZER(obj) return RENDERER_B_SERIALIZER(obj)
@ -32,11 +35,13 @@ class RendererB(BaseRenderer):
class MockView(ResponseMixin, DjangoView): class MockView(ResponseMixin, DjangoView):
renderers = (RendererA, RendererB) renderers = (RendererA, RendererB)
def get(self, request): def get(self, request, **kwargs):
response = Response(DUMMYSTATUS, DUMMYCONTENT) response = Response(DUMMYSTATUS, DUMMYCONTENT)
return self.render(response) return self.render(response)
urlpatterns = patterns('', urlpatterns = patterns('',
url(r'^.*\.(?P<format>.+)$', MockView.as_view(renderers=[RendererA, RendererB])),
url(r'^$', MockView.as_view(renderers=[RendererA, RendererB])), url(r'^$', MockView.as_view(renderers=[RendererA, RendererB])),
) )
@ -85,10 +90,58 @@ 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_specified_renderer_serializes_content_on_accept_query(self):
"""The '_accept' query string should behave in the same way as the Accept header."""
resp = self.client.get('/?_accept=%s' % RendererB.media_type)
self.assertEquals(resp['Content-Type'], RendererB.media_type)
self.assertEquals(resp.content, RENDERER_B_SERIALIZER(DUMMYCONTENT))
self.assertEquals(resp.status_code, DUMMYSTATUS)
def test_unsatisfiable_accept_header_on_request_returns_406_status(self): def test_unsatisfiable_accept_header_on_request_returns_406_status(self):
"""If the Accept header is unsatisfiable we should return a 406 Not Acceptable response.""" """If the Accept header is unsatisfiable we should return a 406 Not Acceptable response."""
resp = self.client.get('/', HTTP_ACCEPT='foo/bar') resp = self.client.get('/', HTTP_ACCEPT='foo/bar')
self.assertEquals(resp.status_code, 406) self.assertEquals(resp.status_code, status.HTTP_406_NOT_ACCEPTABLE)
def test_specified_renderer_serializes_content_on_format_query(self):
"""If a 'format' query is specified, the renderer with the matching
format attribute should serialize the response."""
resp = self.client.get('/?format=%s' % RendererB.format)
self.assertEquals(resp['Content-Type'], RendererB.media_type)
self.assertEquals(resp.content, RENDERER_B_SERIALIZER(DUMMYCONTENT))
self.assertEquals(resp.status_code, DUMMYSTATUS)
def test_specified_renderer_serializes_content_on_format_kwargs(self):
"""If a 'format' keyword arg is specified, the renderer with the matching
format attribute should serialize the response."""
resp = self.client.get('/something.formatb')
self.assertEquals(resp['Content-Type'], RendererB.media_type)
self.assertEquals(resp.content, RENDERER_B_SERIALIZER(DUMMYCONTENT))
self.assertEquals(resp.status_code, DUMMYSTATUS)
def test_specified_renderer_is_used_on_format_query_with_matching_accept(self):
"""If both a 'format' query and a matching Accept header specified,
the renderer with the matching format attribute should serialize the response."""
resp = self.client.get('/?format=%s' % RendererB.format,
HTTP_ACCEPT=RendererB.media_type)
self.assertEquals(resp['Content-Type'], RendererB.media_type)
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)
def test_bla(self):
resp = self.client.get('/?format=formatb',
HTTP_ACCEPT='text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8')
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"]}'

View File

@ -113,6 +113,7 @@ class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView):
# all other authentication is CSRF exempt. # all other authentication is CSRF exempt.
@csrf_exempt @csrf_exempt
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
try:
self.request = request self.request = request
self.args = args self.args = args
self.kwargs = kwargs self.kwargs = kwargs
@ -163,7 +164,10 @@ class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView):
response.headers.update(self.headers) response.headers.update(self.headers)
return self.render(response) return self.render(response)
except:
import traceback
traceback.print_exc()
raise
class ModelView(View): class ModelView(View):
"""A RESTful view that maps to a model in the database.""" """A RESTful view that maps to a model in the database."""