Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Only allow users to specify a single location per parse call & pass full location data to schema.load #420

Merged
merged 13 commits into from Jan 3, 2020
Merged
52 changes: 52 additions & 0 deletions CHANGELOG.rst
@@ -1,6 +1,58 @@
Changelog
---------

6.0.0 (unreleased)
******************

Features:

* *Backwards-incompatible*: Schemas will now load all data from a location, not
only data specified by fields. As a result, schemas with validators which
examine the full input data may change in behavior. The `unknown` parameter
on schemas may be used to alter this. For example,
`unknown=marshmallow.EXCLUDE` will produce behavior similar to webargs v5

Bug fixes:

* *Backwards-incompatible*: all parsers now require the Content-Type to be set
correctly when processing JSON request bodies. This impacts ``DjangoParser``,
``FalconParser``, ``FlaskParser``, and ``PyramidParser``

Refactoring:

* *Backwards-incompatible*: Schema fields may not specify a location any
longer, and `Parser.use_args` and `Parser.use_kwargs` now accept `location`
(singular) instead of `locations` (plural). Instead of using a single field or
schema with multiple `locations`, users are recommended to make multiple
calls to `use_args` or `use_kwargs` with a distinct schema per location. For
example, code should be rewritten like this:

.. code-block:: python

# under webargs v5
@parser.use_args(
{
"q1": ma.fields.Int(location="query"),
"q2": ma.fields.Int(location="query"),
"h1": ma.fields.Int(location="headers"),
},
locations=("query", "headers"),
)
def foo(q1, q2, h1):
...


# should be split up like so under webargs v6
@parser.use_args({"q1": ma.fields.Int(), "q2": ma.fields.Int()}, location="query")
@parser.use_args({"h1": ma.fields.Int()}, location="headers")
def foo(q1, q2, h1):
...

* The `location_handler` decorator has been removed and replaced with
`location_loader`. `location_loader` serves the same purpose (letting you
write custom hooks for loading data) but its expected method signature is
different. See the docs on `location_loader` for proper usage.

5.5.2 (2019-10-06)
******************

Expand Down
133 changes: 80 additions & 53 deletions docs/advanced.rst
Expand Up @@ -6,7 +6,7 @@ This section includes guides for advanced usage patterns.
Custom Location Handlers
------------------------

To add your own custom location handler, write a function that receives a request, an argument name, and a :class:`Field <marshmallow.fields.Field>`, then decorate that function with :func:`Parser.location_handler <webargs.core.Parser.location_handler>`.
To add your own custom location handler, write a function that receives a request, and a :class:`Schema <marshmallow.Schema>`, then decorate that function with :func:`Parser.location_loader <webargs.core.Parser.location_loader>`.


.. code-block:: python
Expand All @@ -15,17 +15,78 @@ To add your own custom location handler, write a function that receives a reques
from webargs.flaskparser import parser


@parser.location_handler("data")
def parse_data(request, name, field):
return request.data.get(name)
@parser.location_loader("data")
def load_data(request, schema):
return request.data


# Now 'data' can be specified as a location
@parser.use_args({"per_page": fields.Int()}, locations=("data",))
@parser.use_args({"per_page": fields.Int()}, location="data")
def posts(args):
return "displaying {} posts".format(args["per_page"])


.. 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:


.. code-block:: python

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)


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 Expand Up @@ -64,7 +125,7 @@ When you need more flexibility in defining input schemas, you can pass a marshma


# You can add additional parameters
@use_kwargs({"posts_per_page": fields.Int(missing=10, location="query")})
@use_kwargs({"posts_per_page": fields.Int(missing=10)}, location="query")
@use_args(UserSchema())
def profile_posts(args, posts_per_page):
username = args["username"]
Expand Down Expand Up @@ -211,12 +272,12 @@ Using the :class:`Method <marshmallow.fields.Method>` and :class:`Function <mars
cube = args["cube"]
# ...

.. _custom-parsers:
.. _custom-loaders:

Custom Parsers
--------------

To add your own parser, extend :class:`Parser <webargs.core.Parser>` and implement the `parse_*` method(s) you need to override. For example, here is a custom Flask parser that handles nested query string arguments.
To add your own parser, extend :class:`Parser <webargs.core.Parser>` and implement the `load_*` method(s) you need to override. For example, here is a custom Flask parser that handles nested query string arguments.


.. code-block:: python
Expand Down Expand Up @@ -245,8 +306,8 @@ To add your own parser, extend :class:`Parser <webargs.core.Parser>` and impleme
}
"""

def parse_querystring(self, req, name, field):
return core.get_value(_structure_dict(req.args), name, field)
def load_querystring(self, req, schema):
return _structure_dict(req.args)


def _structure_dict(dict_):
Expand Down Expand Up @@ -309,7 +370,7 @@ For example, you might implement JSON PATCH according to `RFC 6902 <https://tool


@app.route("/profile/", methods=["patch"])
@use_args(PatchSchema(many=True), locations=("json",))
@use_args(PatchSchema(many=True))
def patch_blog(args):
"""Implements JSON Patch for the user profile

Expand All @@ -324,48 +385,14 @@ For example, you might implement JSON PATCH according to `RFC 6902 <https://tool
Mixing Locations
----------------

Arguments for different locations can be specified by passing ``location`` to each field individually:
Arguments for different locations can be specified by passing ``location`` to each `use_args <webargs.core.Parser.use_args>` call:

.. code-block:: python

# "json" is the default, used explicitly below
@app.route("/stacked", methods=["POST"])
@use_args(
{
"page": fields.Int(location="query"),
"q": fields.Str(location="query"),
"name": fields.Str(location="json"),
}
)
def viewfunc(args):
page = args["page"]
# ...

Alternatively, you can pass multiple locations to `use_args <webargs.core.Parser.use_args>`:

.. code-block:: python

@app.route("/stacked", methods=["POST"])
@use_args(
{"page": fields.Int(), "q": fields.Str(), "name": fields.Str()},
locations=("query", "json"),
)
def viewfunc(args):
page = args["page"]
# ...

However, this allows ``page`` and ``q`` to be passed in the request body and ``name`` to be passed as a query parameter.

To restrict the arguments to single locations without having to pass ``location`` to every field, you can call the `use_args <webargs.core.Parser.use_args>` multiple times:

.. code-block:: python

query_args = {"page": fields.Int(), "q": fields.Int()}
json_args = {"name": fields.Str()}


@app.route("/stacked", methods=["POST"])
@use_args(query_args, locations=("query",))
@use_args(json_args, locations=("json",))
@use_args({"page": fields.Int(), "q": fields.Str()}, location="query")
@use_args({"name": fields.Str()}, location="json")
def viewfunc(query_parsed, json_parsed):
page = query_parsed["page"]
name = json_parsed["name"]
Expand All @@ -377,12 +404,12 @@ To reduce boilerplate, you could create shortcuts, like so:

import functools

query = functools.partial(use_args, locations=("query",))
body = functools.partial(use_args, locations=("json",))
query = functools.partial(use_args, location="query")
body = functools.partial(use_args, location="json")


@query(query_args)
@body(json_args)
@query({"page": fields.Int(), "q": fields.Int()})
@body({"name": fields.Str()})
def viewfunc(query_parsed, json_parsed):
page = query_parsed["page"]
name = json_parsed["name"]
Expand Down
18 changes: 9 additions & 9 deletions docs/framework_support.rst
Expand Up @@ -22,9 +22,9 @@ When using the :meth:`use_args <webargs.flaskparser.FlaskParser.use_args>` decor


@app.route("/user/<int:uid>")
@use_args({"per_page": fields.Int()})
@use_args({"per_page": fields.Int()}, location="query")
def user_detail(args, uid):
return ("The user page for user {uid}, " "showing {per_page} posts.").format(
return ("The user page for user {uid}, showing {per_page} posts.").format(
uid=uid, per_page=args["per_page"]
)

Expand Down Expand Up @@ -64,7 +64,7 @@ The `FlaskParser` supports parsing values from a request's ``view_args``.


@app.route("/greeting/<name>/")
@use_args({"name": fields.Str(location="view_args")})
@use_args({"name": fields.Str()}, location="view_args")
def greeting(args, **kwargs):
return "Hello {}".format(args["name"])

Expand Down Expand Up @@ -95,7 +95,7 @@ When using the :meth:`use_args <webargs.djangoparser.DjangoParser.use_args>` dec
}


@use_args(account_args)
@use_args(account_args, location="form")
def login_user(request, args):
if request.method == "POST":
login(args["username"], args["password"])
Expand All @@ -114,7 +114,7 @@ When using the :meth:`use_args <webargs.djangoparser.DjangoParser.use_args>` dec


class BlogPostView(View):
@use_args(blog_args)
@use_args(blog_args, location="query")
def get(self, request, args):
blog_post = Post.objects.get(title__iexact=args["title"], author=args["author"])
return render_to_response("post_template.html", {"post": blog_post})
Expand Down Expand Up @@ -239,7 +239,7 @@ When using the :meth:`use_args <webargs.pyramidparser.PyramidParser.use_args>` d
from webargs.pyramidparser import use_args


@use_args({"uid": fields.Str(), "per_page": fields.Int()})
@use_args({"uid": fields.Str(), "per_page": fields.Int()}, location="query")
def user_detail(request, args):
uid = args["uid"]
return Response(
Expand All @@ -261,7 +261,7 @@ The `PyramidParser` supports parsing values from a request's matchdict.
from webargs.pyramidparser import use_args


@use_args({"mymatch": fields.Int()}, locations=("matchdict",))
@use_args({"mymatch": fields.Int()}, location="matchdict")
def matched(request, args):
return Response("The value for mymatch is {}".format(args["mymatch"]))

Expand Down Expand Up @@ -317,7 +317,7 @@ You can easily implement hooks by using `parser.parse <webargs.falconparser.Falc
return hook


@falcon.before(add_args({"page": fields.Int(location="query")}))
@falcon.before(add_args({"page": fields.Int()}, location="query"))
class AuthorResource:
def on_get(self, req, resp):
args = req.context["args"]
Expand Down Expand Up @@ -414,7 +414,7 @@ The `AIOHTTPParser <webargs.aiohttpparser.AIOHTTPParser>` supports parsing value
from webargs.aiohttpparser import use_args


@parser.use_args({"slug": fields.Str(location="match_info")})
@parser.use_args({"slug": fields.Str()}, location="match_info")
def article_detail(request, args):
return web.Response(body="Slug: {}".format(args["slug"]).encode("utf-8"))

Expand Down
14 changes: 10 additions & 4 deletions docs/index.rst
Expand Up @@ -17,7 +17,7 @@ webargs is a Python library for parsing and validating HTTP request objects, wit


@app.route("/")
@use_args({"name": fields.Str(required=True)})
@use_args({"name": fields.Str(required=True)}, location="query")
def index(args):
return "Hello " + args["name"]

Expand All @@ -28,26 +28,32 @@ webargs is a Python library for parsing and validating HTTP request objects, wit
# curl http://localhost:5000/\?name\='World'
# Hello World

Webargs will automatically parse:
By default Webargs will automatically parse JSON request bodies. But it also
has support for:

**Query Parameters**
::
$ curl http://localhost:5000/\?name\='Freddie'
Hello Freddie

$ curl http://localhost:5000/\?name\='Freddie'
Hello Freddie
# pass location="query" to use_args

**Form Data**
::

$ curl -d 'name=Brian' http://localhost:5000/
Hello Brian

# pass location="form" to use_args

**JSON Data**
::

$ curl -X POST -H "Content-Type: application/json" -d '{"name":"Roger"}' http://localhost:5000/
Hello Roger

# pass location="json" (or omit location) to use_args

and, optionally:

- Headers
Expand Down