From 648d2be29b0738999742f4d844caab7b7652d1ad Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 10 Oct 2012 12:15:18 +0100 Subject: [PATCH] Make sure JSON output in Browseable API is nicely indented --- docs/api-guide/renderers.md | 19 +++- docs/api-guide/responses.md | 5 + rest_framework/renderers.py | 162 ++++++++++++++++-------------- rest_framework/response.py | 18 ++-- rest_framework/tests/renderers.py | 32 +++--- rest_framework/tests/response.py | 8 +- rest_framework/views.py | 20 +++- 7 files changed, 157 insertions(+), 107 deletions(-) diff --git a/docs/api-guide/renderers.md b/docs/api-guide/renderers.md index 1b266f7ef..b2ebd0c7e 100644 --- a/docs/api-guide/renderers.md +++ b/docs/api-guide/renderers.md @@ -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 diff --git a/docs/api-guide/responses.md b/docs/api-guide/responses.md index e01983240..b0de6824e 100644 --- a/docs/api-guide/responses.md +++ b/docs/api-guide/responses.md @@ -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 diff --git a/rest_framework/renderers.py b/rest_framework/renderers.py index 25e6ed628..27a85ab12 100644 --- a/rest_framework/renderers.py +++ b/rest_framework/renderers.py @@ -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 diff --git a/rest_framework/response.py b/rest_framework/response.py index 9a633a8a7..7a459c8f5 100644 --- a/rest_framework/response.py +++ b/rest_framework/response.py @@ -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, '') diff --git a/rest_framework/tests/renderers.py b/rest_framework/tests/renderers.py index b8c30fcc5..48d8d9bd7 100644 --- a/rest_framework/tests/renderers.py +++ b/rest_framework/tests/renderers.py @@ -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, 'astring') @@ -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, '111') @@ -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, '123.4') @@ -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, '111.2') @@ -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, '') @@ -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, 'first') self.assertXMLContains(content, 'second') @@ -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() diff --git a/rest_framework/tests/response.py b/rest_framework/tests/response.py index d1625b67d..18b6af394 100644 --- a/rest_framework/tests/response.py +++ b/rest_framework/tests/response.py @@ -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): diff --git a/rest_framework/views.py b/rest_framework/views.py index 058a6cd38..b3f360851 100644 --- a/rest_framework/views.py +++ b/rest_framework/views.py @@ -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)