Repository: marshmallow-code/webargs

Issue:
FalconParser should ideally support falcon's native media decoding
Falcon has a native media handling mechanism which can decode an incoming request body based on the `Content-Type` header and adding the dictionary of resulting key-value pairs as a cached property `req.media`. I've written my own FalconParser subclass that (very naively) uses this, but it seems like something that might be worth supporting out of the box.

```python
    def parse_json(self, req, name, field):
        """
        Pull a JSON body value from the request.
        uses falcon's native req.media
        """
        json_data = self._cache.get("json_data")
        if json_data is None:
            self._cache["json_data"] = json_data = req.media
        return core.get_value(json_data, name, field, allow_many_nested=True)
```

This could probably be improved upon; since the `media` property  is already cached on the request object, we could just access `req.media` directly without caching on the parser. (Not sure if this impacts other things that might use that cache, though; I haven't dug deep enough to fully understand that implication.) Also, since `media` was added in 1.3, if webargs still wanted to support older versions of falcon we could add a check for it and fall back to the existing behavior.

Maybe something like:

```python
    def parse_json(self, req, name, field):
        """Pull a JSON body value from the request.
        .. note::
            The request stream will be read and left at EOF.
        """
        json_data = req.media if hasattr(req, 'media') else self._cache.get("json_data")
        if json_data is None:
            self._cache["json_data"] = json_data = parse_json_body(req)
        return core.get_value(json_data, name, field, allow_many_nested=True)
```

Comments:

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index d0778f5..310c851 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -3,7 +3,7 @@ repos:
   rev: v2.7.3
   hooks:
   - id: pyupgrade
-    args: ["--py36-plus", "--keep-mock"]
+    args: ["--py36-plus"]
 - repo: https://github.com/psf/black
   rev: 20.8b1
   hooks:
diff --git a/src/webargs/falconparser.py b/src/webargs/falconparser.py
index 5b4a21f..d2eb448 100644
--- a/src/webargs/falconparser.py
+++ b/src/webargs/falconparser.py
@@ -3,6 +3,8 @@
 import falcon
 from falcon.util.uri import parse_query_string
 
+import marshmallow as ma
+
 from webargs import core
 from webargs.multidictproxy import MultiDictProxy
 
@@ -69,7 +71,21 @@ class HTTPError(falcon.HTTPError):
 
 
 class FalconParser(core.Parser):
-    """Falcon request argument parser."""
+    """Falcon request argument parser.
+
+    Defaults to using the `media` location. See :py:meth:`~FalconParser.load_media` for
+    details on the media location."""
+
+    # by default, Falcon will use the 'media' location to load data
+    #
+    # this effectively looks the same as loading JSON data by default, but if
+    # you add a handler for a different media type to Falcon, webargs will
+    # automatically pick up on that capability
+    DEFAULT_LOCATION = "media"
+    DEFAULT_UNKNOWN_BY_LOCATION = dict(
+        media=ma.RAISE, **core.Parser.DEFAULT_UNKNOWN_BY_LOCATION
+    )
+    __location_map__ = dict(media="load_media", **core.Parser.__location_map__)
 
     # Note on the use of MultiDictProxy throughout:
     # Falcon parses query strings and form values into ordinary dicts, but with
@@ -95,6 +111,25 @@ class FalconParser(core.Parser):
             return form
         return MultiDictProxy(form, schema)
 
+    def load_media(self, req, schema):
+        """Return data unpacked and parsed by one of Falcon's media handlers.
+        By default, Falcon only handles JSON payloads.
+
+        To configure additional media handlers, see the
+        `Falcon documentation on media types`__.
+
+        .. _FalconMedia: https://falcon.readthedocs.io/en/stable/api/media.html
+        __ FalconMedia_
+
+        .. note::
+
+            The request stream will be read and left at EOF.
+        """
+        # if there is no body, return missing instead of erroring
+        if req.content_length in (None, 0):
+            return core.missing
+        return req.media
+
     def _raw_load_json(self, req):
         """Return a json payload from the request for the core parser's load_json
 


Test patch:
diff --git a/src/webargs/testing.py b/src/webargs/testing.py
index ca04040..23bf918 100644
--- a/src/webargs/testing.py
+++ b/src/webargs/testing.py
@@ -62,9 +62,6 @@ class CommonTestCase:
     def test_parse_querystring_default(self, testapp):
         assert testapp.get("/echo").json == {"name": "World"}
 
-    def test_parse_json_default(self, testapp):
-        assert testapp.post_json("/echo_json", {}).json == {"name": "World"}
-
     def test_parse_json_with_charset(self, testapp):
         res = testapp.post(
             "/echo_json",
diff --git a/tests/apps/falcon_app.py b/tests/apps/falcon_app.py
index 314a35a..cb22529 100644
--- a/tests/apps/falcon_app.py
+++ b/tests/apps/falcon_app.py
@@ -37,6 +37,12 @@ class EchoJSON:
         resp.body = json.dumps(parsed)
 
 
+class EchoMedia:
+    def on_post(self, req, resp):
+        parsed = parser.parse(hello_args, req, location="media")
+        resp.body = json.dumps(parsed)
+
+
 class EchoJSONOrForm:
     def on_post(self, req, resp):
         parsed = parser.parse(hello_args, req, location="json_or_form")
@@ -161,6 +167,7 @@ def create_app():
     app.add_route("/echo", Echo())
     app.add_route("/echo_form", EchoForm())
     app.add_route("/echo_json", EchoJSON())
+    app.add_route("/echo_media", EchoMedia())
     app.add_route("/echo_json_or_form", EchoJSONOrForm())
     app.add_route("/echo_use_args", EchoUseArgs())
     app.add_route("/echo_use_kwargs", EchoUseKwargs())
diff --git a/tests/test_falconparser.py b/tests/test_falconparser.py
index 860c132..4f65313 100644
--- a/tests/test_falconparser.py
+++ b/tests/test_falconparser.py
@@ -16,28 +16,47 @@ class TestFalconParser(CommonTestCase):
     def test_use_args_hook(self, testapp):
         assert testapp.get("/echo_use_args_hook?name=Fred").json == {"name": "Fred"}
 
+    def test_parse_media(self, testapp):
+        assert testapp.post_json("/echo_media", {"name": "Fred"}).json == {
+            "name": "Fred"
+        }
+
+    def test_parse_media_missing(self, testapp):
+        assert testapp.post("/echo_media", "").json == {"name": "World"}
+
+    def test_parse_media_empty(self, testapp):
+        assert testapp.post_json("/echo_media", {}).json == {"name": "World"}
+
+    def test_parse_media_error_unexpected_int(self, testapp):
+        res = testapp.post_json("/echo_media", 1, expect_errors=True)
+        assert res.status_code == 422
+
     # https://github.com/marshmallow-code/webargs/issues/427
-    def test_parse_json_with_nonutf8_chars(self, testapp):
+    @pytest.mark.parametrize("path", ["/echo_json", "/echo_media"])
+    def test_parse_json_with_nonutf8_chars(self, testapp, path):
         res = testapp.post(
-            "/echo_json",
+            path,
             b"\xfe",
             headers={"Accept": "application/json", "Content-Type": "application/json"},
             expect_errors=True,
         )
 
         assert res.status_code == 400
-        assert res.json["errors"] == {"json": ["Invalid JSON body."]}
+        if path.endswith("json"):
+            assert res.json["errors"] == {"json": ["Invalid JSON body."]}
 
     # https://github.com/sloria/webargs/issues/329
-    def test_invalid_json(self, testapp):
+    @pytest.mark.parametrize("path", ["/echo_json", "/echo_media"])
+    def test_invalid_json(self, testapp, path):
         res = testapp.post(
-            "/echo_json",
+            path,
             '{"foo": "bar", }',
             headers={"Accept": "application/json", "Content-Type": "application/json"},
             expect_errors=True,
         )
         assert res.status_code == 400
-        assert res.json["errors"] == {"json": ["Invalid JSON body."]}
+        if path.endswith("json"):
+            assert res.json["errors"] == {"json": ["Invalid JSON body."]}
 
     # Falcon converts headers to all-caps
     def test_parsing_headers(self, testapp):