Skip to content

Commit

Permalink
port Flask's JSONMixin implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
davidism committed Jan 26, 2019
1 parent ea3d34c commit 71c630d
Show file tree
Hide file tree
Showing 4 changed files with 148 additions and 25 deletions.
7 changes: 5 additions & 2 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -189,8 +189,10 @@ Unreleased
to try ``pickle`` if ``json`` fails. (`#1413`_)
- ``CGIRootFix`` no longer modifies ``PATH_INFO`` for very old
versions of Lighttpd. ``LighttpdCGIRootFix`` was renamed to
``CGIRootFix`` in 0.9. The old name emits a deprecation warning and
will be removed in the next version. (`#1141`_)
``CGIRootFix`` in 0.9. Both are deprecated and will be removed in
version 1.0. (`#1141`_)
- :class:`werkzeug.wrappers.json.JSONMixin` has been replaced with
Flask's implementation. Check the docs for the full API. (`#1445`_)
- The :doc:`contrib modules </contrib/index>` are deprecated and will
either be moved into ``werkzeug`` core or removed completely in
version 1.0. Some modules that already issued deprecation warnings
Expand Down Expand Up @@ -296,6 +298,7 @@ Unreleased
.. _#1430: https://github.com/pallets/werkzeug/pull/1430
.. _#1433: https://github.com/pallets/werkzeug/pull/1433
.. _#1439: https://github.com/pallets/werkzeug/pull/1439
.. _#1445: https://github.com/pallets/werkzeug/pull/1445


Version 0.14.1
Expand Down
4 changes: 3 additions & 1 deletion docs/wrappers.rst
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,10 @@ These mixins are not included in the default :class:`Request` and
:class:`Response` classes. They provide extra behavior that needs to be
opted into by creating your own subclasses::

class Response(JSONMixin, BaseResponse):
class JSONRequest(JSONMixin, Request):
pass


.. module:: werkzeug.wrappers.json

JSON
Expand All @@ -196,6 +197,7 @@ JSON
.. autoclass:: JSONMixin
:members:


.. module:: werkzeug.wrappers.charset

Dynamic Charset
Expand Down
52 changes: 45 additions & 7 deletions tests/test_wrappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
:license: BSD, see LICENSE for more details.
"""
import contextlib
import json
import os

import pytest
Expand Down Expand Up @@ -1295,14 +1296,51 @@ def test_samesite(self):
])


def test_json_request_mixin():
class MyRequest(JSONMixin, wrappers.Request):
class TestJSONMixin(object):
class Request(JSONMixin, wrappers.Request):
pass
req = MyRequest.from_values(
data=u'{"foä": "bar"}'.encode('utf-8'),
content_type='text/json'
)
assert req.json == {u'foä': 'bar'}

class Response(JSONMixin, wrappers.Response):
pass

def test_request(self):
value = {u"ä": "b"}
request = self.Request.from_values(json=value)
assert request.json == value
assert request.get_data()

def test_response(self):
value = {u"ä": "b"}
response = self.Response(
response=json.dumps(value),
content_type="application/json",
)
assert response.json == value

def test_force(self):
value = [1, 2, 3]
request = self.Request.from_values(json=value, content_type="text/plain")
assert request.json is None
assert request.get_json(force=True) == value

def test_silent(self):
request = self.Request.from_values(
data=b'{"a":}',
content_type="application/json",
)
assert request.get_json(silent=True) is None

with pytest.raises(BadRequest):
request.get_json()

def test_cache_disabled(self):
value = [1, 2, 3]
request = self.Request.from_values(json=value)
assert request.get_json(cache=False) == [1, 2, 3]
assert not request.get_data()

with pytest.raises(BadRequest):
request.get_json()


def test_dynamic_charset_request_mixin():
Expand Down
110 changes: 95 additions & 15 deletions werkzeug/wrappers/json.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,109 @@
from __future__ import absolute_import

from werkzeug.exceptions import BadRequest
from werkzeug.utils import cached_property

try:
from simplejson import loads
import simplejson as json
except ImportError:
from json import loads
import json


class JSONMixin(object):
"""Add json method to a request object. This will parse the input
data through simplejson if possible.
"""Mixin to parse :attr:`data` as JSON. Can be mixed in for both
:class:`~werkzeug.wrappers.Request` and
:class:`~werkzeug.wrappers.Response` classes.
:exc:`~werkzeug.exceptions.BadRequest` will be raised if the
content-type is not json or if the data itself cannot be parsed as
json.
If `simplejson`_ is installed it is preferred over Python's built-in
:mod:`json` module.
.. _simplejson: https://simplejson.readthedocs.io/en/latest/
"""

@cached_property
#: A module or other object that has ``dumps`` and ``loads``
#: functions that match the API of the built-in :mod:`json` module.
json_module = json

@property
def json(self):
"""Get the result of simplejson.loads if possible."""
if 'json' not in self.environ.get('CONTENT_TYPE', ''):
raise BadRequest('Not a JSON request')
"""The parsed JSON data if :attr:`mimetype` indicates JSON
(:mimetype:`application/json`, see :meth:`is_json`).
Calls :meth:`get_json` with default arguments.
"""
return self.get_json()

@property
def is_json(self):
"""Check if the mimetype indicates JSON data, either
:mimetype:`application/json` or :mimetype:`application/*+json`.
"""
mt = self.mimetype
return (
mt == 'application/json'
or mt.startswith('application/')
and mt.endswith('+json')
)

def _get_data_for_json(self, cache):
try:
return self.get_data(cache=cache)
except TypeError:
# Response doesn't have cache param.
return self.get_data()

# Cached values for ``(silent=False, silent=True)``. Initialized
# with sentinel values.
_cached_json = (Ellipsis, Ellipsis)

def get_json(self, force=False, silent=False, cache=True):
"""Parse :attr:`data` as JSON.
If the mimetype does not indicate JSON
(:mimetype:`application/json`, see :meth:`is_json`), this
returns ``None``.
If parsing fails, :meth:`on_json_loading_failed` is called and
its return value is used as the return value.
:param force: Ignore the mimetype and always try to parse JSON.
:param silent: Silence parsing errors and return ``None``
instead.
:param cache: Store the parsed JSON to return for subsequent
calls.
"""
if cache and self._cached_json[silent] is not Ellipsis:
return self._cached_json[silent]

if not (force or self.is_json):
return None

data = self._get_data_for_json(cache=cache)

try:
return loads(self.data.decode(self.charset, self.encoding_errors))
except Exception:
raise BadRequest('Unable to read JSON request')
rv = json.loads(data)
except ValueError as e:
if silent:
rv = None

if cache:
normal_rv, _ = self._cached_json
self._cached_json = (normal_rv, rv)
else:
rv = self.on_json_loading_failed(e)

if cache:
_, silent_rv = self._cached_json
self._cached_json = (rv, silent_rv)
else:
if cache:
self._cached_json = (rv, rv)

return rv

def on_json_loading_failed(self, e):
"""Called if :meth:`get_json` parsing fails and isn't silenced.
If this method returns a value, it is used as the return value
for :meth:`get_json`. The default implementation raises a
:exc:`~BadRequest` exception.
"""
raise BadRequest('Failed to decode JSON object: {0}'.format(e))

0 comments on commit 71c630d

Please sign in to comment.