Repository: falconry/falcon

Issue:
Configure json dumps in a single place
Falcon lets the user customize the json serializer used to serialize the media property of a response, but HttpError and SSEvent do not use the user provided serializer, using instead the default python json one.

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/falconry/falcon/pull/1768?src=pr&el=h1) Report
> Merging [#1768](https://codecov.io/gh/falconry/falcon/pull/1768?src=pr&el=desc) into [master](https://codecov.io/gh/falconry/falcon/commit/e7c139a3ebfa60652c64b7363636146d605a8803?el=desc) will **decrease** coverage by `0.20%`.
> The diff coverage is `93.93%`.

[![Impacted file tree graph](https://codecov.io/gh/falconry/falcon/pull/1768/graphs/tree.svg?width=650&height=150&src=pr&token=zOtkfNQ3h6)](https://codecov.io/gh/falconry/falcon/pull/1768?src=pr&el=tree)

```diff
@@             Coverage Diff             @@
##            master    #1768      +/-   ##
===========================================
- Coverage   100.00%   99.79%   -0.21%     
===========================================
  Files           52       52              
  Lines         4295     4311      +16     
  Branches       690      694       +4     
===========================================
+ Hits          4295     4302       +7     
- Misses           0        7       +7     
- Partials         0        2       +2     
```


| [Impacted Files](https://codecov.io/gh/falconry/falcon/pull/1768?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [falcon/util/\_\_init\_\_.py](https://codecov.io/gh/falconry/falcon/pull/1768/diff?src=pr&el=tree#diff-ZmFsY29uL3V0aWwvX19pbml0X18ucHk=) | `92.30% <83.33%> (-7.70%)` | :arrow_down: |
| [falcon/app\_helpers.py](https://codecov.io/gh/falconry/falcon/pull/1768/diff?src=pr&el=tree#diff-ZmFsY29uL2FwcF9oZWxwZXJzLnB5) | `100.00% <100.00%> (ø)` | |
| [falcon/asgi/app.py](https://codecov.io/gh/falconry/falcon/pull/1768/diff?src=pr&el=tree#diff-ZmFsY29uL2FzZ2kvYXBwLnB5) | `100.00% <100.00%> (ø)` | |
| [falcon/asgi/structures.py](https://codecov.io/gh/falconry/falcon/pull/1768/diff?src=pr&el=tree#diff-ZmFsY29uL2FzZ2kvc3RydWN0dXJlcy5weQ==) | `100.00% <100.00%> (ø)` | |
| [falcon/http\_error.py](https://codecov.io/gh/falconry/falcon/pull/1768/diff?src=pr&el=tree#diff-ZmFsY29uL2h0dHBfZXJyb3IucHk=) | `100.00% <100.00%> (ø)` | |
| [falcon/media/json.py](https://codecov.io/gh/falconry/falcon/pull/1768/diff?src=pr&el=tree#diff-ZmFsY29uL21lZGlhL2pzb24ucHk=) | `100.00% <100.00%> (ø)` | |
| [falcon/testing/client.py](https://codecov.io/gh/falconry/falcon/pull/1768/diff?src=pr&el=tree#diff-ZmFsY29uL3Rlc3RpbmcvY2xpZW50LnB5) | `100.00% <100.00%> (ø)` | |
| [falcon/util/misc.py](https://codecov.io/gh/falconry/falcon/pull/1768/diff?src=pr&el=tree#diff-ZmFsY29uL3V0aWwvbWlzYy5weQ==) | `96.26% <0.00%> (-3.74%)` | :arrow_down: |
| [falcon/media/multipart.py](https://codecov.io/gh/falconry/falcon/pull/1768/diff?src=pr&el=tree#diff-ZmFsY29uL21lZGlhL211bHRpcGFydC5weQ==) | `98.81% <0.00%> (-1.19%)` | :arrow_down: |
| [falcon/util/reader.py](https://codecov.io/gh/falconry/falcon/pull/1768/diff?src=pr&el=tree#diff-ZmFsY29uL3V0aWwvcmVhZGVyLnB5) | `100.00% <0.00%> (ø)` | |
| ... and [1 more](https://codecov.io/gh/falconry/falcon/pull/1768/diff?src=pr&el=tree-more) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/falconry/falcon/pull/1768?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/falconry/falcon/pull/1768?src=pr&el=footer). Last update [e7c139a...9f05362](https://codecov.io/gh/falconry/falcon/pull/1768?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

CaselIT: > The way to deprecate stuff at the module level on 3.7+ is cool though, we could actually use it at more places if/when needed.

Indeed. It was one of the reason why it was added. [PEP562](https://www.python.org/dev/peps/pep-0562/)

Regarding the rest, I guess we should wait for websocket to land and the update this one.
vytas7: One more random thought on unifying serialization. Ideally, the default error serializer should also use response media handlers; so if one is, for instance, building a YAML or Msgpack API, the error structures should by default also pick the default media type set for the API.

Some open questions:
* How could this look for XML?
* How would an error while serializing an exception look like? (See also: https://github.com/falconry/falcon/issues/1607 and [`HTTP 776`](https://github.com/joho/7XX-rfc) )
CaselIT: This should maybe wait for https://github.com/falconry/falcon/pull/1741
vytas7: For reference, reposting some random thoughts of the top of my head from [Gitter](https://gitter.im/falconry/dev?at=5fc29b6cf7e4510fcef788c5):
> I was basically thinking that we would have two types of handlers:
> * Stream handlers, much like the current BaseHandler; the ASGI variant awaits serialization, the WSGI counterpart obviously does not.
> * Binary data handlers, not dissimilar to the current BinaryBaseHandlerWS. Always sync.
>
> The latter would handle WebSocket media, SSE, and error serialization. Error serialization probably being the most questionable one whether it is enough.
> However, in case more complex logic is needed, one could override the awaitable custom error serializer (is it awaitable now?..).
CaselIT: > 
> 
> Thanks @CaselIT, this is a great effort to unify these scattered handlers, enabling reuse of the same media handler interface.
> 
> Unfortunately, it seems that we need to rethink our designs here before we can move forward.
> 
> Currently, both your HTTP error serializer and `SSEvent` JSON serializer would simply try to call `handler.serialize(<data>, MEDIA_JSON)`.
> However, the ASGI flavour of the framework primarily relies not on `serialize()`, but instead, [`serialize_async()`](https://falcon.readthedocs.io/en/latest/api/media.html#falcon.media.BaseHandler.serialize_async) is `await`ed (although the latter can piggyback on the former, if not defined explicitly).

I've updated to docs to require that the json handler have the sync implementation even for asgi application.

While inconvenient, I don't think it would cause to much of an hassle to users, considering that we also provide a falcon implementation.
kgriffs: I agree that this approach shouldn't be a big burden on the developer since the differences between the two methods will me small and easy to maintain. Also, most people will just use the default media handlers and not even think about it. 

Noting this requirement in the docs is a good idea. We should also make sure that we raise a user-friendly error if the method is not implemented on the handler.

CaselIT: documentation updated @kgriffs 
vytas7: > Noting this requirement in the docs is a good idea. We should also make sure that we raise a user-friendly error if the method is not implemented on the handler.

Could we maybe fallback to the default handler upon `NotImplementedError`, and document this behaviour? It ain't pretty but FAPP it would be quite robust.
CaselIT: > 
> 
> > Noting this requirement in the docs is a good idea. We should also make sure that we raise a user-friendly error if the method is not implemented on the handler.
> 
> Could we maybe fall back to the default handler upon `NotImplementedError`, and document this behaviour? It ain't pretty but FAPP it would be quite robust.

that may also be an option
kgriffs: > Could we maybe fall back to the default handler upon NotImplementedError

I kind of think we should just go ahead and raise it to the app just to be sure the developer isn't getting a result they don't expect (i.e., they thought we were calling their thing when all along we weren't but we were silently falling back to something else).
vytas7: That's a good point @kgriffs .
I had this as an idea to offer improved damage control, but after all, it's an edge case that shouldn't be seen to often. As such, it's probably better to be explicit indeed.
CaselIT: updated
CaselIT: Updated, I think ready for final review

Patch:
diff --git a/docs/_newsfragments/1767.breakingchange.rst b/docs/_newsfragments/1767.breakingchange.rst
new file mode 100644
index 0000000..6bbf27a
--- /dev/null
+++ b/docs/_newsfragments/1767.breakingchange.rst
@@ -0,0 +1,2 @@
+The :meth:`falcon.HTTPError.to_json` now returns ``bytes`` instead of ``str``.
+Importing ``json`` from ``falcon.util`` is deprecated.
\ No newline at end of file
diff --git a/docs/_newsfragments/1767.misc.rst b/docs/_newsfragments/1767.misc.rst
new file mode 100644
index 0000000..696baba
--- /dev/null
+++ b/docs/_newsfragments/1767.misc.rst
@@ -0,0 +1,4 @@
+Falcon now uses the :class:`falcon.Response` media handlers when serializing
+to JSON :class:`falcon.HTTPError` and :class:`falcon.asgi.SSEvent`.
+:class:`falcon.Request` will use its defined media handler when loading a
+param as JSON with :meth:`falcon.Request.get_param_as_json`.
diff --git a/docs/api/media.rst b/docs/api/media.rst
index 9f9594f..d04f59e 100644
--- a/docs/api/media.rst
+++ b/docs/api/media.rst
@@ -15,7 +15,7 @@ specified on your :any:`falcon.App`.
 
     WebSocket media is handled differently from regular HTTP requests. For
     information regarding WebSocket media handlers, please
-    see: :ref:`ws_media_handlers`.
+    see: :ref:`ws_media_handlers` in the WebSocket section.
 
 Usage
 -----
@@ -176,6 +176,23 @@ removing the default handlers, this can be easily done as follows:
 The ``falcon`` module provides a number of constants for common media types.
 See also: :ref:`media_type_constants`.
 
+.. _note_json_handler:
+
+.. note::
+
+    The configured :class:`falcon.Response` JSON handler is also used to serialize
+    :class:`falcon.HTTPError` and the ``json`` attribute of :class:`falcon.asgi.SSEvent`.
+    The JSON handler configured in :class:`falcon.Request` is used by
+    :meth:`falcon.Request.get_param_as_json` to deserialize query params.
+
+    When implementing a custom handler for the JSON media it is therefore required
+    that the sync interface methods, meaning
+    :meth:`falcon.media.BaseHandler.serialize` and :meth:`falcon.media.BaseHandler.deserialize`,
+    are implemented even in ``ASGI`` applications. The default json handler
+    :class:`falcon.media.JSONHandler` already implements all the required methods to
+    work with both type of applications.
+
+
 Supported Handler Types
 -----------------------
 
@@ -198,7 +215,13 @@ Custom Handler Type
 
 If Falcon doesn't have an Internet media type handler that supports your
 use case, you can easily implement your own using the abstract base class
-provided by Falcon:
+provided by Falcon and documented below.
+
+In general ``WSGI`` applications only use the sync methods, while
+``ASGI`` applications only use the async one.
+The JSON handled is an exception to this, since it's used also by
+other parts of the framework, not only in the media handling.
+See the :ref:`note above<note_json_handler>` for more details.
 
 .. autoclass:: falcon.media.BaseHandler
     :members:
diff --git a/falcon/__init__.py b/falcon/__init__.py
index 75cf1b0..05c559c 100644
--- a/falcon/__init__.py
+++ b/falcon/__init__.py
@@ -50,7 +50,7 @@ from falcon.request import Request, RequestOptions, Forwarded  # NOQA
 from falcon.response import Response, ResponseOptions  # NOQA
 
 
-ASGI_SUPPORTED = _sys.version_info.minor > 5
+ASGI_SUPPORTED = _sys.version_info >= (3, 6)
 """Set to ``True`` when ASGI is supported for the current Python version."""
 
 
diff --git a/falcon/app.py b/falcon/app.py
index 1401173..03aa80b 100644
--- a/falcon/app.py
+++ b/falcon/app.py
@@ -795,16 +795,13 @@ class App:
             def my_serializer(req, resp, exception):
                 representation = None
 
-                preferred = req.client_prefers(('application/x-yaml',
-                                                'application/json'))
+                preferred = req.client_prefers((falcon.MEDIA_YAML, falcon.MEDIA_JSON))
 
                 if preferred is not None:
-                    if preferred == 'application/json':
-                        representation = exception.to_json()
+                    if preferred == falcon.MEDIA_JSON:
+                        resp.data = exception.to_json()
                     else:
-                        representation = yaml.dump(exception.to_dict(),
-                                                   encoding=None)
-                    resp.body = representation
+                        resp.body = yaml.dump(exception.to_dict(), encoding=None)
                     resp.content_type = preferred
 
                 resp.append_header('Vary', 'Accept')
diff --git a/falcon/app_helpers.py b/falcon/app_helpers.py
index ddd8ae1..c3e8f8f 100644
--- a/falcon/app_helpers.py
+++ b/falcon/app_helpers.py
@@ -16,6 +16,7 @@
 
 from inspect import iscoroutinefunction
 
+from falcon import MEDIA_JSON, MEDIA_XML
 from falcon import util
 from falcon.errors import CompatibilityError
 from falcon.util.sync import _wrap_non_coroutine_unsafe
@@ -212,9 +213,7 @@ def default_serialize_error(req, resp, exception):
         resp: Instance of ``falcon.Response``
         exception: Instance of ``falcon.HTTPError``
     """
-    preferred = req.client_prefers(('application/xml',
-                                    'text/xml',
-                                    'application/json'))
+    preferred = req.client_prefers((MEDIA_XML, 'text/xml', MEDIA_JSON))
 
     if preferred is None:
         # NOTE(kgriffs): See if the client expects a custom media
@@ -231,15 +230,17 @@ def default_serialize_error(req, resp, exception):
         # need to be more sophisticated, we can always change it
         # later (YAGNI).
         if '+json' in accept:
-            preferred = 'application/json'
+            preferred = MEDIA_JSON
         elif '+xml' in accept:
-            preferred = 'application/xml'
+            preferred = MEDIA_XML
 
     if preferred is not None:
-        if preferred == 'application/json':
-            resp.body = exception.to_json()
+        if preferred == MEDIA_JSON:
+            handler = resp.options.media_handlers.find_by_media_type(
+                MEDIA_JSON, MEDIA_JSON, raise_not_found=False
+            )
+            resp.data = exception.to_json(handler)
         else:
-            # NOTE(caselit): to_xml already returns bytes
             resp.data = exception.to_xml()
 
         # NOTE(kgriffs): No need to append the charset param, since
diff --git a/falcon/asgi/__init__.py b/falcon/asgi/__init__.py
index 4a9e57f..932738e 100644
--- a/falcon/asgi/__init__.py
+++ b/falcon/asgi/__init__.py
@@ -29,7 +29,7 @@ question.
 
 import sys as _sys
 
-if _sys.version_info.minor < 6:
+if _sys.version_info < (3, 6):
     raise ImportError('falcon.asgi requires Python 3.6+')
 
 from .app import App  # NOQA
diff --git a/falcon/asgi/app.py b/falcon/asgi/app.py
index 737086e..b504ae4 100644
--- a/falcon/asgi/app.py
+++ b/falcon/asgi/app.py
@@ -21,6 +21,7 @@ import traceback
 import falcon.app
 from falcon.app_helpers import prepare_middleware, prepare_middleware_ws
 from falcon.asgi_spec import EventType, WSCloseCode
+from falcon.constants import MEDIA_JSON
 from falcon.errors import (
     CompatibilityError,
     HTTPBadRequest,
@@ -546,6 +547,10 @@ class App(falcon.app.App):
 
             self._schedule_callbacks(resp)
 
+            handler = self.resp_options.media_handlers.find_by_media_type(
+                MEDIA_JSON, MEDIA_JSON, raise_not_found=False
+            )
+
             # TODO(kgriffs): Do we need to do anything special to handle when
             #   a connection is closed?
             async for event in sse_emitter:
@@ -557,7 +562,7 @@ class App(falcon.app.App):
                 #   the connection state using watch_disconnect() above.
                 await send({
                     'type': EventType.HTTP_RESPONSE_BODY,
-                    'body': event.serialize(),
+                    'body': event.serialize(handler),
                     'more_body': True
                 })
 
diff --git a/falcon/asgi/structures.py b/falcon/asgi/structures.py
index f68957d..f649082 100644
--- a/falcon/asgi/structures.py
+++ b/falcon/asgi/structures.py
@@ -1,4 +1,5 @@
-from json import dumps as json_dumps
+from falcon.constants import MEDIA_JSON
+from falcon.media.json import _DEFAULT_JSON_HANDLER
 
 
 __all__ = ['SSEvent']
@@ -111,14 +112,24 @@ class SSEvent:
 
         self.comment = comment
 
-    def serialize(self):
+    def serialize(self, handler=None):
+        """Serialize this event to string.
+
+        Args:
+            handler: Handler object that will be used to serialize the ``json`` attribute to
+                string. When not provided, a default handler using the builtin
+                JSON library will be used (default ``None``).
+
+        Returns:
+            bytes: string representation of this event.
+        """
         if self.comment is not None:
-            block = ': ' + self.comment + '\n'
+            block = f': {self.comment}\n'
         else:
             block = ''
 
         if self.event is not None:
-            block += 'event: ' + self.event + '\n'
+            block += f'event: {self.event}\n'
 
         if self.event_id is not None:
             # NOTE(kgriffs): f-strings are a tiny bit faster than str().
@@ -134,11 +145,15 @@ class SSEvent:
             #   attribute, but rather the text and json ones instead. If that
             #   is true, it makes sense to construct the entire string
             #   first, then encode it all in one go at the end.
-            block += 'data: ' + self.data.decode() + '\n'
+            block += f'data: {self.data.decode()}\n'
         elif self.text is not None:
-            block += 'data: ' + self.text + '\n'
+            block += f'data: {self.text}\n'
         elif self.json is not None:
-            block += 'data: ' + json_dumps(self.json, ensure_ascii=False) + '\n'
+            if handler is None:
+                handler = _DEFAULT_JSON_HANDLER
+            serialized = handler.serialize(self.json, MEDIA_JSON)
+            block += 'data: '
+            return block.encode() + serialized + b'\n\n'
 
         if not block:
             return b': ping\n\n'
diff --git a/falcon/http_error.py b/falcon/http_error.py
index af92c33..ea2ed2e 100644
--- a/falcon/http_error.py
+++ b/falcon/http_error.py
@@ -17,7 +17,8 @@
 from collections import OrderedDict
 import xml.etree.ElementTree as et
 
-from falcon.util import json, uri
+from falcon.constants import MEDIA_JSON
+from falcon.util import uri
 from falcon.util.deprecation import deprecated, deprecated_args
 
 
@@ -165,16 +166,23 @@ class HTTPError(Exception):
 
         return obj
 
-    def to_json(self):
-        """Return a pretty-printed JSON representation of the error.
+    def to_json(self, handler=None):
+        """Return a JSON representation of the error.
+
+        Args:
+            handler: Handler object that will be used to serialize the representation of this
+                error to JSON. When not provided, a default handler using the builtin
+                JSON library will be used (default ``None``).
 
         Returns:
-            str: A JSON document for the error.
+            bytes: A JSON document for the error.
 
         """
 
         obj = self.to_dict(OrderedDict)
-        return json.dumps(obj, ensure_ascii=False)
+        if handler is None:
+            handler = _DEFAULT_JSON_HANDLER
+        return handler.serialize(obj, MEDIA_JSON)
 
     def to_xml(self):
         """Return an XML-encoded representation of the error.
@@ -204,6 +212,11 @@ class HTTPError(Exception):
                 et.tostring(error_element, encoding='utf-8'))
 
 
+# NOTE: initialized in falcon.media.json, that is always imported since Request/Respose
+# are imported by falcon init.
+_DEFAULT_JSON_HANDLER = None
+
+
 class NoRepresentation:
     """Mixin for ``HTTPError`` child classes that have no representation.
 
diff --git a/falcon/media/base.py b/falcon/media/base.py
index 766d08c..debc50c 100644
--- a/falcon/media/base.py
+++ b/falcon/media/base.py
@@ -2,6 +2,8 @@ import abc
 import io
 from typing import Union
 
+from falcon.constants import MEDIA_JSON
+
 
 class BaseHandler(metaclass=abc.ABCMeta):
     """Abstract Base Class for an internet media type handler."""
@@ -16,6 +18,12 @@ class BaseHandler(metaclass=abc.ABCMeta):
         with ASGI apps, as long as they override
         :py:meth:`~.BaseHandler.serialize_async`.
 
+        Note:
+
+            The JSON media handler is an exception in requiring the implementation of
+            the sync version also for ASGI apps. See the
+            :ref:`this section<note_json_handler>` for more details.
+
         Args:
             media (object): A serializable object.
             content_type (str): Type of response content.
@@ -23,7 +31,13 @@ class BaseHandler(metaclass=abc.ABCMeta):
         Returns:
             bytes: The resulting serialized bytes from the input object.
         """
-        raise NotImplementedError()
+        if MEDIA_JSON in content_type:
+            raise NotImplementedError(
+                'The JSON media handler requires the sync interface to be implemented even in '
+                "ASGI applications, because it's used internally by the Falcon framework."
+            )
+        else:
+            raise NotImplementedError()
 
     async def serialize_async(self, media, content_type):
         """Serialize the media object on a :any:`falcon.Response`.
@@ -62,6 +76,11 @@ class BaseHandler(metaclass=abc.ABCMeta):
         with ASGI apps, as long as they override
         :py:meth:`~.BaseHandler.deserialize_async`.
 
+        Note:
+
+            The JSON media handler is an exception in requiring the implementation of
+            the sync version also for ASGI apps. See the
+            :ref:`this section<note_json_handler>` for more details.
 
         Args:
             stream (object): Readable file-like object to deserialize.
@@ -71,7 +90,13 @@ class BaseHandler(metaclass=abc.ABCMeta):
         Returns:
             object: A deserialized object.
         """
-        raise NotImplementedError()
+        if MEDIA_JSON in content_type:
+            raise NotImplementedError(
+                'The JSON media handler requires the sync interface to be implemented even in '
+                "ASGI applications, because it's used internally by the Falcon framework."
+            )
+        else:
+            raise NotImplementedError()
 
     async def deserialize_async(self, stream, content_type, content_length):
         """Deserialize the :any:`falcon.Request` body.
diff --git a/falcon/media/handlers.py b/falcon/media/handlers.py
index 584e382..a5dd9e2 100644
--- a/falcon/media/handlers.py
+++ b/falcon/media/handlers.py
@@ -54,7 +54,7 @@ class Handlers(UserDict):
 
         return resolved
 
-    def find_by_media_type(self, media_type, default):
+    def find_by_media_type(self, media_type, default, raise_not_found=True):
         # PERF(jmvrbanac): Check via a quick methods first for performance
         if media_type == '*/*' or not media_type:
             media_type = default
@@ -68,9 +68,11 @@ class Handlers(UserDict):
         resolved = self._resolve_media_type(media_type, self.data.keys())
 
         if not resolved:
-            raise errors.HTTPUnsupportedMediaType(
-                description='{0} is an unsupported media type.'.format(media_type)
-            )
+            if raise_not_found:
+                raise errors.HTTPUnsupportedMediaType(
+                    description='{0} is an unsupported media type.'.format(media_type)
+                )
+            return None
 
         return self.data[resolved]
 
diff --git a/falcon/media/json.py b/falcon/media/json.py
index cb06ad3..4e171a7 100644
--- a/falcon/media/json.py
+++ b/falcon/media/json.py
@@ -1,8 +1,9 @@
 from functools import partial
+import json
 
 from falcon import errors
+from falcon import http_error
 from falcon.media.base import BaseHandler, TextBaseHandlerWS
-from falcon.util import json
 
 
 class JSONHandler(BaseHandler):
@@ -75,7 +76,7 @@ class JSONHandler(BaseHandler):
         except ValueError as err:
             raise errors.HTTPBadRequest(
                 title='Invalid JSON',
-                description='Could not parse JSON body - {0}'.format(err)
+                description='Could not parse JSON - {0}'.format(err)
             )
 
     async def deserialize_async(self, stream, content_type, content_length):
@@ -86,7 +87,7 @@ class JSONHandler(BaseHandler):
         except ValueError as err:
             raise errors.HTTPBadRequest(
                 title='Invalid JSON',
-                description='Could not parse JSON body - {0}'.format(err)
+                description='Could not parse JSON - {0}'.format(err)
             )
 
     def serialize(self, media, content_type):
@@ -179,3 +180,6 @@ class JSONHandlerWS(TextBaseHandlerWS):
 
     def deserialize(self, payload: str) -> object:
         return self.loads(payload)
+
+
+http_error._DEFAULT_JSON_HANDLER = _DEFAULT_JSON_HANDLER = JSONHandler()  # type: ignore
diff --git a/falcon/request.py b/falcon/request.py
index 0e21deb..a9f3387 100644
--- a/falcon/request.py
+++ b/falcon/request.py
@@ -13,16 +13,18 @@
 """Request class."""
 
 from datetime import datetime
+from io import BytesIO
 from uuid import UUID
 
 from falcon import DEFAULT_MEDIA_TYPE
 from falcon import errors
+from falcon import MEDIA_JSON
 from falcon import request_helpers as helpers
 from falcon import util
 from falcon.forwarded import _parse_forwarded_header
 from falcon.forwarded import Forwarded  # NOQA
 from falcon.media import Handlers
-from falcon.util import json
+from falcon.media.json import _DEFAULT_JSON_HANDLER
 from falcon.util import structures
 from falcon.util.misc import isascii
 from falcon.util.uri import parse_host, parse_query_string
@@ -1739,9 +1741,19 @@ class Request:
         if param_value is None:
             return default
 
+        handler = self.options.media_handlers.find_by_media_type(
+            MEDIA_JSON, MEDIA_JSON, raise_not_found=False
+        )
+        if handler is None:
+            handler = _DEFAULT_JSON_HANDLER
+
         try:
-            val = json.loads(param_value)
-        except ValueError:
+            # TODO(CaselIT): find a way to avoid encode + BytesIO if handlers
+            # interface is refactored. Possibly using the WS interface?
+            val = handler.deserialize(
+                BytesIO(param_value.encode()), MEDIA_JSON, len(param_value)
+            )
+        except errors.HTTPBadRequest:
             msg = 'It could not be parsed as JSON.'
             raise errors.HTTPInvalidParam(msg, name)
 
diff --git a/falcon/util/__init__.py b/falcon/util/__init__.py
index fce326c..32c226a 100644
--- a/falcon/util/__init__.py
+++ b/falcon/util/__init__.py
@@ -21,7 +21,6 @@ Conversely, the `uri` module must be imported explicitly::
 """
 
 from http import cookies as http_cookies
-import json  # NOQA
 import sys
 
 # Hoist misc. utils
@@ -52,3 +51,21 @@ except ImportError:
 # NOTE(vytas): Cythonized BufferedReader makes heavy use of Py_ssize_t which
 #   would overflow on 32-bit systems with form parts larger than 2 GiB.
 BufferedReader = (_CyBufferedReader or _PyBufferedReader) if IS_64_BITS else _PyBufferedReader
+
+if sys.version_info >= (3, 7):
+    # NOTE(caselit): __getattr__ support for modules was added only in py 3.7. Deprecating an
+    # import on previous version is hard to do, so we are displaying the warning only on 3.7+
+    def __getattr__(name):
+        if name == 'json':
+            import warnings
+            import json  # NOQA
+            from .deprecation import DeprecatedWarning
+
+            warnings.warn('Importing json from "falcon.util" is deprecated.', DeprecatedWarning)
+            return json
+        from types import ModuleType
+        # fallback to the default implementation
+        mod = sys.modules[__name__]
+        return ModuleType.__getattr__(mod, name)
+else:
+    import json  # NOQA


Test patch:
diff --git a/falcon/testing/client.py b/falcon/testing/client.py
index f5734ad..95691e6 100644
--- a/falcon/testing/client.py
+++ b/falcon/testing/client.py
@@ -21,6 +21,7 @@ WSGI callable, without having to stand up a WSGI server.
 import asyncio
 import datetime as dt
 import inspect
+import json as json_module
 import time
 from typing import Dict, Optional, Sequence, Union
 import warnings
@@ -39,7 +40,6 @@ from falcon.util import (
     get_running_loop,
     http_cookies,
     http_date_to_dt,
-    json as util_json,
     to_query_str,
 )
 
@@ -326,7 +326,7 @@ class Result(_ResultBase):
         if not self.text:
             return None
 
-        return util_json.loads(self.text)
+        return json_module.loads(self.text)
 
 
 class StreamedResult(_ResultBase):
@@ -1984,7 +1984,7 @@ def _prepare_sim_args(
         headers['Content-Type'] = content_type
 
     if json is not None:
-        body = util_json.dumps(json, ensure_ascii=False)
+        body = json_module.dumps(json, ensure_ascii=False)
         headers = headers or {}
         headers['Content-Type'] = MEDIA_JSON
 
diff --git a/tests/asgi/test_middleware_asgi.py b/tests/asgi/test_middleware_asgi.py
index 0085ba3..33b7fde 100644
--- a/tests/asgi/test_middleware_asgi.py
+++ b/tests/asgi/test_middleware_asgi.py
@@ -25,7 +25,7 @@ class MiddlewareIncompatibleWithWSGI_C:
     (MiddlewareIncompatibleWithWSGI_C(), MiddlewareIncompatibleWithWSGI_A()),
 ])
 def test_raise_on_incompatible(middleware):
-    api = falcon.API()
+    api = falcon.App()
 
     with pytest.raises(falcon.CompatibilityError):
         api.add_middleware(middleware)
diff --git a/tests/asgi/test_sse.py b/tests/asgi/test_sse.py
index 9734766..27b20f2 100644
--- a/tests/asgi/test_sse.py
+++ b/tests/asgi/test_sse.py
@@ -1,4 +1,5 @@
 import asyncio
+import json
 
 import pytest
 
@@ -182,6 +183,51 @@ def test_multiple_events_early_disconnect():
     falcon.async_to_sync(_test)
 
 
+class TestSerializeJson:
+    @pytest.fixture
+    def client(self):
+        class SomeResource:
+            async def on_get(self, req, resp):
+                async def emitter():
+                    yield SSEvent(json={'foo': 'bar'})
+                    yield SSEvent(json={'bar': 'baz'})
+
+                resp.sse = emitter()
+
+        resource = SomeResource()
+
+        app = App()
+        app.add_route('/', resource)
+
+        client = testing.TestClient(app)
+        return client
+
+    def test_use_media_handler_dumps(self, client):
+        h = client.app.resp_options.media_handlers[falcon.MEDIA_JSON]
+        h.dumps = lambda x: json.dumps(x).upper()
+
+        result = client.simulate_get()
+        assert result.text == (
+            'data: {"FOO": "BAR"}\n'
+            '\n'
+            'data: {"BAR": "BAZ"}\n'
+            '\n'
+        )
+
+    def test_no_json_media_handler(self, client):
+        for h in list(client.app.resp_options.media_handlers):
+            if 'json' in h.casefold():
+                client.app.resp_options.media_handlers.pop(h)
+
+        result = client.simulate_get()
+        assert result.text == (
+            'data: {"foo": "bar"}\n'
+            '\n'
+            'data: {"bar": "baz"}\n'
+            '\n'
+        )
+
+
 def test_invalid_event_values():
     with pytest.raises(TypeError):
         SSEvent(data='notbytes')
diff --git a/tests/test_custom_router.py b/tests/test_custom_router.py
index 1355abb..f9d16e3 100644
--- a/tests/test_custom_router.py
+++ b/tests/test_custom_router.py
@@ -68,7 +68,7 @@ def test_custom_router_find_should_be_used(asgi):
 
     for uri in ('/404', '/404/backwards-compat'):
         response = client.simulate_request(path=uri)
-        assert response.text == falcon.HTTPNotFound().to_json()
+        assert response.content == falcon.HTTPNotFound().to_json()
         assert response.status == falcon.HTTP_404
 
     assert router.reached_backwards_compat
diff --git a/tests/test_httperror.py b/tests/test_httperror.py
index 55f620f..92c405a 100644
--- a/tests/test_httperror.py
+++ b/tests/test_httperror.py
@@ -1,6 +1,7 @@
 # -*- coding: utf-8
 
 import datetime
+import json
 import wsgiref.validate
 import xml.etree.ElementTree as et  # noqa: I202
 
@@ -10,7 +11,6 @@ import yaml
 import falcon
 from falcon.http_error import NoRepresentation, OptionalRepresentation
 import falcon.testing as testing
-from falcon.util import json
 from falcon.util.deprecation import DeprecatedWarning
 
 from _util import create_app  # NOQA
@@ -343,19 +343,14 @@ class TestHTTPError:
         }
 
         def _my_serializer(req, resp, exception):
-            representation = None
-
-            preferred = req.client_prefers(('application/x-yaml',
-                                            'application/json'))
+            preferred = req.client_prefers((falcon.MEDIA_YAML, falcon.MEDIA_JSON))
 
             if preferred is not None:
-                if preferred == 'application/json':
-                    representation = exception.to_json()
+                if preferred == falcon.MEDIA_JSON:
+                    resp.data = exception.to_json()
                 else:
-                    representation = yaml.dump(exception.to_dict(),
-                                               encoding=None)
+                    resp.body = yaml.dump(exception.to_dict(), encoding=None)
 
-                resp.body = representation
                 resp.content_type = preferred
 
         def _check(media_type, deserializer):
@@ -367,8 +362,8 @@ class TestHTTPError:
             actual_doc = deserializer(response.content.decode('utf-8'))
             assert expected_doc == actual_doc
 
-        _check('application/x-yaml', yaml.safe_load)
-        _check('application/json', json.loads)
+        _check(falcon.MEDIA_YAML, yaml.safe_load)
+        _check(falcon.MEDIA_JSON, json.loads)
 
     @pytest.mark.parametrize('method,path,status', [
         ('GET', '/404', 404),
@@ -590,7 +585,7 @@ class TestHTTPError:
 
         response = client.simulate_request(path='/405')
         assert response.status == falcon.HTTP_405
-        assert response.text == falcon.HTTPMethodNotAllowed(['PUT']).to_json()
+        assert response.content == falcon.HTTPMethodNotAllowed(['PUT']).to_json()
         assert response.json == {'title': falcon.HTTP_METHOD_NOT_ALLOWED}
         assert response.headers['allow'] == 'PUT'
 
@@ -599,7 +594,7 @@ class TestHTTPError:
 
         response = client.simulate_request(path='/405')
         assert response.status == falcon.HTTP_405
-        assert response.text == falcon.HTTPMethodNotAllowed([]).to_json()
+        assert response.content == falcon.HTTPMethodNotAllowed([]).to_json()
         assert response.headers['allow'] == 'PUT'
         assert response.headers['x-ping'] == 'pong'
 
@@ -634,7 +629,7 @@ class TestHTTPError:
         response = client.simulate_request(path='/410')
 
         assert response.status == falcon.HTTP_410
-        assert response.text == falcon.HTTPGone().to_json()
+        assert response.content == falcon.HTTPGone().to_json()
         assert response.json == {'title': '410 Gone'}
 
     def test_410_with_body(self, client):
@@ -872,6 +867,36 @@ class TestHTTPError:
         assert response.status == headers['X-Error-Status']
         assert response.json['title'] == headers['X-Error-Status']
 
+    def test_to_json_dumps(self):
+        e = falcon.HTTPError(status=falcon.HTTP_418, title='foo', description='bar')
+        assert e.to_json() == b'{"title": "foo", "description": "bar"}'
+
+        class Handler:
+            def serialize(self, obj, type):
+                assert type == falcon.MEDIA_JSON
+                return b'{"a": "b"}'
+
+        assert e.to_json(Handler()) == b'{"a": "b"}'
+
+    def test_serialize_error_uses_media_handler(self, client):
+        client.app.add_route('/path', NotFoundResource())
+        h = client.app.resp_options.media_handlers[falcon.MEDIA_JSON]
+        h.dumps = lambda x: json.dumps(x).upper()
+        response = client.simulate_request(path='/path')
+
+        assert response.status == falcon.HTTP_404
+        assert response.json == {'TITLE': falcon.HTTP_NOT_FOUND.upper()}
+
+    def test_serialize_no_json_media_handler(self, client):
+        client.app.add_route('/path', NotFoundResource())
+        for h in list(client.app.resp_options.media_handlers):
+            if 'json' in h.casefold():
+                client.app.resp_options.media_handlers.pop(h)
+        response = client.simulate_request(path='/path')
+
+        assert response.status == falcon.HTTP_404
+        assert response.json == {'title': falcon.HTTP_NOT_FOUND}
+
 
 def test_kw_only():
     # only deprecated for now
diff --git a/tests/test_media_handlers.py b/tests/test_media_handlers.py
index b4c9ea8..de292e4 100644
--- a/tests/test_media_handlers.py
+++ b/tests/test_media_handlers.py
@@ -192,10 +192,11 @@ def test_sync_methods_not_overridden(asgi):
 
     app.add_route('/', ResourceAsync() if asgi else Resource())
 
-    result = testing.simulate_get(app, '/')
+    # NOTE(caselit): force serialization in xml, since error.to_json uses the faulty handler
+    result = testing.simulate_get(app, '/', headers={'Accept': 'text/xml'})
     assert result.status_code == 500
 
-    result = testing.simulate_post(app, '/', json={})
+    result = testing.simulate_post(app, '/', json={}, headers={'Accept': 'text/xml'})
     assert result.status_code == 500
 
 
@@ -249,3 +250,48 @@ def test_async_handler_returning_none():
     result = testing.simulate_post(app, '/', json=doc)
     assert result.status_code == 200
     assert result.json == [None]
+
+
+def test_json_err_no_handler(asgi):
+    app = create_app(asgi)
+
+    handlers = media.Handlers({falcon.MEDIA_URLENCODED: media.URLEncodedFormHandler()})
+    app.req_options.media_handlers = handlers
+    app.resp_options.media_handlers = handlers
+
+    class Resource:
+        def on_get(self, req, resp):
+            raise falcon.HTTPForbidden()
+
+    app.add_route('/', Resource())
+
+    result = testing.simulate_get(app, '/')
+    assert result.status_code == 403
+    assert result.json == falcon.HTTPForbidden().to_dict()
+
+
+class TestBaseHandler:
+    def test_defaultError(self):
+        h = media.BaseHandler()
+
+        def test(call):
+            with pytest.raises(NotImplementedError) as e:
+                call()
+
+            assert e.value.args == ()
+
+        test(lambda: h.serialize({}, 'my-type'))
+        test(lambda: h.deserialize('', 'my-type', 0))
+
+    def test_json(self):
+        h = media.BaseHandler()
+
+        with pytest.raises(NotImplementedError, match='The JSON media handler requires'):
+            h.serialize({}, falcon.MEDIA_JSON)
+        with pytest.raises(NotImplementedError, match='The JSON media handler requires'):
+            h.deserialize('', falcon.MEDIA_JSON, 0)
+
+        with pytest.raises(NotImplementedError, match='The JSON media handler requires'):
+            h.serialize({}, falcon.MEDIA_JSON + '; charset=UTF-8')
+        with pytest.raises(NotImplementedError, match='The JSON media handler requires'):
+            h.deserialize('', falcon.MEDIA_JSON + '; charset=UTF-8', 0)
diff --git a/tests/test_middleware.py b/tests/test_middleware.py
index 612af84..9427a6a 100644
--- a/tests/test_middleware.py
+++ b/tests/test_middleware.py
@@ -838,7 +838,7 @@ class TestErrorHandling(TestMiddleware):
         assert response.status == falcon.HTTP_403
         assert mw.resp.status == response.status
 
-        composed_body = json.loads(mw.resp.body)
+        composed_body = json.loads(mw.resp.data.decode())
         assert composed_body['title'] == response.status
 
         assert not mw.req_succeeded
diff --git a/tests/test_query_params.py b/tests/test_query_params.py
index 2d4996a..78284af 100644
--- a/tests/test_query_params.py
+++ b/tests/test_query_params.py
@@ -841,6 +841,24 @@ class TestQueryParams:
         with pytest.raises(HTTPInvalidParam):
             req.get_param_as_json('payload')
 
+    def test_get_param_as_json_handler_json(self, client, resource):
+        client.app.add_route('/', resource)
+        payload_dict = {'foo': 'bar'}
+        query_string = 'payload={}'.format(json.dumps(payload_dict))
+        client.app.req_options.media_handlers[falcon.MEDIA_JSON].loads = lambda x: {'x': 'y'}
+        client.simulate_get(path='/', query_string=query_string)
+        req = resource.captured_req
+        assert req.get_param_as_json('payload') == {'x': 'y'}
+
+    def test_get_param_as_json_no_handler_json(self, client, resource):
+        client.app.add_route('/', resource)
+        payload_dict = {'foo': 'bar'}
+        query_string = 'payload={}'.format(json.dumps(payload_dict))
+        client.app.req_options.media_handlers.pop(falcon.MEDIA_JSON)
+        client.simulate_get(path='/', query_string=query_string)
+        req = resource.captured_req
+        assert req.get_param_as_json('payload') == {'foo': 'bar'}
+
     def test_has_param(self, simulate_request, client, resource):
         client.app.add_route('/', resource)
         query_string = 'ant=1'
diff --git a/tests/test_request_media.py b/tests/test_request_media.py
index 56d2e9f..641d801 100644
--- a/tests/test_request_media.py
+++ b/tests/test_request_media.py
@@ -142,7 +142,7 @@ def test_invalid_json(asgi):
     headers = {'Content-Type': 'application/json'}
     client.simulate_post('/', body=expected_body, headers=headers)
 
-    assert 'Could not parse JSON body' in client.resource.captured_error.value.description
+    assert 'Could not parse JSON' in client.resource.captured_error.value.description
 
 
 def test_invalid_msgpack(asgi):
diff --git a/tests/test_utils.py b/tests/test_utils.py
index cad461d..ec82b8b 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -4,7 +4,9 @@ from datetime import datetime
 import functools
 import http
 import itertools
+import json
 import random
+import sys
 from urllib.parse import quote, unquote_plus
 
 import pytest
@@ -14,7 +16,7 @@ from falcon import media
 from falcon import testing
 from falcon import util
 from falcon.constants import MEDIA_JSON
-from falcon.util import deprecation, json, misc, structures, uri
+from falcon.util import deprecation, misc, structures, uri
 
 from _util import create_app, to_coroutine  # NOQA
 
@@ -1147,3 +1149,12 @@ class TestDeprecatedArgs:
         assert len(recwarn) == 0
         a_function(1, b=2)
         assert len(recwarn) == 1
+
+
+@pytest.mark.skipif(sys.version_info < (3, 7), reason='module __getattr__ requires python 3.7')
+def test_json_deprecation():
+    with pytest.warns(deprecation.DeprecatedWarning, match='json'):
+        util.json
+
+    with pytest.raises(AttributeError):
+        util.some_imaginary_module