Commit d8bec115 by Michael Fötsch

Allow .json .html .xml style urls and also allow these formats to be specified…

Allow .json .html .xml style urls and also allow these formats to be specified in a "?format=..." query string.
parent d3024ff1
...@@ -11,6 +11,7 @@ from django.http.multipartparser import LimitBytes ...@@ -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): ...@@ -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,12 +299,12 @@ class ResponseMixin(object): ...@@ -298,12 +299,12 @@ 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,
{'detail': 'Could not satisfy the client\'s Accept header', {'detail': 'Could not satisfy the client\'s Accept header',
...@@ -316,6 +317,13 @@ class ResponseMixin(object): ...@@ -316,6 +317,13 @@ class ResponseMixin(object):
Return an list of all the media types that this view can render. Return an list of all the media types that this view can render.
""" """
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): ...@@ -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)
......
...@@ -40,8 +40,11 @@ class BaseRenderer(object): ...@@ -40,8 +40,11 @@ class BaseRenderer(object):
All renderers must extend this class, set the :attr:`media_type` attribute, All renderers must extend this class, set the :attr:`media_type` attribute,
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): ...@@ -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): ...@@ -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): ...@@ -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): ...@@ -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): ...@@ -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): ...@@ -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): ...@@ -339,6 +351,7 @@ class DocumentingPlainTextRenderer(DocumentingTemplateRenderer):
""" """
media_type = 'text/plain' media_type = 'text/plain'
format = 'txt'
template = 'renderer.txt' template = 'renderer.txt'
......
...@@ -2,6 +2,7 @@ ...@@ -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'),
......
...@@ -48,9 +48,9 @@ ...@@ -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>
...@@ -122,4 +122,4 @@ ...@@ -122,4 +122,4 @@
</div> </div>
</div> </div>
</body> </body>
</html> </html>
\ No newline at end of file
...@@ -2,6 +2,7 @@ from django.conf.urls.defaults import patterns, url ...@@ -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 ...@@ -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 ...@@ -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): ...@@ -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): ...@@ -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"]}'
......
...@@ -113,57 +113,61 @@ class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView): ...@@ -113,57 +113,61 @@ 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):
self.request = request
self.args = args
self.kwargs = kwargs
self.headers = {}
# Calls to 'reverse' will not be fully qualified unless we set the scheme/host/port here.
prefix = '%s://%s' % (request.is_secure() and 'https' or 'http', request.get_host())
set_script_prefix(prefix)
try: try:
self.initial(request, *args, **kwargs) self.request = request
self.args = args
# Authenticate and check request has the relevant permissions self.kwargs = kwargs
self._check_permissions() self.headers = {}
# Get the appropriate handler method
if self.method.lower() in self.http_method_names:
handler = getattr(self, self.method.lower(), self.http_method_not_allowed)
else:
handler = self.http_method_not_allowed
response_obj = handler(request, *args, **kwargs)
# Allow return value to be either HttpResponse, Response, or an object, or None
if isinstance(response_obj, HttpResponse):
return response_obj
elif isinstance(response_obj, Response):
response = response_obj
elif response_obj is not None:
response = Response(status.HTTP_200_OK, response_obj)
else:
response = Response(status.HTTP_204_NO_CONTENT)
# Pre-serialize filtering (eg filter complex objects into natively serializable types)
response.cleaned_content = self.filter_response(response.raw_content)
except ErrorResponse, exc: # Calls to 'reverse' will not be fully qualified unless we set the scheme/host/port here.
response = exc.response prefix = '%s://%s' % (request.is_secure() and 'https' or 'http', request.get_host())
set_script_prefix(prefix)
# Always add these headers.
# try:
# TODO - this isn't actually the correct way to set the vary header, self.initial(request, *args, **kwargs)
# also it's currently sub-obtimal for HTTP caching - need to sort that out.
response.headers['Allow'] = ', '.join(self.allowed_methods) # Authenticate and check request has the relevant permissions
response.headers['Vary'] = 'Authenticate, Accept' self._check_permissions()
# merge with headers possibly set at some point in the view # Get the appropriate handler method
response.headers.update(self.headers) if self.method.lower() in self.http_method_names:
handler = getattr(self, self.method.lower(), self.http_method_not_allowed)
else:
handler = self.http_method_not_allowed
response_obj = handler(request, *args, **kwargs)
# Allow return value to be either HttpResponse, Response, or an object, or None
if isinstance(response_obj, HttpResponse):
return response_obj
elif isinstance(response_obj, Response):
response = response_obj
elif response_obj is not None:
response = Response(status.HTTP_200_OK, response_obj)
else:
response = Response(status.HTTP_204_NO_CONTENT)
# Pre-serialize filtering (eg filter complex objects into natively serializable types)
response.cleaned_content = self.filter_response(response.raw_content)
return self.render(response) except ErrorResponse, exc:
response = exc.response
# Always add these headers.
#
# TODO - this isn't actually the correct way to set the vary header,
# also it's currently sub-obtimal for HTTP caching - need to sort that out.
response.headers['Allow'] = ', '.join(self.allowed_methods)
response.headers['Vary'] = 'Authenticate, Accept'
# merge with headers possibly set at some point in the view
response.headers.update(self.headers)
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."""
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment