renderer API work

This commit is contained in:
Tom Christie 2011-05-10 12:21:48 +01:00
parent 8f58ee489d
commit 527e4ffdf7
6 changed files with 222 additions and 119 deletions

View File

@ -17,14 +17,16 @@ import re
from StringIO import StringIO from StringIO import StringIO
__all__ = ('RequestMixin', __all__ = (
'ResponseMixin', 'RequestMixin',
'AuthMixin', 'ResponseMixin',
'ReadModelMixin', 'AuthMixin',
'CreateModelMixin', 'ReadModelMixin',
'UpdateModelMixin', 'CreateModelMixin',
'DeleteModelMixin', 'UpdateModelMixin',
'ListModelMixin') 'DeleteModelMixin',
'ListModelMixin'
)
########## Request Mixin ########## ########## Request Mixin ##########
@ -267,7 +269,7 @@ class ResponseMixin(object):
# Serialize the response content # Serialize the response content
if response.has_content_body: if response.has_content_body:
content = renderer(self).render(output=response.cleaned_content) content = renderer(self).render(response.cleaned_content, renderer.media_type)
else: else:
content = renderer(self).render() content = renderer(self).render()

View File

@ -1,4 +1,5 @@
"""Renderers are used to serialize a View's output into specific media types. """
Renderers are used to serialize a View's output into specific media types.
django-rest-framework also provides HTML and PlainText renderers that help self-document the API, django-rest-framework also provides HTML and PlainText renderers that help self-document the API,
by serializing the output along with documentation regarding the Resource, output status and headers, by serializing the output along with documentation regarding the Resource, output status and headers,
and providing forms and links depending on the allowed methods, renderers and parsers on the Resource. and providing forms and links depending on the allowed methods, renderers and parsers on the Resource.
@ -7,64 +8,78 @@ from django import forms
from django.conf import settings from django.conf import settings
from django.template import RequestContext, loader from django.template import RequestContext, loader
from django.utils import simplejson as json from django.utils import simplejson as json
from django import forms
from djangorestframework.utils import dict2xml, url_resolves from djangorestframework import status
from djangorestframework.compat import apply_markdown from djangorestframework.compat import apply_markdown
from djangorestframework.utils import dict2xml, url_resolves
from djangorestframework.utils.breadcrumbs import get_breadcrumbs from djangorestframework.utils.breadcrumbs import get_breadcrumbs
from djangorestframework.utils.description import get_name, get_description from djangorestframework.utils.description import get_name, get_description
from djangorestframework import status from djangorestframework.utils.mediatypes import get_media_type_params, add_media_type_param
from urllib import quote_plus
import string
import re
from decimal import Decimal from decimal import Decimal
import re
import string
from urllib import quote_plus
__all__ = (
'BaseRenderer',
'JSONRenderer',
'DocumentingHTMLRenderer',
'DocumentingXHTMLRenderer',
'DocumentingPlainTextRenderer',
'XMLRenderer'
)
# TODO: Rename verbose to something more appropriate
# TODO: Maybe None could be handled more cleanly. It'd be nice if it was handled by default,
# and only have an renderer output anything if it explicitly provides support for that.
class BaseRenderer(object): class BaseRenderer(object):
"""All renderers must extend this class, set the media_type attribute, and """
override the render() function.""" All renderers must extend this class, set the media_type attribute, and
override the render() function.
"""
media_type = None media_type = None
def __init__(self, view): def __init__(self, view):
self.view = view self.view = view
def render(self, output=None, verbose=False): def render(self, obj=None, media_type=None):
"""By default render simply returns the ouput as-is. """
Override this method to provide for other behaviour.""" By default render simply returns the ouput as-is.
if output is None: Override this method to provide for other behavior.
"""
if obj is None:
return '' return ''
return output return obj
class TemplateRenderer(BaseRenderer): class TemplateRenderer(BaseRenderer):
"""A Base class provided for convenience. """
A Base class provided for convenience.
Render the output simply by using the given template. Render the object simply by using the given template.
To create a template renderer, subclass this, and set To create a template renderer, subclass this, and set
the ``media_type`` and ``template`` attributes""" the ``media_type`` and ``template`` attributes
"""
media_type = None media_type = None
template = None template = None
def render(self, output=None, verbose=False): def render(self, obj=None, media_type=None):
if output is None: if obj is None:
return '' return ''
context = RequestContext(self.request, output) context = RequestContext(self.request, obj)
return self.template.render(context) return self.template.render(context)
class DocumentingTemplateRenderer(BaseRenderer): class DocumentingTemplateRenderer(BaseRenderer):
"""Base class for renderers used to self-document the API. """
Implementing classes should extend this class and set the template attribute.""" Base class for renderers used to self-document the API.
Implementing classes should extend this class and set the template attribute.
"""
template = None template = None
def _get_content(self, resource, request, output): def _get_content(self, resource, request, obj, media_type):
"""Get the content as if it had been renderted by a non-documenting renderer. """Get the content as if it had been rendered by a non-documenting renderer.
(Typically this will be the content as it would have been if the Resource had been (Typically this will be the content as it would have been if the Resource had been
requested with an 'Accept: */*' header, although with verbose style formatting if appropriate.)""" requested with an 'Accept: */*' header, although with verbose style formatting if appropriate.)"""
@ -73,8 +88,9 @@ class DocumentingTemplateRenderer(BaseRenderer):
renderers = [renderer for renderer in resource.renderers if not isinstance(renderer, DocumentingTemplateRenderer)] renderers = [renderer for renderer in resource.renderers if not isinstance(renderer, DocumentingTemplateRenderer)]
if not renderers: if not renderers:
return '[No renderers were found]' return '[No renderers were found]'
content = renderers[0](resource).render(output, verbose=True) media_type = add_media_type_param(media_type, 'indent', '4')
content = renderers[0](resource).render(obj, media_type)
if not all(char in string.printable for char in content): if not all(char in string.printable for char in content):
return '[%d bytes of binary content]' return '[%d bytes of binary content]'
@ -149,8 +165,8 @@ class DocumentingTemplateRenderer(BaseRenderer):
return GenericContentForm(resource) return GenericContentForm(resource)
def render(self, output=None): def render(self, obj=None, media_type=None):
content = self._get_content(self.view, self.view.request, output) content = self._get_content(self.view, self.view.request, obj, media_type)
form_instance = self._get_form_instance(self.view) form_instance = self._get_form_instance(self.view)
if url_resolves(settings.LOGIN_URL) and url_resolves(settings.LOGOUT_URL): if url_resolves(settings.LOGIN_URL) and url_resolves(settings.LOGOUT_URL):
@ -194,46 +210,63 @@ class DocumentingTemplateRenderer(BaseRenderer):
class JSONRenderer(BaseRenderer): class JSONRenderer(BaseRenderer):
"""Renderer which serializes to JSON""" """
Renderer which serializes to JSON
"""
media_type = 'application/json' media_type = 'application/json'
def render(self, output=None, verbose=False): def render(self, obj=None, media_type=None):
if output is None: if obj is None:
return '' return ''
if verbose:
return json.dumps(output, indent=4, sort_keys=True) indent = get_media_type_params(media_type).get('indent', None)
return json.dumps(output) if indent is not None:
try:
indent = int(indent)
except ValueError:
indent = None
sort_keys = indent and True or False
return json.dumps(obj, indent=indent, sort_keys=sort_keys)
class XMLRenderer(BaseRenderer): class XMLRenderer(BaseRenderer):
"""Renderer which serializes to XML.""" """
Renderer which serializes to XML.
"""
media_type = 'application/xml' media_type = 'application/xml'
def render(self, output=None, verbose=False): def render(self, obj=None, media_type=None):
if output is None: if obj is None:
return '' return ''
return dict2xml(output) return dict2xml(obj)
class DocumentingHTMLRenderer(DocumentingTemplateRenderer): class DocumentingHTMLRenderer(DocumentingTemplateRenderer):
"""Renderer which provides a browsable HTML interface for an API. """
See the examples listed in the django-rest-framework documentation to see this in actions.""" Renderer which provides a browsable HTML interface for an API.
See the examples at http://api.django-rest-framework.org to see this in action.
"""
media_type = 'text/html' media_type = 'text/html'
template = 'renderer.html' template = 'renderer.html'
class DocumentingXHTMLRenderer(DocumentingTemplateRenderer): class DocumentingXHTMLRenderer(DocumentingTemplateRenderer):
"""Identical to DocumentingHTMLRenderer, except with an xhtml media type. """
Identical to DocumentingHTMLRenderer, except with an xhtml media type.
We need this to be listed in preference to xml in order to return HTML to WebKit based browsers, We need this to be listed in preference to xml in order to return HTML to WebKit based browsers,
given their Accept headers.""" given their Accept headers.
"""
media_type = 'application/xhtml+xml' media_type = 'application/xhtml+xml'
template = 'renderer.html' template = 'renderer.html'
class DocumentingPlainTextRenderer(DocumentingTemplateRenderer): class DocumentingPlainTextRenderer(DocumentingTemplateRenderer):
"""Renderer that serializes the output with the default renderer, but also provides plain-text """
doumentation of the returned status and headers, and of the resource's name and description. Renderer that serializes the object with the default renderer, but also provides plain-text
Useful for browsing an API with command line tools.""" documentation of the returned status and headers, and of the resource's name and description.
Useful for browsing an API with command line tools.
"""
media_type = 'text/plain' media_type = 'text/plain'
template = 'renderer.txt' template = 'renderer.txt'

View File

@ -2,9 +2,10 @@ 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.compat import View from djangorestframework.compat import View
from djangorestframework.renderers import BaseRenderer from djangorestframework.renderers import BaseRenderer, JSONRenderer
from djangorestframework.mixins import ResponseMixin from djangorestframework.mixins import ResponseMixin
from djangorestframework.response import Response from djangorestframework.response import Response
from djangorestframework.utils.mediatypes import add_media_type_param
DUMMYSTATUS = 200 DUMMYSTATUS = 200
DUMMYCONTENT = 'dummycontent' DUMMYCONTENT = 'dummycontent'
@ -20,14 +21,14 @@ class MockView(ResponseMixin, View):
class RendererA(BaseRenderer): class RendererA(BaseRenderer):
media_type = 'mock/renderera' media_type = 'mock/renderera'
def render(self, output, verbose=False): def render(self, obj=None, content_type=None):
return RENDERER_A_SERIALIZER(output) return RENDERER_A_SERIALIZER(obj)
class RendererB(BaseRenderer): class RendererB(BaseRenderer):
media_type = 'mock/rendererb' media_type = 'mock/rendererb'
def render(self, output, verbose=False): def render(self, obj=None, content_type=None):
return RENDERER_B_SERIALIZER(output) return RENDERER_B_SERIALIZER(obj)
urlpatterns = patterns('', urlpatterns = patterns('',
@ -36,7 +37,9 @@ urlpatterns = patterns('',
class RendererIntegrationTests(TestCase): class RendererIntegrationTests(TestCase):
"""End-to-end testing of renderers using an RendererMixin on a generic view.""" """
End-to-end testing of renderers using an RendererMixin on a generic view.
"""
urls = 'djangorestframework.tests.renderers' urls = 'djangorestframework.tests.renderers'
@ -73,4 +76,32 @@ class RendererIntegrationTests(TestCase):
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, 406)
_flat_repr = '{"foo": ["bar", "baz"]}'
_indented_repr = """{
"foo": [
"bar",
"baz"
]
}"""
class JSONRendererTests(TestCase):
"""
Tests specific to the JSON Renderer
"""
def test_without_content_type_args(self):
obj = {'foo':['bar','baz']}
renderer = JSONRenderer(None)
content = renderer.render(obj, 'application/json')
self.assertEquals(content, _flat_repr)
def test_with_content_type_args(self):
obj = {'foo':['bar','baz']}
renderer = JSONRenderer(None)
content = renderer.render(obj, 'application/json; indent=2')
self.assertEquals(content, _indented_repr)

View File

@ -16,7 +16,15 @@ import xml.etree.ElementTree as ET
MSIE_USER_AGENT_REGEX = re.compile(r'^Mozilla/[0-9]+\.[0-9]+ \([^)]*; MSIE [0-9]+\.[0-9]+[a-z]?;[^)]*\)(?!.* Opera )') MSIE_USER_AGENT_REGEX = re.compile(r'^Mozilla/[0-9]+\.[0-9]+ \([^)]*; MSIE [0-9]+\.[0-9]+[a-z]?;[^)]*\)(?!.* Opera )')
def as_tuple(obj): def as_tuple(obj):
"""Given obj return a tuple""" """
Given an object which may be a list/tuple, another object, or None,
return that object in list form.
IE:
If the object is already a list/tuple just return it.
If the object is not None, return it in a list with a single element.
If the object is None return an empty list.
"""
if obj is None: if obj is None:
return () return ()
elif isinstance(obj, list): elif isinstance(obj, list):
@ -27,7 +35,9 @@ def as_tuple(obj):
def url_resolves(url): def url_resolves(url):
"""Return True if the given URL is mapped to a view in the urlconf, False otherwise.""" """
Return True if the given URL is mapped to a view in the urlconf, False otherwise.
"""
try: try:
resolve(url) resolve(url)
except: except:

View File

@ -15,7 +15,7 @@ def media_type_matches(lhs, rhs):
Valid media type strings include: Valid media type strings include:
'application/json indent=4' 'application/json; indent=4'
'application/json' 'application/json'
'text/*' 'text/*'
'*/*' '*/*'
@ -33,10 +33,28 @@ def is_form_media_type(media_type):
media_type = _MediaType(media_type) media_type = _MediaType(media_type)
return media_type.full_type == 'application/x-www-form-urlencoded' or \ return media_type.full_type == 'application/x-www-form-urlencoded' or \
media_type.full_type == 'multipart/form-data' media_type.full_type == 'multipart/form-data'
def add_media_type_param(media_type, key, val):
"""
Add a key, value parameter to a media type string, and return the new media type string.
"""
media_type = _MediaType(media_type)
media_type.params[key] = val
return str(media_type)
def get_media_type_params(media_type):
"""
Return a dictionary of the parameters on the given media type.
"""
return _MediaType(media_type).params
class _MediaType(object): class _MediaType(object):
def __init__(self, media_type_str): def __init__(self, media_type_str):
if media_type_str is None:
media_type_str = ''
self.orig = media_type_str self.orig = media_type_str
self.full_type, self.params = parse_header(media_type_str) self.full_type, self.params = parse_header(media_type_str)
self.main_type, sep, self.sub_type = self.full_type.partition('/') self.main_type, sep, self.sub_type = self.full_type.partition('/')
@ -94,5 +112,8 @@ class _MediaType(object):
return unicode(self).encode('utf-8') return unicode(self).encode('utf-8')
def __unicode__(self): def __unicode__(self):
return self.orig ret = "%s/%s" % (self.main_type, self.sub_type)
for key, val in self.params.items():
ret += "; %s=%s" % (key, val)
return ret

View File

@ -7,11 +7,13 @@ from djangorestframework.mixins import *
from djangorestframework import resource, renderers, parsers, authentication, permissions, validators, status from djangorestframework import resource, renderers, parsers, authentication, permissions, validators, status
__all__ = ('BaseView', __all__ = (
'ModelView', 'BaseView',
'InstanceModelView', 'ModelView',
'ListOrModelView', 'InstanceModelView',
'ListOrCreateModelView') 'ListOrModelView',
'ListOrCreateModelView'
)
@ -78,55 +80,59 @@ class BaseView(RequestMixin, ResponseMixin, AuthMixin, View):
# 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
# 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:
# If using a form POST with '_method'/'_content'/'_content_type' overrides, then alter self.request = request
# self.method, self.content_type, self.RAW_CONTENT & self.CONTENT appropriately. self.args = args
self.perform_form_overloading() self.kwargs = kwargs
# Authenticate and check request is has the relevant permissions
self._check_permissions()
# 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 Response, or an object, or None
if 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.resource.object_to_serializable(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)
try:
# If using a form POST with '_method'/'_content'/'_content_type' overrides, then alter
# self.method, self.content_type, self.RAW_CONTENT & self.CONTENT appropriately.
self.perform_form_overloading()
# Authenticate and check request is has the relevant permissions
self._check_permissions()
# 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 Response, or an object, or None
if 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.resource.object_to_serializable(response.raw_content)
except ErrorResponse, exc:
response = exc.response
except:
import traceback
traceback.print_exc()
# 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'
return self.render(response)
except: except:
import traceback import traceback
traceback.print_exc() traceback.print_exc()
# 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'
return self.render(response)