├── .github └── workflows │ └── test.yml ├── .gitignore ├── .travis.yml ├── CHANGELOG ├── LICENSE ├── MANIFEST.in ├── README.rst ├── jsonview ├── __init__.py ├── decorators.py ├── exceptions.py ├── models.py ├── tests.py └── views.py ├── run.sh ├── setup.cfg ├── setup.py └── test_settings.py /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: [3.5, 3.6, 3.7, 3.8] 12 | django: [2.2, 3.0, 3.1, master] 13 | exclude: 14 | - python-version: 3.5 15 | django: 3.0 16 | - python-version: 3.5 17 | django: 3.1 18 | - python-version: 3.5 19 | django: master 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v2 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | if [ "${{ matrix.django }}" != "master" ]; then echo "installing Django ${{ matrix.django }}"; pip install --pre -q "Django>=${{ matrix.django }},<${{ matrix.django }}.99"; pip freeze | grep -i "django=";fi 31 | if [ "${{ matrix.django }}" = "master" ]; then pip install https://github.com/django/django/archive/master.tar.gz; fi 32 | pip install flake8 coverage mock 33 | - name: Lint with flake8 34 | run: | 35 | ./run.sh check 36 | - name: Test 37 | run: | 38 | ./run.sh coverage 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | *.swp 3 | .coverage 4 | *.egg-info 5 | build 6 | dist 7 | __pycache__ 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: false 3 | python: 4 | - "3.5" 5 | - "3.6" 6 | - "3.7" 7 | - "3.8" 8 | - "pypy3.5" 9 | env: 10 | - DJANGO_VERSION=2.1 11 | - DJANGO_VERSION=2.2 12 | - DJANGO_VERSION=3.0 13 | install: 14 | - pip install -q "Django>=${DJANGO_VERSION},<${DJANGO_VERSION}.99" 15 | - pip install -q flake8 coverage 16 | matrix: 17 | exclude: 18 | - python: "3.4" 19 | env: DJANGO_VERSION=3.0 20 | - python: "3.5" 21 | env: DJANGO_VERSION=3.0 22 | - python: "pypy3.5" 23 | env: DJANGO_VERSION=3.0 24 | script: 25 | - ./run.sh coverage 26 | - ./run.sh check 27 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | v2.0.0 5 | ------ 6 | 7 | - Update Django versions to 2.1, 2.2, 3.0 8 | 9 | v1.3.1 10 | ------ 11 | 12 | - Remove Sphinx-specific directives from README 13 | 14 | 15 | v1.3.0 16 | ------ 17 | 18 | - Propagate exception details to signal (#43) 19 | - Test against Django 2.1 and 2.2 20 | 21 | v1.2.0 22 | ------ 23 | 24 | - Add JsonView CBV base class (#39) 25 | 26 | v1.1.0 27 | ------ 28 | 29 | - Add `JSON_DEFAULT_CONTENT_TYPE` setting 30 | - Update compatibility/test for Django 1.8, 1.10, 1.11. 31 | - Fix issue #36 mutating settings. 32 | 33 | 34 | v1.0.0 35 | ------ 36 | 37 | - Remove deprecated JSON_USE_DJANGO_SERIALIZER setting. 38 | 39 | 40 | v0.5.1 41 | ------ 42 | 43 | - Drop unnecesary log message on module init. 44 | - Drop compatibility for Django<1.8. 45 | 46 | 47 | v0.5.0 48 | ------ 49 | 50 | - Deprecate JSON_USE_DJANGO_SERIALIZER in favor of new JSON_OPTIONS 51 | setting. 52 | - Improve exception handling and compatibility with mail_admins. 53 | - Include traceback in response when settings.DEBUG is True. 54 | - Improve Django 1.8 and Python 3 compatibility. 55 | 56 | 57 | v0.4.3 58 | ------ 59 | 60 | - Add setting to make Django's serializer optional when using 3rd party 61 | JSON modules. 62 | 63 | 64 | v0.4.2 65 | ------ 66 | 67 | - Add support for datetime handling. 68 | 69 | 70 | v0.4.1 71 | ------ 72 | 73 | - Fix trove classifiers for Python 3. 74 | 75 | 76 | v0.4.0 77 | ------ 78 | 79 | - Supports new Djangos: 1.4.x, 1.5.x, 1.6.x, and 1.7bx. 80 | - Accepts a content_type kwarg to change from default application/json. 81 | - Pass HttpResponse objects through verbatim. (Previously choked.) 82 | - Only send exception text when settings.DEBUG is True. 83 | 84 | 85 | v0.3.0 86 | ------ 87 | 88 | - Python3 support 89 | 90 | 91 | v0.2.2 92 | ------ 93 | 94 | - Can now specify a json module instead of always using standard 95 | library. 96 | 97 | 98 | v0.2.1 99 | ------ 100 | 101 | - Fix issue handling unicode in exceptions. 102 | 103 | 104 | v0.2.0 105 | ------ 106 | 107 | - Propertly emit exception signal to play nicely with e.g. Raven. 108 | - Can return custom headers along with response code. 109 | - Follow more Django core conventions for logging. 110 | - Testing infrasctructure clean up. 111 | 112 | 113 | v0.1.1 114 | ------ 115 | 116 | - First release 117 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 James Socol 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | include MANIFEST.in 4 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | django-jsonview 3 | =============== 4 | 5 | **django-jsonview** is a simple decorator that translates Python objects 6 | to JSON and makes sure your view will always return JSON. 7 | 8 | .. image:: https://github.com/jsocol/django-jsonview/workflows/test/badge.svg?branch=main 9 | :target: https://github.com/jsocol/django-jsonview/actions 10 | 11 | 12 | Installation 13 | ============ 14 | 15 | Just install with ``pip``:: 16 | 17 | pip install django-jsonview 18 | 19 | No need to add to ``INSTALLED_APPS`` or anything. 20 | 21 | 22 | Usage 23 | ===== 24 | 25 | Just import the decorator, use, and return a JSON-serializable object 26 | 27 | .. code-block:: python 28 | 29 | from jsonview.decorators import json_view 30 | 31 | @json_view 32 | def my_view(request): 33 | return { 34 | 'foo': 'bar', 35 | } 36 | 37 | 38 | `Class-based views`_ (CBVs) can inherit from JsonView, use Django's 39 | ``@method_decorator`` or wrap the output of ``.as_view()`` 40 | 41 | .. code-block:: python 42 | 43 | # inherit from JsonView 44 | from jsonview.views import JsonView 45 | 46 | 47 | class MyView(JsonView): 48 | def get_context_data(self, **kwargs): 49 | context = super(MyView, self).get_context_data(**kwargs) 50 | context['my_key'] = 'some value' 51 | return context 52 | 53 | # or, method decorator 54 | from django.utils.decorators import method_decorator 55 | from jsonview.decorators import json_view 56 | 57 | 58 | class MyView(View): 59 | @method_decorator(json_view) 60 | def dispatch(self, *args, **kwargs): 61 | return super(MyView, self).dispatch(*args, **kwargs) 62 | 63 | # or, in URLconf 64 | 65 | patterns = [ 66 | url(r'^/my-view/$', json_view(MyView.as_view())), 67 | ] 68 | 69 | 70 | Content Types 71 | ------------- 72 | 73 | If you need to return a content type other than the standard 74 | ``application/json``, you can specify that in the decorator with the 75 | ``content_type`` argument, for example 76 | 77 | .. code-block:: python 78 | 79 | from jsonview.decorators import json_view 80 | 81 | @json_view(content_type='application/vnd.github+json') 82 | def myview(request): 83 | return {'foo': 'bar'} 84 | 85 | The response will have the appropriate content type header. 86 | 87 | 88 | Return Values 89 | ------------- 90 | 91 | The default case is to serialize your return value and respond with HTTP 92 | 200 and a Content-Type of ``application/json``. 93 | 94 | The ``@json_view`` decorator will handle many exceptions and other 95 | cases, including: 96 | 97 | * ``Http404`` 98 | * ``PermissionDenied`` 99 | * ``HttpResponseNotAllowed`` (e.g. ``require_GET``, ``require_POST``) 100 | * ``jsonview.exceptions.BadRequest`` (see below) 101 | * Any other exception (logged to ``django.request``). 102 | 103 | Any of these exceptions will return the correct status code (i.e., 404, 104 | 403, 405, 400, 500) a Content-Type of ``application/json``, and a 105 | response body that looks like 106 | 107 | .. code-block:: python 108 | 109 | json.dumps({ 110 | 'error': STATUS_CODE, 111 | 'message': str(exception), 112 | }) 113 | 114 | .. note:: 115 | 116 | As of v0.4, application exceptions do **not** behave this way if 117 | ``DEBUG = False``. When ``DEBUG = False``, the ``message`` value is 118 | always ``An error occurred``. When ``DEBUG = True``, the exception 119 | message is sent back. 120 | 121 | 122 | ``BadRequest`` 123 | -------------- 124 | 125 | HTTP does not have a great status code for "you submitted a form that 126 | didn't validate," and so Django doesn't support it very well. Most 127 | examples just return 200 OK. 128 | 129 | Normally, this is fine. But if you're submitting a form via Ajax, it's 130 | nice to have a distinct status for "OK" and "Nope." The HTTP 400 Bad 131 | Request response is the fallback for issues with a request 132 | not-otherwise-specified, so let's do that. 133 | 134 | To cause ``@json_view`` to return a 400, just raise a 135 | ``jsonview.exceptions.BadRequest`` with whatever appropriate error 136 | message. 137 | 138 | 139 | Exceptions 140 | ---------- 141 | 142 | If your view raises an exception, ``@json_view`` will catch the 143 | exception, log it to the normal ``django.request`` logger_, and return a 144 | JSON response with a status of 500 and a body that looks like the 145 | exceptions in the `Return Values`_ section. 146 | 147 | .. note:: 148 | 149 | Because the ``@json_view`` decorator handles the exception instead of 150 | propagating it, any exception middleware will **not** be called, and 151 | any response middleware **will** be called. 152 | 153 | 154 | Status Codes 155 | ------------ 156 | 157 | If you need to return a different HTTP status code, just return two 158 | values instead of one. The first is your serializable object, the second 159 | is the integer status code 160 | 161 | .. code-block:: python 162 | 163 | @json_view 164 | def myview(request): 165 | if not request.user.is_subscribed(): 166 | # Send a 402 Payment Required status. 167 | return {'subscribed': False}, 402 168 | # Send a 200 OK. 169 | return {'subscribed': True} 170 | 171 | 172 | Extra Headers 173 | ------------- 174 | 175 | You can add custom headers to the response by returning a tuple of three 176 | values: an object, a status code, and a dictionary of headers. 177 | 178 | .. code-block:: python 179 | 180 | @json_view 181 | def myview(request): 182 | return {}, 200, {'X-Server': 'myserver'} 183 | 184 | Custom header values may be overwritten by response middleware. 185 | 186 | 187 | Raw Return Values 188 | ----------------- 189 | 190 | To make it possible to cache JSON responses as strings (and because they 191 | aren't JSON serializable anyway) if you return an ``HttpResponse`` 192 | object (or subclass) it will be passed through unchanged, e.g. 193 | 194 | .. code-block:: python 195 | 196 | from django import http 197 | from jsonview.decorators import JSON 198 | 199 | @json_view 200 | def caching_view(request): 201 | kached = cache.get('cache-key') 202 | if kached: 203 | return http.HttpResponse(kached, content_type=JSON) 204 | # Assuming something else populates this cache. 205 | return {'complicated': 'object'} 206 | 207 | .. note:: 208 | 209 | ``@require_POST`` and the other HTTP method decorators work by 210 | *returning* a response, rather than *raising*, an exception, so 211 | ``HttpResponseNotAllowed`` is handled specially. 212 | 213 | 214 | Alternative JSON Implementations 215 | ================================ 216 | 217 | There is a healthy collection of JSON parsing and generating libraries 218 | out there. By default, it will use the old standby, the stdlib ``json`` 219 | module. But, if you'd rather use ujson_, or cjson_ or yajl_, you should 220 | go for it. Just add this to your Django settings 221 | 222 | .. code-block:: python 223 | 224 | JSON_MODULE = 'ujson' 225 | 226 | Anything, as long as it's a module that has ``.loads()`` and ``.dumps()`` 227 | methods. 228 | 229 | 230 | Configuring JSON Output 231 | ----------------------- 232 | 233 | Additional keyword arguments can be passed to ``json.dumps()`` via the 234 | ``JSON_OPTIONS = {}`` Django setting. For example, to pretty-print JSON 235 | output 236 | 237 | .. code-block:: python 238 | 239 | JSON_OPTIONS = { 240 | 'indent': 4, 241 | } 242 | 243 | Or to compactify it 244 | 245 | .. code-block:: python 246 | 247 | JSON_OPTIONS = { 248 | 'separators': (',', ':'), 249 | } 250 | 251 | jsonview uses ``DjangoJSONEncoder`` by default. To use a different JSON 252 | encoder, use the ``cls`` option 253 | 254 | .. code-block:: python 255 | 256 | JSON_OPTIONS = { 257 | 'cls': 'path.to.MyJSONEncoder', 258 | } 259 | 260 | ``JSON_OPTIONS['cls']`` may be a dotted string or a ``JSONEncoder`` 261 | class. 262 | 263 | **If you are using a JSON module that does not support the ``cls`` 264 | kwarg**, such as ujson, set the ``cls`` option to ``None`` 265 | 266 | .. code-block:: python 267 | 268 | JSON_OPTIONS = { 269 | 'cls': None, 270 | } 271 | 272 | Default value of content-type is 'application/json'. You can change it 273 | via the ``JSON_DEFAULT_CONTENT_TYPE`` Django settings. For example, to 274 | add charset 275 | 276 | .. code-block:: python 277 | 278 | JSON_DEFAULT_CONTENT_TYPE = 'application/json; charset=utf-8' 279 | 280 | 281 | Atomic Requests 282 | =============== 283 | 284 | Because ``@json_view`` catches exceptions, the normal Django setting 285 | ``ATOMIC_REQUESTS`` does not correctly cause a rollback. This can be 286 | worked around by explicitly setting ``@transaction.atomic`` *below* the 287 | ``@json_view`` decorator, e.g. 288 | 289 | .. code:: python 290 | 291 | @json_view 292 | @transaction.atomic 293 | def my_func(request): 294 | # ... 295 | 296 | 297 | Contributing 298 | ============ 299 | 300 | `Pull requests`_ and issues_ welcome! I ask two simple things: 301 | 302 | - Tests, including the new ones you added, must pass. (See below.) 303 | - Coverage should not drop below 100. You can install ``coverage`` with 304 | pip and run ``./run.sh coverage`` to check. 305 | - The ``flake8`` tool should not return any issues. 306 | 307 | 308 | Running Tests 309 | ------------- 310 | 311 | To run the tests, you probably want to create a virtualenv_, then 312 | install Django and Mock with ``pip``:: 313 | 314 | pip install Django==${DJANGO_VERSION} mock==1.0.1 315 | 316 | Then run the tests with:: 317 | 318 | ./run.sh test 319 | 320 | 321 | .. _logger: 322 | https://docs.djangoproject.com/en/dev/topics/logging/#django-request 323 | .. _Pull requests: https://github.com/jsocol/django-jsonview/pulls 324 | .. _issues: https://github.com/jsocol/django-jsonview/issues 325 | .. _virtualenv: http://www.virtualenv.org/ 326 | .. _ujson: https://pypi.python.org/pypi/ujson 327 | .. _cjson: https://pypi.python.org/pypi/python-cjson 328 | .. _yajl: https://pypi.python.org/pypi/yajl 329 | .. _Class-based views: https://docs.djangoproject.com/en/1.9/topics/class-based-views/intro/#decorating-class-based-views 330 | -------------------------------------------------------------------------------- /jsonview/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = (2, 0, 0) 2 | __version__ = '.'.join(map(str, VERSION)) 3 | -------------------------------------------------------------------------------- /jsonview/decorators.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | import logging 4 | import traceback 5 | from functools import wraps 6 | from importlib import import_module 7 | 8 | from django import http 9 | from django.conf import settings 10 | from django.core.exceptions import PermissionDenied 11 | from django.core.handlers.base import BaseHandler 12 | from django.core.serializers.json import DjangoJSONEncoder 13 | from django.core.signals import got_request_exception 14 | from django.utils.module_loading import import_string 15 | 16 | from .exceptions import BadRequest 17 | 18 | json = import_module(getattr(settings, 'JSON_MODULE', 'json')) 19 | JSON = getattr(settings, 'JSON_DEFAULT_CONTENT_TYPE', 'application/json') 20 | logger = logging.getLogger('django.request') 21 | 22 | 23 | def _dump_json(data): 24 | options = {} 25 | options.update(getattr(settings, 'JSON_OPTIONS', {})) 26 | 27 | # Use the DjangoJSONEncoder by default, unless cls is set to None. 28 | options.setdefault('cls', DjangoJSONEncoder) 29 | if isinstance(options['cls'], str): 30 | options['cls'] = import_string(options['cls']) 31 | elif options['cls'] is None: 32 | options.pop('cls') 33 | 34 | return json.dumps(data, **options) 35 | 36 | 37 | def json_view(*args, **kwargs): 38 | """Ensure the response content is well-formed JSON. 39 | 40 | Views wrapped in @json_view can return JSON-serializable Python objects, 41 | like lists and dicts, and the decorator will serialize the output and set 42 | the correct Content-type. 43 | 44 | Views may also throw known exceptions, like Http404, PermissionDenied, etc, 45 | and @json_view will convert the response to a standard JSON error format, 46 | and set the status code and content type. 47 | 48 | If you return a two item tuple, the first is a JSON-serializable object and 49 | the second is an integer used for the HTTP status code, e.g.: 50 | 51 | >>> @json_view 52 | ... def example(request): 53 | ... return {'foo': 'bar'}, 418 54 | 55 | By default all responses will get application/json as their content type. 56 | You can override it for non-error responses by giving the content_type 57 | keyword parameter to the decorator, e.g.: 58 | 59 | >>> @json_view(content_type='application/vnd.example-v1.0+json') 60 | ... def example2(request): 61 | ... return {'foo': 'bar'} 62 | 63 | """ 64 | 65 | content_type = kwargs.get('content_type', JSON) 66 | 67 | def decorator(f): 68 | @wraps(f) 69 | def _wrapped(request, *a, **kw): 70 | try: 71 | status = 200 72 | headers = {} 73 | ret = f(request, *a, **kw) 74 | 75 | if isinstance(ret, tuple): 76 | if len(ret) == 3: 77 | ret, status, headers = ret 78 | else: 79 | ret, status = ret 80 | 81 | # Some errors are not exceptions. :\ 82 | if isinstance(ret, http.HttpResponseNotAllowed): 83 | blob = _dump_json({ 84 | 'error': 405, 85 | 'message': 'HTTP method not allowed.' 86 | }) 87 | return http.HttpResponse( 88 | blob, status=405, content_type=JSON) 89 | 90 | # Allow HttpResponses to go straight through. 91 | if isinstance(ret, http.HttpResponse): 92 | return ret 93 | 94 | blob = _dump_json(ret) 95 | response = http.HttpResponse(blob, status=status, 96 | content_type=content_type) 97 | for k in headers: 98 | response[k] = headers[k] 99 | return response 100 | except http.Http404 as e: 101 | blob = _dump_json({ 102 | 'error': 404, 103 | 'message': str(e), 104 | }) 105 | logger.warning('Not found: %s', request.path, 106 | extra={ 107 | 'status_code': 404, 108 | 'request': request, 109 | }) 110 | return http.HttpResponseNotFound(blob, content_type=JSON) 111 | except PermissionDenied as e: 112 | logger.warning( 113 | 'Forbidden (Permission denied): %s', request.path, 114 | extra={ 115 | 'status_code': 403, 116 | 'request': request, 117 | }) 118 | blob = _dump_json({ 119 | 'error': 403, 120 | 'message': str(e), 121 | }) 122 | return http.HttpResponseForbidden(blob, content_type=JSON) 123 | except BadRequest as e: 124 | blob = _dump_json({ 125 | 'error': 400, 126 | 'message': str(e), 127 | }) 128 | return http.HttpResponseBadRequest(blob, content_type=JSON) 129 | except Exception as e: 130 | exc_data = { 131 | 'error': 500, 132 | 'message': 'An error occurred', 133 | } 134 | if settings.DEBUG: 135 | exc_data['message'] = str(e) 136 | exc_data['traceback'] = traceback.format_exc() 137 | 138 | blob = _dump_json(exc_data) 139 | 140 | # Generate the usual 500 error email with stack trace and full 141 | # debugging information 142 | logger.error( 143 | 'Internal Server Error: %s', request.path, 144 | exc_info=True, 145 | extra={ 146 | 'status_code': 500, 147 | 'request': request 148 | } 149 | ) 150 | 151 | # Here we lie a little bit. Because we swallow the exception, 152 | # the BaseHandler doesn't get to send this signal. It sets the 153 | # sender argument to self.__class__, in case the BaseHandler 154 | # is subclassed. 155 | got_request_exception.send( 156 | sender=BaseHandler, 157 | request=request, 158 | exception=e, 159 | exc_data=exc_data 160 | ) 161 | return http.HttpResponseServerError(blob, content_type=JSON) 162 | return _wrapped 163 | 164 | if len(args) == 1 and callable(args[0]): 165 | return decorator(args[0]) 166 | else: 167 | return decorator 168 | -------------------------------------------------------------------------------- /jsonview/exceptions.py: -------------------------------------------------------------------------------- 1 | class BadRequest(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /jsonview/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsocol/django-jsonview/2acddcf9a1c82524d54f7b3eb4a63cbdf6588532/jsonview/models.py -------------------------------------------------------------------------------- /jsonview/tests.py: -------------------------------------------------------------------------------- 1 | import json 2 | from unittest import SkipTest 3 | 4 | import django 5 | from django import http 6 | from django.core.exceptions import PermissionDenied 7 | from django.core.serializers.json import DjangoJSONEncoder 8 | from django.test import RequestFactory, TestCase 9 | from django.test.utils import override_settings 10 | from django.utils import timezone 11 | from django.utils.decorators import method_decorator 12 | from django.views.decorators.http import require_POST 13 | from django.views.generic import View 14 | 15 | import mock 16 | 17 | from .decorators import json_view 18 | from .exceptions import BadRequest 19 | from .views import JsonView 20 | 21 | 22 | JSON = 'application/json' 23 | rf = RequestFactory() 24 | 25 | 26 | def eq_(a, b, msg=None): 27 | """From nose.tools.eq_.""" 28 | assert a == b, msg or '%r != %r' % (a, b) 29 | 30 | 31 | class CustomTestEncoder(DjangoJSONEncoder): 32 | def default(self, o): 33 | try: 34 | return o.for_json() 35 | except AttributeError: 36 | return super(CustomTestEncoder, self).default(o) 37 | 38 | 39 | class JsonViewTests(TestCase): 40 | def test_object(self): 41 | data = { 42 | 'foo': 'bar', 43 | 'baz': 'qux', 44 | 'quz': [{'foo': 'bar'}], 45 | } 46 | 47 | @json_view 48 | def temp(req): 49 | return data 50 | 51 | res = temp(rf.get('/')) 52 | eq_(200, res.status_code) 53 | eq_(data, json.loads(res.content.decode('utf-8'))) 54 | eq_(JSON, res['content-type']) 55 | 56 | def test_list(self): 57 | data = ['foo', 'bar', 'baz'] 58 | 59 | @json_view 60 | def temp(req): 61 | return data 62 | 63 | res = temp(rf.get('/')) 64 | eq_(200, res.status_code) 65 | eq_(data, json.loads(res.content.decode('utf-8'))) 66 | eq_(JSON, res['content-type']) 67 | 68 | def test_404(self): 69 | @json_view 70 | def temp(req): 71 | raise http.Http404('foo') 72 | 73 | res = temp(rf.get('/')) 74 | eq_(404, res.status_code) 75 | eq_(JSON, res['content-type']) 76 | data = json.loads(res.content.decode('utf-8')) 77 | eq_(404, data['error']) 78 | eq_('foo', data['message']) 79 | 80 | def test_permission(self): 81 | @json_view 82 | def temp(req): 83 | raise PermissionDenied('bar') 84 | 85 | res = temp(rf.get('/')) 86 | eq_(403, res.status_code) 87 | eq_(JSON, res['content-type']) 88 | data = json.loads(res.content.decode('utf-8')) 89 | eq_(403, data['error']) 90 | eq_('bar', data['message']) 91 | 92 | def test_bad_request(self): 93 | @json_view 94 | def temp(req): 95 | raise BadRequest('baz') 96 | 97 | res = temp(rf.get('/')) 98 | eq_(400, res.status_code) 99 | eq_(JSON, res['content-type']) 100 | data = json.loads(res.content.decode('utf-8')) 101 | eq_(400, data['error']) 102 | eq_('baz', data['message']) 103 | 104 | def test_not_allowed(self): 105 | @json_view 106 | @require_POST 107 | def temp(req): 108 | return {} 109 | 110 | res = temp(rf.get('/')) 111 | eq_(405, res.status_code) 112 | eq_(JSON, res['content-type']) 113 | data = json.loads(res.content.decode('utf-8')) 114 | eq_(405, data['error']) 115 | 116 | res = temp(rf.post('/')) 117 | eq_(200, res.status_code) 118 | 119 | @override_settings(DEBUG=True) 120 | def test_server_error_debug(self): 121 | @json_view 122 | def temp(req): 123 | raise TypeError('fail') 124 | 125 | res = temp(rf.get('/')) 126 | eq_(500, res.status_code) 127 | eq_(JSON, res['content-type']) 128 | data = json.loads(res.content.decode('utf-8')) 129 | eq_(500, data['error']) 130 | eq_('fail', data['message']) 131 | assert 'traceback' in data 132 | 133 | @override_settings(DEBUG=False) 134 | def test_server_error_no_debug(self): 135 | @json_view 136 | def temp(req): 137 | raise TypeError('fail') 138 | 139 | res = temp(rf.get('/')) 140 | eq_(500, res.status_code) 141 | eq_(JSON, res['content-type']) 142 | data = json.loads(res.content.decode('utf-8')) 143 | eq_(500, data['error']) 144 | eq_('An error occurred', data['message']) 145 | 146 | def test_http_status(self): 147 | @json_view 148 | def temp(req): 149 | return {}, 402 150 | res = temp(rf.get('/')) 151 | eq_(402, res.status_code) 152 | eq_(JSON, res['content-type']) 153 | data = json.loads(res.content.decode('utf-8')) 154 | eq_({}, data) 155 | 156 | def test_headers(self): 157 | @json_view 158 | def temp(req): 159 | return {}, 302, {'X-Foo': 'Bar'} 160 | res = temp(rf.get('/')) 161 | eq_(302, res.status_code) 162 | eq_(JSON, res['content-type']) 163 | eq_('Bar', res['X-Foo']) 164 | data = json.loads(res.content.decode('utf-8')) 165 | eq_({}, data) 166 | 167 | def test_signal_sent(self): 168 | from . import decorators 169 | 170 | @json_view 171 | def temp(req): 172 | [][0] # sic. 173 | 174 | with mock.patch.object(decorators, 'got_request_exception') as s: 175 | res = temp(rf.get('/')) 176 | 177 | assert s.send.called 178 | eq_(JSON, res['content-type']) 179 | 180 | @override_settings(DEBUG=True) 181 | def test_signal_sent_with_propagated_exception(self): 182 | from django.core.signals import got_request_exception 183 | 184 | def assertion_handler(sender, request, **kwargs): 185 | for key in ['exception', 'exc_data']: 186 | assert key in kwargs 187 | for key in ['error', 'message', 'traceback']: 188 | assert key in kwargs['exc_data'] 189 | assert isinstance(kwargs['exception'], Exception) 190 | 191 | got_request_exception.connect(assertion_handler) 192 | 193 | @json_view 194 | def temp(req): 195 | 1/0 # sic. 196 | 197 | temp(rf.get('/')) 198 | 199 | def test_unicode_error(self): 200 | @json_view 201 | def temp(req): 202 | raise http.Http404('page \xe7\xe9 not found') 203 | 204 | res = temp(rf.get('/\xe7\xe9')) 205 | eq_(404, res.status_code) 206 | data = json.loads(res.content.decode('utf-8')) 207 | assert '\xe7\xe9' in data['message'] 208 | 209 | def test_override_content_type(self): 210 | testtype = 'application/vnd.helloworld+json' 211 | data = {'foo': 'bar'} 212 | 213 | @json_view(content_type=testtype) 214 | def temp(req): 215 | return data 216 | 217 | res = temp(rf.get('/')) 218 | eq_(200, res.status_code) 219 | eq_(data, json.loads(res.content.decode('utf-8'))) 220 | eq_(testtype, res['content-type']) 221 | 222 | def test_passthrough_response(self): 223 | """Allow HttpResponse objects through untouched.""" 224 | payload = json.dumps({'foo': 'bar'}).encode('utf-8') 225 | 226 | @json_view 227 | def temp(req): 228 | return http.HttpResponse(payload, content_type='text/plain') 229 | 230 | res = temp(rf.get('/')) 231 | eq_(200, res.status_code) 232 | eq_(payload, res.content) 233 | eq_('text/plain', res['content-type']) 234 | 235 | def test_datetime(self): 236 | now = timezone.now() 237 | 238 | @json_view 239 | def temp(req): 240 | return {'datetime': now} 241 | 242 | res = temp(rf.get('/')) 243 | eq_(200, res.status_code) 244 | payload = json.dumps({'datetime': now}, cls=DjangoJSONEncoder) 245 | eq_(payload, res.content) 246 | 247 | @override_settings(JSON_OPTIONS={'cls': None}) 248 | def test_datetime_no_serializer(self): 249 | now = timezone.now() 250 | 251 | @json_view 252 | def temp(req): 253 | return {'datetime': now} 254 | 255 | res = temp(rf.get('/')) 256 | eq_(500, res.status_code) 257 | payload = json.loads(res.content.decode('utf-8')) 258 | eq_(500, payload['error']) 259 | 260 | @override_settings(JSON_OPTIONS={'cls': None}) 261 | def test_dont_mutate_json_settings(self): 262 | """Don't mutate JSON settings during a request. 263 | 264 | The second request should use the same settings as the first, so we 265 | should get two 500s in a row. 266 | """ 267 | now = timezone.now() 268 | 269 | @json_view 270 | def temp(req): 271 | return {'datetime': now} 272 | 273 | res = temp(rf.get('/')) 274 | eq_(500, res.status_code) 275 | 276 | # calling this a second time should still generate a 500 and not 277 | # use the `DjangoJSONEncoder` 278 | res2 = temp(rf.get('/')) 279 | eq_(500, res2.status_code) 280 | 281 | @override_settings( 282 | JSON_OPTIONS={'cls': 'jsonview.tests.CustomTestEncoder'}) 283 | def test_json_custom_serializer_string(self): 284 | payload = json.dumps({'foo': 'Custom JSON'}).encode('utf-8') 285 | 286 | class Obj(object): 287 | def for_json(self): 288 | return 'Custom JSON' 289 | 290 | @json_view 291 | def temp(req): 292 | return {'foo': Obj()} 293 | 294 | res = temp(rf.get('/')) 295 | eq_(200, res.status_code) 296 | eq_(payload, res.content) 297 | 298 | @override_settings( 299 | JSON_OPTIONS={'cls': CustomTestEncoder}) 300 | def test_json_custom_serializer_class(self): 301 | payload = json.dumps({'foo': 'Custom JSON'}).encode('utf-8') 302 | 303 | class Obj(object): 304 | def for_json(self): 305 | return 'Custom JSON' 306 | 307 | @json_view 308 | def temp(req): 309 | return {'foo': Obj()} 310 | 311 | res = temp(rf.get('/')) 312 | eq_(200, res.status_code) 313 | eq_(payload, res.content) 314 | 315 | def test_method_decorator_on_dispatch(self): 316 | class TV(View): 317 | @method_decorator(json_view) 318 | def dispatch(self, *a, **kw): 319 | return super(TV, self).dispatch(*a, **kw) 320 | 321 | def get(self, request): 322 | return {'foo': 'bar'} 323 | 324 | view = TV.as_view() 325 | res = view(rf.get('/')) 326 | eq_(200, res.status_code) 327 | eq_(JSON, res['content-type']) 328 | data = json.loads(res.content.decode('utf-8')) 329 | eq_('bar', data['foo']) 330 | 331 | def test_method_decorator_on_class(self): 332 | if django.VERSION < (1, 9): 333 | raise SkipTest('Feature added in Django 1.9') 334 | 335 | @method_decorator(json_view, name='dispatch') 336 | class TV(View): 337 | def get(self, request): 338 | return {'foo': 'bar'} 339 | 340 | view = TV.as_view() 341 | res = view(rf.get('/')) 342 | eq_(200, res.status_code) 343 | eq_(JSON, res['content-type']) 344 | data = json.loads(res.content.decode('utf-8')) 345 | eq_('bar', data['foo']) 346 | 347 | def test_view_class_get(self): 348 | class MyView(JsonView): 349 | def get_context_data(self, **kwargs): 350 | context = super(MyView, self).get_context_data(**kwargs) 351 | context['foo'] = 'bar' 352 | return context 353 | 354 | view = MyView.as_view() 355 | 356 | res = view(rf.get('/')) 357 | eq_(200, res.status_code) 358 | eq_(JSON, res['content-type']) 359 | data = json.loads(res.content.decode('utf-8')) 360 | eq_('bar', data['foo']) 361 | 362 | def test_view_class_post(self): 363 | class MyView(JsonView): 364 | def post(self, request, *args, **kwargs): 365 | return self.get(request, *args, **kwargs) 366 | 367 | view = MyView.as_view() 368 | 369 | res = view(rf.post('/')) 370 | eq_(200, res.status_code) 371 | eq_(JSON, res['content-type']) 372 | data = json.loads(res.content.decode('utf-8')) 373 | eq_({}, data) 374 | 375 | def test_wrap_as_view(self): 376 | class TV(View): 377 | def get(self, request): 378 | return {'foo': 'bar'} 379 | 380 | view = json_view(TV.as_view()) 381 | res = view(rf.get('/')) 382 | eq_(200, res.status_code) 383 | eq_(JSON, res['content-type']) 384 | data = json.loads(res.content.decode('utf-8')) 385 | eq_('bar', data['foo']) 386 | -------------------------------------------------------------------------------- /jsonview/views.py: -------------------------------------------------------------------------------- 1 | from django.utils.decorators import method_decorator 2 | from django.views.generic import View 3 | from django.views.generic.base import ContextMixin 4 | 5 | from jsonview.decorators import json_view 6 | 7 | 8 | class JsonView(ContextMixin, View): 9 | 10 | def get_context_data(self, **kwargs): 11 | return {} 12 | 13 | def get(self, request, *args, **kwargs): 14 | context = self.get_context_data(**kwargs) 15 | return context 16 | 17 | @method_decorator(json_view) 18 | def dispatch(self, *args, **kwargs): 19 | return super(JsonView, self).dispatch(*args, **kwargs) 20 | -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | export PYTHONPATH=".:$PYTHONPATH" 4 | export DJANGO_SETTINGS_MODULE="test_settings" 5 | 6 | PROG="$0" 7 | CMD="$1" 8 | shift 9 | 10 | usage() { 11 | echo "USAGE: $PROG [command]" 12 | echo " test - run the jsonview tests" 13 | echo " shell - open the Django shell" 14 | echo " check - run flake8" 15 | echo " coverage - run tests with coverage" 16 | exit 1 17 | } 18 | 19 | case "$CMD" in 20 | "test" ) 21 | echo "Django version: $(python -m django --version)" 22 | python -m django test jsonview "$@" 23 | ;; 24 | "shell" ) 25 | python -m django shell "$@" 26 | ;; 27 | "check" ) 28 | echo "Flake8 version: $(flake8 --version)" 29 | flake8 jsonview "$@" 30 | ;; 31 | "coverage" ) 32 | echo "Django version: $(python -m django --version)" 33 | coverage3 run -m django test jsonview "$@" 34 | coverage3 report \ 35 | -m \ 36 | --include=jsonview/* \ 37 | --omit=jsonview/tests.py \ 38 | --fail-under=100 39 | ;; 40 | * ) 41 | usage ;; 42 | esac 43 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | import jsonview 4 | 5 | 6 | setup( 7 | name='django-jsonview', 8 | version=jsonview.__version__, 9 | description='Always return JSON from your Django view.', 10 | long_description=open('README.rst').read(), 11 | author='James Socol', 12 | author_email='me@jamessocol.com', 13 | url='https://github.com/jsocol/django-jsonview', 14 | license='Apache v2.0', 15 | packages=find_packages(exclude=['test_settings.py']), 16 | include_package_data=True, 17 | package_data={'': ['README.rst']}, 18 | zip_safe=False, 19 | classifiers=[ 20 | 'Development Status :: 5 - Production/Stable', 21 | 'Environment :: Web Environment', 22 | 'Framework :: Django', 23 | 'Intended Audience :: Developers', 24 | 'License :: OSI Approved :: Apache Software License', 25 | 'Operating System :: OS Independent', 26 | 'Programming Language :: Python', 27 | 'Programming Language :: Python :: 2.7', 28 | 'Programming Language :: Python :: 3.2', 29 | 'Programming Language :: Python :: 3.3', 30 | 'Programming Language :: Python :: 3.4', 31 | 'Programming Language :: Python :: 3.5', 32 | 'Topic :: Software Development :: Libraries :: Python Modules', 33 | ] 34 | ) 35 | -------------------------------------------------------------------------------- /test_settings.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | logging.disable(logging.CRITICAL) 5 | 6 | INSTALLED_APPS = ('jsonview',) 7 | 8 | SECRET_KEY = 'foo' 9 | 10 | DATABASES = { 11 | 'default': { 12 | 'ENGINE': 'django.db.backends.sqlite3', 13 | 'NAME': 'test.db', 14 | }, 15 | } 16 | --------------------------------------------------------------------------------