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)
```


Discussion:
I'm open to this, though it might make Falcon's parsing inconsistent with the other parsers. But perhaps that's OK, since Falcon users may expect `req.media` to be used.

I'd certainly review/merge a PR.
I'm looking at this as something we can clean out of the backlog.
After 6.0, we now are supporting falcon 2.x only, so I think it's quite easy.

I'd rather define `media` as a distinct location. So you could use a falcon parser with `location="media"` and it will do basically what was proposed above (modified suitably to fit with webargs v6).

We could even add a note for webargs 7.0 to upgrade to have `FalconParser.DEFAULT_LOCATION = "media"`, which would tweak the default. Everything remains consistent that way, and we don't muddle `json` with the `media` behavior. I don't know that I'd like to make that change in 6.x -- it feels too breaky for too specific of a niche for me.

If someone submitted a PR with that, I'd happily review, but I'll try to remember to get to it at some point soon.
@lafrech, I haven't looked at this in a while, but I think maybe we should do it as part of 7.0. I'm adding it to the milestone and we can take it out if desired.

Falcon is the framework I'm probably least familiar with, so I'll want to read up on this feature again to make sure I understand what `req.media` does/is.

Resolving patch:
diff --git a/src/webargs/falconparser.py b/src/webargs/falconparser.py
--- 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 @@ def to_dict(self, *args, **kwargs):
 
 
 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 @@ def load_form(self, req, schema):
             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
--- a/src/webargs/testing.py
+++ b/src/webargs/testing.py
@@ -62,9 +62,6 @@ def test_parse_json_or_form(self, testapp):
     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
--- a/tests/apps/falcon_app.py
+++ b/tests/apps/falcon_app.py
@@ -37,6 +37,12 @@ def on_post(self, req, resp):
         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
--- a/tests/test_falconparser.py
+++ b/tests/test_falconparser.py
@@ -16,28 +16,47 @@ def test_parse_files(self, testapp):
     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):