Repository: falconry/falcon

Issue:
Custom media handlers: Unexpected issue when providing custom json handler
This is in falcon-2.0

Look at the documentation [here][1] for using rapidjson for encoding/decoding json. By providing:

`extra_handlers={'application/json': json_handler}` we are still left with the default handler for content-type `application-json; charset=UTF-8`. This results in an unexpected behaviour when some client library (e.g. Retrofit for Android) includes the charset in the header. 

While the documentation should be updated, the expected behaviour is that if the handler for `application/json` is updated - it should also update the handler for variant with charset (or at least throw a warning) otherwise there is a possibility of hidden bugs. 

[1]: https://falcon.readthedocs.io/en/stable/api/media.html

Comments:

Patch:
diff --git a/docs/_newsfragments/1717.newandimproved.rst b/docs/_newsfragments/1717.newandimproved.rst
new file mode 100644
index 0000000..eec51fa
--- /dev/null
+++ b/docs/_newsfragments/1717.newandimproved.rst
@@ -0,0 +1,5 @@
+:class:`Default media handlers <falcon.media.Handlers>` were simplified by
+removing a separate handler for the now-obsolete
+``application/json; charset=UTF-8``.
+As a result, providing a custom JSON media handler will now unambiguously cover
+both ``application/json`` and the former ``Content-type``.
diff --git a/falcon/media/handlers.py b/falcon/media/handlers.py
index 1ce1c25..ba15989 100644
--- a/falcon/media/handlers.py
+++ b/falcon/media/handlers.py
@@ -1,7 +1,7 @@
 from collections import UserDict
 
 from falcon import errors
-from falcon.constants import MEDIA_MULTIPART, MEDIA_URLENCODED
+from falcon.constants import MEDIA_JSON, MEDIA_MULTIPART, MEDIA_URLENCODED
 from falcon.media.json import JSONHandler
 from falcon.media.multipart import MultipartFormHandler, MultipartParseOptions
 from falcon.media.urlencoded import URLEncodedFormHandler
@@ -12,8 +12,7 @@ class Handlers(UserDict):
     """A :class:`dict`-like object that manages Internet media type handlers."""
     def __init__(self, initial=None):
         handlers = initial or {
-            'application/json': JSONHandler(),
-            'application/json; charset=UTF-8': JSONHandler(),
+            MEDIA_JSON: JSONHandler(),
             MEDIA_MULTIPART: MultipartFormHandler(),
             MEDIA_URLENCODED: URLEncodedFormHandler(),
         }
@@ -60,7 +59,6 @@ class Handlers(UserDict):
 
 # NOTE(vytas): An ugly way to work around circular imports.
 MultipartParseOptions._DEFAULT_HANDLERS = Handlers({
-    'application/json': JSONHandler(),
-    'application/json; charset=UTF-8': JSONHandler(),
+    MEDIA_JSON: JSONHandler(),
     MEDIA_URLENCODED: URLEncodedFormHandler(),
 })  # type: ignore


Test patch:
diff --git a/tests/test_utils.py b/tests/test_utils.py
index af4b5cb..cad461d 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -10,8 +10,10 @@ from urllib.parse import quote, unquote_plus
 import pytest
 
 import falcon
+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 _util import create_app, to_coroutine  # NOQA
@@ -874,6 +876,81 @@ class TestFalconTestingUtils:
         assert result.status_code == 200
         assert result.text == 'test'
 
+    @pytest.mark.parametrize('content_type', [
+        'application/json',
+        'application/json; charset=UTF-8',
+        'application/yaml',
+    ])
+    def test_simulate_content_type(self, content_type):
+        class MediaMirror():
+            def on_post(self, req, resp):
+                resp.media = req.media
+
+        app = create_app(asgi=False)
+        app.add_route('/', MediaMirror())
+
+        client = testing.TestClient(app)
+        headers = {'Content-Type': content_type}
+        payload = b'{"hello": "world"}'
+
+        resp = client.simulate_post('/', headers=headers, body=payload)
+
+        if MEDIA_JSON in content_type:
+            assert resp.status_code == 200
+            assert resp.json == {'hello': 'world'}
+        else:
+            # JSON handler should not have been called for YAML
+            assert resp.status_code == 415
+
+    @pytest.mark.parametrize('content_type', [
+        'application/json',
+        'application/json; charset=UTF-8',
+        'application/yaml'
+    ])
+    def test_simulate_content_type_extra_handler(self, asgi, content_type):
+        class TrackingJSONHandler(media.JSONHandler):
+            def __init__(self):
+                super().__init__()
+                self.deserialize_count = 0
+
+            def deserialize(self, *args, **kwargs):
+                result = super().deserialize(*args, **kwargs)
+                self.deserialize_count += 1
+                return result
+
+            async def deserialize_async(self, *args, **kwargs):
+                result = await super().deserialize_async(*args, **kwargs)
+                self.deserialize_count += 1
+                return result
+
+        resource = testing.SimpleTestResourceAsync() if asgi else testing.SimpleTestResource()
+        app = create_app(asgi)
+        app.add_route('/', resource)
+
+        handler = TrackingJSONHandler()
+        extra_handlers = {'application/json': handler}
+        app.req_options.media_handlers.update(extra_handlers)
+        app.resp_options.media_handlers.update(extra_handlers)
+
+        client = testing.TestClient(app)
+        headers = {
+            'Content-Type': content_type,
+            'capture-req-media': 'y',
+        }
+        payload = b'{"hello": "world"}'
+        resp = client.simulate_post('/', headers=headers, body=payload)
+
+        if MEDIA_JSON in content_type:
+            # Test that our custom deserializer was called
+            assert handler.deserialize_count == 1
+            assert resource.captured_req_media == {'hello': 'world'}
+            assert resp.status_code == 200
+        else:
+            # YAML should not get handled
+            assert handler.deserialize_count == 0
+            assert resource.captured_req_media is None
+            assert resp.status_code == 415
+
 
 class TestNoApiClass(testing.TestCase):
     def test_something(self):