Make sure JSON output in Browseable API is nicely indented

This commit is contained in:
Tom Christie 2012-10-10 12:15:18 +01:00
parent ccd2b0117d
commit 648d2be29b
7 changed files with 157 additions and 107 deletions

View File

@ -132,7 +132,7 @@ Renders data into HTML for the Browseable API. This renderer will determine whi
## Custom renderers
To implement a custom renderer, you should override `BaseRenderer`, set the `.media_type` and `.format` properties, and implement the `.render(self, data, media_type)` method.
To implement a custom renderer, you should override `BaseRenderer`, set the `.media_type` and `.format` properties, and implement the `.render(self, data, media_type=None, renderer_context=None)` method.
For example:
@ -144,11 +144,26 @@ For example:
media_type = 'text/plain'
format = 'txt'
def render(self, data, media_type):
def render(self, data, media_type=None, renderer_context=None):
if isinstance(data, basestring):
return data
return smart_unicode(data)
The arguments passed to the `.render()` method are:
#### `data`
The request data, as set by the `Response()` instantiation.
#### `media_type=None`
Optional. If provided, this is the accepted media type, as determined by the content negotiation stage. Depending on the client's `Accept:` header, this may be more specific than the renderer's `media_type` attribute, and may include media type parameters. For example `"application/json; nested=true"`.
#### `renderer_context=None`
Optional. If provided, this is a dictionary of contextual information provided by the view.
By default this will include the following keys: `view`, `request`, `response`, `args`, `kwargs`.
---
# Advanced renderer usage

View File

@ -82,6 +82,11 @@ The media type that was selected by the content negotiation stage.
Set automatically by the `APIView` or `@api_view` immediately before the response is returned from the view.
## .renderer_context
A dictionary of additional context information that will be passed to the renderer's `.render()` method.
Set automatically by the `APIView` or `@api_view` immediately before the response is returned from the view.
[cite]: https://docs.djangoproject.com/en/dev/ref/template-response/
[statuscodes]: status-codes.md

View File

@ -16,7 +16,7 @@ from rest_framework.request import clone_request
from rest_framework.utils import dict2xml
from rest_framework.utils import encoders
from rest_framework.utils.breadcrumbs import get_breadcrumbs
from rest_framework.utils.mediatypes import get_media_type_params, add_media_type_param
from rest_framework.utils.mediatypes import get_media_type_params
from rest_framework import VERSION
from rest_framework import serializers
@ -30,10 +30,7 @@ class BaseRenderer(object):
media_type = None
format = None
def __init__(self, view=None):
self.view = view
def render(self, data=None, accepted_media_type=None):
def render(self, data, accepted_media_type=None, renderer_context=None):
raise NotImplemented('Renderer class requires .render() to be implemented')
@ -46,22 +43,29 @@ class JSONRenderer(BaseRenderer):
format = 'json'
encoder_class = encoders.JSONEncoder
def render(self, data=None, accepted_media_type=None):
def render(self, data, accepted_media_type=None, renderer_context=None):
"""
Render `obj` into json.
"""
if data is None:
return ''
# If the media type looks like 'application/json; indent=4', then
# pretty print the result.
indent = get_media_type_params(accepted_media_type).get('indent', None)
sort_keys = False
try:
indent = max(min(int(indent), 8), 0)
sort_keys = True
except (ValueError, TypeError):
indent = None
# If 'indent' is provided in the context, then pretty print the result.
# E.g. If we're being called by the BrowseableAPIRenderer.
renderer_context = renderer_context or {}
indent = renderer_context.get('indent', None)
sort_keys = indent and True or False
if accepted_media_type:
# If the media type looks like 'application/json; indent=4',
# then pretty print the result.
params = get_media_type_params(accepted_media_type)
indent = params.get('indent', indent)
try:
indent = max(min(int(indent), 8), 0)
sort_keys = True
except (ValueError, TypeError):
indent = None
return json.dumps(data, cls=self.encoder_class,
indent=indent, sort_keys=sort_keys)
@ -78,22 +82,25 @@ class JSONPRenderer(JSONRenderer):
callback_parameter = 'callback'
default_callback = 'callback'
def get_callback(self):
def get_callback(self, renderer_context):
"""
Determine the name of the callback to wrap around the json output.
"""
params = self.view.request.GET
request = renderer_context.get('request', None)
params = request and request.GET or {}
return params.get(self.callback_parameter, self.default_callback)
def render(self, data=None, accepted_media_type=None):
def render(self, data, accepted_media_type=None, renderer_context=None):
"""
Renders into jsonp, wrapping the json output in a callback function.
Clients may set the callback function name using a query parameter
on the URL, for example: ?callback=exampleCallbackName
"""
callback = self.get_callback()
json = super(JSONPRenderer, self).render(data, accepted_media_type)
renderer_context = renderer_context or {}
callback = self.get_callback(renderer_context)
json = super(JSONPRenderer, self).render(data, accepted_media_type,
renderer_context)
return "%s(%s);" % (callback, json)
@ -105,7 +112,7 @@ class XMLRenderer(BaseRenderer):
media_type = 'application/xml'
format = 'xml'
def render(self, data=None, accepted_media_type=None):
def render(self, data, accepted_media_type=None, renderer_context=None):
"""
Renders *obj* into serialized XML.
"""
@ -122,7 +129,7 @@ class YAMLRenderer(BaseRenderer):
media_type = 'application/yaml'
format = 'yaml'
def render(self, data=None, accepted_media_type=None):
def render(self, data, accepted_media_type=None, renderer_context=None):
"""
Renders *obj* into serialized YAML.
"""
@ -145,7 +152,7 @@ class HTMLRenderer(BaseRenderer):
format = 'html'
template_name = None
def render(self, data=None, accepted_media_type=None):
def render(self, data, accepted_media_type=None, renderer_context=None):
"""
Renders data to HTML, using Django's standard template rendering.
@ -155,8 +162,10 @@ class HTMLRenderer(BaseRenderer):
2. An explicit .template_name set on this class.
3. The return result of calling view.get_template_names().
"""
view = self.view
request, response = view.request, view.response
renderer_context = renderer_context or {}
view = renderer_context['view']
request = renderer_context['request']
response = renderer_context['response']
template_names = self.get_template_names(response, view)
template = self.resolve_template(template_names)
@ -187,22 +196,29 @@ class BrowsableAPIRenderer(BaseRenderer):
format = 'api'
template = 'rest_framework/api.html'
def get_content(self, view, request, data, accepted_media_type):
def get_default_renderer(self, view):
"""
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.)
Return an instance of the first valid renderer.
(Don't use another documenting renderer.)
"""
# Find the first valid renderer and render the content. (Don't use another documenting renderer.)
renderers = [renderer for renderer in view.renderer_classes
if not issubclass(renderer, BrowsableAPIRenderer)]
if not renderers:
return None
return renderers[0]()
def get_content(self, renderer, data,
accepted_media_type, renderer_context):
"""
Get the content as if it had been rendered by the default
non-documenting renderer.
"""
if not renderer:
return '[No renderers were found]'
accepted_media_type = add_media_type_param(accepted_media_type, 'indent', '4')
content = renderers[0](view).render(data, accepted_media_type)
renderer_context['indent'] = 4
content = renderer.render(data, accepted_media_type, renderer_context)
if not all(char in string.printable for char in content):
return '[%d bytes of binary content]'
@ -228,7 +244,8 @@ class BrowsableAPIRenderer(BaseRenderer):
return True # Don't actually need to return a form
if not getattr(view, 'get_serializer', None):
return self.get_generic_content_form(view)
media_types = [parser.media_type for parser in view.parser_classes]
return self.get_generic_content_form(media_types)
#####
# TODO: This is a little bit of a hack. Actually we'd like to remove
@ -273,9 +290,10 @@ class BrowsableAPIRenderer(BaseRenderer):
form_instance = OnTheFlyForm(data)
return form_instance
def get_generic_content_form(self, view):
def get_generic_content_form(self, media_types):
"""
Returns a form that allows for arbitrary content types to be tunneled via standard HTML forms
Returns a form that allows for arbitrary content types to be tunneled
via standard HTML forms.
(Which are typically application/x-www-form-urlencoded)
"""
@ -285,74 +303,68 @@ class BrowsableAPIRenderer(BaseRenderer):
and api_settings.FORM_CONTENTTYPE_OVERRIDE):
return None
content_type_field = api_settings.FORM_CONTENTTYPE_OVERRIDE
content_field = api_settings.FORM_CONTENT_OVERRIDE
choices = [(media_type, media_type) for media_type in media_types]
initial = media_types[0]
# NB. http://jacobian.org/writing/dynamic-form-generation/
class GenericContentForm(forms.Form):
def __init__(self, view, request):
"""We don't know the names of the fields we want to set until the point the form is instantiated,
as they are determined by the Resource the form is being created against.
Add the fields dynamically."""
def __init__(self):
super(GenericContentForm, self).__init__()
parsed_media_types = [parser.media_type for parser in view.parser_classes]
contenttype_choices = [(media_type, media_type) for media_type in parsed_media_types]
initial_contenttype = parsed_media_types[0]
self.fields[api_settings.FORM_CONTENTTYPE_OVERRIDE] = forms.ChoiceField(
self.fields[content_type_field] = forms.ChoiceField(
label='Content Type',
choices=contenttype_choices,
initial=initial_contenttype
choices=choices,
initial=initial
)
self.fields[api_settings.FORM_CONTENT_OVERRIDE] = forms.CharField(
self.fields[content_field] = forms.CharField(
label='Content',
widget=forms.Textarea
)
# If either of these reserved parameters are turned off then content tunneling is not possible
if self.view.request._CONTENTTYPE_PARAM is None or self.view.request._CONTENT_PARAM is None:
return None
return GenericContentForm()
# Okey doke, let's do it
return GenericContentForm(view, view.request)
def get_name(self):
def get_name(self, view):
try:
return self.view.get_name()
return view.get_name()
except AttributeError:
return self.view.__doc__
return view.__doc__
def get_description(self, html=None):
if html is None:
html = bool('html' in self.format)
def get_description(self, view):
try:
return self.view.get_description(html)
return view.get_description(html=True)
except AttributeError:
return self.view.__doc__
return view.__doc__
def render(self, data=None, accepted_media_type=None):
def render(self, data, accepted_media_type=None, renderer_context=None):
"""
Renders *obj* using the :attr:`template` set on the class.
The context used in the template contains all the information
needed to self-document the response to this request.
"""
view = self.view
request = view.request
response = view.response
accepted_media_type = accepted_media_type or ''
renderer_context = renderer_context or {}
content = self.get_content(view, request, data, accepted_media_type)
view = renderer_context['view']
request = renderer_context['request']
response = renderer_context['response']
renderer = self.get_default_renderer(view)
content = self.get_content(renderer, data, accepted_media_type, renderer_context)
put_form = self.get_form(view, 'PUT', request)
post_form = self.get_form(view, 'POST', request)
delete_form = self.get_form(view, 'DELETE', request)
options_form = self.get_form(view, 'OPTIONS', request)
name = self.get_name()
description = self.get_description()
breadcrumb_list = get_breadcrumbs(self.view.request.path)
name = self.get_name(view)
description = self.get_description(view)
breadcrumb_list = get_breadcrumbs(request.path)
template = loader.get_template(self.template)
context = RequestContext(self.view.request, {
context = RequestContext(request, {
'content': content,
'view': view,
'request': request,
@ -375,7 +387,7 @@ class BrowsableAPIRenderer(BaseRenderer):
# Munge DELETE Response code to allow us to return content
# (Do this *after* we've rendered the template so that we include
# the normal deletion response code in the output)
if self.view.response.status_code == 204:
self.view.response.status_code = 200
if response.status_code == 204:
response.status_code = 200
return ret

View File

@ -24,17 +24,17 @@ class Response(SimpleTemplateResponse):
@property
def rendered_content(self):
renderer = self.accepted_renderer
media_type = self.accepted_media_type
renderer = getattr(self, 'accepted_renderer', None)
media_type = getattr(self, 'accepted_media_type', None)
context = getattr(self, 'renderer_context', None)
assert renderer, "No accepted renderer set on Response"
assert media_type, "No accepted media type set on Response"
assert renderer, ".accepted_renderer not set on Response"
assert media_type, ".accepted_media_type not set on Response"
assert context, ".renderer_context not set on Response"
context['response'] = self
self['Content-Type'] = media_type
if self.data is None:
return renderer.render()
return renderer.render(self.data, media_type)
return renderer.render(self.data, media_type, context)
@property
def status_text(self):
@ -42,4 +42,6 @@ class Response(SimpleTemplateResponse):
Returns reason text corresponding to our HTTP response status code.
Provided for convenience.
"""
# TODO: Deprecate and use a template tag instead
# TODO: Status code text for RFC 6585 status codes
return STATUS_CODE_TEXT.get(self.status_code, '')

View File

@ -41,16 +41,16 @@ class RendererA(BaseRenderer):
media_type = 'mock/renderera'
format = "formata"
def render(self, obj=None, media_type=None):
return RENDERER_A_SERIALIZER(obj)
def render(self, data, media_type=None, renderer_context=None):
return RENDERER_A_SERIALIZER(data)
class RendererB(BaseRenderer):
media_type = 'mock/rendererb'
format = "formatb"
def render(self, obj=None, media_type=None):
return RENDERER_B_SERIALIZER(obj)
def render(self, data, media_type=None, renderer_context=None):
return RENDERER_B_SERIALIZER(data)
class MockView(APIView):
@ -235,7 +235,7 @@ class JSONRendererTests(TestCase):
Test basic JSON rendering.
"""
obj = {'foo': ['bar', 'baz']}
renderer = JSONRenderer(None)
renderer = JSONRenderer()
content = renderer.render(obj, 'application/json')
# Fix failing test case which depends on version of JSON library.
self.assertEquals(content, _flat_repr)
@ -245,7 +245,7 @@ class JSONRendererTests(TestCase):
Test JSON rendering with additional content type arguments supplied.
"""
obj = {'foo': ['bar', 'baz']}
renderer = JSONRenderer(None)
renderer = JSONRenderer()
content = renderer.render(obj, 'application/json; indent=2')
self.assertEquals(strip_trailing_whitespace(content), _indented_repr)
@ -302,7 +302,7 @@ if yaml:
Test basic YAML rendering.
"""
obj = {'foo': ['bar', 'baz']}
renderer = YAMLRenderer(None)
renderer = YAMLRenderer()
content = renderer.render(obj, 'application/yaml')
self.assertEquals(content, _yaml_repr)
@ -313,7 +313,7 @@ if yaml:
"""
obj = {'foo': ['bar', 'baz']}
renderer = YAMLRenderer(None)
renderer = YAMLRenderer()
parser = YAMLParser()
content = renderer.render(obj, 'application/yaml')
@ -345,7 +345,7 @@ class XMLRendererTestCase(TestCase):
"""
Test XML rendering.
"""
renderer = XMLRenderer(None)
renderer = XMLRenderer()
content = renderer.render({'field': 'astring'}, 'application/xml')
self.assertXMLContains(content, '<field>astring</field>')
@ -353,7 +353,7 @@ class XMLRendererTestCase(TestCase):
"""
Test XML rendering.
"""
renderer = XMLRenderer(None)
renderer = XMLRenderer()
content = renderer.render({'field': 111}, 'application/xml')
self.assertXMLContains(content, '<field>111</field>')
@ -361,7 +361,7 @@ class XMLRendererTestCase(TestCase):
"""
Test XML rendering.
"""
renderer = XMLRenderer(None)
renderer = XMLRenderer()
content = renderer.render({
'field': datetime.datetime(2011, 12, 25, 12, 45, 00)
}, 'application/xml')
@ -371,7 +371,7 @@ class XMLRendererTestCase(TestCase):
"""
Test XML rendering.
"""
renderer = XMLRenderer(None)
renderer = XMLRenderer()
content = renderer.render({'field': 123.4}, 'application/xml')
self.assertXMLContains(content, '<field>123.4</field>')
@ -379,7 +379,7 @@ class XMLRendererTestCase(TestCase):
"""
Test XML rendering.
"""
renderer = XMLRenderer(None)
renderer = XMLRenderer()
content = renderer.render({'field': Decimal('111.2')}, 'application/xml')
self.assertXMLContains(content, '<field>111.2</field>')
@ -387,7 +387,7 @@ class XMLRendererTestCase(TestCase):
"""
Test XML rendering.
"""
renderer = XMLRenderer(None)
renderer = XMLRenderer()
content = renderer.render({'field': None}, 'application/xml')
self.assertXMLContains(content, '<field></field>')
@ -395,7 +395,7 @@ class XMLRendererTestCase(TestCase):
"""
Test XML rendering.
"""
renderer = XMLRenderer(None)
renderer = XMLRenderer()
content = renderer.render(self._complex_data, 'application/xml')
self.assertXMLContains(content, '<sub_name>first</sub_name>')
self.assertXMLContains(content, '<sub_name>second</sub_name>')
@ -404,7 +404,7 @@ class XMLRendererTestCase(TestCase):
"""
Test XML rendering.
"""
renderer = XMLRenderer(None)
renderer = XMLRenderer()
content = StringIO(renderer.render(self._complex_data, 'application/xml'))
parser = XMLParser()

View File

@ -33,16 +33,16 @@ class RendererA(BaseRenderer):
media_type = 'mock/renderera'
format = "formata"
def render(self, obj=None, media_type=None):
return RENDERER_A_SERIALIZER(obj)
def render(self, data, media_type=None, renderer_context=None):
return RENDERER_A_SERIALIZER(data)
class RendererB(BaseRenderer):
media_type = 'mock/rendererb'
format = "formatb"
def render(self, obj=None, media_type=None):
return RENDERER_B_SERIALIZER(obj)
def render(self, data, media_type=None, renderer_context=None):
return RENDERER_B_SERIALIZER(data)
class MockView(APIView):

View File

@ -86,6 +86,7 @@ class APIView(View):
@property
def default_response_headers(self):
# TODO: deprecate?
# TODO: Only vary by accept if multiple renderers
return {
'Allow': ', '.join(self.allowed_methods),
@ -158,6 +159,20 @@ class APIView(View):
"""
raise exceptions.Throttled(wait)
def get_renderer_context(self):
"""
Returns a dict that is passed through to the Renderer.render(),
as the `renderer_context` keyword argument.
"""
# Note: Additionally 'response' will also be set on the context,
# by the Response object.
return {
'view': self,
'request': self.request,
'args': self.args,
'kwargs': self.kwargs
}
# API policy instantiation methods
def get_format_suffix(self, **kwargs):
@ -171,7 +186,7 @@ class APIView(View):
"""
Instantiates and returns the list of renderers that this view can use.
"""
return [renderer(self) for renderer in self.renderer_classes]
return [renderer() for renderer in self.renderer_classes]
def get_parsers(self):
"""
@ -269,6 +284,7 @@ class APIView(View):
response.accepted_renderer = request.accepted_renderer
response.accepted_media_type = request.accepted_media_type
response.renderer_context = self.get_renderer_context()
for key, value in self.headers.items():
response[key] = value
@ -306,7 +322,7 @@ class APIView(View):
self.request = request
self.args = args
self.kwargs = kwargs
self.headers = self.default_response_headers
self.headers = self.default_response_headers # deprecate?
try:
self.initial(request, *args, **kwargs)