From 0242134d32aa99a54442211ed05576b7061866d1 Mon Sep 17 00:00:00 2001 From: Lukasz Balcerzak Date: Fri, 14 Feb 2014 18:28:51 +0100 Subject: [PATCH] Fixed #17942 -- Added a JsonResponse class to more easily create JSON encoded responses. Thanks leahculver for the suggestion and Erik Romijn, Simon Charette, and Marc Tamlyn for the reviews. --- django/http/__init__.py | 4 +- django/http/response.py | 26 ++++++++++- docs/ref/request-response.txt | 70 ++++++++++++++++++++++++++++- docs/releases/1.7.txt | 8 +++- docs/topics/serialization.txt | 2 + tests/httpwrappers/tests.py | 33 +++++++++++++- tests/view_tests/generic_urls.py | 5 +++ tests/view_tests/tests/test_json.py | 22 +++++++++ tests/view_tests/views.py | 14 +++++- 9 files changed, 176 insertions(+), 8 deletions(-) create mode 100644 tests/view_tests/tests/test_json.py diff --git a/django/http/__init__.py b/django/http/__init__.py index dd24495c0f..5895c5e3ce 100644 --- a/django/http/__init__.py +++ b/django/http/__init__.py @@ -5,7 +5,7 @@ from django.http.response import (HttpResponse, StreamingHttpResponse, HttpResponseRedirect, HttpResponsePermanentRedirect, HttpResponseNotModified, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseNotFound, HttpResponseNotAllowed, HttpResponseGone, - HttpResponseServerError, Http404, BadHeaderError) + HttpResponseServerError, Http404, BadHeaderError, JsonResponse) from django.http.utils import (fix_location_header, conditional_content_removal, fix_IE_for_attach, fix_IE_for_vary) @@ -16,6 +16,6 @@ __all__ = [ 'HttpResponsePermanentRedirect', 'HttpResponseNotModified', 'HttpResponseBadRequest', 'HttpResponseForbidden', 'HttpResponseNotFound', 'HttpResponseNotAllowed', 'HttpResponseGone', 'HttpResponseServerError', - 'Http404', 'BadHeaderError', 'fix_location_header', + 'Http404', 'BadHeaderError', 'fix_location_header', 'JsonResponse', 'conditional_content_removal', 'fix_IE_for_attach', 'fix_IE_for_vary', ] diff --git a/django/http/response.py b/django/http/response.py index f38c4918cd..324f67c847 100644 --- a/django/http/response.py +++ b/django/http/response.py @@ -1,8 +1,9 @@ from __future__ import unicode_literals import datetime -import time +import json import sys +import time from email.header import Header try: from urllib.parse import urlparse @@ -13,6 +14,7 @@ from django.conf import settings from django.core import signals from django.core import signing from django.core.exceptions import DisallowedRedirect +from django.core.serializers.json import DjangoJSONEncoder from django.http.cookie import SimpleCookie from django.utils import six, timezone from django.utils.encoding import force_bytes, force_text, iri_to_uri @@ -456,3 +458,25 @@ class HttpResponseServerError(HttpResponse): class Http404(Exception): pass + + +class JsonResponse(HttpResponse): + """ + An HTTP response class that consumes data to be serialized to JSON. + + :param data: Data to be dumped into json. By default only ``dict`` objects + are allowed to be passed due to a security flaw before EcmaScript 5. See + the ``safe`` parameter for more information. + :param encoder: Should be an json encoder class. Defaults to + ``django.core.serializers.json.DjangoJSONEncoder``. + :param safe: Controls if only ``dict`` objects may be serialized. Defaults + to ``True``. + """ + + def __init__(self, data, encoder=DjangoJSONEncoder, safe=True, **kwargs): + if safe and not isinstance(data, dict): + raise TypeError('In order to allow non-dict objects to be ' + 'serialized set the safe parameter to False') + kwargs.setdefault('content_type', 'application/json') + data = json.dumps(data, cls=encoder) + super(JsonResponse, self).__init__(content=data, **kwargs) diff --git a/docs/ref/request-response.txt b/docs/ref/request-response.txt index 5c466a6c6e..3cff5c26d9 100644 --- a/docs/ref/request-response.txt +++ b/docs/ref/request-response.txt @@ -36,7 +36,7 @@ All attributes should be considered read-only, unless stated otherwise below. .. versionadded:: 1.7 - A string representing the scheme of the request (``http`` or ``https`` + A string representing the scheme of the request (``http`` or ``https`` usually). .. attribute:: HttpRequest.body @@ -823,6 +823,74 @@ types of HTTP responses. Like ``HttpResponse``, these subclasses live in :class:`~django.template.response.SimpleTemplateResponse`, and the ``render`` method must itself return a valid response object. +JsonResponse objects +==================== + +.. versionadded:: 1.7 + +.. class:: JsonResponse + +.. method:: JsonResponse.__init__(data, encoder=DjangoJSONEncoder, safe=True, **kwargs) + + An :class:`HttpResponse` subclass that helps to create a JSON-encoded + response. It inherits most behavior from its superclass with a couple + differences: + + Its default ``Content-Type`` header is set to ``application/json``. + + The first parameter, ``data``, should be a ``dict`` instance. If the ``safe`` + parameter is set to ``False`` (see below) it can be any JSON-serializable + object. + + The ``encoder``, which defaults to + ``django.core.serializers.json.DjangoJSONEncoder``, will be used to + serialize the data. See :ref:`JSON serialization + ` for more details about this serializer. + + The ``safe`` boolean parameter defaults to ``True``. If it's set to ``False``, + any object can be passed for serialization (otherwise only ``dict`` instances + are allowed). If ``safe`` is ``True`` and a non-``dict`` object is passed as + the first argument, a :exc:`~exceptions.TypeError` will be raised. + +Usage +----- + +Typical usage could look like:: + + >>> from django.http import JsonResponse + >>> response = JsonResponse({'foo': 'bar'}) + >>> response.content + '{"foo": "bar"}' + + +Serializing non-dictionary objects +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In order to serialize objects other than ``dict`` you must set the ``safe`` +parameter to ``False``:: + + >>> response = JsonResponse([1, 2, 3], safe=False) + +Without passing ``safe=False``, a :exc:`~exceptions.TypeError` will be raised. + +.. warning:: + + Before the `5th edition of EcmaScript + `_ + it was possible to poison the JavaScript ``Array`` constructor. For this + reason, Django does not allow passing non-dict objects to the + :class:`~django.http.JsonResponse` constructor by default. However, most + modern browsers implement EcmaScript 5 which removes this attack vector. + Therefore it is possible to disable this security precaution. + +Changing the default JSON encoder +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you need to use a differ JSON encoder class you can pass the ``encoder`` +parameter to the constructor method:: + + >>> response = JsonResponse(data, encoder=MyJSONEncoder) + .. _httpresponse-streaming: StreamingHttpResponse objects diff --git a/docs/releases/1.7.txt b/docs/releases/1.7.txt index 1fe657dfc5..d2f1c39ec3 100644 --- a/docs/releases/1.7.txt +++ b/docs/releases/1.7.txt @@ -671,15 +671,19 @@ Templates * The new :tfilter:`truncatechars_html` filter truncates a string to be no longer than the specified number of characters, taking HTML into account. -Requests -^^^^^^^^ +Requests and Responses +^^^^^^^^^^^^^^^^^^^^^^ * The new :attr:`HttpRequest.scheme ` attribute specifies the scheme of the request (``http`` or ``https`` normally). + * The shortcut :func:`redirect() ` now supports relative URLs. +* The new :class:`~django.http.JsonResponse` subclass of + :class:`~django.http.HttpResponse` helps easily create JSON-encoded responses. + Tests ^^^^^ diff --git a/docs/topics/serialization.txt b/docs/topics/serialization.txt index 5a171470ac..2435289b16 100644 --- a/docs/topics/serialization.txt +++ b/docs/topics/serialization.txt @@ -216,6 +216,8 @@ the auth.User model has such a relation to the auth.Permission model:: This example links the given user with the permission models with PKs 46 and 47. +.. _serialization-formats-json: + JSON ~~~~ diff --git a/tests/httpwrappers/tests.py b/tests/httpwrappers/tests.py index 06b29f89b9..20b95d101b 100644 --- a/tests/httpwrappers/tests.py +++ b/tests/httpwrappers/tests.py @@ -2,18 +2,20 @@ from __future__ import unicode_literals import copy +import json import os import pickle import unittest import warnings from django.core.exceptions import SuspiciousOperation +from django.core.serializers.json import DjangoJSONEncoder from django.core.signals import request_finished from django.db import close_old_connections from django.http import (QueryDict, HttpResponse, HttpResponseRedirect, HttpResponsePermanentRedirect, HttpResponseNotAllowed, HttpResponseNotModified, StreamingHttpResponse, - SimpleCookie, BadHeaderError, + SimpleCookie, BadHeaderError, JsonResponse, parse_cookie) from django.test import TestCase from django.utils.encoding import smart_str, force_text @@ -451,6 +453,35 @@ class HttpResponseSubclassesTests(TestCase): self.assertContains(response, 'Only the GET method is allowed', status_code=405) +class JsonResponseTests(TestCase): + def test_json_response_non_ascii(self): + data = {'key': 'łóżko'} + response = JsonResponse(data) + self.assertEqual(json.loads(response.content.decode()), data) + + def test_json_response_raises_type_error_with_default_setting(self): + with self.assertRaisesMessage(TypeError, + 'In order to allow non-dict objects to be serialized set the ' + 'safe parameter to False'): + JsonResponse([1, 2, 3]) + + def test_json_response_text(self): + response = JsonResponse('foobar', safe=False) + self.assertEqual(json.loads(response.content.decode()), 'foobar') + + def test_json_response_list(self): + response = JsonResponse(['foo', 'bar'], safe=False) + self.assertEqual(json.loads(response.content.decode()), ['foo', 'bar']) + + def test_json_response_custom_encoder(self): + class CustomDjangoJSONEncoder(DjangoJSONEncoder): + def encode(self, o): + return json.dumps({'foo': 'bar'}) + + response = JsonResponse({}, encoder=CustomDjangoJSONEncoder) + self.assertEqual(json.loads(response.content.decode()), {'foo': 'bar'}) + + class StreamingHttpResponseTests(TestCase): def test_streaming_response(self): r = StreamingHttpResponse(iter(['hello', 'world'])) diff --git a/tests/view_tests/generic_urls.py b/tests/view_tests/generic_urls.py index 5130f21024..a228290424 100644 --- a/tests/view_tests/generic_urls.py +++ b/tests/view_tests/generic_urls.py @@ -56,3 +56,8 @@ urlpatterns += patterns('view_tests.views', (r'^shortcuts/render/dirs/$', 'render_with_dirs'), (r'^shortcuts/render/current_app_conflict/$', 'render_view_with_current_app_conflict'), ) + +# json response +urlpatterns += patterns('view_tests.views', + (r'^json/response/$', 'json_response_view'), +) diff --git a/tests/view_tests/tests/test_json.py b/tests/view_tests/tests/test_json.py new file mode 100644 index 0000000000..b236e60c7d --- /dev/null +++ b/tests/view_tests/tests/test_json.py @@ -0,0 +1,22 @@ +# encoding: utf8 +from __future__ import unicode_literals + +import json + +from django.test import TestCase + + +class JsonResponseTests(TestCase): + urls = 'view_tests.generic_urls' + + def test_json_response(self): + response = self.client.get('/json/response/') + self.assertEqual(response.status_code, 200) + self.assertEqual( + response['content-type'], 'application/json') + self.assertEqual(json.loads(response.content.decode()), { + 'a': [1, 2, 3], + 'foo': {'bar': 'baz'}, + 'timestamp': '2013-05-19T20:00:00', + 'value': '3.14', + }) diff --git a/tests/view_tests/views.py b/tests/view_tests/views.py index 4373ef12f9..e0f6fdb753 100644 --- a/tests/view_tests/views.py +++ b/tests/view_tests/views.py @@ -1,11 +1,13 @@ from __future__ import unicode_literals +import datetime +import decimal import os import sys from django.core.exceptions import PermissionDenied, SuspiciousOperation from django.core.urlresolvers import get_resolver -from django.http import HttpResponse, HttpResponseRedirect +from django.http import HttpResponse, HttpResponseRedirect, JsonResponse from django.shortcuts import render_to_response, render from django.template import Context, RequestContext, TemplateDoesNotExist from django.views.debug import technical_500_response, SafeExceptionReporterFilter @@ -334,3 +336,13 @@ def multivalue_dict_key_error(request): exc_info = sys.exc_info() send_log(request, exc_info) return technical_500_response(request, *exc_info) + + +def json_response_view(request): + return JsonResponse({ + 'a': [1, 2, 3], + 'foo': {'bar': 'baz'}, + # Make sure datetime and Decimal objects would be serialized properly + 'timestamp': datetime.datetime(2013, 5, 19, 20), + 'value': decimal.Decimal('3.14'), + })