Skip to content

Commit

Permalink
Merge pull request #420 from sirosen/single-location
Browse files Browse the repository at this point in the history
Only allow users to specify a single location per parse call & pass full location data to schema.load
  • Loading branch information
lafrech committed Jan 3, 2020
2 parents f862eb3 + 007ad84 commit df66fe8
Show file tree
Hide file tree
Showing 33 changed files with 1,598 additions and 1,259 deletions.
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

0 comments on commit df66fe8

Please sign in to comment.