Repository: pallets/werkzeug

Issue:
Use Flask's JSONMixin
After #1442, remove the old `JSONRequestMixin` and replace it with Flask's `JSONMixin`.


Discussion:

Resolving patch:
diff --git a/werkzeug/utils.py b/werkzeug/utils.py
--- a/werkzeug/utils.py
+++ b/werkzeug/utils.py
@@ -10,6 +10,7 @@
     :copyright: (c) 2014 by the Werkzeug Team, see AUTHORS for more details.
     :license: BSD, see LICENSE for more details.
 """
+import codecs
 import re
 import os
 import sys
@@ -249,6 +250,53 @@ def get_content_type(mimetype, charset):
     return mimetype
 
 
+def detect_utf_encoding(data):
+    """Detect which UTF encoding was used to encode the given bytes.
+
+    The latest JSON standard (:rfc:`8259`) suggests that only UTF-8 is
+    accepted. Older documents allowed 8, 16, or 32. 16 and 32 can be big
+    or little endian. Some editors or libraries may prepend a BOM.
+
+    :internal:
+
+    :param data: Bytes in unknown UTF encoding.
+    :return: UTF encoding name
+
+    .. versionadded:: 0.15
+    """
+    head = data[:4]
+
+    if head[:3] == codecs.BOM_UTF8:
+        return "utf-8-sig"
+
+    if b"\x00" not in head:
+        return "utf-8"
+
+    if head in (codecs.BOM_UTF32_BE, codecs.BOM_UTF32_LE):
+        return "utf-32"
+
+    if head[:2] in (codecs.BOM_UTF16_BE, codecs.BOM_UTF16_LE):
+        return "utf-16"
+
+    if len(head) == 4:
+        if head[:3] == b"\x00\x00\x00":
+            return "utf-32-be"
+
+        if head[::2] == b"\x00\x00":
+            return "utf-16-be"
+
+        if head[1:] == b"\x00\x00\x00":
+            return "utf-32-le"
+
+        if head[1::2] == b"\x00\x00":
+            return "utf-16-le"
+
+    if len(head) == 2:
+        return "utf-16-be" if head.startswith(b'\x00') else "utf-16-le"
+
+    return "utf-8"
+
+
 def format_string(string, context):
     """String-template format a string:
 
diff --git a/werkzeug/wrappers/json.py b/werkzeug/wrappers/json.py
--- a/werkzeug/wrappers/json.py
+++ b/werkzeug/wrappers/json.py
@@ -1,29 +1,145 @@
 from __future__ import absolute_import
 
-from werkzeug.exceptions import BadRequest
-from werkzeug.utils import cached_property
+import datetime
+import uuid
+
+from werkzeug.utils import detect_utf_encoding
+from .._compat import text_type
+from ..exceptions import BadRequest
 
 try:
-    from simplejson import loads
+    import simplejson as _json
 except ImportError:
-    from json import loads
+    import json as _json
+
+
+class _JSONModule(object):
+    @staticmethod
+    def _default(o):
+        if isinstance(o, datetime.date):
+            return o.isoformat()
+
+        if isinstance(o, uuid.UUID):
+            return str(o)
+
+        if hasattr(o, "__html__"):
+            return text_type(o.__html__())
+
+        raise TypeError()
+
+    @classmethod
+    def dumps(cls, obj, **kw):
+        kw.setdefault("separators", (",", ":"))
+        kw.setdefault("default", cls._default)
+        kw.setdefault("sort_keys", True)
+        return _json.dumps(obj, **kw)
+
+    @staticmethod
+    def loads(s, **kw):
+        if isinstance(s, bytes):
+            # Needed for Python < 3.6
+            encoding = detect_utf_encoding(s)
+            s = s.decode(encoding)
+
+        return _json.loads(s, **kw)
 
 
 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 = _JSONModule
+
+    @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 loads(self.data.decode(self.charset, self.encoding_errors))
-        except Exception:
-            raise BadRequest('Unable to read JSON request')
+            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:
+            rv = self.json_module.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
+        :exc:`~werkzeug.exceptions.BadRequest`.
+        """
+        raise BadRequest('Failed to decode JSON object: {0}'.format(e))


Test patch:
diff --git a/tests/test_wrappers.py b/tests/test_wrappers.py
--- a/tests/test_wrappers.py
+++ b/tests/test_wrappers.py
@@ -9,6 +9,7 @@
     :license: BSD, see LICENSE for more details.
 """
 import contextlib
+import json
 import os
 
 import pytest
@@ -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():