webargs icon indicating copy to clipboard operation
webargs copied to clipboard

RFC: Use annotations for parsing arguments

Open sloria opened this issue 5 years ago • 6 comments

Add a decorator that will parse request arguments from the view function's type annotations.

I've built a working proof of concept of this idea in webargs-starlette.

@app.route("/")
@use_annotations(locations=("query",))
async def index(request, name: str = "World"):
    return JSONResponse({"Hello": name})

A marshmallow Schema is generated from the annotations using Schema.TYPE_MAPPING to construct fields.

The code for use_annotations mostly isn't tied to Starlette and could be adapted for AsyncParser (and core.Parser when we drop Python 2 support).

sloria avatar Jan 04 '19 21:01 sloria

I was searching the web looking exactly for a solution to have method signature read by a decorator, parsed from request and fed to the method. This is perfect for when you have a method that you suddenly want to make available on the web (e.g. as a CloudFunction or a Lambda). I would no longer have to change the method to take input from a request object or have to write a redundant declaration of the desired fields when I already have all type hints right there on the method signature.

Hoping to see this in webargs soon.

mbello avatar Dec 18 '19 13:12 mbello

But... I would prefer a different name. Annotations are a different thing.

Maybe @use_type_hints or @args_from_method_signature

mbello avatar Dec 18 '19 13:12 mbello

I keep circling back to this to think about it, but with the same concern. How would such a decorator distinguish between type annotations meant for it, vs ones which are just part of a view function?

In particular, if a view function is already decorated with user-defined decorators, it may be receiving a variety of arguments.

I think that for really simple cases like your example it's very nice. But what if instead of

def index(request, name: str = "World")

it's

@get_authenticated_username  # passes username
@use_annotations(location="query")
def index(request, username: str, name: str = "World")

or something similar?

Would we simply not support such usages? Maybe that's okay, and just has to be documented, or maybe there's a workaround?

sirosen avatar Mar 03 '20 17:03 sirosen

I was just looking at webargs-starlette for inspiration, thinking about whether or not we could do this as part of v7 (and whether there are any backwards-incompatible changes we'd like to make for it, which would make now a good time for it).

I think my above concern is basically a non-issue. First because nothing would force people to use such a feature, but also because we could easily add an optional exclude list to the annotations helper. My above case can then be solved simply with @use_annotations(location="query", exclude=["username"]).

sirosen avatar Sep 11 '20 16:09 sirosen

Not sure how active this undertaking is, but usability could be improved even further by using PEP593 typing.Annotated[…] objects, which have been included in python 3.9.

For instance, sqlalchemy uses them for its dataclass integration to augment to allow for more fine-grained configuration than what you can do with a type map.

lukasjuhrich avatar Mar 26 '23 07:03 lukasjuhrich

To elaborate, the objective is that passing e.g. fields.String as the data type breaks type checking, because clearly we get a string, not an instance of the field type, at runtime.

For instance, the following snippet with vanilla webargs has type hints correctly reflecting the types possible at runtime:

@bp.route('/accounts/<int:account_id>/json')
@use_kwargs({
    "style": fields.String(),
    "limit": fields.Int(),
    "offset": fields.Int(),
    "sort_by": fields.String(data_key="sort", missing="valid_on"),
    "sort_order": fields.String(data_key="order", missing="desc"),
    "search": fields.String(),
    "splitted": fields.Bool(missing=False),
}, location="query")
def accounts_show_json(
    account_id: int,
    *,
    style: str | None = None,
    limit: int | None = None,
    offset: int | None = None,
    sort_by: str,
    sort_order: str,
    search: str | None = None,
    splitted: bool,
): ...

However, clearly the information here is rendundant, so inferring the type information from the annotations is desirable.

Now with webargs-starlette, in my understanding the code would have to look like this:

@bp.route('/accounts/<int:account_id>/json')
@use_annotations(location="query", exclude={"account_id"})
def accounts_show_json(
    account_id: int,
    *,
    style: str | None = None,
    limit: int | None = None,
    offset: int | None = None,
    sort_by: fields.String(data_key="sort") = "valid_on",
    sort_order: fields.String(data_key="order") = "desc",
    search: str | None = None,
    splitted: bool = False,
):
    ...

However, as mentioned the above would not type check. PEP593 Annotated to the rescue:

import typing as t

@bp.route('/accounts/<int:account_id>/json')
@use_annotations(location="query", exclude={"account_id"})
def accounts_show_json(
    account_id: int,
    *,
    style: str | None = None,
    limit: int | None = None,
    offset: int | None = None,
    sort_by: t.Annotated[str, fields.String(data_key="sort")] = "valid_on",
    sort_order: t.Annotated[str, fields.String(data_key="order")] = "desc",
    search: str | None = None,
    splitted: bool = False,
):
    ...

Note that in the example, I expect default values to be incorporated in the field's missing= attribute, even if I give a field directly. I did not check whether webargs-starlette supports this.

lukasjuhrich avatar Mar 26 '23 07:03 lukasjuhrich