diff options
| author | Tom Christie | 2012-10-10 12:15:18 +0100 | 
|---|---|---|
| committer | Tom Christie | 2012-10-10 12:15:18 +0100 | 
| commit | 648d2be29b0738999742f4d844caab7b7652d1ad (patch) | |
| tree | aa4b8a630231f06477e2356abcfee80d54d2c8d8 | |
| parent | ccd2b0117d9c26199b1862a302b1eb06dd2f07b2 (diff) | |
| download | django-rest-framework-648d2be29b0738999742f4d844caab7b7652d1ad.tar.bz2 | |
Make sure JSON output in Browseable API is nicely indented
| -rw-r--r-- | docs/api-guide/renderers.md | 19 | ||||
| -rw-r--r-- | docs/api-guide/responses.md | 5 | ||||
| -rw-r--r-- | rest_framework/renderers.py | 162 | ||||
| -rw-r--r-- | rest_framework/response.py | 18 | ||||
| -rw-r--r-- | rest_framework/tests/renderers.py | 32 | ||||
| -rw-r--r-- | rest_framework/tests/response.py | 8 | ||||
| -rw-r--r-- | 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 1b266f7e..b2ebd0c7 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 e0198324..b0de6824 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 25e6ed62..27a85ab1 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 - -        # Okey doke, let's do it -        return GenericContentForm(view, view.request) +        return GenericContentForm() -    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 9a633a8a..7a459c8f 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 b8c30fcc..48d8d9bd 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, '<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() diff --git a/rest_framework/tests/response.py b/rest_framework/tests/response.py index d1625b67..18b6af39 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 058a6cd3..b3f36085 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) | 
