Skip to content

Commit

Permalink
Add the 'json_or_form' location + docs
Browse files Browse the repository at this point in the history
'json_or_form' is defined as first trying to load the JSON data, then
falling back to form data.

Adds a test to the testsuite for this which tries sending JSON, tries
sending Form, and tries sending no data, verifying that we get the
correct result in each case.

This does *not* try to support more elaborate behaviors, e.g. multipart
bodies, which probably will work variably well or poorly based on the
framework in use. In these cases, the content type will not detect as
JSON (because it is 'multipart'), and we'll failover to using form data.
This is acceptable, since anyone using `json_or_form` is expecting to
allow form posts anyway.

Docs cover not only the new json_or_form location, but also the *idea*
of a "meta location" which combines multiple locations. This is worth
documenting in the advanced docs since it is the way to get back certain
behaviors from webargs v5.x
  • Loading branch information
sirosen committed Oct 2, 2019
1 parent bf34ddf commit 1d352d2
Show file tree
Hide file tree
Showing 11 changed files with 117 additions and 1 deletion.
42 changes: 41 additions & 1 deletion docs/advanced.rst
Expand Up @@ -26,14 +26,54 @@ To add your own custom location handler, write a function that receives a reques
return "displaying {} posts".format(args["per_page"])
.. NOTE::
.. NOTE::

The schema is passed so that it can be used to wrap multidict types and
unpack List fields correctly. If you are writing a loader for a multidict
type, consider looking at
:class:`MultiDictProxy <webargs.multidictproxy.MultiDictProxy>` for an
example of how to do this.

"meta" Locations
~~~~~~~~~~~~~~~~

You can define your own locations which mix data from several existing
locations.

The `json_or_form` location does this -- first trying to load data as JSON and
then falling back to a form body -- and its implementation is quite simple:

.. autofunction:: webargs.core.Parser.load_json_or_form


You can imagine your own locations with custom behaviors like this.
For example, to mix query parameters and form body data, you might write the
following:

.. code-block:: python
from webargs import fields
from webargs.multidictproxy import MultiDictProxy
from webargs.flaskparser import parser
@parser.location_loader("query_and_form")
def load_data(request, schema):
# relies on the Flask (werkzeug) MultiDict type's implementation of
# these methods, but when you're extending webargs, you may know things
# about your framework of choice
newdata = request.args.copy()
newdata.update(request.form)
return MultiDictProxy(newdata, schema)
# Now 'query_and_form' means you can send these values in either location,
# and they will be *mixed* together into a new dict to pass to your schema
@parser.use_args({"favorite_food": fields.String()}, location="query_and_form")
def set_favorite_food(args):
... # do stuff
return "your favorite food is now set to {}".format(args["favorite_food"])
marshmallow Integration
-----------------------

Expand Down
8 changes: 8 additions & 0 deletions src/webargs/aiohttpparser.py
Expand Up @@ -89,6 +89,14 @@ async def load_form(self, req: Request, schema: Schema) -> MultiDictProxy:
self._cache["post"] = await req.post()
return MultiDictProxy(self._cache["post"], schema)

async def load_json_or_form(
self, req: Request, schema: Schema
) -> typing.Union[typing.Dict, MultiDictProxy]:
data = await self.load_json(req, schema)
if data is not core.missing:
return data
return await self.load_form(req, schema)

async def load_json(self, req: Request, schema: Schema) -> typing.Dict:
"""Return a parsed json payload from the request."""
json_data = self._cache.get("json")
Expand Down
13 changes: 13 additions & 0 deletions src/webargs/core.py
Expand Up @@ -124,6 +124,7 @@ class Parser(object):
"headers": "load_headers",
"cookies": "load_cookies",
"files": "load_files",
"json_or_form": "load_json_or_form",
}

def __init__(self, location=None, error_handler=None, schema_class=None):
Expand Down Expand Up @@ -473,6 +474,18 @@ def load_json(self, req, schema):

return json_data

def load_json_or_form(self, req, schema):
"""Load data from a request, accepting either JSON or form-encoded
data.
The data will first be loaded as JSON, and, if that fails, it will be
loaded as a form post.
"""
data = self.load_json(req, schema)
if data is not missing:
return data
return self.load_form(req, schema)

# Abstract Methods

def _raw_load_json(self, req):
Expand Down
9 changes: 9 additions & 0 deletions src/webargs/testing.py
Expand Up @@ -51,6 +51,15 @@ def test_parse_json(self, testapp):
def test_parse_json_missing(self, testapp):
assert testapp.post("/echo_json", "").json == {"name": "World"}

def test_parse_json_or_form(self, testapp):
assert testapp.post_json("/echo_json_or_form", {"name": "Fred"}).json == {
"name": "Fred"
}
assert testapp.post("/echo_json_or_form", {"name": "Joe"}).json == {
"name": "Joe"
}
assert testapp.post("/echo_json_or_form", "").json == {"name": "World"}

def test_parse_querystring_default(self, testapp):
assert testapp.get("/echo").json == {"name": "World"}

Expand Down
12 changes: 12 additions & 0 deletions tests/apps/aiohttp_app.py
Expand Up @@ -55,6 +55,17 @@ async def echo_json(request):
return json_response(parsed)


async def echo_json_or_form(request):
try:
parsed = await parser.parse(hello_args, request, location="json_or_form")
except json.JSONDecodeError:
raise web.HTTPBadRequest(
body=json.dumps(["Invalid JSON."]).encode("utf-8"),
content_type="application/json",
)
return json_response(parsed)


@use_args(hello_args, location="query")
async def echo_use_args(request, args):
return json_response(args)
Expand Down Expand Up @@ -200,6 +211,7 @@ def create_app():
add_route(app, ["GET"], "/echo", echo)
add_route(app, ["POST"], "/echo_form", echo_form)
add_route(app, ["POST"], "/echo_json", echo_json)
add_route(app, ["POST"], "/echo_json_or_form", echo_json_or_form)
add_route(app, ["GET"], "/echo_use_args", echo_use_args)
add_route(app, ["GET"], "/echo_use_kwargs", echo_use_kwargs)
add_route(app, ["POST"], "/echo_use_args_validated", echo_use_args_validated)
Expand Down
5 changes: 5 additions & 0 deletions tests/apps/bottle_app.py
Expand Up @@ -44,6 +44,11 @@ def echo_json():
return parser.parse(hello_args)


@app.route("/echo_json_or_form", method=["POST"])
def echo_json_or_form():
return parser.parse(hello_args, location="json_or_form")


@app.route("/echo_use_args", method=["GET"])
@use_args(hello_args, location="query")
def echo_use_args(args):
Expand Down
1 change: 1 addition & 0 deletions tests/apps/django_app/base/urls.py
Expand Up @@ -7,6 +7,7 @@
url(r"^echo$", views.echo),
url(r"^echo_form$", views.echo_form),
url(r"^echo_json$", views.echo_json),
url(r"^echo_json_or_form$", views.echo_json_or_form),
url(r"^echo_use_args$", views.echo_use_args),
url(r"^echo_use_args_validated$", views.echo_use_args_validated),
url(r"^echo_ignoring_extra_data$", views.echo_ignoring_extra_data),
Expand Down
5 changes: 5 additions & 0 deletions tests/apps/django_app/echo/views.py
Expand Up @@ -57,6 +57,11 @@ def echo_json(request):
return json_response(parser.parse(hello_args, request))


@handle_view_errors
def echo_json_or_form(request):
return json_response(parser.parse(hello_args, request, location="json_or_form"))


@handle_view_errors
@use_args(hello_args, location="query")
def echo_use_args(request, args):
Expand Down
7 changes: 7 additions & 0 deletions tests/apps/falcon_app.py
Expand Up @@ -40,6 +40,12 @@ def on_post(self, req, resp):
resp.body = json.dumps(parsed)


class EchoJSONOrForm(object):
def on_post(self, req, resp):
parsed = parser.parse(hello_args, req, location="json_or_form")
resp.body = json.dumps(parsed)


class EchoUseArgs(object):
@use_args(hello_args, location="query")
def on_get(self, req, resp, args):
Expand Down Expand Up @@ -160,6 +166,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_json_or_form", EchoJSONOrForm())
app.add_route("/echo_use_args", EchoUseArgs())
app.add_route("/echo_use_kwargs", EchoUseKwargs())
app.add_route("/echo_use_args_validated", EchoUseArgsValidated())
Expand Down
5 changes: 5 additions & 0 deletions tests/apps/flask_app.py
Expand Up @@ -48,6 +48,11 @@ def echo_json():
return J(parser.parse(hello_args))


@app.route("/echo_json_or_form", methods=["POST"])
def echo_json_or_form():
return J(parser.parse(hello_args, location="json_or_form"))


@app.route("/echo_use_args", methods=["GET"])
@use_args(hello_args, location="query")
def echo_use_args(args):
Expand Down
11 changes: 11 additions & 0 deletions tests/apps/pyramid_app.py
Expand Up @@ -44,6 +44,16 @@ def echo_json(request):
raise error


def echo_json_or_form(request):
try:
return parser.parse(hello_args, request, location="json_or_form")
except json.JSONDecodeError:
error = HTTPBadRequest()
error.body = json.dumps(["Invalid JSON."]).encode("utf-8")
error.content_type = "application/json"
raise error


def echo_json_ignore_extra_data(request):
try:
return parser.parse(hello_exclude_schema, request)
Expand Down Expand Up @@ -163,6 +173,7 @@ def create_app():
add_route(config, "/echo", echo)
add_route(config, "/echo_form", echo_form)
add_route(config, "/echo_json", echo_json)
add_route(config, "/echo_json_or_form", echo_json_or_form)
add_route(config, "/echo_query", echo_query)
add_route(config, "/echo_ignoring_extra_data", echo_json_ignore_extra_data)
add_route(config, "/echo_use_args", echo_use_args)
Expand Down

0 comments on commit 1d352d2

Please sign in to comment.