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
__all__ = ('RequestMixin',
'ResponseMixin',
'AuthMixin',
'ReadModelMixin',
'CreateModelMixin',
'UpdateModelMixin',
'DeleteModelMixin',
'ListModelMixin')
__all__ = (
'RequestMixin',
'ResponseMixin',
'AuthMixin',
'ReadModelMixin',
'CreateModelMixin',
'UpdateModelMixin',
'DeleteModelMixin',
'ListModelMixin'
)
########## Request Mixin ##########
@ -267,7 +269,7 @@ class ResponseMixin(object):
# Serialize the response content
if response.has_content_body:
content = renderer(self).render(output=response.cleaned_content)
content = renderer(self).render(response.cleaned_content, renderer.media_type)
else:
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,
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.
@ -7,64 +8,78 @@ from django import forms
from django.conf import settings
from django.template import RequestContext, loader
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.utils import dict2xml, url_resolves
from djangorestframework.utils.breadcrumbs import get_breadcrumbs
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
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):
"""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
def __init__(self, view):
self.view = view
def render(self, output=None, verbose=False):
"""By default render simply returns the ouput as-is.
Override this method to provide for other behaviour."""
if output is None:
def render(self, obj=None, media_type=None):
"""
By default render simply returns the ouput as-is.
Override this method to provide for other behavior.
"""
if obj is None:
return ''
return output
return obj
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
the ``media_type`` and ``template`` attributes"""
the ``media_type`` and ``template`` attributes
"""
media_type = None
template = None
def render(self, output=None, verbose=False):
if output is None:
def render(self, obj=None, media_type=None):
if obj is None:
return ''
context = RequestContext(self.request, output)
context = RequestContext(self.request, obj)
return self.template.render(context)
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
def _get_content(self, resource, request, output):
"""Get the content as if it had been renderted by a non-documenting renderer.
def _get_content(self, resource, request, obj, media_type):
"""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
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)]
if not renderers:
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):
return '[%d bytes of binary content]'
@ -149,8 +165,8 @@ class DocumentingTemplateRenderer(BaseRenderer):
return GenericContentForm(resource)
def render(self, output=None):
content = self._get_content(self.view, self.view.request, output)
def render(self, obj=None, media_type=None):
content = self._get_content(self.view, self.view.request, obj, media_type)
form_instance = self._get_form_instance(self.view)
if url_resolves(settings.LOGIN_URL) and url_resolves(settings.LOGOUT_URL):
@ -194,46 +210,63 @@ class DocumentingTemplateRenderer(BaseRenderer):
class JSONRenderer(BaseRenderer):
"""Renderer which serializes to JSON"""
"""
Renderer which serializes to JSON
"""
media_type = 'application/json'
def render(self, output=None, verbose=False):
if output is None:
def render(self, obj=None, media_type=None):
if obj is None:
return ''
if verbose:
return json.dumps(output, indent=4, sort_keys=True)
return json.dumps(output)
indent = get_media_type_params(media_type).get('indent', None)
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):
"""Renderer which serializes to XML."""
"""
Renderer which serializes to XML.
"""
media_type = 'application/xml'
def render(self, output=None, verbose=False):
if output is None:
def render(self, obj=None, media_type=None):
if obj is None:
return ''
return dict2xml(output)
return dict2xml(obj)
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'
template = 'renderer.html'
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,
given their Accept headers."""
given their Accept headers.
"""
media_type = 'application/xhtml+xml'
template = 'renderer.html'
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.
Useful for browsing an API with command line tools."""
"""
Renderer that serializes the object with the default renderer, but also provides plain-text
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'
template = 'renderer.txt'

View File

@ -2,9 +2,10 @@ from django.conf.urls.defaults import patterns, url
from django import http
from django.test import TestCase
from djangorestframework.compat import View
from djangorestframework.renderers import BaseRenderer
from djangorestframework.renderers import BaseRenderer, JSONRenderer
from djangorestframework.mixins import ResponseMixin
from djangorestframework.response import Response
from djangorestframework.utils.mediatypes import add_media_type_param
DUMMYSTATUS = 200
DUMMYCONTENT = 'dummycontent'
@ -20,14 +21,14 @@ class MockView(ResponseMixin, View):
class RendererA(BaseRenderer):
media_type = 'mock/renderera'
def render(self, output, verbose=False):
return RENDERER_A_SERIALIZER(output)
def render(self, obj=None, content_type=None):
return RENDERER_A_SERIALIZER(obj)
class RendererB(BaseRenderer):
media_type = 'mock/rendererb'
def render(self, output, verbose=False):
return RENDERER_B_SERIALIZER(output)
def render(self, obj=None, content_type=None):
return RENDERER_B_SERIALIZER(obj)
urlpatterns = patterns('',
@ -36,7 +37,9 @@ urlpatterns = patterns('',
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'
@ -73,4 +76,32 @@ class RendererIntegrationTests(TestCase):
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."""
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 )')
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:
return ()
elif isinstance(obj, list):
@ -27,7 +35,9 @@ def as_tuple(obj):
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:
resolve(url)
except:

View File

@ -15,7 +15,7 @@ def media_type_matches(lhs, rhs):
Valid media type strings include:
'application/json indent=4'
'application/json; indent=4'
'application/json'
'text/*'
'*/*'
@ -33,10 +33,28 @@ def is_form_media_type(media_type):
media_type = _MediaType(media_type)
return media_type.full_type == 'application/x-www-form-urlencoded' or \
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):
def __init__(self, media_type_str):
if media_type_str is None:
media_type_str = ''
self.orig = media_type_str
self.full_type, self.params = parse_header(media_type_str)
self.main_type, sep, self.sub_type = self.full_type.partition('/')
@ -94,5 +112,8 @@ class _MediaType(object):
return unicode(self).encode('utf-8')
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
__all__ = ('BaseView',
'ModelView',
'InstanceModelView',
'ListOrModelView',
'ListOrCreateModelView')
__all__ = (
'BaseView',
'ModelView',
'InstanceModelView',
'ListOrModelView',
'ListOrCreateModelView'
)
@ -78,55 +80,59 @@ class BaseView(RequestMixin, ResponseMixin, AuthMixin, View):
# all other authentication is CSRF exempt.
@csrf_exempt
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:
# 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)
self.request = request
self.args = args
self.kwargs = kwargs
except ErrorResponse, exc:
response = exc.response
# 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:
# 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:
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)