diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..4d0125f --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +open_collective: "marshmallow" +tidelift: "pypi/webargs" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5570044..e579c22 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,22 +1,29 @@ repos: -- repo: https://github.com/ambv/black - rev: 18.9b0 +- repo: https://github.com/asottile/pyupgrade + rev: v1.26.0 + hooks: + - id: pyupgrade + args: ["--py3-plus"] +- repo: https://github.com/psf/black + rev: 19.10b0 hooks: - id: black - language_version: python3.6 + args: ["--target-version", "py35"] + language_version: python3 - repo: https://gitlab.com/pycqa/flake8 - rev: 3.7.4 + rev: 3.7.9 hooks: - id: flake8 - additional_dependencies: ["flake8-bugbear==18.8.0"] + additional_dependencies: [flake8-bugbear==20.1.0] - repo: https://github.com/asottile/blacken-docs - rev: v0.3.0 + rev: v1.5.0-1 hooks: - id: blacken-docs - additional_dependencies: [black==18.9b0] + additional_dependencies: [black==19.10b0] + args: ["--target-version", "py35"] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.660 + rev: v0.761 hooks: - id: mypy - language_version: python3.6 - files: ^webargs/ + language_version: python3 + files: ^src/webargs/ diff --git a/.readthedocs.yml b/.readthedocs.yml index 14d2f0e..75bf21b 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,5 +1,11 @@ -build: - image: latest +version: 2 +sphinx: + configuration: docs/conf.py +formats: all python: - version: 3.6 - pip_install: true + version: 3.7 + install: + - method: pip + path: . + extra_requirements: + - docs diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 2d19f69..0000000 --- a/.travis.yml +++ /dev/null @@ -1,51 +0,0 @@ -language: python -cache: pip -install: travis_retry pip install -U tox -script: tox -jobs: - fast_finish: true - - include: - - { python: '3.6', env: TOXENV=lint } - - - { python: '2.7', env: TOXENV=py27-marshmallow2 } - - { python: '2.7', env: TOXENV=py27-marshmallow3 } - - - { python: '3.5', env: TOXENV=py35-marshmallow2 } - - { python: '3.5', env: TOXENV=py35-marshmallow3 } - - - { python: '3.6', env: TOXENV=py36-marshmallow2 } - - { python: '3.6', env: TOXENV=py36-marshmallow3 } - - - { python: '3.7', env: TOXENV=py37-marshmallow2, dist: xenial } - - { python: '3.7', env: TOXENV=py37-marshmallow3, dist: xenial } - - - { python: '3.6', env: TOXENV=docs } - - - stage: PyPI Release - if: tag IS present - python: "3.6" - install: skip - script: skip - deploy: - provider: pypi - user: sloria - on: - tags: true - distributions: sdist bdist_wheel - password: - secure: TMeTi5OPl2cYsl5hNP4w1xESd2vQUOy8NgZ0c3KbrVSSeBuUCGOKyYJZNGzD9KDMucCvYFuxCwYiDxP8tB5iT85z3rhdVkzppZTy3/3kXMODjlhMzqTnCdJSOoZZ+D5/Y3Zqb8QxU78NggPutfX4bbUU/wNsVbdODXWHe5y2q3k= - - - stage: PyPI Release - if: tag IS present - python: "2.7" - install: skip - script: skip - deploy: - provider: pypi - user: sloria - on: - tags: true - distributions: bdist_wheel - password: - secure: TMeTi5OPl2cYsl5hNP4w1xESd2vQUOy8NgZ0c3KbrVSSeBuUCGOKyYJZNGzD9KDMucCvYFuxCwYiDxP8tB5iT85z3rhdVkzppZTy3/3kXMODjlhMzqTnCdJSOoZZ+D5/Y3Zqb8QxU78NggPutfX4bbUU/wNsVbdODXWHe5y2q3k= diff --git a/AUTHORS.rst b/AUTHORS.rst index f3ac266..8cde5c4 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -5,37 +5,48 @@ Lead ---- -* Steven Loria -* Jérôme Lafréchoux +* Steven Loria `@sloria `_ +* Jérôme Lafréchoux `@lafrech `_ Contributors (chronological) ---------------------------- -* @venuatu -* Javier Santacruz @jvrsantacruz -* Josh Carp -* @philtay -* Andriy Yurchuk -* Stas Sușcov -* Josh Johnston -* Rory Hart -* Jace Browning -* @marcellarius -* Damian Heard -* Daniel Imhoff -* immerrr -* Brett Higgins -* Vlad Frolov -* Tuukka Mustonen -* Francois-Xavier Darveau -* Jérôme Lafréchoux -* @DmitriyS -* Svetlozar Argirov -* Florian S. -* @daniel98321 -* @Itayazolay -* @Reskov -* @cedzz -* F. Moukayed (כוכב) -* Xiaoyu Lee -* Jonathan Angelo +* Steven Manuatu `@venuatu `_ +* Javier Santacruz `@jvrsantacruz `_ +* Josh Carp `@jmcarp `_ +* `@philtay `_ +* Andriy Yurchuk `@Ch00k `_ +* Stas Sușcov `@stas `_ +* Josh Johnston `@Trii `_ +* Rory Hart `@hartror `_ +* Jace Browning `@jacebrowning `_ +* marcellarius `@marcellarius `_ +* Damian Heard `@DamianHeard `_ +* Daniel Imhoff `@dwieeb `_ +* `@immerrr `_ +* Brett Higgins `@brettdh `_ +* Vlad Frolov `@frol `_ +* Tuukka Mustonen `@tuukkamustonen `_ +* Francois-Xavier Darveau `@EFF `_ +* Jérôme Lafréchoux `@lafrech `_ +* `@DmitriyS `_ +* Svetlozar Argirov `@zaro `_ +* Florian S. `@nebularazer `_ +* `@daniel98321 `_ +* `@Itayazolay `_ +* `@Reskov `_ +* `@cedzz `_ +* F. Moukayed (כוכב) `@kochab `_ +* Xiaoyu Lee `@lee3164 `_ +* Jonathan Angelo `@jangelo `_ +* `@zhenhua32 `_ +* Martin Roy `@lindycoder `_ +* Kubilay Kocak `@koobs `_ +* Stephen Rosen `@sirosen `_ +* `@dodumosu `_ +* Nate Dellinger `@Nateyo `_ +* Karthikeyan Singaravelan `@tirkarthi `_ +* Sami Salonen `@suola `_ +* Tim Gates `@timgates42 `_ +* Lefteris Karapetsas `@lefterisjp `_ +* Utku Gultopu `@ugultopu `_ diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a6bbb4a..f72d2b0 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,5 +1,243 @@ Changelog --------- + +6.1.0 (2020-04-05) +****************** + +Features: + +* Add ``fields.DelimitedTuple`` when using marshmallow 3. This behaves as a + combination of ``fields.DelimitedList`` and ``marshmallow.fields.Tuple``. It + takes an iterable of fields, plus a delimiter (defaults to ``,``), and parses + delimiter-separated strings into tuples. (:pr:`509`) + +* Add ``__str__`` and ``__repr__`` to MultiDictProxy to make it easier to work + with (:pr:`488`) + +Support: + +* Various docs updates (:pr:`482`, :pr:`486`, :pr:`489`, :pr:`498`, :pr:`508`). + Thanks :user:`lefterisjp`, :user:`timgates42`, and :user:`ugultopu` for the PRs. + + +6.0.0 (2020-02-27) +****************** + +Features: + +* ``FalconParser``: Pass request content length to ``req.stream.read`` to + provide compatibility with ``falcon.testing`` (:pr:`477`). + Thanks :user:`suola` for the PR. + +* *Backwards-incompatible*: Factorize the ``use_args`` / ``use_kwargs`` branch + in all parsers. When ``as_kwargs`` is ``False``, arguments are now + consistently appended to the arguments list by the ``use_args`` decorator. + Before this change, the ``PyramidParser`` would prepend the argument list on + each call to ``use_args``. Pyramid view functions must reverse the order of + their arguments. (:pr:`478`) + +6.0.0b8 (2020-02-16) +******************** + +Refactoring: + +* *Backwards-incompatible*: Use keyword-only arguments (:pr:`472`). + +6.0.0b7 (2020-02-14) +******************** + +Features: + +* *Backwards-incompatible*: webargs will rewrite the error messages in + ValidationErrors to be namespaced under the location which raised the error. + The `messages` field on errors will therefore be one layer deeper with a + single top-level key. + +6.0.0b6 (2020-01-31) +******************** + +Refactoring: + +* Remove the cache attached to webargs parsers. Due to changes between webargs + v5 and v6, the cache is no longer considered useful. + +Other changes: + +* Import ``Mapping`` from ``collections.abc`` in pyramidparser.py (:pr:`471`). + Thanks :user:`tirkarthi` for the PR. + +6.0.0b5 (2020-01-30) +******************** + +Refactoring: + +* *Backwards-incompatible*: `DelimitedList` now requires that its input be a + string and always serializes as a string. It can still serialize and deserialize + using another field, e.g. `DelimitedList(Int())` is still valid and requires + that the values in the list parse as ints. + +6.0.0b4 (2020-01-28) +******************** + +Bug fixes: + +* :cve:`CVE-2020-7965`: Don't attempt to parse JSON if request's content type is mismatched + (bugfix from 5.5.3). + +6.0.0b3 (2020-01-21) +******************** + +Features: + +* *Backwards-incompatible*: Support Falcon 2.0. Drop support for Falcon 1.x + (:pr:`459`). Thanks :user:`dodumosu` and :user:`Nateyo` for the PR. + +6.0.0b2 (2020-01-07) +******************** + +Other changes: + +* *Backwards-incompatible*: Drop support for Python 2 (:issue:`440`). + Thanks :user:`hugovk` for the PR. + +6.0.0b1 (2020-01-06) +******************** + +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 a 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 + + # webargs 5.x and older + @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): + ... + + + # webargs 6.x + @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. + +Thanks :user:`sirosen` for the PR! + +5.5.3 (2020-01-28) +****************** + +Bug fixes: + +* :cve:`CVE-2020-7965`: Don't attempt to parse JSON if request's content type is mismatched. + +5.5.2 (2019-10-06) +****************** + +Bug fixes: + +* Handle ``UnicodeDecodeError`` when parsing JSON payloads (:issue:`427`). + Thanks :user:`lindycoder` for the catch and patch. + +5.5.1 (2019-09-15) +****************** + +Bug fixes: + +* Remove usage of deprecated ``Field.fail`` when using marshmallow 3. + +5.5.0 (2019-09-07) +****************** + +Support: + +* Various docs updates (:pr:`414`, :pr:`421`). + +Refactoring: + +* Don't mutate ``globals()`` in ``webargs.fields`` (:pr:`411`). +* Use marshmallow 3's ``Schema.from_dict`` if available (:pr:`415`). + +5.4.0 (2019-07-23) +****************** + +Changes: + +* Use explicit type check for `fields.DelimitedList` when deciding to + parse value with `getlist()` (`#406 (comment) `_ ). + +Support: + +* Add "Parsing Lists in Query Strings" section to docs (:issue:`406`). + +5.3.2 (2019-06-19) +****************** + +Bug fixes: + +* marshmallow 3.0.0rc7 compatibility (:pr:`395`). + +5.3.1 (2019-05-05) +****************** + +Bug fixes: + +* marshmallow 3.0.0rc6 compatibility (:pr:`384`). + +5.3.0 (2019-04-08) +****************** + +Features: + +* Add `"path"` location to ``AIOHTTPParser``, ``FlaskParser``, and + ``PyramidParser`` (:pr:`379`). Thanks :user:`zhenhua32` for the PR. +* Add ``webargs.__version_info__``. + +5.2.0 (2019-03-16) +****************** + +Features: + +* Make the schema class used when generating a schema from a + dict overridable (:issue:`375`). Thanks :user:`ThiefMaster`. + +5.1.3 (2019-03-11) +****************** + +Bug fixes: + +* :cve:`CVE-2019-9710`: Fix race condition between parallel requests when the cache is used + (:issue:`371`). Thanks :user:`ThiefMaster` for reporting and fixing. 5.1.2 (2019-02-03) ****************** diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index a438037..1ab1a74 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -19,21 +19,8 @@ Contributing Code ----------------- -In General -++++++++++ - -- `PEP 8`_, when sensible. -- Test ruthlessly. Write docs for new features. -- Even more important than Test-Driven Development--*Human-Driven Development*. - -.. _`PEP 8`: http://www.python.org/dev/peps/pep-0008/ - -In Particular -+++++++++++++ - - Integration with a Another Web Framework… -***************************************** ++++++++++++++++++++++++++++++++++++++++++ …should be released as a separate package. @@ -45,9 +32,9 @@ the `GitHub wiki `_ . Setting Up for Local Development -******************************** +++++++++++++++++++++++++++++++++ -1. Fork webargs_ on GitHub. +1. Fork webargs_ on GitHub. :: @@ -63,7 +50,7 @@ # After activating your virtualenv $ pip install -e '.[dev]' -3. (Optional, but recommended) Install the pre-commit hooks, which will format and lint your git staged files. +3. (Optional, but recommended) Install the pre-commit hooks, which will format and lint your git staged files. :: @@ -72,10 +59,11 @@ .. note:: - webargs uses `black `_ for code formatting, which is only compatible with Python>=3.6. Therefore, the ``pre-commit install`` command will only work if you have the ``python3.6`` interpreter installed. + webargs uses `black `_ for code formatting, which is only compatible with Python>=3.6. + Therefore, the pre-commit hooks require a minimum Python version of 3.6. Git Branch Structure -******************** +++++++++++++++++++++ Webargs abides by the following branching model: @@ -89,7 +77,7 @@ **Always make a new branch for your work**, no matter how small. Also, **do not put unrelated changes in the same branch or pull request**. This makes it more difficult to merge your changes. Pull Requests -************** +++++++++++++++ 1. Create a new local branch. @@ -113,10 +101,11 @@ - If the pull request adds functionality, it is tested and the docs are updated. - You've added yourself to ``AUTHORS.rst``. -4. Submit a pull request to ``marshmallow-code:dev`` or the appropriate maintenance branch. The `Travis CI `_ build must be passing before your pull request is merged. +4. Submit a pull request to ``marshmallow-code:dev`` or the appropriate maintenance branch. +The `CI `_ build must be passing before your pull request is merged. Running Tests -************* ++++++++++++++ To run all tests: :: @@ -126,12 +115,12 @@ $ tox -e lint -(Optional) To run tests on Python 2.7, 3.5, 3.6, and 3.7 virtual environments (must have each interpreter installed): :: +(Optional) To run tests in all supported Python versions in their own virtual environments (must have each interpreter installed): :: $ tox Documentation -************* ++++++++++++++ Contributions to the documentation are welcome. Documentation is written in `reStructured Text`_ (rST). A quick rST reference can be found `here `_. Builds are powered by Sphinx_. @@ -142,7 +131,7 @@ Changes in the `docs/` directory will automatically trigger a rebuild. Contributing Examples -********************* ++++++++++++++++++++++ Have a usage example you'd like to share? Feel free to add it to the `examples `_ directory and send a pull request. diff --git a/LICENSE b/LICENSE index 1fa8cbb..f79bc18 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright 2014-2019 Steven Loria and contributors +Copyright 2014-2020 Steven Loria and contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/MANIFEST.in b/MANIFEST.in index 7d61281..7d4e712 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,4 @@ +graft tests include LICENSE include *.rst +include tox.ini diff --git a/README.rst b/README.rst index 811a54a..6dde7ca 100644 --- a/README.rst +++ b/README.rst @@ -6,9 +6,9 @@ :target: https://pypi.org/project/webargs/ :alt: PyPI version -.. image:: https://badgen.net/travis/marshmallow-code/webargs - :target: https://travis-ci.org/marshmallow-code/webargs - :alt: TravisCI build status +.. image:: https://dev.azure.com/sloria/sloria/_apis/build/status/marshmallow-code.webargs?branchName=dev + :target: https://dev.azure.com/sloria/sloria/_build/latest?definitionId=6&branchName=dev + :alt: Build status .. image:: https://readthedocs.org/projects/webargs/badge/ :target: https://webargs.readthedocs.io/ @@ -34,11 +34,9 @@ app = Flask(__name__) - hello_args = {"name": fields.Str(required=True)} - @app.route("/") - @use_args(hello_args) + @use_args({"name": fields.Str(required=True)}, location="query") def index(args): return "Hello " + args["name"] @@ -56,7 +54,7 @@ pip install -U webargs -webargs supports Python >= 2.7 or >= 3.5. +webargs supports Python >= 3.5. Documentation @@ -105,6 +103,7 @@ - Docs: https://webargs.readthedocs.io/ - Changelog: https://webargs.readthedocs.io/en/latest/changelog.html +- Contributing Guidelines: https://webargs.readthedocs.io/en/latest/contributing.html - PyPI: https://pypi.python.org/pypi/webargs - Issues: https://github.com/marshmallow-code/webargs/issues diff --git a/azure-pipelines.yml b/azure-pipelines.yml new file mode 100644 index 0000000..2d1281d --- /dev/null +++ b/azure-pipelines.yml @@ -0,0 +1,50 @@ +trigger: + branches: + include: [dev, test-me-*] + tags: + include: ['*'] + +# Run builds nightly to catch incompatibilities with new marshmallow releases +schedules: +- cron: "0 0 * * *" + displayName: Daily midnight build + branches: + include: + - dev + always: "true" + +resources: + repositories: + - repository: sloria + type: github + endpoint: github + name: sloria/azure-pipeline-templates + ref: refs/heads/sloria + +jobs: +- template: job--python-tox.yml@sloria + parameters: + toxenvs: + - lint + + - py35-marshmallow2 + - py35-marshmallow3 + + - py36-marshmallow3 + + - py37-marshmallow3 + + - py38-marshmallow2 + - py38-marshmallow3 + + - py38-marshmallowdev + + - docs + os: linux +# Build wheels +- template: job--pypi-release.yml@sloria + parameters: + python: "3.7" + distributions: "sdist bdist_wheel" + dependsOn: + - tox_linux diff --git a/docs/advanced.rst b/docs/advanced.rst index 9879dac..e29c9cd 100644 --- a/docs/advanced.rst +++ b/docs/advanced.rst @@ -6,7 +6,7 @@ Custom Location Handlers ------------------------ -To add your own custom location handler, write a function that receives a request, an argument name, and a :class:`Field `, then decorate that function with :func:`Parser.location_handler `. +To add your own custom location handler, write a function that receives a request, and a :class:`Schema `, then decorate that function with :func:`Parser.location_loader `. .. code-block:: python @@ -15,18 +15,79 @@ 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"]) -Marshmallow Integration +.. 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 ` 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 ----------------------- When you need more flexibility in defining input schemas, you can pass a marshmallow `Schema ` instead of a dictionary to `Parser.parse `, `Parser.use_args `, and `Parser.use_kwargs `. @@ -46,8 +107,9 @@ last_name = fields.Str(missing="") date_registered = fields.DateTime(dump_only=True) - class Meta: - strict = True + # NOTE: Uncomment below two lines if you're using marshmallow 2 + # class Meta: + # strict = True @use_args(UserSchema()) @@ -63,7 +125,7 @@ # 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"] @@ -72,8 +134,42 @@ .. warning:: If you're using marshmallow 2, you should always set ``strict=True`` (either as a ``class Meta`` option or in the Schema's constructor) when passing a schema to webargs. This will ensure that the parser's error handler is invoked when expected. -.. warning:: - Any `Schema ` passed to `use_kwargs ` MUST deserialize to a dictionary of data. Keep this in mind when writing `post_load ` methods. + +When to avoid `use_kwargs` +-------------------------- + +Any `Schema ` passed to `use_kwargs ` MUST deserialize to a dictionary of data. +If your schema has a `post_load ` method +that returns a non-dictionary, +you should use `use_args ` instead. + +.. code-block:: python + + from marshmallow import Schema, fields, post_load + from webargs.flaskparser import use_args + + + class Rectangle: + def __init__(self, length, width): + self.length = length + self.width = width + + + class RectangleSchema(Schema): + length = fields.Float() + width = fields.Float() + + @post_load + def make_object(self, data, **kwargs): + return Rectangle(**data) + + + @use_args(RectangleSchema) + def post(self, rect: Rectangle): + return f"Area: {rect.length * rect.width}" + +Packages such as `marshmallow-sqlalchemy `_ and `marshmallow-dataclass `_ generate schemas that deserialize to non-dictionary objects. +Therefore, `use_args ` should be used with those schemas. Schema Factories @@ -88,8 +184,11 @@ .. code-block:: python + from flask import Flask from marshmallow import Schema, fields from webargs.flaskparser import use_args + + app = Flask(__name__) class UserSchema(Schema): @@ -100,13 +199,11 @@ last_name = fields.Str(missing="") date_registered = fields.DateTime(dump_only=True) - class Meta: - strict = True - def make_user_schema(request): # Filter based on 'fields' query parameter - only = request.args.get("fields", None) + fields = request.args.get("fields", None) + only = fields.split(",") if fields else None # Respect partial updates for PATCH requests partial = request.method == "PATCH" # Add current request to the schema's context @@ -114,10 +211,12 @@ # Pass the factory to .parse, .use_args, or .use_kwargs + @app.route("/profile/", methods=["GET", "POST", "PATCH"]) @use_args(make_user_schema) def profile_view(args): - username = args["username"] - # ... + username = args.get("username") + # ... + Reducing Boilerplate @@ -138,14 +237,8 @@ only = request.args.get("fields", None) # Respect partial updates for PATCH requests partial = request.method == "PATCH" - # Add current request to the schema's context - # and ensure we're always using strict mode return schema_cls( - only=only, - partial=partial, - strict=True, - context={"request": request}, - **schema_kwargs + only=only, partial=partial, context={"request": request}, **schema_kwargs ) return use_args(factory, **kwargs) @@ -179,12 +272,12 @@ cube = args["cube"] # ... -.. _custom-parsers: +.. _custom-loaders: Custom Parsers -------------- -To add your own parser, extend :class:`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 ` 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 @@ -213,8 +306,8 @@ } """ - 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_): @@ -275,12 +368,9 @@ path = fields.Str(required=True) value = fields.Str(required=True) - class Meta: - strict = True - @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 @@ -295,48 +385,14 @@ Mixing Locations ---------------- -Arguments for different locations can be specified by passing ``location`` to each field individually: - -.. code-block:: python - +Arguments for different locations can be specified by passing ``location`` to each `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 `: - -.. 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 ` 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"] @@ -348,12 +404,12 @@ import functools - query = functools.partial(use_args, locations=("query",)) - body = functools.partial(use_args, locations=("json",)) - - - @query(query_args) - @body(json_args) + query = functools.partial(use_args, location="query") + body = functools.partial(use_args, location="json") + + + @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"] diff --git a/docs/api.rst b/docs/api.rst index d9a4ad1..6499e47 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -15,6 +15,14 @@ .. automodule:: webargs.fields :members: Nested, DelimitedList + + +webargs.multidictproxy +---------------------- + +.. automodule:: webargs.multidictproxy + :members: + webargs.asyncparser ------------------- diff --git a/docs/conf.py b/docs/conf.py index d6678f0..5db7a8d 100755 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import datetime as dt import sys import os @@ -7,7 +6,7 @@ # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -sys.path.insert(0, os.path.abspath("..")) +sys.path.insert(0, os.path.abspath(os.path.join("..", "src"))) import webargs # noqa extensions = [ @@ -37,8 +36,8 @@ html_domain_indices = False source_suffix = ".rst" -project = u"webargs" -copyright = u"2014-{0:%Y}, Steven Loria and contributors".format(dt.datetime.utcnow()) +project = "webargs" +copyright = "2014-{:%Y}, Steven Loria and contributors".format(dt.datetime.utcnow()) version = release = webargs.__version__ templates_path = ["_templates"] exclude_patterns = ["_build"] diff --git a/docs/framework_support.rst b/docs/framework_support.rst index c1b2f9a..f33df31 100644 --- a/docs/framework_support.rst +++ b/docs/framework_support.rst @@ -22,9 +22,9 @@ @app.route("/user/") - @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"] ) @@ -64,7 +64,7 @@ @app.route("/greeting//") - @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"]) @@ -95,7 +95,7 @@ } - @use_args(account_args) + @use_args(account_args, location="form") def login_user(request, args): if request.method == "POST": login(args["username"], args["password"]) @@ -114,7 +114,7 @@ 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}) @@ -239,7 +239,7 @@ 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( @@ -261,7 +261,7 @@ 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"])) @@ -310,14 +310,14 @@ def add_args(argmap, **kwargs): - def hook(req, resp, params): + def hook(req, resp, resource, params): parsed_args = parser.parse(argmap, req=req, **kwargs) req.context["args"] = parsed_args 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"] @@ -414,7 +414,7 @@ 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")) diff --git a/docs/index.rst b/docs/index.rst index 0de96a3..2be1d94 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -6,6 +6,14 @@ webargs is a Python library for parsing and validating HTTP request objects, with built-in support for popular web frameworks, including Flask, Django, Bottle, Tornado, Pyramid, webapp2, Falcon, and aiohttp. +Upgrading from an older version? +-------------------------------- + +See the :doc:`Upgrading to Newer Releases ` page for notes on getting your code up-to-date with the latest version. + + +Usage and Simple Examples +------------------------- .. code-block:: python @@ -15,11 +23,9 @@ app = Flask(__name__) - hello_args = {"name": fields.Str(required=True)} - @app.route("/") - @use_args(hello_args) + @use_args({"name": fields.Str(required=True)}, location="query") def index(args): return "Hello " + args["name"] @@ -30,13 +36,15 @@ # 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** :: @@ -44,11 +52,15 @@ $ 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: @@ -105,5 +117,6 @@ license changelog + upgrading authors contributing diff --git a/docs/install.rst b/docs/install.rst index 62f36ea..b96a683 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -1,7 +1,7 @@ Install ======= -**webargs** requires Python >= 2.7 or >= 3.5. It depends on `marshmallow `_ >= 2.7.0. +**webargs** requires Python >= 3.5. It depends on `marshmallow `_ >= 2.7.0. From the PyPI ------------- diff --git a/docs/quickstart.rst b/docs/quickstart.rst index fe2d425..3d7ada0 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -23,17 +23,11 @@ "nickname": fields.List(fields.Str()), # Delimited list, e.g. "/?languages=python,javascript" "languages": fields.DelimitedList(fields.Str()), - # When you know where an argument should be parsed from - "active": fields.Bool(location="query"), # When value is keyed on a variable-unsafe name # or you want to rename a key - "content_type": fields.Str(load_from="Content-Type", location="headers"), + "user_type": fields.Str(load_from="user-type"), # OR, on marshmallow 3 - # "content_type": fields.Str(data_key="Content-Type", location="headers"), - # File uploads - "profile_image": fields.Field( - location="files", validate=lambda f: f.mimetype in ["image/jpeg", "image/png"] - ), + # "user_type": fields.Str(data_key="user-type"), } .. note:: @@ -105,12 +99,12 @@ Request "Locations" ------------------- -By default, webargs will search for arguments from the URL query string (e.g. ``"/?name=foo"``), form data, and JSON data (in that order). You can explicitly specify which locations to search, like so: +By default, webargs will search for arguments from the request body as JSON. You can specify a different location from which to load data like so: .. code-block:: python @app.route("/register") - @use_args(user_args, locations=("json", "form")) + @use_args(user_args, location="form") def register(args): return "registration page" @@ -147,15 +141,25 @@ raise ValidationError("User does not exist") - argmap = {"id": fields.Int(validate=must_exist_in_db)} - -.. note:: - - If a validator returns ``None``, validation will pass. A validator must return ``False`` or raise a `ValidationError ` for validation to fail. - -.. note:: - - You may pass a list of validators to the ``validate`` parameter. + args = {"id": fields.Int(validate=must_exist_in_db)} + +.. note:: + + If a validator returns ``None``, validation will pass. A validator must return ``False`` or raise a `ValidationError ` + for validation to fail. + + +There are a number of built-in validators from `marshmallow.validate ` +(re-exported as `webargs.validate`). + +.. code-block:: python + + from webargs import fields, validate + + args = { + "name": fields.Str(required=True, validate=[validate.Length(min=1, max=9999)]), + "age": fields.Int(validate=[validate.Range(min=1, max=999)]), + } The full arguments dictionary can also be validated by passing ``validate`` to :meth:`Parser.parse `, :meth:`Parser.use_args `, :meth:`Parser.use_kwargs `. @@ -192,8 +196,29 @@ @parser.error_handler - def handle_error(error, req, schema, status_code, headers): + def handle_error(error, req, schema, *, status_code, headers): raise CustomError(error.messages) + +Parsing Lists in Query Strings +------------------------------ + +Use `fields.DelimitedList ` to parse comma-separated +lists in query parameters, e.g. ``/?permissions=read,write`` + +.. code-block:: python + + from webargs import fields + + args = {"permissions": fields.DelimitedList(fields.Str())} + +If you expect repeated query parameters, e.g. ``/?repo=webargs&repo=marshmallow``, use +`fields.List ` instead. + +.. code-block:: python + + from webargs import fields + + args = {"repo": fields.List(fields.Str())} Nesting Fields -------------- @@ -212,7 +237,7 @@ .. note:: - By default, webargs only parses nested fields using the ``json`` request location. You can, however, :ref:`implement your own parser ` to add nested field functionality to the other locations. + Of the default supported locations in webargs, only the ``json`` request location supports nested datastructures. You can, however, :ref:`implement your own data loader ` to add nested field functionality to the other locations. Next Steps ---------- diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index ae4ca2a..0000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ --e '.[frameworks]' -Sphinx==1.8.3 -sphinx-issues==1.2.0 -sphinx-typlog-theme==0.7.1 diff --git a/docs/upgrading.rst b/docs/upgrading.rst new file mode 100644 index 0000000..9879692 --- /dev/null +++ b/docs/upgrading.rst @@ -0,0 +1,475 @@ +Upgrading to Newer Releases +=========================== + +This section documents migration paths to new releases. + +Upgrading to 6.0 +++++++++++++++++ + +Multiple Locations Are No Longer Supported In A Single Call +----------------------------------------------------------- + +The default location is JSON/body. + +Under webargs 5.x, code often did not have to specify a location. + +Because webargs would parse data from multiple locations automatically, users +did not need to specify where a parameter, call it `q`, was passed. +`q` could be in a query parameter or in a JSON or form-post body. + +Now, webargs requires that users specify only one location for data loading per +`use_args` call, and `"json"` is the default. If `q` is intended to be a query +parameter, the developer must be explicit and rewrite like so: + +.. code-block:: python + + # webargs 5.x + @parser.use_args({"q": ma.fields.String()}) + def foo(args): + return some_function(user_query=args.get("q")) + + + # webargs 6.x + @parser.use_args({"q": ma.fields.String()}, location="query") + def foo(args): + return some_function(user_query=args.get("q")) + +This also means that another usage from 5.x is not supported. Code with +multiple locations in a single `use_args`, `use_kwargs`, or `parse` call +must be rewritten in multiple separate `use_args` or `use_kwargs` invocations, +like so: + +.. code-block:: python + + # webargs 5.x + @parser.use_kwargs( + { + "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): + ... + + + # webargs 6.x + @parser.use_kwargs({"q1": ma.fields.Int(), "q2": ma.fields.Int()}, location="query") + @parser.use_kwargs({"h1": ma.fields.Int()}, location="headers") + def foo(q1, q2, h1): + ... + + +Fields No Longer Support location=... +------------------------------------- + +Because a single `parser.use_args`, `parser.use_kwargs`, or `parser.parse` call +cannot specify multiple locations, it is not necessary for a field to be able +to specify its location. Rewrite code like so: + +.. code-block:: python + + # webargs 5.x + @parser.use_args({"q": ma.fields.String(location="query")}) + def foo(args): + return some_function(user_query=args.get("q")) + + + # webargs 6.x + @parser.use_args({"q": ma.fields.String()}, location="query") + def foo(args): + return some_function(user_query=args.get("q")) + +location_handler Has Been Replaced With location_loader +------------------------------------------------------- + +This is not just a name change. The expected signature of a `location_loader` +is slightly different from the signature for a `location_handler`. + +Where previously a `location_handler` code took the incoming request data and +details of a single field being loaded, a `location_loader` takes the request +and the schema as a pair. It does not return a specific field's data, but data +for the whole location. + +Rewrite code like this: + +.. code-block:: python + + # webargs 5.x + @parser.location_handler("data") + def load_data(request, name, field): + return request.data.get(name) + + + # webargs 6.x + @parser.location_loader("data") + def load_data(request, schema): + return request.data + +Data Is Not Filtered Before Being Passed To Schemas, And It May Be Proxified +---------------------------------------------------------------------------- + +In webargs 5.x, the deserialization schema was used to pull data out of the +request object. That data was compiled into a dictionary which was then passed +to the schema. + +One of the major changes in webargs 6.x allows the use of `unknown` parameter +on schemas. This lets a schema decide what to do with fields not specified in +the schema. In order to achieve this, webargs now passes the full data from +the specified location to the schema. + +Therefore, users should specify `unknown=marshmallow.EXCLUDE` on their schemas in +order to filter out unknown fields. Like so: + +.. code-block:: python + + # webargs 5.x + # this can assume that "q" is the only parameter passed, and all other + # parameters will be ignored + @parser.use_kwargs({"q": ma.fields.String()}, locations=("query",)) + def foo(q): + ... + + + # webargs 6.x, Solution 1: declare a schema with Meta.unknown set + class QuerySchema(ma.Schema): + q = ma.fields.String() + + class Meta: + unknown = ma.EXCLUDE + + + @parser.use_kwargs(QuerySchema, location="query") + def foo(q): + ... + + + # webargs 6.x, Solution 2: instantiate a schema with unknown set + class QuerySchema(ma.Schema): + q = ma.fields.String() + + + @parser.use_kwargs(QuerySchema(unknown=ma.EXCLUDE), location="query") + def foo(q): + ... + + +This also allows usage which passes the unknown parameters through, like so: + +.. code-block:: python + + # webargs 6.x only! cannot be done in 5.x + class QuerySchema(ma.Schema): + q = ma.fields.String() + + + # will pass *all* query params through as "kwargs" + @parser.use_kwargs(QuerySchema(unknown=ma.INCLUDE), location="query") + def foo(q, **kwargs): + ... + + +However, many types of request data are so-called "multidicts" -- dictionary-like +types which can return one or multiple values. To handle `marshmallow.fields.List` +and `webargs.fields.DelimitedList` fields correctly, passing list data, webargs +must combine schema information with the raw request data. This is done in the +:class:`MultiDictProxy ` type, which +will often be passed to schemas. + +This means that if a schema has a `pre_load` hook which interacts with the data, +it may need modifications. For example, a `flask` query string will be parsed +into an `ImmutableMultiDict` type, which will break pre-load hooks which modify +the data in-place. Such usages need rewrites like so: + +.. code-block:: python + + # webargs 5.x + # flask query params is just an example -- applies to several types + from webargs.flaskparser import use_kwargs + + + class QuerySchema(ma.Schema): + q = ma.fields.String() + + @ma.pre_load + def convert_nil_to_none(self, obj, **kwargs): + if obj.get("q") == "nil": + obj["q"] = None + return obj + + + @use_kwargs(QuerySchema, locations=("query",)) + def foo(q): + ... + + + # webargs 6.x + class QuerySchema(ma.Schema): + q = ma.fields.String() + + # unlike under 5.x, we cannot modify 'obj' in-place because writing + # to the MultiDictProxy will try to write to the underlying + # ImmutableMultiDict, which is not allowed + @ma.pre_load + def convert_nil_to_none(self, obj, **kwargs): + # creating a dict from a MultiDictProxy works well because it + # "unwraps" lists and delimited lists correctly + data = dict(obj) + if data.get("q") == "nil": + data["q"] = None + return data + + + @parser.use_kwargs(QuerySchema, location="query") + def foo(q): + ... + + +DelimitedList Now Only Takes A String Input +------------------------------------------- + +Combining `List` and string parsing functionality in a single type had some +messy corner cases. For the most part, this should not require rewrites. But +for APIs which need to allow both usages, rewrites are possible like so: + +.. code-block:: python + + # webargs 5.x + # this allows ...?x=1&x=2&x=3 + # as well as ...?x=1,2,3 + @use_kwargs({"x": webargs.fields.DelimitedList(ma.fields.Int)}, locations=("query",)) + def foo(x): + ... + + + # webargs 6.x + # this accepts x=1,2,3 but NOT x=1&x=2&x=3 + @use_kwargs({"x": webargs.fields.DelimitedList(ma.fields.Int)}, location="query") + def foo(x): + ... + + + # webargs 6.x + # this accepts x=1,2,3 ; x=1&x=2&x=3 ; x=1,2&x=3 + # to do this, it needs a post_load hook which will flatten out the list data + class UnpackingDelimitedListSchema(ma.Schema): + x = ma.fields.List(webargs.fields.DelimitedList(ma.fields.Int)) + + @ma.post_load + def flatten_lists(self, data, **kwargs): + new_x = [] + for x in data["x"]: + new_x.extend(x) + data["x"] = new_x + return data + + + @parser.use_kwargs(UnpackingDelimitedListSchema, location="query") + def foo(x): + ... + + +ValidationError Messages Are Namespaced Under The Location +---------------------------------------------------------- + +Code parsing ValidationError messages will notice a change in the messages +produced by webargs. +What would previously have come back with messages like `{"foo":["Not a valid integer."]}` +will now have messages nested one layer deeper, like +`{"json":{"foo":["Not a valid integer."]}}`. + +To rewrite code which was handling these errors, the handler will need to be +prepared to traverse messages by one additional level. For example: + +.. code-block:: python + + import logging + + log = logging.getLogger(__name__) + + + # webargs 5.x + # logs debug messages like + # bad value for 'foo': ["Not a valid integer."] + # bad value for 'bar': ["Not a valid boolean."] + def log_invalid_parameters(validation_error): + for field, messages in validation_error.messages.items(): + log.debug("bad value for '{}': {}".format(field, messages)) + + + # webargs 6.x + # logs debug messages like + # bad value for 'foo' [query]: ["Not a valid integer."] + # bad value for 'bar' [json]: ["Not a valid boolean."] + def log_invalid_parameters(validation_error): + for location, fielddata in validation_error.messages.items(): + for field, messages in fielddata.items(): + log.debug("bad value for '{}' [{}]: {}".format(field, location, messages)) + + +Some Functions Take Keyword-Only Arguments Now +---------------------------------------------- + +The signature of several methods has changed to have keyword-only arguments. +For the most part, this should not require any changes, but here's a list of +the changes. + +`parser.error_handler` methods: + +.. code-block:: python + + # webargs 5.x + def handle_error(error, req, schema, status_code, headers): + ... + + + # webargs 6.x + def handle_error(error, req, schema, *, status_code, headers): + ... + +`parser.__init__` methods: + +.. code-block:: python + + # webargs 5.x + def __init__(self, location=None, error_handler=None, schema_class=None): + ... + + + # webargs 6.x + def __init__(self, location=None, *, error_handler=None, schema_class=None): + ... + +`parser.parse`, `parser.use_args`, and `parser.use_kwargs` methods: + + +.. code-block:: python + + # webargs 5.x + def parse( + self, + argmap, + req=None, + location=None, + validate=None, + error_status_code=None, + error_headers=None, + ): + ... + + + # webargs 6.x + def parse( + self, + argmap, + req=None, + *, + location=None, + validate=None, + error_status_code=None, + error_headers=None + ): + ... + + + # webargs 5.x + def use_args( + self, + argmap, + req=None, + location=None, + as_kwargs=False, + validate=None, + error_status_code=None, + error_headers=None, + ): + ... + + + # webargs 6.x + def use_args( + self, + argmap, + req=None, + *, + location=None, + as_kwargs=False, + validate=None, + error_status_code=None, + error_headers=None + ): + ... + + + # use_kwargs is just an alias for use_args with as_kwargs=True + +and finally, the `dict2schema` function: + +.. code-block:: python + + # webargs 5.x + def dict2schema(dct, schema_class=ma.Schema): + ... + + + # webargs 6.x + def dict2schema(dct, *, schema_class=ma.Schema): + ... + + +PyramidParser Now Appends Arguments (Used To Prepend) +----------------------------------------------------- + +`PyramidParser.use_args` was not conformant with the other parsers in webargs. +While all other parsers added new arguments to the end of the argument list of +a decorated view function, the Pyramid implementation added them to the front +of the argument list. + +This has been corrected, but as a result pyramid views with `use_args` may need +to be rewritten. The `request` object is always passed first in both versions, +so the issue is only apparent with view functions taking other positional +arguments. + +For example, imagine code with a decorator for passing user information, +`pass_userinfo`, like so: + +.. code-block:: python + + # a decorator which gets information about the authenticated user + def pass_userinfo(f): + def decorator(request, *args, **kwargs): + return f(request, get_userinfo(), *args, **kwargs) + + return decorator + +You will see a behavioral change if `pass_userinfo` is called on a function +decorated with `use_args`. The difference between the two versions will be like +so: + +.. code-block:: python + + from webargs.pyramidparser import use_args + + # webargs 5.x + # pass_userinfo is called first, webargs sees positional arguments of + # (userinfo,) + # and changes it to + # (request, args, userinfo) + @pass_userinfo + @use_args({"q": ma.fields.String()}, locations=("query",)) + def viewfunc(request, args, userinfo): + q = args.get("q") + ... + + + # webargs 6.x + # pass_userinfo is called first, webargs sees positional arguments of + # (userinfo,) + # and changes it to + # (request, userinfo, args) + @pass_userinfo + @use_args({"q": ma.fields.String()}, location="query") + def viewfunc(request, userinfo, args): + q = args.get("q") + ... diff --git a/examples/annotations_example.py b/examples/annotations_example.py index 63ed8dd..10eb94f 100644 --- a/examples/annotations_example.py +++ b/examples/annotations_example.py @@ -1,18 +1,33 @@ """Example of using Python 3 function annotations to define request arguments and output schemas. + +Run the app: + + $ python examples/annotations_example.py + +Try the following with httpie (a cURL-like utility, http://httpie.org): + + $ pip install httpie + $ http GET :5001/ + $ http GET :5001/ name==Ada + $ http POST :5001/add x=40 y=2 + $ http GET :5001/users/42 """ -import datetime as dt +import random import functools -from flask import Flask, jsonify, request +from flask import Flask, request from marshmallow import Schema from webargs import fields from webargs.flaskparser import parser + app = Flask(__name__) +##### Routing wrapper #### -def route(*args, response_formatter=jsonify, **kwargs): + +def route(*args, **kwargs): """Combines `Flask.route` and webargs parsing. Allows arguments to be specified as function annotations. An output schema can optionally be specified by a return annotation. @@ -29,45 +44,81 @@ if isinstance(value, fields.Field) and name != "return" } response_schema = annotations.get("return") - parsed = parser.parse(reqargs, request) + schema_cls = Schema.from_dict(reqargs) + partial = request.method != "POST" + parsed = parser.parse(schema_cls(partial=partial), request) kw.update(parsed) response_data = func(*a, **kw) if response_schema: - return response_formatter(response_schema.dump(response_data).data) + return response_schema.dump(response_data) else: - return response_formatter(func(*a, **kw)) + return func(*a, **kw) return wrapped_view return decorator +##### Fake database and model ##### + + +class Model: + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + def update(self, **kwargs): + self.__dict__.update(kwargs) + + @classmethod + def insert(cls, db, **kwargs): + collection = db[cls.collection] + new_id = None + if "id" in kwargs: # for setting up fixtures + new_id = kwargs.pop("id") + else: # find a new id + found_id = False + while not found_id: + new_id = random.randint(1, 9999) + if new_id not in collection: + found_id = True + new_record = cls(id=new_id, **kwargs) + collection[new_id] = new_record + return new_record + + +class User(Model): + collection = "users" + + +db = {"users": {}} + +##### Views ##### + + @route("/", methods=["GET"]) -def index(name: fields.Str(missing="Friend")): # noqa: E252 +def index(name: fields.Str(missing="Friend")): return {"message": "Hello, {}!".format(name)} @route("/add", methods=["POST"]) -def add(x: fields.Float(required=True), y: fields.Float(required=True)): # noqa: E252 +def add(x: fields.Float(required=True), y: fields.Float(required=True)): return {"result": x + y} class UserSchema(Schema): - id = fields.Int(required=True) - name = fields.Str(required=True) - date_created = fields.DateTime(dump_only=True) + id = fields.Int(dump_only=True) + username = fields.Str(required=True) + first_name = fields.Str() + last_name = fields.Str() -class User: - def __init__(self, id, name): - self.id = id - self.name = name - self.date_created = dt.datetime.utcnow() - - -@route("/users/", methods=["POST"]) -def user_detail(user_id, name: fields.Str(required=True)) -> UserSchema(): # noqa: E252 - user = User(id=user_id, name=name) +@route("/users/", methods=["GET", "PATCH"]) +def user_detail(user_id, username: fields.Str(required=True) = None) -> UserSchema(): + user = db["users"].get(user_id) + if not user: + return {"message": "User not found"}, 404 + if request.method == "PATCH": + user.update(username=username) return user @@ -78,10 +129,13 @@ headers = err.data.get("headers", None) messages = err.data.get("messages", ["Invalid request."]) if headers: - return jsonify({"errors": messages}), err.code, headers + return {"errors": messages}, err.code, headers else: - return jsonify({"errors": messages}), err.code + return {"errors": messages}, err.code if __name__ == "__main__": + User.insert( + db=db, id=42, username="fred", first_name="Freddie", last_name="Mercury" + ) app.run(port=5001, debug=True) diff --git a/examples/falcon_example.py b/examples/falcon_example.py index c5fd596..1b5b973 100644 --- a/examples/falcon_example.py +++ b/examples/falcon_example.py @@ -27,7 +27,7 @@ ### Middleware and hooks ### -class JSONTranslator(object): +class JSONTranslator: def process_response(self, req, resp, resource): if "result" not in req.context: return @@ -44,7 +44,7 @@ ### Resources ### -class HelloResource(object): +class HelloResource: """A welcome page.""" hello_args = {"name": fields.Str(missing="Friend", location="query")} @@ -54,7 +54,7 @@ req.context["result"] = {"message": "Welcome, {}!".format(args["name"])} -class AdderResource(object): +class AdderResource: """An addition endpoint.""" adder_args = {"x": fields.Float(required=True), "y": fields.Float(required=True)} @@ -64,7 +64,7 @@ req.context["result"] = {"result": x + y} -class DateAddResource(object): +class DateAddResource: """A datetime adder endpoint.""" dateadd_args = { diff --git a/examples/flaskrestful_example.py b/examples/flaskrestful_example.py index 0dfaa77..8e83d80 100644 --- a/examples/flaskrestful_example.py +++ b/examples/flaskrestful_example.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """A simple number and datetime addition JSON API. Run the app: @@ -70,7 +69,7 @@ # This error handler is necessary for usage with Flask-RESTful @parser.error_handler -def handle_request_parsing_error(err, req, schema, error_status_code, error_headers): +def handle_request_parsing_error(err, req, schema, *, error_status_code, error_headers): """webargs error handler that uses Flask-RESTful's abort function to return a JSON error response to the client. """ diff --git a/examples/requirements.txt b/examples/requirements.txt index 83ea70c..a98db13 100644 --- a/examples/requirements.txt +++ b/examples/requirements.txt @@ -1,4 +1,4 @@ -python-dateutil==2.7.5 +python-dateutil==2.8.1 Flask bottle tornado diff --git a/examples/schema_example.py b/examples/schema_example.py index 11887b3..a52d0f2 100644 --- a/examples/schema_example.py +++ b/examples/schema_example.py @@ -9,12 +9,12 @@ $ pip install httpie $ http GET :5001/users/ $ http GET :5001/users/42 - $ http POST :5001/users/ usename=brian first_name=Brian last_name=May + $ http POST :5001/users/ username=brian first_name=Brian last_name=May $ http PATCH :5001/users/42 username=freddie $ http GET :5001/users/ limit==1 """ import functools -from flask import Flask, request, jsonify +from flask import Flask, request import random from marshmallow import Schema, fields, post_dump @@ -22,7 +22,7 @@ app = Flask(__name__) -##### Fake database and models ##### +##### Fake database and model ##### class Model: @@ -59,7 +59,7 @@ ##### use_schema ##### -def use_schema(schema, list_view=False, locations=None): +def use_schema(schema_cls, list_view=False, locations=None): """View decorator for using a marshmallow schema to (1) parse a request's input and (2) serializing the view's output to a JSON response. @@ -68,12 +68,13 @@ def decorator(func): @functools.wraps(func) def wrapped(*args, **kwargs): + partial = request.method != "POST" + schema = schema_cls(partial=partial) use_args_wrapper = parser.use_args(schema, locations=locations) # Function wrapped with use_args func_with_args = use_args_wrapper(func) ret = func_with_args(*args, **kwargs) - # Serialize and jsonify the return value - return jsonify(schema.dump(ret, many=list_view).data) + return schema.dump(ret, many=list_view) return wrapped @@ -85,15 +86,12 @@ class UserSchema(Schema): id = fields.Int(dump_only=True) - username = fields.Str() + username = fields.Str(required=True) first_name = fields.Str() last_name = fields.Str() - class Meta: - strict = True - @post_dump(pass_many=True) - def wrap_with_envelope(self, data, many): + def wrap_with_envelope(self, data, many, **kwargs): return {"data": data} @@ -101,11 +99,11 @@ @app.route("/users/", methods=["GET", "PATCH"]) -@use_schema(UserSchema()) +@use_schema(UserSchema) def user_detail(reqargs, user_id): user = db["users"].get(user_id) if not user: - return jsonify({"message": "User not found"}), 404 + return {"message": "User not found"}, 404 if request.method == "PATCH" and reqargs: user.update(**reqargs) return user @@ -114,7 +112,7 @@ # You can add additional arguments with use_kwargs @app.route("/users/", methods=["GET", "POST"]) @use_kwargs({"limit": fields.Int(missing=10, location="query")}) -@use_schema(UserSchema(), list_view=True) +@use_schema(UserSchema, list_view=True) def user_list(reqargs, limit): users = db["users"].values() if request.method == "POST": @@ -134,9 +132,9 @@ headers = None messages = ["Invalid request."] if headers: - return jsonify({"errors": messages}), err.code, headers + return {"errors": messages}, err.code, headers else: - return jsonify({"errors": messages}), err.code + return {"errors": messages}, err.code if __name__ == "__main__": diff --git a/examples/webapp2_example.py b/examples/webapp2_example.py index 9d0869f..2111ed8 100755 --- a/examples/webapp2_example.py +++ b/examples/webapp2_example.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- """A Hello, World! example using Webapp2 in a Google App Engine environment Run the app: diff --git a/setup.cfg b/setup.cfg index 6f10b32..7c247eb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,10 +2,7 @@ license_files = LICENSE [bdist_wheel] -# We build separate wheels for -# Python 2 and 3 because of the conditional -# dependency on simplejson -universal = 0 +universal = 1 [flake8] ignore = E203, E266, E501, W503 diff --git a/setup.py b/setup.py index 0da8cd9..3dc116a 100644 --- a/setup.py +++ b/setup.py @@ -1,11 +1,5 @@ -# -*- coding: utf-8 -*- -import sys import re from setuptools import setup, find_packages - -INSTALL_REQUIRES = ["marshmallow>=2.15.2"] -if sys.version_info[0] < 3: - INSTALL_REQUIRES.append("simplejson>=2.1.0") FRAMEWORKS = [ "Flask>=0.12.2", @@ -14,25 +8,27 @@ "tornado>=4.5.2", "pyramid>=1.9.1", "webapp2>=3.0.0b1", - "falcon>=1.4.0", - 'aiohttp>=3.0.0; python_version >= "3.5"', + "falcon>=2.0.0", + "aiohttp>=3.0.0", ] EXTRAS_REQUIRE = { "frameworks": FRAMEWORKS, "tests": [ "pytest", - "mock", - "webtest==2.0.32", - 'webtest-aiohttp==2.0.0; python_version >= "3.5"', - 'pytest-aiohttp>=0.3.0; python_version >= "3.5"', + 'mock; python_version == "3.5"', + "webtest==2.0.35", + "webtest-aiohttp==2.0.0", + "pytest-aiohttp>=0.3.0", ] + FRAMEWORKS, "lint": [ - 'mypy==0.650; python_version >= "3.5"', - "flake8==3.6.0", - 'flake8-bugbear==18.8.0; python_version >= "3.5"', - "pre-commit==1.13.0", + "mypy==0.770", + "flake8==3.7.9", + "flake8-bugbear==20.1.4", + "pre-commit>=1.20,<3.0", ], + "docs": ["Sphinx==3.0.3", "sphinx-issues==1.2.0", "sphinx-typlog-theme==0.8.0"] + + FRAMEWORKS, } EXTRAS_REQUIRE["dev"] = EXTRAS_REQUIRE["tests"] + EXTRAS_REQUIRE["lint"] + ["tox"] @@ -62,7 +58,7 @@ setup( name="webargs", - version=find_version("webargs/__init__.py"), + version=find_version("src/webargs/__init__.py"), description=( "Declarative parsing and validation of HTTP request objects, " "with built-in support for popular web frameworks, including " @@ -72,9 +68,9 @@ author="Steven Loria", author_email="sloria1@gmail.com", url="https://github.com/marshmallow-code/webargs", - packages=find_packages(exclude=("test*", "examples")), - package_dir={"webargs": "webargs"}, - install_requires=INSTALL_REQUIRES, + packages=find_packages("src"), + package_dir={"": "src"}, + install_requires=["marshmallow>=2.15.2"], extras_require=EXTRAS_REQUIRE, license="MIT", zip_safe=False, @@ -95,17 +91,18 @@ "api", "marshmallow", ), + python_requires=">=3.5", classifiers=[ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Natural Language :: English", - "Programming Language :: Python :: 2", - "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3 :: Only", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", ], diff --git a/src/webargs/__init__.py b/src/webargs/__init__.py new file mode 100755 index 0000000..2e60fbe --- /dev/null +++ b/src/webargs/__init__.py @@ -0,0 +1,13 @@ +from distutils.version import LooseVersion +from marshmallow.utils import missing + +# Make marshmallow's validation functions importable from webargs +from marshmallow import validate + +from webargs.core import ValidationError +from webargs.dict2schema import dict2schema +from webargs import fields + +__version__ = "6.1.0" +__version_info__ = tuple(LooseVersion(__version__).version) +__all__ = ("dict2schema", "ValidationError", "fields", "missing", "validate") diff --git a/src/webargs/aiohttpparser.py b/src/webargs/aiohttpparser.py new file mode 100644 index 0000000..2242c02 --- /dev/null +++ b/src/webargs/aiohttpparser.py @@ -0,0 +1,187 @@ +"""aiohttp request argument parsing module. + +Example: :: + + import asyncio + from aiohttp import web + + from webargs import fields + from webargs.aiohttpparser import use_args + + + hello_args = { + 'name': fields.Str(required=True) + } + @asyncio.coroutine + @use_args(hello_args) + def index(request, args): + return web.Response( + body='Hello {}'.format(args['name']).encode('utf-8') + ) + + app = web.Application() + app.router.add_route('GET', '/', index) +""" +import typing + +from aiohttp import web +from aiohttp.web import Request +from aiohttp import web_exceptions +from marshmallow import Schema, ValidationError + +from webargs import core +from webargs.core import json +from webargs.asyncparser import AsyncParser +from webargs.multidictproxy import MultiDictProxy + + +def is_json_request(req: Request) -> bool: + content_type = req.content_type + return core.is_json(content_type) + + +class HTTPUnprocessableEntity(web.HTTPClientError): + status_code = 422 + + +# Mapping of status codes to exception classes +# Adapted from werkzeug +exception_map = {422: HTTPUnprocessableEntity} + + +def _find_exceptions() -> None: + for name in web_exceptions.__all__: + obj = getattr(web_exceptions, name) + try: + is_http_exception = issubclass(obj, web_exceptions.HTTPException) + except TypeError: + is_http_exception = False + if not is_http_exception or obj.status_code is None: + continue + old_obj = exception_map.get(obj.status_code, None) + if old_obj is not None and issubclass(obj, old_obj): + continue + exception_map[obj.status_code] = obj + + +# Collect all exceptions from aiohttp.web_exceptions +_find_exceptions() +del _find_exceptions + + +class AIOHTTPParser(AsyncParser): + """aiohttp request argument parser.""" + + __location_map__ = dict( + match_info="load_match_info", + path="load_match_info", + **core.Parser.__location_map__, + ) + + def load_querystring(self, req: Request, schema: Schema) -> MultiDictProxy: + """Return query params from the request as a MultiDictProxy.""" + return MultiDictProxy(req.query, schema) + + async def load_form(self, req: Request, schema: Schema) -> MultiDictProxy: + """Return form values from the request as a MultiDictProxy.""" + post_data = await req.post() + return MultiDictProxy(post_data, 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.""" + if not (req.body_exists and is_json_request(req)): + return core.missing + try: + return await req.json(loads=json.loads) + except json.JSONDecodeError as exc: + if exc.doc == "": + return core.missing + return self._handle_invalid_json_error(exc, req) + except UnicodeDecodeError as exc: + return self._handle_invalid_json_error(exc, req) + + def load_headers(self, req: Request, schema: Schema) -> MultiDictProxy: + """Return headers from the request as a MultiDictProxy.""" + return MultiDictProxy(req.headers, schema) + + def load_cookies(self, req: Request, schema: Schema) -> MultiDictProxy: + """Return cookies from the request as a MultiDictProxy.""" + return MultiDictProxy(req.cookies, schema) + + def load_files(self, req: Request, schema: Schema) -> "typing.NoReturn": + raise NotImplementedError( + "load_files is not implemented. You may be able to use load_form for " + "parsing upload data." + ) + + def load_match_info(self, req: Request, schema: Schema) -> typing.Mapping: + """Load the request's ``match_info``.""" + return req.match_info + + def get_request_from_view_args( + self, view: typing.Callable, args: typing.Iterable, kwargs: typing.Mapping + ) -> Request: + """Get request object from a handler function or method. Used internally by + ``use_args`` and ``use_kwargs``. + """ + req = None + for arg in args: + if isinstance(arg, web.Request): + req = arg + break + if isinstance(arg, web.View): + req = arg.request + break + if not isinstance(req, web.Request): + raise ValueError("Request argument not found for handler") + return req + + def handle_error( + self, + error: ValidationError, + req: Request, + schema: Schema, + *, + error_status_code: typing.Union[int, None], + error_headers: typing.Union[typing.Mapping[str, str], None] + ) -> "typing.NoReturn": + """Handle ValidationErrors and return a JSON response of error messages + to the client. + """ + error_class = exception_map.get( + error_status_code or self.DEFAULT_VALIDATION_STATUS + ) + if not error_class: + raise LookupError("No exception for {}".format(error_status_code)) + headers = error_headers + raise error_class( + body=json.dumps(error.messages).encode("utf-8"), + headers=headers, + content_type="application/json", + ) + + def _handle_invalid_json_error( + self, + error: typing.Union[json.JSONDecodeError, UnicodeDecodeError], + req: Request, + *args, + **kwargs + ) -> "typing.NoReturn": + error_class = exception_map[400] + messages = {"json": ["Invalid JSON body."]} + raise error_class( + body=json.dumps(messages).encode("utf-8"), content_type="application/json" + ) + + +parser = AIOHTTPParser() +use_args = parser.use_args # type: typing.Callable +use_kwargs = parser.use_kwargs # type: typing.Callable diff --git a/src/webargs/asyncparser.py b/src/webargs/asyncparser.py new file mode 100644 index 0000000..1ba77c7 --- /dev/null +++ b/src/webargs/asyncparser.py @@ -0,0 +1,179 @@ +"""Asynchronous request parser. Compatible with Python>=3.5.""" +import asyncio +import functools +import inspect +import typing +from collections.abc import Mapping + +from marshmallow import Schema, ValidationError +from marshmallow.fields import Field +import marshmallow as ma + +from webargs import core + +Request = typing.TypeVar("Request") +ArgMap = typing.Union[Schema, typing.Mapping[str, Field]] +Validate = typing.Union[typing.Callable, typing.Iterable[typing.Callable]] + + +class AsyncParser(core.Parser): + """Asynchronous variant of `webargs.core.Parser`, where parsing methods may be + either coroutines or regular methods. + """ + + # TODO: Lots of duplication from core.Parser here. Rethink. + async def parse( + self, + argmap: ArgMap, + req: Request = None, + *, + location: str = None, + validate: Validate = None, + error_status_code: typing.Union[int, None] = None, + error_headers: typing.Union[typing.Mapping[str, str], None] = None + ) -> typing.Union[typing.Mapping, None]: + """Coroutine variant of `webargs.core.Parser`. + + Receives the same arguments as `webargs.core.Parser.parse`. + """ + req = req if req is not None else self.get_default_request() + location = location or self.location + if req is None: + raise ValueError("Must pass req object") + data = None + validators = core._ensure_list_of_callables(validate) + schema = self._get_schema(argmap, req) + try: + location_data = await self._load_location_data( + schema=schema, req=req, location=location + ) + result = schema.load(location_data) + data = result.data if core.MARSHMALLOW_VERSION_INFO[0] < 3 else result + self._validate_arguments(data, validators) + except ma.exceptions.ValidationError as error: + await self._on_validation_error( + error, + req, + schema, + location, + error_status_code=error_status_code, + error_headers=error_headers, + ) + return data + + async def _load_location_data(self, schema, req, location): + """Return a dictionary-like object for the location on the given request. + + Needs to have the schema in hand in order to correctly handle loading + lists from multidict objects and `many=True` schemas. + """ + loader_func = self._get_loader(location) + if asyncio.iscoroutinefunction(loader_func): + data = await loader_func(req, schema) + else: + data = loader_func(req, schema) + + # when the desired location is empty (no data), provide an empty + # dict as the default so that optional arguments in a location + # (e.g. optional JSON body) work smoothly + if data is core.missing: + data = {} + return data + + async def _on_validation_error( + self, + error: ValidationError, + req: Request, + schema: Schema, + location: str, + *, + error_status_code: typing.Union[int, None], + error_headers: typing.Union[typing.Mapping[str, str], None] + ) -> None: + # rewrite messages to be namespaced under the location which created + # them + # e.g. {"json":{"foo":["Not a valid integer."]}} + # instead of + # {"foo":["Not a valid integer."]} + error.messages = {location: error.messages} + error_handler = self.error_callback or self.handle_error + await error_handler( + error, + req, + schema, + error_status_code=error_status_code, + error_headers=error_headers, + ) + + def use_args( + self, + argmap: ArgMap, + req: typing.Optional[Request] = None, + *, + location: str = None, + as_kwargs: bool = False, + validate: Validate = None, + error_status_code: typing.Optional[int] = None, + error_headers: typing.Union[typing.Mapping[str, str], None] = None + ) -> typing.Callable[..., typing.Callable]: + """Decorator that injects parsed arguments into a view function or method. + + Receives the same arguments as `webargs.core.Parser.use_args`. + """ + location = location or self.location + request_obj = req + # Optimization: If argmap is passed as a dictionary, we only need + # to generate a Schema once + if isinstance(argmap, Mapping): + argmap = core.dict2schema(argmap, schema_class=self.schema_class)() + + def decorator(func: typing.Callable) -> typing.Callable: + req_ = request_obj + + if inspect.iscoroutinefunction(func): + + @functools.wraps(func) + async def wrapper(*args, **kwargs): + req_obj = req_ + + if not req_obj: + req_obj = self.get_request_from_view_args(func, args, kwargs) + # NOTE: At this point, argmap may be a Schema, callable, or dict + parsed_args = await self.parse( + argmap, + req=req_obj, + location=location, + validate=validate, + error_status_code=error_status_code, + error_headers=error_headers, + ) + args, kwargs = self._update_args_kwargs( + args, kwargs, parsed_args, as_kwargs + ) + return await func(*args, **kwargs) + + else: + + @functools.wraps(func) # type: ignore + def wrapper(*args, **kwargs): + req_obj = req_ + + if not req_obj: + req_obj = self.get_request_from_view_args(func, args, kwargs) + # NOTE: At this point, argmap may be a Schema, callable, or dict + parsed_args = yield from self.parse( # type: ignore + argmap, + req=req_obj, + location=location, + validate=validate, + error_status_code=error_status_code, + error_headers=error_headers, + ) + args, kwargs = self._update_args_kwargs( + args, kwargs, parsed_args, as_kwargs + ) + return func(*args, **kwargs) + + return wrapper + + return decorator diff --git a/src/webargs/bottleparser.py b/src/webargs/bottleparser.py new file mode 100644 index 0000000..3cfd299 --- /dev/null +++ b/src/webargs/bottleparser.py @@ -0,0 +1,94 @@ +"""Bottle request argument parsing module. + +Example: :: + + from bottle import route, run + from marshmallow import fields + from webargs.bottleparser import use_args + + hello_args = { + 'name': fields.Str(missing='World') + } + @route('/', method='GET', apply=use_args(hello_args)) + def index(args): + return 'Hello ' + args['name'] + + if __name__ == '__main__': + run(debug=True) +""" +import bottle + +from webargs import core +from webargs.multidictproxy import MultiDictProxy + + +class BottleParser(core.Parser): + """Bottle.py request argument parser.""" + + def _handle_invalid_json_error(self, error, req, *args, **kwargs): + raise bottle.HTTPError( + status=400, body={"json": ["Invalid JSON body."]}, exception=error + ) + + def _raw_load_json(self, req): + """Read a json payload from the request.""" + try: + data = req.json + except AttributeError: + return core.missing + + # unfortunately, bottle does not distinguish between an emtpy body, "", + # and a body containing the valid JSON value null, "null" + # so these can't be properly disambiguated + # as our best-effort solution, treat None as missing and ignore the + # (admittedly unusual) "null" case + # see: https://github.com/bottlepy/bottle/issues/1160 + if data is None: + return core.missing + return data + + def load_querystring(self, req, schema): + """Return query params from the request as a MultiDictProxy.""" + return MultiDictProxy(req.query, schema) + + def load_form(self, req, schema): + """Return form values from the request as a MultiDictProxy.""" + # For consistency with other parsers' behavior, don't attempt to + # parse if content-type is mismatched. + # TODO: Make this check more specific + if core.is_json(req.content_type): + return core.missing + return MultiDictProxy(req.forms, schema) + + def load_headers(self, req, schema): + """Return headers from the request as a MultiDictProxy.""" + return MultiDictProxy(req.headers, schema) + + def load_cookies(self, req, schema): + """Return cookies from the request.""" + return req.cookies + + def load_files(self, req, schema): + """Return files from the request as a MultiDictProxy.""" + return MultiDictProxy(req.files, schema) + + def handle_error(self, error, req, schema, *, error_status_code, error_headers): + """Handles errors during parsing. Aborts the current request with a + 400 error. + """ + status_code = error_status_code or self.DEFAULT_VALIDATION_STATUS + raise bottle.HTTPError( + status=status_code, + body=error.messages, + headers=error_headers, + exception=error, + ) + + def get_default_request(self): + """Override to use bottle's thread-local request object by default.""" + return bottle.request + + +parser = BottleParser() +use_args = parser.use_args +use_kwargs = parser.use_kwargs diff --git a/src/webargs/compat.py b/src/webargs/compat.py new file mode 100644 index 0000000..2bbe8d5 --- /dev/null +++ b/src/webargs/compat.py @@ -0,0 +1,6 @@ +# flake8: noqa +from distutils.version import LooseVersion + +import marshmallow as ma + +MARSHMALLOW_VERSION_INFO = tuple(LooseVersion(ma.__version__).version) # type: tuple diff --git a/src/webargs/core.py b/src/webargs/core.py new file mode 100644 index 0000000..4888fe0 --- /dev/null +++ b/src/webargs/core.py @@ -0,0 +1,537 @@ +import functools +import inspect +import typing +import logging +import warnings +from collections.abc import Mapping +import json + +import marshmallow as ma +from marshmallow import ValidationError +from marshmallow.utils import missing + +from webargs.compat import MARSHMALLOW_VERSION_INFO +from webargs.dict2schema import dict2schema +from webargs.fields import DelimitedList + +logger = logging.getLogger(__name__) + + +__all__ = [ + "ValidationError", + "dict2schema", + "is_multiple", + "Parser", + "missing", + "parse_json", +] + + +DEFAULT_VALIDATION_STATUS = 422 # type: int + + +def _callable_or_raise(obj): + """Makes sure an object is callable if it is not ``None``. If not + callable, a ValueError is raised. + """ + if obj and not callable(obj): + raise ValueError("{!r} is not callable.".format(obj)) + return obj + + +def is_multiple(field): + """Return whether or not `field` handles repeated/multi-value arguments.""" + return isinstance(field, ma.fields.List) and not isinstance(field, DelimitedList) + + +def get_mimetype(content_type): + return content_type.split(";")[0].strip() if content_type else None + + +# Adapted from werkzeug: +# https://github.com/mitsuhiko/werkzeug/blob/master/werkzeug/wrappers.py +def is_json(mimetype): + """Indicates if this mimetype is JSON or not. By default a request + is considered to include JSON data if the mimetype is + ``application/json`` or ``application/*+json``. + """ + if not mimetype: + return False + if ";" in mimetype: # Allow Content-Type header to be passed + mimetype = get_mimetype(mimetype) + if mimetype == "application/json": + return True + if mimetype.startswith("application/") and mimetype.endswith("+json"): + return True + return False + + +def parse_json(string, *, encoding="utf-8"): + if isinstance(string, bytes): + try: + string = string.decode(encoding) + except UnicodeDecodeError as exc: + raise json.JSONDecodeError( + "Bytes decoding error : {}".format(exc.reason), + doc=str(exc.object), + pos=exc.start, + ) + return json.loads(string) + + +def _ensure_list_of_callables(obj): + if obj: + if isinstance(obj, (list, tuple)): + validators = obj + elif callable(obj): + validators = [obj] + else: + raise ValueError("{!r} is not a callable or list of callables.".format(obj)) + else: + validators = [] + return validators + + +class Parser: + """Base parser class that provides high-level implementation for parsing + a request. + + Descendant classes must provide lower-level implementations for reading + data from different locations, e.g. ``load_json``, ``load_querystring``, + etc. + + :param str location: Default location to use for data + :param callable error_handler: Custom error handler function. + """ + + #: Default location to check for data + DEFAULT_LOCATION = "json" + #: The marshmallow Schema class to use when creating new schemas + DEFAULT_SCHEMA_CLASS = ma.Schema + #: Default status code to return for validation errors + DEFAULT_VALIDATION_STATUS = DEFAULT_VALIDATION_STATUS + #: Default error message for validation errors + DEFAULT_VALIDATION_MESSAGE = "Invalid value." + + #: Maps location => method name + __location_map__ = { + "json": "load_json", + "querystring": "load_querystring", + "query": "load_querystring", + "form": "load_form", + "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): + self.location = location or self.DEFAULT_LOCATION + self.error_callback = _callable_or_raise(error_handler) + self.schema_class = schema_class or self.DEFAULT_SCHEMA_CLASS + + def _get_loader(self, location): + """Get the loader function for the given location. + + :raises: ValueError if a given location is invalid. + """ + valid_locations = set(self.__location_map__.keys()) + if location not in valid_locations: + msg = "Invalid location argument: {}".format(location) + raise ValueError(msg) + + # Parsing function to call + # May be a method name (str) or a function + func = self.__location_map__.get(location) + if func: + if inspect.isfunction(func): + function = func + else: + function = getattr(self, func) + else: + raise ValueError('Invalid location: "{}"'.format(location)) + return function + + def _load_location_data(self, *, schema, req, location): + """Return a dictionary-like object for the location on the given request. + + Needs to have the schema in hand in order to correctly handle loading + lists from multidict objects and `many=True` schemas. + """ + loader_func = self._get_loader(location) + data = loader_func(req, schema) + # when the desired location is empty (no data), provide an empty + # dict as the default so that optional arguments in a location + # (e.g. optional JSON body) work smoothly + if data is missing: + data = {} + return data + + def _on_validation_error( + self, error, req, schema, location, *, error_status_code, error_headers + ): + # rewrite messages to be namespaced under the location which created + # them + # e.g. {"json":{"foo":["Not a valid integer."]}} + # instead of + # {"foo":["Not a valid integer."]} + error.messages = {location: error.messages} + error_handler = self.error_callback or self.handle_error + error_handler( + error, + req, + schema, + error_status_code=error_status_code, + error_headers=error_headers, + ) + + def _validate_arguments(self, data, validators): + for validator in validators: + if validator(data) is False: + msg = self.DEFAULT_VALIDATION_MESSAGE + raise ValidationError(msg, data=data) + + def _get_schema(self, argmap, req): + """Return a `marshmallow.Schema` for the given argmap and request. + + :param argmap: Either a `marshmallow.Schema`, `dict` + of argname -> `marshmallow.fields.Field` pairs, or a callable that returns + a `marshmallow.Schema` instance. + :param req: The request object being parsed. + :rtype: marshmallow.Schema + """ + if isinstance(argmap, ma.Schema): + schema = argmap + elif isinstance(argmap, type) and issubclass(argmap, ma.Schema): + schema = argmap() + elif callable(argmap): + schema = argmap(req) + else: + schema = dict2schema(argmap, schema_class=self.schema_class)() + if MARSHMALLOW_VERSION_INFO[0] < 3 and not schema.strict: + warnings.warn( + "It is highly recommended that you set strict=True on your schema " + "so that the parser's error handler will be invoked when expected.", + UserWarning, + ) + return schema + + def parse( + self, + argmap, + req=None, + *, + location=None, + validate=None, + error_status_code=None, + error_headers=None + ): + """Main request parsing method. + + :param argmap: Either a `marshmallow.Schema`, a `dict` + of argname -> `marshmallow.fields.Field` pairs, or a callable + which accepts a request and returns a `marshmallow.Schema`. + :param req: The request object to parse. + :param str location: Where on the request to load values. + Can be any of the values in :py:attr:`~__location_map__`. By + default, that means one of ``('json', 'query', 'querystring', + 'form', 'headers', 'cookies', 'files', 'json_or_form')``. + :param callable validate: Validation function or list of validation functions + that receives the dictionary of parsed arguments. Validator either returns a + boolean or raises a :exc:`ValidationError`. + :param int error_status_code: Status code passed to error handler functions when + a `ValidationError` is raised. + :param dict error_headers: Headers passed to error handler functions when a + a `ValidationError` is raised. + + :return: A dictionary of parsed arguments + """ + req = req if req is not None else self.get_default_request() + location = location or self.location + if req is None: + raise ValueError("Must pass req object") + data = None + validators = _ensure_list_of_callables(validate) + schema = self._get_schema(argmap, req) + try: + location_data = self._load_location_data( + schema=schema, req=req, location=location + ) + result = schema.load(location_data) + data = result.data if MARSHMALLOW_VERSION_INFO[0] < 3 else result + self._validate_arguments(data, validators) + except ma.exceptions.ValidationError as error: + self._on_validation_error( + error, + req, + schema, + location, + error_status_code=error_status_code, + error_headers=error_headers, + ) + return data + + def get_default_request(self): + """Optional override. Provides a hook for frameworks that use thread-local + request objects. + """ + return None + + def get_request_from_view_args(self, view, args, kwargs): + """Optional override. Returns the request object to be parsed, given a view + function's args and kwargs. + + Used by the `use_args` and `use_kwargs` to get a request object from a + view's arguments. + + :param callable view: The view function or method being decorated by + `use_args` or `use_kwargs` + :param tuple args: Positional arguments passed to ``view``. + :param dict kwargs: Keyword arguments passed to ``view``. + """ + return None + + @staticmethod + def _update_args_kwargs(args, kwargs, parsed_args, as_kwargs): + """Update args or kwargs with parsed_args depending on as_kwargs""" + if as_kwargs: + kwargs.update(parsed_args) + else: + # Add parsed_args after other positional arguments + args += (parsed_args,) + return args, kwargs + + def use_args( + self, + argmap, + req=None, + *, + location=None, + as_kwargs=False, + validate=None, + error_status_code=None, + error_headers=None + ): + """Decorator that injects parsed arguments into a view function or method. + + Example usage with Flask: :: + + @app.route('/echo', methods=['get', 'post']) + @parser.use_args({'name': fields.Str()}, location="querystring") + def greet(args): + return 'Hello ' + args['name'] + + :param argmap: Either a `marshmallow.Schema`, a `dict` + of argname -> `marshmallow.fields.Field` pairs, or a callable + which accepts a request and returns a `marshmallow.Schema`. + :param str location: Where on the request to load values. + :param bool as_kwargs: Whether to insert arguments as keyword arguments. + :param callable validate: Validation function that receives the dictionary + of parsed arguments. If the function returns ``False``, the parser + will raise a :exc:`ValidationError`. + :param int error_status_code: Status code passed to error handler functions when + a `ValidationError` is raised. + :param dict error_headers: Headers passed to error handler functions when a + a `ValidationError` is raised. + """ + location = location or self.location + request_obj = req + # Optimization: If argmap is passed as a dictionary, we only need + # to generate a Schema once + if isinstance(argmap, Mapping): + argmap = dict2schema(argmap, schema_class=self.schema_class)() + + def decorator(func): + req_ = request_obj + + @functools.wraps(func) + def wrapper(*args, **kwargs): + req_obj = req_ + + if not req_obj: + req_obj = self.get_request_from_view_args(func, args, kwargs) + + # NOTE: At this point, argmap may be a Schema, or a callable + parsed_args = self.parse( + argmap, + req=req_obj, + location=location, + validate=validate, + error_status_code=error_status_code, + error_headers=error_headers, + ) + args, kwargs = self._update_args_kwargs( + args, kwargs, parsed_args, as_kwargs + ) + return func(*args, **kwargs) + + wrapper.__wrapped__ = func + return wrapper + + return decorator + + def use_kwargs(self, *args, **kwargs) -> typing.Callable: + """Decorator that injects parsed arguments into a view function or method + as keyword arguments. + + This is a shortcut to :meth:`use_args` with ``as_kwargs=True``. + + Example usage with Flask: :: + + @app.route('/echo', methods=['get', 'post']) + @parser.use_kwargs({'name': fields.Str()}) + def greet(name): + return 'Hello ' + name + + Receives the same ``args`` and ``kwargs`` as :meth:`use_args`. + """ + kwargs["as_kwargs"] = True + return self.use_args(*args, **kwargs) + + def location_loader(self, name): + """Decorator that registers a function for loading a request location. + The wrapped function receives a schema and a request. + + The schema will usually not be relevant, but it's important in some + cases -- most notably in order to correctly load multidict values into + list fields. Without the schema, there would be no way to know whether + to simply `.get()` or `.getall()` from a multidict for a given value. + + Example: :: + + from webargs import core + parser = core.Parser() + + @parser.location_loader("name") + def load_data(request, schema): + return request.data + + :param str name: The name of the location to register. + """ + + def decorator(func): + self.__location_map__[name] = func + return func + + return decorator + + def error_handler(self, func): + """Decorator that registers a custom error handling function. The + function should receive the raised error, request object, + `marshmallow.Schema` instance used to parse the request, error status code, + and headers to use for the error response. Overrides + the parser's ``handle_error`` method. + + Example: :: + + from webargs import flaskparser + + parser = flaskparser.FlaskParser() + + + class CustomError(Exception): + pass + + + @parser.error_handler + def handle_error(error, req, schema, *, status_code, headers): + raise CustomError(error.messages) + + :param callable func: The error callback to register. + """ + self.error_callback = func + return func + + def _handle_invalid_json_error(self, error, req, *args, **kwargs): + """Internal hook for overriding treatment of JSONDecodeErrors. + + Invoked by default `load_json` implementation. + + External parsers can just implement their own behavior for load_json , + so this is not part of the public parser API. + """ + raise error + + def load_json(self, req, schema): + """Load JSON from a request object or return `missing` if no value can + be found. + """ + # NOTE: although this implementation is real/concrete and used by + # several of the parsers in webargs, it relies on the internal hooks + # `_handle_invalid_json_error` and `_raw_load_json` + # these methods are not part of the public API and are used to simplify + # code sharing amongst the built-in webargs parsers + try: + return self._raw_load_json(req) + except json.JSONDecodeError as exc: + if exc.doc == "": + return missing + return self._handle_invalid_json_error(exc, req) + except UnicodeDecodeError as exc: + return self._handle_invalid_json_error(exc, req) + + 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): + """Internal hook method for implementing load_json() + + Get a request body for feeding in to `load_json`, and parse it either + using core.parse_json() or similar utilities which raise + JSONDecodeErrors. + Ensure consistent behavior when encountering decoding errors. + + The default implementation here simply returns `missing`, and the default + implementation of `load_json` above will pass that value through. + However, by implementing a "mostly concrete" version of load_json with + this as a hook for getting data, we consolidate the logic for handling + those JSONDecodeErrors. + """ + return missing + + def load_querystring(self, req, schema): + """Load the query string of a request object or return `missing` if no + value can be found. + """ + return missing + + def load_form(self, req, schema): + """Load the form data of a request object or return `missing` if no + value can be found. + """ + return missing + + def load_headers(self, req, schema): + """Load the headers or return `missing` if no value can be found. + """ + return missing + + def load_cookies(self, req, schema): + """Load the cookies from the request or return `missing` if no value + can be found. + """ + return missing + + def load_files(self, req, schema): + """Load files from the request or return `missing` if no values can be + found. + """ + return missing + + def handle_error(self, error, req, schema, *, error_status_code, error_headers): + """Called if an error occurs while parsing args. By default, just logs and + raises ``error``. + """ + logger.error(error) + raise error diff --git a/src/webargs/dict2schema.py b/src/webargs/dict2schema.py new file mode 100644 index 0000000..ca38f32 --- /dev/null +++ b/src/webargs/dict2schema.py @@ -0,0 +1,16 @@ +import marshmallow as ma + + +def dict2schema(dct, *, schema_class=ma.Schema): + """Generate a `marshmallow.Schema` class given a dictionary of + `Fields `. + """ + if hasattr(schema_class, "from_dict"): # marshmallow 3 + return schema_class.from_dict(dct) + attrs = dct.copy() + + class Meta: + strict = True + + attrs["Meta"] = Meta + return type("", (schema_class,), attrs) diff --git a/src/webargs/djangoparser.py b/src/webargs/djangoparser.py new file mode 100644 index 0000000..65daee1 --- /dev/null +++ b/src/webargs/djangoparser.py @@ -0,0 +1,80 @@ +"""Django request argument parsing. + +Example usage: :: + + from django.views.generic import View + from django.http import HttpResponse + from marshmallow import fields + from webargs.djangoparser import use_args + + hello_args = { + 'name': fields.Str(missing='World') + } + + class MyView(View): + + @use_args(hello_args) + def get(self, args, request): + return HttpResponse('Hello ' + args['name']) +""" +from webargs import core +from webargs.multidictproxy import MultiDictProxy + + +def is_json_request(req): + return core.is_json(req.content_type) + + +class DjangoParser(core.Parser): + """Django request argument parser. + + .. warning:: + + :class:`DjangoParser` does not override + :meth:`handle_error `, so your Django + views are responsible for catching any :exc:`ValidationErrors` raised by + the parser and returning the appropriate `HTTPResponse`. + """ + + def _raw_load_json(self, req): + """Read a json payload from the request for the core parser's load_json + + Checks the input mimetype and may return 'missing' if the mimetype is + non-json, even if the request body is parseable as json.""" + if not is_json_request(req): + return core.missing + + return core.parse_json(req.body) + + def load_querystring(self, req, schema): + """Return query params from the request as a MultiDictProxy.""" + return MultiDictProxy(req.GET, schema) + + def load_form(self, req, schema): + """Return form values from the request as a MultiDictProxy.""" + return MultiDictProxy(req.POST, schema) + + def load_cookies(self, req, schema): + """Return cookies from the request.""" + return req.COOKIES + + def load_headers(self, req, schema): + raise NotImplementedError( + "Header parsing not supported by {}".format(self.__class__.__name__) + ) + + def load_files(self, req, schema): + """Return files from the request as a MultiDictProxy.""" + return MultiDictProxy(req.FILES, schema) + + def get_request_from_view_args(self, view, args, kwargs): + # The first argument is either `self` or `request` + try: # self.request + return args[0].request + except AttributeError: # first arg is request + return args[0] + + +parser = DjangoParser() +use_args = parser.use_args +use_kwargs = parser.use_kwargs diff --git a/src/webargs/falconparser.py b/src/webargs/falconparser.py new file mode 100644 index 0000000..85c7f1d --- /dev/null +++ b/src/webargs/falconparser.py @@ -0,0 +1,153 @@ +"""Falcon request argument parsing module. +""" +import falcon +from falcon.util.uri import parse_query_string + +from webargs import core +from webargs.multidictproxy import MultiDictProxy + +HTTP_422 = "422 Unprocessable Entity" + +# Mapping of int status codes to string status +status_map = {422: HTTP_422} + + +# Collect all exceptions from falcon.status_codes +def _find_exceptions(): + for name in filter(lambda n: n.startswith("HTTP"), dir(falcon.status_codes)): + status = getattr(falcon.status_codes, name) + status_code = int(status.split(" ")[0]) + status_map[status_code] = status + + +_find_exceptions() +del _find_exceptions + + +def is_json_request(req): + content_type = req.get_header("Content-Type") + return content_type and core.is_json(content_type) + + +# NOTE: Adapted from falcon.request.Request._parse_form_urlencoded +def parse_form_body(req): + if ( + req.content_type is not None + and "application/x-www-form-urlencoded" in req.content_type + ): + body = req.stream.read(req.content_length or 0) + try: + body = body.decode("ascii") + except UnicodeDecodeError: + body = None + req.log_error( + "Non-ASCII characters found in form body " + "with Content-Type of " + "application/x-www-form-urlencoded. Body " + "will be ignored." + ) + + if body: + return parse_query_string(body, keep_blank=req.options.keep_blank_qs_values) + + return core.missing + + +class HTTPError(falcon.HTTPError): + """HTTPError that stores a dictionary of validation error messages. + """ + + def __init__(self, status, errors, *args, **kwargs): + self.errors = errors + super().__init__(status, *args, **kwargs) + + def to_dict(self, *args, **kwargs): + """Override `falcon.HTTPError` to include error messages in responses.""" + ret = super().to_dict(*args, **kwargs) + if self.errors is not None: + ret["errors"] = self.errors + return ret + + +class FalconParser(core.Parser): + """Falcon request argument parser.""" + + # Note on the use of MultiDictProxy throughout: + # Falcon parses query strings and form values into ordinary dicts, but with + # the values listified where appropriate + # it is still therefore necessary in these cases to wrap them in + # MultiDictProxy because we need to use the schema to determine when single + # values should be wrapped in lists due to the type of the destination + # field + + def load_querystring(self, req, schema): + """Return query params from the request as a MultiDictProxy.""" + return MultiDictProxy(req.params, schema) + + def load_form(self, req, schema): + """Return form values from the request as a MultiDictProxy + + .. note:: + + The request stream will be read and left at EOF. + """ + form = parse_form_body(req) + if form is core.missing: + return form + return MultiDictProxy(form, schema) + + def _raw_load_json(self, req): + """Return a json payload from the request for the core parser's load_json + + Checks the input mimetype and may return 'missing' if the mimetype is + non-json, even if the request body is parseable as json.""" + if not is_json_request(req) or req.content_length in (None, 0): + return core.missing + body = req.stream.read(req.content_length) + if body: + return core.parse_json(body) + return core.missing + + def load_headers(self, req, schema): + """Return headers from the request.""" + # Falcon only exposes headers as a dict (not multidict) + return req.headers + + def load_cookies(self, req, schema): + """Return cookies from the request.""" + # Cookies are expressed in Falcon as a dict, but the possibility of + # multiple values for a cookie is preserved internally -- if desired in + # the future, webargs could add a MultiDict type for Cookies here built + # from (req, schema), but Falcon does not provide one out of the box + return req.cookies + + def get_request_from_view_args(self, view, args, kwargs): + """Get request from a resource method's arguments. Assumes that + request is the second argument. + """ + req = args[1] + if not isinstance(req, falcon.Request): + raise TypeError("Argument is not a falcon.Request") + return req + + def load_files(self, req, schema): + raise NotImplementedError( + "Parsing files not yet supported by {}".format(self.__class__.__name__) + ) + + def handle_error(self, error, req, schema, *, error_status_code, error_headers): + """Handles errors during parsing.""" + status = status_map.get(error_status_code or self.DEFAULT_VALIDATION_STATUS) + if status is None: + raise LookupError("Status code {} not supported".format(error_status_code)) + raise HTTPError(status, errors=error.messages, headers=error_headers) + + def _handle_invalid_json_error(self, error, req, *args, **kwargs): + status = status_map[400] + messages = {"json": ["Invalid JSON body."]} + raise HTTPError(status, errors=messages) + + +parser = FalconParser() +use_args = parser.use_args +use_kwargs = parser.use_kwargs diff --git a/src/webargs/fields.py b/src/webargs/fields.py new file mode 100644 index 0000000..2227f3f --- /dev/null +++ b/src/webargs/fields.py @@ -0,0 +1,118 @@ +"""Field classes. + +Includes all fields from `marshmallow.fields` in addition to a custom +`Nested` field and `DelimitedList`. + +All fields can optionally take a special `location` keyword argument, which +tells webargs where to parse the request argument from. + +.. code-block:: python + + args = { + "active": fields.Bool(location="query"), + "content_type": fields.Str(data_key="Content-Type", location="headers"), + } + +Note: `data_key` replaced `load_from` in marshmallow 3. +When using marshmallow 2, use `load_from`. +""" +import marshmallow as ma + +# Expose all fields from marshmallow.fields. +from marshmallow.fields import * # noqa: F40 +from webargs.compat import MARSHMALLOW_VERSION_INFO +from webargs.dict2schema import dict2schema + +__all__ = ["DelimitedList"] + ma.fields.__all__ + + +class Nested(ma.fields.Nested): + """Same as `marshmallow.fields.Nested`, except can be passed a dictionary as + the first argument, which will be converted to a `marshmallow.Schema`. + + .. note:: + + The schema class here will always be `marshmallow.Schema`, regardless + of whether a custom schema class is set on the parser. Pass an explicit schema + class if necessary. + """ + + def __init__(self, nested, *args, **kwargs): + if isinstance(nested, dict): + nested = dict2schema(nested) + super().__init__(nested, *args, **kwargs) + + +class DelimitedFieldMixin: + """ + This is a mixin class for subclasses of ma.fields.List and ma.fields.Tuple + which split on a pre-specified delimiter. By default, the delimiter will be "," + + Because we want the MRO to reach this class before the List or Tuple class, + it must be listed first in the superclasses + + For example, a DelimitedList-like type can be defined like so: + + >>> class MyDelimitedList(DelimitedFieldMixin, ma.fields.List): + >>> pass + """ + + delimiter = "," + + def _serialize(self, value, attr, obj): + # serializing will start with parent-class serialization, so that we correctly + # output lists of non-primitive types, e.g. DelimitedList(DateTime) + return self.delimiter.join( + format(each) for each in super()._serialize(value, attr, obj) + ) + + def _deserialize(self, value, attr, data, **kwargs): + # attempting to deserialize from a non-string source is an error + if not isinstance(value, (str, bytes)): + if MARSHMALLOW_VERSION_INFO[0] < 3: + self.fail("invalid") + else: + raise self.make_error("invalid") + return super()._deserialize(value.split(self.delimiter), attr, data, **kwargs) + + +class DelimitedList(DelimitedFieldMixin, ma.fields.List): + """A field which is similar to a List, but takes its input as a delimited + string (e.g. "foo,bar,baz"). + + Like List, it can be given a nested field type which it will use to + de/serialize each element of the list. + + :param Field cls_or_instance: A field class or instance. + :param str delimiter: Delimiter between values. + """ + + default_error_messages = {"invalid": "Not a valid delimited list."} + delimiter = "," + + def __init__(self, cls_or_instance, *, delimiter=None, **kwargs): + self.delimiter = delimiter or self.delimiter + super().__init__(cls_or_instance, **kwargs) + + +# DelimitedTuple can only be defined when using marshmallow3, when Tuple was +# added +if MARSHMALLOW_VERSION_INFO[0] >= 3: + + class DelimitedTuple(DelimitedFieldMixin, ma.fields.Tuple): + """A field which is similar to a Tuple, but takes its input as a delimited + string (e.g. "foo,bar,baz"). + + Like Tuple, it can be given a tuple of nested field types which it will use to + de/serialize each element of the tuple. + + :param Iterable[Field] tuple_fields: An iterable of field classes or instances. + :param str delimiter: Delimiter between values. + """ + + default_error_messages = {"invalid": "Not a valid delimited tuple."} + delimiter = "," + + def __init__(self, tuple_fields, *, delimiter=None, **kwargs): + self.delimiter = delimiter or self.delimiter + super().__init__(tuple_fields, **kwargs) diff --git a/src/webargs/flaskparser.py b/src/webargs/flaskparser.py new file mode 100644 index 0000000..06b1815 --- /dev/null +++ b/src/webargs/flaskparser.py @@ -0,0 +1,125 @@ +"""Flask request argument parsing module. + +Example: :: + + from flask import Flask + + from webargs import fields + from webargs.flaskparser import use_args + + app = Flask(__name__) + + user_detail_args = { + 'per_page': fields.Int() + } + + @app.route("/user/") + @use_args(user_detail_args) + def user_detail(args, uid): + return ("The user page for user {uid}, showing {per_page} posts.").format( + uid=uid, per_page=args["per_page"] + ) +""" +import flask +from werkzeug.exceptions import HTTPException + +from webargs import core +from webargs.compat import MARSHMALLOW_VERSION_INFO +from webargs.multidictproxy import MultiDictProxy + + +def abort(http_status_code, exc=None, **kwargs): + """Raise a HTTPException for the given http_status_code. Attach any keyword + arguments to the exception for later processing. + + From Flask-Restful. See NOTICE file for license information. + """ + try: + flask.abort(http_status_code) + except HTTPException as err: + err.data = kwargs + err.exc = exc + raise err + + +def is_json_request(req): + return core.is_json(req.mimetype) + + +class FlaskParser(core.Parser): + """Flask request argument parser.""" + + __location_map__ = dict( + view_args="load_view_args", + path="load_view_args", + **core.Parser.__location_map__, + ) + + def _raw_load_json(self, req): + """Return a json payload from the request for the core parser's load_json + + Checks the input mimetype and may return 'missing' if the mimetype is + non-json, even if the request body is parseable as json.""" + if not is_json_request(req): + return core.missing + + return core.parse_json(req.get_data(cache=True)) + + def _handle_invalid_json_error(self, error, req, *args, **kwargs): + abort(400, exc=error, messages={"json": ["Invalid JSON body."]}) + + def load_view_args(self, req, schema): + """Return the request's ``view_args`` or ``missing`` if there are none.""" + return req.view_args or core.missing + + def load_querystring(self, req, schema): + """Return query params from the request as a MultiDictProxy.""" + return MultiDictProxy(req.args, schema) + + def load_form(self, req, schema): + """Return form values from the request as a MultiDictProxy.""" + return MultiDictProxy(req.form, schema) + + def load_headers(self, req, schema): + """Return headers from the request as a MultiDictProxy.""" + return MultiDictProxy(req.headers, schema) + + def load_cookies(self, req, schema): + """Return cookies from the request.""" + return req.cookies + + def load_files(self, req, schema): + """Return files from the request as a MultiDictProxy.""" + return MultiDictProxy(req.files, schema) + + def handle_error(self, error, req, schema, *, error_status_code, error_headers): + """Handles errors during parsing. Aborts the current HTTP request and + responds with a 422 error. + """ + status_code = error_status_code or self.DEFAULT_VALIDATION_STATUS + # on marshmallow 2, a many schema receiving a non-list value will + # produce this specific error back -- reformat it to match the + # marshmallow 3 message so that Flask can properly encode it + messages = error.messages + if ( + MARSHMALLOW_VERSION_INFO[0] < 3 + and schema.many + and messages == {0: {}, "_schema": ["Invalid input type."]} + ): + messages.pop(0) + abort( + status_code, + exc=error, + messages=error.messages, + schema=schema, + headers=error_headers, + ) + + def get_default_request(self): + """Override to use Flask's thread-local request object by default""" + return flask.request + + +parser = FlaskParser() +use_args = parser.use_args +use_kwargs = parser.use_kwargs diff --git a/src/webargs/multidictproxy.py b/src/webargs/multidictproxy.py new file mode 100644 index 0000000..6d7c8cc --- /dev/null +++ b/src/webargs/multidictproxy.py @@ -0,0 +1,77 @@ +from collections.abc import Mapping + +from webargs.compat import MARSHMALLOW_VERSION_INFO +from webargs.core import missing, is_multiple + + +class MultiDictProxy(Mapping): + """ + A proxy object which wraps multidict types along with a matching schema + Whenever a value is looked up, it is checked against the schema to see if + there is a matching field where `is_multiple` is True. If there is, then + the data should be loaded as a list or tuple. + + In all other cases, __getitem__ proxies directly to the input multidict. + """ + + def __init__(self, multidict, schema): + self.data = multidict + self.multiple_keys = self._collect_multiple_keys(schema) + + @staticmethod + def _collect_multiple_keys(schema): + result = set() + for name, field in schema.fields.items(): + if not is_multiple(field): + continue + if MARSHMALLOW_VERSION_INFO[0] < 3: + result.add(field.load_from if field.load_from is not None else name) + else: + result.add(field.data_key if field.data_key is not None else name) + return result + + def __getitem__(self, key): + val = self.data.get(key, missing) + if val is missing or key not in self.multiple_keys: + return val + if hasattr(self.data, "getlist"): + return self.data.getlist(key) + if hasattr(self.data, "getall"): + return self.data.getall(key) + if isinstance(val, (list, tuple)): + return val + if val is None: + return None + return [val] + + def __str__(self): # str(proxy) proxies to str(proxy.data) + return str(self.data) + + def __repr__(self): + return "MultiDictProxy(data={!r}, multiple_keys={!r})".format( + self.data, self.multiple_keys + ) + + def __delitem__(self, key): + del self.data[key] + + def __setitem__(self, key, value): + self.data[key] = value + + def __getattr__(self, name): + return getattr(self.data, name) + + def __iter__(self): + return iter(self.data) + + def __contains__(self, x): + return x in self.data + + def __len__(self): + return len(self.data) + + def __eq__(self, other): + return self.data == other + + def __ne__(self, other): + return self.data != other diff --git a/src/webargs/pyramidparser.py b/src/webargs/pyramidparser.py new file mode 100644 index 0000000..7f9d5c0 --- /dev/null +++ b/src/webargs/pyramidparser.py @@ -0,0 +1,175 @@ +"""Pyramid request argument parsing. + +Example usage: :: + + from wsgiref.simple_server import make_server + from pyramid.config import Configurator + from pyramid.response import Response + from marshmallow import fields + from webargs.pyramidparser import use_args + + hello_args = { + 'name': fields.Str(missing='World') + } + + @use_args(hello_args) + def hello_world(request, args): + return Response('Hello ' + args['name']) + + if __name__ == '__main__': + config = Configurator() + config.add_route('hello', '/') + config.add_view(hello_world, route_name='hello') + app = config.make_wsgi_app() + server = make_server('0.0.0.0', 6543, app) + server.serve_forever() +""" +import functools +from collections.abc import Mapping + +from webob.multidict import MultiDict +from pyramid.httpexceptions import exception_response + +from webargs import core +from webargs.core import json +from webargs.multidictproxy import MultiDictProxy + + +def is_json_request(req): + return core.is_json(req.headers.get("content-type")) + + +class PyramidParser(core.Parser): + """Pyramid request argument parser.""" + + __location_map__ = dict( + matchdict="load_matchdict", + path="load_matchdict", + **core.Parser.__location_map__, + ) + + def _raw_load_json(self, req): + """Return a json payload from the request for the core parser's load_json + + Checks the input mimetype and may return 'missing' if the mimetype is + non-json, even if the request body is parseable as json.""" + if not is_json_request(req): + return core.missing + + return core.parse_json(req.body, encoding=req.charset) + + def load_querystring(self, req, schema): + """Return query params from the request as a MultiDictProxy.""" + return MultiDictProxy(req.GET, schema) + + def load_form(self, req, schema): + """Return form values from the request as a MultiDictProxy.""" + return MultiDictProxy(req.POST, schema) + + def load_cookies(self, req, schema): + """Return cookies from the request as a MultiDictProxy.""" + return MultiDictProxy(req.cookies, schema) + + def load_headers(self, req, schema): + """Return headers from the request as a MultiDictProxy.""" + return MultiDictProxy(req.headers, schema) + + def load_files(self, req, schema): + """Return files from the request as a MultiDictProxy.""" + files = ((k, v) for k, v in req.POST.items() if hasattr(v, "file")) + return MultiDictProxy(MultiDict(files), schema) + + def load_matchdict(self, req, schema): + """Return the request's ``matchdict`` as a MultiDictProxy.""" + return MultiDictProxy(req.matchdict, schema) + + def handle_error(self, error, req, schema, *, error_status_code, error_headers): + """Handles errors during parsing. Aborts the current HTTP request and + responds with a 400 error. + """ + status_code = error_status_code or self.DEFAULT_VALIDATION_STATUS + response = exception_response( + status_code, + detail=str(error), + headers=error_headers, + content_type="application/json", + ) + body = json.dumps(error.messages) + response.body = body.encode("utf-8") if isinstance(body, str) else body + raise response + + def _handle_invalid_json_error(self, error, req, *args, **kwargs): + messages = {"json": ["Invalid JSON body."]} + response = exception_response( + 400, detail=str(messages), content_type="application/json" + ) + body = json.dumps(messages) + response.body = body.encode("utf-8") if isinstance(body, str) else body + raise response + + def use_args( + self, + argmap, + req=None, + *, + location=core.Parser.DEFAULT_LOCATION, + as_kwargs=False, + validate=None, + error_status_code=None, + error_headers=None + ): + """Decorator that injects parsed arguments into a view callable. + Supports the *Class-based View* pattern where `request` is saved as an instance + attribute on a view class. + + :param dict argmap: Either a `marshmallow.Schema`, a `dict` + of argname -> `marshmallow.fields.Field` pairs, or a callable + which accepts a request and returns a `marshmallow.Schema`. + :param req: The request object to parse. Pulled off of the view by default. + :param str location: Where on the request to load values. + :param bool as_kwargs: Whether to insert arguments as keyword arguments. + :param callable validate: Validation function that receives the dictionary + of parsed arguments. If the function returns ``False``, the parser + will raise a :exc:`ValidationError`. + :param int error_status_code: Status code passed to error handler functions when + a `ValidationError` is raised. + :param dict error_headers: Headers passed to error handler functions when a + a `ValidationError` is raised. + """ + location = location or self.location + # Optimization: If argmap is passed as a dictionary, we only need + # to generate a Schema once + if isinstance(argmap, Mapping): + argmap = core.dict2schema(argmap, schema_class=self.schema_class)() + + def decorator(func): + @functools.wraps(func) + def wrapper(obj, *args, **kwargs): + # The first argument is either `self` or `request` + try: # get self.request + request = req or obj.request + except AttributeError: # first arg is request + request = obj + # NOTE: At this point, argmap may be a Schema, callable, or dict + parsed_args = self.parse( + argmap, + req=request, + location=location, + validate=validate, + error_status_code=error_status_code, + error_headers=error_headers, + ) + args, kwargs = self._update_args_kwargs( + args, kwargs, parsed_args, as_kwargs + ) + return func(obj, *args, **kwargs) + + wrapper.__wrapped__ = func + return wrapper + + return decorator + + +parser = PyramidParser() +use_args = parser.use_args +use_kwargs = parser.use_kwargs diff --git a/src/webargs/testing.py b/src/webargs/testing.py new file mode 100644 index 0000000..ca04040 --- /dev/null +++ b/src/webargs/testing.py @@ -0,0 +1,270 @@ +"""Utilities for testing. Includes a base test class +for testing parsers. + +.. warning:: + + Methods and functions in this module may change without + warning and without a major version change. +""" +import pytest +import webtest + +from webargs.core import json + + +class CommonTestCase: + """Base test class that defines test methods for common functionality across all + parsers. Subclasses must define `create_app`, which returns a WSGI-like app. + """ + + def create_app(self): + """Return a WSGI app""" + raise NotImplementedError("Must define create_app()") + + def create_testapp(self, app): + return webtest.TestApp(app) + + def before_create_app(self): + pass + + def after_create_app(self): + pass + + @pytest.fixture(scope="class") + def testapp(self): + self.before_create_app() + yield self.create_testapp(self.create_app()) + self.after_create_app() + + def test_parse_querystring_args(self, testapp): + assert testapp.get("/echo?name=Fred").json == {"name": "Fred"} + + def test_parse_form(self, testapp): + assert testapp.post("/echo_form", {"name": "Joe"}).json == {"name": "Joe"} + + def test_parse_json(self, testapp): + assert testapp.post_json("/echo_json", {"name": "Fred"}).json == { + "name": "Fred" + } + + 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"} + + 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", + json.dumps({"name": "Steve"}), + content_type="application/json;charset=UTF-8", + ) + assert res.json == {"name": "Steve"} + + def test_parse_json_with_vendor_media_type(self, testapp): + res = testapp.post( + "/echo_json", + json.dumps({"name": "Steve"}), + content_type="application/vnd.api+json;charset=UTF-8", + ) + assert res.json == {"name": "Steve"} + + def test_parse_ignore_extra_data(self, testapp): + assert testapp.post_json( + "/echo_ignoring_extra_data", {"extra": "data"} + ).json == {"name": "World"} + + def test_parse_json_empty(self, testapp): + assert testapp.post_json("/echo_json", {}).json == {"name": "World"} + + def test_parse_json_error_unexpected_int(self, testapp): + res = testapp.post_json("/echo_json", 1, expect_errors=True) + assert res.status_code == 422 + + def test_parse_json_error_unexpected_list(self, testapp): + res = testapp.post_json("/echo_json", [{"extra": "data"}], expect_errors=True) + assert res.status_code == 422 + + def test_parse_json_many_schema_invalid_input(self, testapp): + res = testapp.post_json( + "/echo_many_schema", [{"name": "a"}], expect_errors=True + ) + assert res.status_code == 422 + + def test_parse_json_many_schema(self, testapp): + res = testapp.post_json("/echo_many_schema", [{"name": "Steve"}]).json + assert res == [{"name": "Steve"}] + + def test_parse_json_many_schema_error_malformed_data(self, testapp): + res = testapp.post_json( + "/echo_many_schema", {"extra": "data"}, expect_errors=True + ) + assert res.status_code == 422 + + def test_parsing_form_default(self, testapp): + assert testapp.post("/echo_form", {}).json == {"name": "World"} + + def test_parse_querystring_multiple(self, testapp): + expected = {"name": ["steve", "Loria"]} + assert testapp.get("/echo_multi?name=steve&name=Loria").json == expected + + # test that passing a single value parses correctly + # on parsers like falconparser, where there is no native MultiDict type, + # this verifies the usage of MultiDictProxy to ensure that single values + # are "listified" + def test_parse_querystring_multiple_single_value(self, testapp): + expected = {"name": ["steve"]} + assert testapp.get("/echo_multi?name=steve").json == expected + + def test_parse_form_multiple(self, testapp): + expected = {"name": ["steve", "Loria"]} + assert ( + testapp.post("/echo_multi_form", {"name": ["steve", "Loria"]}).json + == expected + ) + + def test_parse_json_list(self, testapp): + expected = {"name": ["Steve"]} + assert ( + testapp.post_json("/echo_multi_json", {"name": ["Steve"]}).json == expected + ) + + def test_parse_json_list_error_malformed_data(self, testapp): + res = testapp.post_json( + "/echo_multi_json", {"name": "Steve"}, expect_errors=True + ) + assert res.status_code == 422 + + def test_parse_json_with_nonascii_chars(self, testapp): + text = "øˆƒ£ºº∆ƒˆ∆" + assert testapp.post_json("/echo_json", {"name": text}).json == {"name": text} + + # https://github.com/marshmallow-code/webargs/issues/427 + def test_parse_json_with_nonutf8_chars(self, testapp): + res = testapp.post( + "/echo_json", + b"\xfe", + headers={"Accept": "application/json", "Content-Type": "application/json"}, + expect_errors=True, + ) + + assert res.status_code == 400 + assert res.json == {"json": ["Invalid JSON body."]} + + def test_validation_error_returns_422_response(self, testapp): + res = testapp.post_json("/echo_json", {"name": "b"}, expect_errors=True) + assert res.status_code == 422 + + def test_user_validation_error_returns_422_response_by_default(self, testapp): + res = testapp.post_json("/error", {"text": "foo"}, expect_errors=True) + assert res.status_code == 422 + + def test_use_args_decorator(self, testapp): + assert testapp.get("/echo_use_args?name=Fred").json == {"name": "Fred"} + + def test_use_args_with_path_param(self, testapp): + url = "/echo_use_args_with_path_param/foo" + res = testapp.get(url + "?value=42") + assert res.json == {"value": 42} + + def test_use_args_with_validation(self, testapp): + result = testapp.post("/echo_use_args_validated", {"value": 43}) + assert result.status_code == 200 + result = testapp.post( + "/echo_use_args_validated", {"value": 41}, expect_errors=True + ) + assert result.status_code == 422 + + def test_use_kwargs_decorator(self, testapp): + assert testapp.get("/echo_use_kwargs?name=Fred").json == {"name": "Fred"} + + def test_use_kwargs_with_path_param(self, testapp): + url = "/echo_use_kwargs_with_path_param/foo" + res = testapp.get(url + "?value=42") + assert res.json == {"value": 42} + + def test_parsing_headers(self, testapp): + res = testapp.get("/echo_headers", headers={"name": "Fred"}) + assert res.json == {"name": "Fred"} + + def test_parsing_cookies(self, testapp): + testapp.set_cookie("name", "Steve") + res = testapp.get("/echo_cookie") + assert res.json == {"name": "Steve"} + + def test_parse_nested_json(self, testapp): + res = testapp.post_json( + "/echo_nested", {"name": {"first": "Steve", "last": "Loria"}} + ) + assert res.json == {"name": {"first": "Steve", "last": "Loria"}} + + def test_parse_nested_many_json(self, testapp): + in_data = {"users": [{"id": 1, "name": "foo"}, {"id": 2, "name": "bar"}]} + res = testapp.post_json("/echo_nested_many", in_data) + assert res.json == in_data + + # Regression test for https://github.com/marshmallow-code/webargs/issues/120 + def test_parse_nested_many_missing(self, testapp): + in_data = {} + res = testapp.post_json("/echo_nested_many", in_data) + assert res.json == {} + + def test_parse_files(self, testapp): + res = testapp.post( + "/echo_file", {"myfile": webtest.Upload("README.rst", b"data")} + ) + assert res.json == {"myfile": "data"} + + # https://github.com/sloria/webargs/pull/297 + def test_empty_json(self, testapp): + res = testapp.post("/echo_json") + assert res.status_code == 200 + assert res.json == {"name": "World"} + + # https://github.com/sloria/webargs/pull/297 + def test_empty_json_with_headers(self, testapp): + res = testapp.post( + "/echo_json", + "", + headers={"Accept": "application/json", "Content-Type": "application/json"}, + ) + assert res.status_code == 200 + assert res.json == {"name": "World"} + + # https://github.com/sloria/webargs/issues/329 + def test_invalid_json(self, testapp): + res = testapp.post( + "/echo_json", + '{"foo": "bar", }', + headers={"Accept": "application/json", "Content-Type": "application/json"}, + expect_errors=True, + ) + assert res.status_code == 400 + assert res.json == {"json": ["Invalid JSON body."]} + + @pytest.mark.parametrize( + ("path", "payload", "content_type"), + [ + ( + "/echo_json", + json.dumps({"name": "foo"}), + "application/x-www-form-urlencoded", + ), + ("/echo_form", {"name": "foo"}, "application/json"), + ], + ) + def test_content_type_mismatch(self, testapp, path, payload, content_type): + res = testapp.post(path, payload, headers={"Content-Type": content_type}) + assert res.json == {"name": "World"} diff --git a/src/webargs/tornadoparser.py b/src/webargs/tornadoparser.py new file mode 100644 index 0000000..4c919a0 --- /dev/null +++ b/src/webargs/tornadoparser.py @@ -0,0 +1,151 @@ +"""Tornado request argument parsing module. + +Example: :: + + import tornado.web + from marshmallow import fields + from webargs.tornadoparser import use_args + + class HelloHandler(tornado.web.RequestHandler): + + @use_args({'name': fields.Str(missing='World')}) + def get(self, args): + response = {'message': 'Hello {}'.format(args['name'])} + self.write(response) +""" +import tornado.web +import tornado.concurrent +from tornado.escape import _unicode + +from webargs import core +from webargs.multidictproxy import MultiDictProxy + + +class HTTPError(tornado.web.HTTPError): + """`tornado.web.HTTPError` that stores validation errors.""" + + def __init__(self, *args, **kwargs): + self.messages = kwargs.pop("messages", {}) + self.headers = kwargs.pop("headers", None) + super().__init__(*args, **kwargs) + + +def is_json_request(req): + content_type = req.headers.get("Content-Type") + return content_type is not None and core.is_json(content_type) + + +class WebArgsTornadoMultiDictProxy(MultiDictProxy): + """ + Override class for Tornado multidicts, handles argument decoding + requirements. + """ + + def __getitem__(self, key): + try: + value = self.data.get(key, core.missing) + if value is core.missing: + return core.missing + if key in self.multiple_keys: + return [ + _unicode(v) if isinstance(v, (str, bytes)) else v for v in value + ] + if value and isinstance(value, (list, tuple)): + value = value[0] + + if isinstance(value, (str, bytes)): + return _unicode(value) + return value + # based on tornado.web.RequestHandler.decode_argument + except UnicodeDecodeError: + raise HTTPError(400, "Invalid unicode in {}: {!r}".format(key, value[:40])) + + +class WebArgsTornadoCookiesMultiDictProxy(MultiDictProxy): + """ + And a special override for cookies because they come back as objects with a + `value` attribute we need to extract. + Also, does not use the `_unicode` decoding step + """ + + def __getitem__(self, key): + cookie = self.data.get(key, core.missing) + if cookie is core.missing: + return core.missing + if key in self.multiple_keys: + return [cookie.value] + return cookie.value + + +class TornadoParser(core.Parser): + """Tornado request argument parser.""" + + def _raw_load_json(self, req): + """Return a json payload from the request for the core parser's load_json + + Checks the input mimetype and may return 'missing' if the mimetype is + non-json, even if the request body is parseable as json.""" + if not is_json_request(req): + return core.missing + + # request.body may be a concurrent.Future on streaming requests + # this would cause a TypeError if we try to parse it + if isinstance(req.body, tornado.concurrent.Future): + return core.missing + + return core.parse_json(req.body) + + def load_querystring(self, req, schema): + """Return query params from the request as a MultiDictProxy.""" + return WebArgsTornadoMultiDictProxy(req.query_arguments, schema) + + def load_form(self, req, schema): + """Return form values from the request as a MultiDictProxy.""" + return WebArgsTornadoMultiDictProxy(req.body_arguments, schema) + + def load_headers(self, req, schema): + """Return headers from the request as a MultiDictProxy.""" + return WebArgsTornadoMultiDictProxy(req.headers, schema) + + def load_cookies(self, req, schema): + """Return cookies from the request as a MultiDictProxy.""" + # use the specialized subclass specifically for handling Tornado + # cookies + return WebArgsTornadoCookiesMultiDictProxy(req.cookies, schema) + + def load_files(self, req, schema): + """Return files from the request as a MultiDictProxy.""" + return WebArgsTornadoMultiDictProxy(req.files, schema) + + def handle_error(self, error, req, schema, *, error_status_code, error_headers): + """Handles errors during parsing. Raises a `tornado.web.HTTPError` + with a 400 error. + """ + status_code = error_status_code or self.DEFAULT_VALIDATION_STATUS + if status_code == 422: + reason = "Unprocessable Entity" + else: + reason = None + raise HTTPError( + status_code, + log_message=str(error.messages), + reason=reason, + messages=error.messages, + headers=error_headers, + ) + + def _handle_invalid_json_error(self, error, req, *args, **kwargs): + raise HTTPError( + 400, + log_message="Invalid JSON body.", + reason="Bad Request", + messages={"json": ["Invalid JSON body."]}, + ) + + def get_request_from_view_args(self, view, args, kwargs): + return args[0].request + + +parser = TornadoParser() +use_args = parser.use_args +use_kwargs = parser.use_kwargs diff --git a/src/webargs/webapp2parser.py b/src/webargs/webapp2parser.py new file mode 100644 index 0000000..e06ad1b --- /dev/null +++ b/src/webargs/webapp2parser.py @@ -0,0 +1,72 @@ +"""Webapp2 request argument parsing module. + +Example: :: + + import webapp2 + + from marshmallow import fields + from webargs.webobparser import use_args + + hello_args = { + 'name': fields.Str(missing='World') + } + + class MainPage(webapp2.RequestHandler): + + @use_args(hello_args) + def get_args(self, args): + self.response.write('Hello, {name}!'.format(name=args['name'])) + + @use_kwargs(hello_args) + def get_kwargs(self, name=None): + self.response.write('Hello, {name}!'.format(name=name)) + + app = webapp2.WSGIApplication([ + webapp2.Route(r'/hello', MainPage, handler_method='get_args'), + webapp2.Route(r'/hello_dict', MainPage, handler_method='get_kwargs'), + ], debug=True) +""" +import webapp2 +import webob.multidict + +from webargs import core +from webargs.multidictproxy import MultiDictProxy + + +class Webapp2Parser(core.Parser): + """webapp2 request argument parser.""" + + def _raw_load_json(self, req): + """Return a json payload from the request for the core parser's load_json.""" + if not core.is_json(req.content_type): + return core.missing + return core.parse_json(req.body) + + def load_querystring(self, req, schema): + """Return query params from the request as a MultiDictProxy.""" + return MultiDictProxy(req.GET, schema) + + def load_form(self, req, schema): + """Return form values from the request as a MultiDictProxy.""" + return MultiDictProxy(req.POST, schema) + + def load_cookies(self, req, schema): + """Return cookies from the request as a MultiDictProxy.""" + return MultiDictProxy(req.cookies, schema) + + def load_headers(self, req, schema): + """Return headers from the request as a MultiDictProxy.""" + return MultiDictProxy(req.headers, schema) + + def load_files(self, req, schema): + """Return files from the request as a MultiDictProxy.""" + files = ((k, v) for k, v in req.POST.items() if hasattr(v, "file")) + return MultiDictProxy(webob.multidict.MultiDict(files), schema) + + def get_default_request(self): + return webapp2.get_request() + + +parser = Webapp2Parser() +use_args = parser.use_args +use_kwargs = parser.use_kwargs diff --git a/tests/__init__.py b/tests/__init__.py index 40a96af..e69de29 100755 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +0,0 @@ -# -*- coding: utf-8 -*- diff --git a/tests/apps/aiohttp_app.py b/tests/apps/aiohttp_app.py index dcdf6ef..a0b3807 100644 --- a/tests/apps/aiohttp_app.py +++ b/tests/apps/aiohttp_app.py @@ -2,7 +2,6 @@ import aiohttp from aiohttp.web import json_response -from aiohttp import web import marshmallow as ma from webargs import fields @@ -25,62 +24,101 @@ strict_kwargs = {"strict": True} if MARSHMALLOW_VERSION_INFO[0] < 3 else {} hello_many_schema = HelloSchema(many=True, **strict_kwargs) +# variant which ignores unknown fields +exclude_kwargs = ( + {"strict": True} if MARSHMALLOW_VERSION_INFO[0] < 3 else {"unknown": ma.EXCLUDE} +) +hello_exclude_schema = HelloSchema(**exclude_kwargs) + + ##### Handlers ##### async def echo(request): + parsed = await parser.parse(hello_args, request, location="query") + return json_response(parsed) + + +async def echo_form(request): + parsed = await parser.parse(hello_args, request, location="form") + return json_response(parsed) + + +async def echo_json(request): try: - parsed = await parser.parse(hello_args, request) + parsed = await parser.parse(hello_args, request, location="json") except json.JSONDecodeError: - raise web.HTTPBadRequest( + raise aiohttp.web.HTTPBadRequest( body=json.dumps(["Invalid JSON."]).encode("utf-8"), content_type="application/json", ) return json_response(parsed) -async def echo_query(request): - parsed = await parser.parse(hello_args, request, locations=("query",)) - return json_response(parsed) - - -@use_args(hello_args) +async def echo_json_or_form(request): + try: + parsed = await parser.parse(hello_args, request, location="json_or_form") + except json.JSONDecodeError: + raise aiohttp.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) -@use_kwargs(hello_args) +@use_kwargs(hello_args, location="query") async def echo_use_kwargs(request, name): return json_response({"name": name}) -@use_args({"value": fields.Int()}, validate=lambda args: args["value"] > 42) +@use_args( + {"value": fields.Int()}, validate=lambda args: args["value"] > 42, location="form" +) async def echo_use_args_validated(request, args): return json_response(args) +async def echo_ignoring_extra_data(request): + return json_response(await parser.parse(hello_exclude_schema, request)) + + async def echo_multi(request): + parsed = await parser.parse(hello_multiple, request, location="query") + return json_response(parsed) + + +async def echo_multi_form(request): + parsed = await parser.parse(hello_multiple, request, location="form") + return json_response(parsed) + + +async def echo_multi_json(request): parsed = await parser.parse(hello_multiple, request) return json_response(parsed) async def echo_many_schema(request): - parsed = await parser.parse(hello_many_schema, request, locations=("json",)) - return json_response(parsed) - - -@use_args({"value": fields.Int()}) + parsed = await parser.parse(hello_many_schema, request) + return json_response(parsed) + + +@use_args({"value": fields.Int()}, location="query") async def echo_use_args_with_path_param(request, args): return json_response(args) -@use_kwargs({"value": fields.Int()}) +@use_kwargs({"value": fields.Int()}, location="query") async def echo_use_kwargs_with_path_param(request, value): return json_response({"value": value}) -@use_args({"page": fields.Int(), "q": fields.Int()}, locations=("query",)) -@use_args({"name": fields.Str()}, locations=("json",)) +@use_args({"page": fields.Int(), "q": fields.Int()}, location="query") +@use_args({"name": fields.Str()}) async def echo_use_args_multiple(request, query_parsed, json_parsed): return json_response({"query_parsed": query_parsed, "json_parsed": json_parsed}) @@ -95,12 +133,12 @@ async def echo_headers(request): - parsed = await parser.parse(hello_args, request, locations=("headers",)) + parsed = await parser.parse(hello_exclude_schema, request, location="headers") return json_response(parsed) async def echo_cookie(request): - parsed = await parser.parse(hello_args, request, locations=("cookies",)) + parsed = await parser.parse(hello_args, request, location="cookies") return json_response(parsed) @@ -134,25 +172,27 @@ async def echo_match_info(request): - parsed = await parser.parse({"mymatch": fields.Int(location="match_info")}, request) + parsed = await parser.parse( + {"mymatch": fields.Int()}, request, location="match_info" + ) return json_response(parsed) class EchoHandler: - @use_args(hello_args) + @use_args(hello_args, location="query") async def get(self, request, args): return json_response(args) -class EchoHandlerView(web.View): +class EchoHandlerView(aiohttp.web.View): @asyncio.coroutine - @use_args(hello_args) + @use_args(hello_args, location="query") def get(self, args): return json_response(args) @asyncio.coroutine -@use_args(HelloSchema, as_kwargs=True) +@use_args(HelloSchema, as_kwargs=True, location="query") def echo_use_schema_as_kwargs(request, name): return json_response({"name": name}) @@ -168,12 +208,17 @@ def create_app(): app = aiohttp.web.Application() - add_route(app, ["GET", "POST"], "/echo", echo) - add_route(app, ["GET"], "/echo_query", echo_query) - add_route(app, ["GET", "POST"], "/echo_use_args", echo_use_args) - add_route(app, ["GET", "POST"], "/echo_use_kwargs", echo_use_kwargs) - add_route(app, ["GET", "POST"], "/echo_use_args_validated", echo_use_args_validated) - add_route(app, ["GET", "POST"], "/echo_multi", echo_multi) + 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) + add_route(app, ["POST"], "/echo_ignoring_extra_data", echo_ignoring_extra_data) + add_route(app, ["GET"], "/echo_multi", echo_multi) + add_route(app, ["POST"], "/echo_multi_form", echo_multi_form) + add_route(app, ["POST"], "/echo_multi_json", echo_multi_json) add_route(app, ["GET", "POST"], "/echo_many_schema", echo_many_schema) add_route( app, diff --git a/tests/apps/bottle_app.py b/tests/apps/bottle_app.py index b8b9ae7..f03ef9c 100644 --- a/tests/apps/bottle_app.py +++ b/tests/apps/bottle_app.py @@ -1,10 +1,10 @@ -from webargs.core import json from bottle import Bottle, HTTPResponse, debug, request, response import marshmallow as ma from webargs import fields from webargs.bottleparser import parser, use_args, use_kwargs -from webargs.core import MARSHMALLOW_VERSION_INFO +from webargs.core import json, MARSHMALLOW_VERSION_INFO + hello_args = {"name": fields.Str(missing="World", validate=lambda n: len(n) >= 3)} hello_multiple = {"name": fields.List(fields.Str())} @@ -17,61 +17,100 @@ strict_kwargs = {"strict": True} if MARSHMALLOW_VERSION_INFO[0] < 3 else {} hello_many_schema = HelloSchema(many=True, **strict_kwargs) +# variant which ignores unknown fields +exclude_kwargs = ( + {"strict": True} if MARSHMALLOW_VERSION_INFO[0] < 3 else {"unknown": ma.EXCLUDE} +) +hello_exclude_schema = HelloSchema(**exclude_kwargs) + app = Bottle() debug(True) -@app.route("/echo", method=["GET", "POST"]) +@app.route("/echo", method=["GET"]) def echo(): - return parser.parse(hello_args, request) + return parser.parse(hello_args, request, location="query") -@app.route("/echo_query") -def echo_query(): - return parser.parse(hello_args, request, locations=("query",)) +@app.route("/echo_form", method=["POST"]) +def echo_form(): + return parser.parse(hello_args, location="form") -@app.route("/echo_use_args", method=["GET", "POST"]) -@use_args(hello_args) +@app.route("/echo_json", method=["POST"]) +def echo_json(): + return parser.parse(hello_args, location="json") + + +@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): return args -@app.route("/echo_use_kwargs", method=["GET", "POST"], apply=use_kwargs(hello_args)) -def echo_use_kwargs(name): - return {"name": name} - - @app.route( "/echo_use_args_validated", - method=["GET", "POST"], - apply=use_args({"value": fields.Int()}, validate=lambda args: args["value"] > 42), + method=["POST"], + apply=use_args( + {"value": fields.Int()}, + validate=lambda args: args["value"] > 42, + location="form", + ), ) def echo_use_args_validated(args): return args -@app.route("/echo_multi", method=["GET", "POST"]) -def echo_multi(): - return parser.parse(hello_multiple, request) +@app.route("/echo_ignoring_extra_data", method=["POST"]) +def echo_json_ignore_extra_data(): + return parser.parse(hello_exclude_schema) -@app.route("/echo_many_schema", method=["GET", "POST"]) +@app.route( + "/echo_use_kwargs", method=["GET"], apply=use_kwargs(hello_args, location="query") +) +def echo_use_kwargs(name): + return {"name": name} + + +@app.route("/echo_multi", method=["GET"]) +def echo_multi(): + return parser.parse(hello_multiple, request, location="query") + + +@app.route("/echo_multi_form", method=["POST"]) +def multi_form(): + return parser.parse(hello_multiple, location="form") + + +@app.route("/echo_multi_json", method=["POST"]) +def multi_json(): + return parser.parse(hello_multiple) + + +@app.route("/echo_many_schema", method=["POST"]) def echo_many_schema(): - arguments = parser.parse(hello_many_schema, request, locations=("json",)) + arguments = parser.parse(hello_many_schema, request) return HTTPResponse(body=json.dumps(arguments), content_type="application/json") @app.route( - "/echo_use_args_with_path_param/", apply=use_args({"value": fields.Int()}) + "/echo_use_args_with_path_param/", + apply=use_args({"value": fields.Int()}, location="query"), ) def echo_use_args_with_path_param(args, name): return args @app.route( - "/echo_use_kwargs_with_path_param/", apply=use_kwargs({"value": fields.Int()}) + "/echo_use_kwargs_with_path_param/", + apply=use_kwargs({"value": fields.Int()}, location="query"), ) def echo_use_kwargs_with_path_param(name, value): return {"value": value} @@ -88,18 +127,20 @@ @app.route("/echo_headers") def echo_headers(): - return parser.parse(hello_args, request, locations=("headers",)) + # the "exclude schema" must be used in this case because WSGI headers may + # be populated with many fields not sent by the caller + return parser.parse(hello_exclude_schema, request, location="headers") @app.route("/echo_cookie") def echo_cookie(): - return parser.parse(hello_args, request, locations=("cookies",)) + return parser.parse(hello_args, request, location="cookies") @app.route("/echo_file", method=["POST"]) def echo_file(): args = {"myfile": fields.Field()} - result = parser.parse(args, locations=("files",)) + result = parser.parse(args, location="files") myfile = result["myfile"] content = myfile.file.read().decode("utf8") return {"myfile": content} diff --git a/tests/apps/django_app/base/settings.py b/tests/apps/django_app/base/settings.py index a127df7..0dd41b0 100644 --- a/tests/apps/django_app/base/settings.py +++ b/tests/apps/django_app/base/settings.py @@ -7,7 +7,7 @@ TEMPLATE_DEBUG = True -ALLOWED_HOSTS = [] +ALLOWED_HOSTS = ["*"] # Application definition INSTALLED_APPS = ("django.contrib.contenttypes",) diff --git a/tests/apps/django_app/base/urls.py b/tests/apps/django_app/base/urls.py index 9613c74..07a86e9 100644 --- a/tests/apps/django_app/base/urls.py +++ b/tests/apps/django_app/base/urls.py @@ -2,12 +2,19 @@ from tests.apps.django_app.echo import views + urlpatterns = [ url(r"^echo$", views.echo), - url(r"^echo_query$", views.echo_query), + 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), url(r"^echo_use_kwargs$", views.echo_use_kwargs), url(r"^echo_multi$", views.echo_multi), + url(r"^echo_multi_form$", views.echo_multi_form), + url(r"^echo_multi_json$", views.echo_multi_json), url(r"^echo_many_schema$", views.echo_many_schema), url( r"^echo_use_args_with_path_param/(?P\w+)$", diff --git a/tests/apps/django_app/echo/views.py b/tests/apps/django_app/echo/views.py index d08e83a..363dbc9 100644 --- a/tests/apps/django_app/echo/views.py +++ b/tests/apps/django_app/echo/views.py @@ -1,11 +1,11 @@ -from webargs.core import json from django.http import HttpResponse from django.views.generic import View +import marshmallow as ma -import marshmallow as ma from webargs import fields from webargs.djangoparser import parser, use_args, use_kwargs -from webargs.core import MARSHMALLOW_VERSION_INFO +from webargs.core import json, MARSHMALLOW_VERSION_INFO + hello_args = {"name": fields.Str(missing="World", validate=lambda n: len(n) >= 3)} hello_multiple = {"name": fields.List(fields.Str())} @@ -18,90 +18,143 @@ strict_kwargs = {"strict": True} if MARSHMALLOW_VERSION_INFO[0] < 3 else {} hello_many_schema = HelloSchema(many=True, **strict_kwargs) +# variant which ignores unknown fields +exclude_kwargs = ( + {"strict": True} if MARSHMALLOW_VERSION_INFO[0] < 3 else {"unknown": ma.EXCLUDE} +) +hello_exclude_schema = HelloSchema(**exclude_kwargs) + def json_response(data, **kwargs): return HttpResponse(json.dumps(data), content_type="application/json", **kwargs) -def echo(request): - try: - args = parser.parse(hello_args, request) - except ma.ValidationError as err: - return json_response(err.messages, status=parser.DEFAULT_VALIDATION_STATUS) - except json.JSONDecodeError: - return json_response({"json": ["Invalid JSON body."]}, status=400) - return json_response(args) +def handle_view_errors(f): + def wrapped(*args, **kwargs): + try: + return f(*args, **kwargs) + except ma.ValidationError as err: + return json_response(err.messages, status=422) + except json.JSONDecodeError: + return json_response({"json": ["Invalid JSON body."]}, status=400) + + return wrapped -def echo_query(request): - return json_response(parser.parse(hello_args, request, locations=("query",))) +@handle_view_errors +def echo(request): + return json_response(parser.parse(hello_args, request, location="query")) -@use_args(hello_args) +@handle_view_errors +def echo_form(request): + return json_response(parser.parse(hello_args, request, location="form")) + + +@handle_view_errors +def echo_json(request): + return json_response(parser.parse(hello_args, request, location="json")) + + +@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): return json_response(args) -@use_kwargs(hello_args) +@handle_view_errors +@use_args( + {"value": fields.Int()}, validate=lambda args: args["value"] > 42, location="form" +) +def echo_use_args_validated(args): + return json_response(args) + + +@handle_view_errors +def echo_ignoring_extra_data(request): + return json_response(parser.parse(hello_exclude_schema, request)) + + +@handle_view_errors +@use_kwargs(hello_args, location="query") def echo_use_kwargs(request, name): return json_response({"name": name}) +@handle_view_errors def echo_multi(request): + return json_response(parser.parse(hello_multiple, request, location="query")) + + +@handle_view_errors +def echo_multi_form(request): + return json_response(parser.parse(hello_multiple, request, location="form")) + + +@handle_view_errors +def echo_multi_json(request): return json_response(parser.parse(hello_multiple, request)) +@handle_view_errors def echo_many_schema(request): - try: - return json_response( - parser.parse(hello_many_schema, request, locations=("json",)) - ) - except ma.ValidationError as err: - return json_response(err.messages, status=parser.DEFAULT_VALIDATION_STATUS) + return json_response(parser.parse(hello_many_schema, request)) -@use_args({"value": fields.Int()}) +@handle_view_errors +@use_args({"value": fields.Int()}, location="query") def echo_use_args_with_path_param(request, args, name): return json_response(args) -@use_kwargs({"value": fields.Int()}) +@handle_view_errors +@use_kwargs({"value": fields.Int()}, location="query") def echo_use_kwargs_with_path_param(request, value, name): return json_response({"value": value}) +@handle_view_errors def always_error(request): def always_fail(value): raise ma.ValidationError("something went wrong") argmap = {"text": fields.Str(validate=always_fail)} - try: - return parser.parse(argmap, request) - except ma.ValidationError as err: - return json_response(err.messages, status=parser.DEFAULT_VALIDATION_STATUS) + return parser.parse(argmap, request) +@handle_view_errors def echo_headers(request): - return json_response(parser.parse(hello_args, request, locations=("headers",))) + return json_response( + parser.parse(hello_exclude_schema, request, location="headers") + ) +@handle_view_errors def echo_cookie(request): - return json_response(parser.parse(hello_args, request, locations=("cookies",))) + return json_response(parser.parse(hello_args, request, location="cookies")) +@handle_view_errors def echo_file(request): args = {"myfile": fields.Field()} - result = parser.parse(args, request, locations=("files",)) + result = parser.parse(args, request, location="files") myfile = result["myfile"] content = myfile.read().decode("utf8") return json_response({"myfile": content}) +@handle_view_errors def echo_nested(request): argmap = {"name": fields.Nested({"first": fields.Str(), "last": fields.Str()})} return json_response(parser.parse(argmap, request)) +@handle_view_errors def echo_nested_many(request): argmap = { "users": fields.Nested({"id": fields.Int(), "name": fields.Str()}, many=True) @@ -110,27 +163,33 @@ class EchoCBV(View): + @handle_view_errors def get(self, request): - try: - args = parser.parse(hello_args, self.request) - except ma.ValidationError as err: - return json_response(err.messages, status=parser.DEFAULT_VALIDATION_STATUS) - return json_response(args) + location_kwarg = {} if request.method == "POST" else {"location": "query"} + return json_response(parser.parse(hello_args, self.request, **location_kwarg)) post = get class EchoUseArgsCBV(View): - @use_args(hello_args) + @handle_view_errors + @use_args(hello_args, location="query") def get(self, request, args): return json_response(args) - post = get + @handle_view_errors + @use_args(hello_args) + def post(self, request, args): + return json_response(args) class EchoUseArgsWithParamCBV(View): - @use_args(hello_args) + @handle_view_errors + @use_args(hello_args, location="query") def get(self, request, args, pid): return json_response(args) - post = get + @handle_view_errors + @use_args(hello_args) + def post(self, request, args, pid): + return json_response(args) diff --git a/tests/apps/falcon_app.py b/tests/apps/falcon_app.py index f68541f..3643019 100644 --- a/tests/apps/falcon_app.py +++ b/tests/apps/falcon_app.py @@ -1,10 +1,9 @@ -from webargs.core import json - import falcon import marshmallow as ma + from webargs import fields +from webargs.core import MARSHMALLOW_VERSION_INFO, json from webargs.falconparser import parser, use_args, use_kwargs -from webargs.core import MARSHMALLOW_VERSION_INFO hello_args = {"name": fields.Str(missing="World", validate=lambda n: len(n) >= 3)} hello_multiple = {"name": fields.List(fields.Str())} @@ -17,79 +16,97 @@ strict_kwargs = {"strict": True} if MARSHMALLOW_VERSION_INFO[0] < 3 else {} hello_many_schema = HelloSchema(many=True, **strict_kwargs) - -class Echo(object): - def on_get(self, req, resp): - try: - parsed = parser.parse(hello_args, req) - except json.JSONDecodeError: - resp.body = json.dumps(["Invalid JSON."]) - resp.status = falcon.HTTP_400 - else: - resp.body = json.dumps(parsed) - - on_post = on_get +# variant which ignores unknown fields +exclude_kwargs = ( + {"strict": True} if MARSHMALLOW_VERSION_INFO[0] < 3 else {"unknown": ma.EXCLUDE} +) +hello_exclude_schema = HelloSchema(**exclude_kwargs) -class EchoQuery(object): +class Echo: def on_get(self, req, resp): - parsed = parser.parse(hello_args, req, locations=("query",)) + parsed = parser.parse(hello_args, req, location="query") resp.body = json.dumps(parsed) -class EchoUseArgs(object): - @use_args(hello_args) +class EchoForm: + def on_post(self, req, resp): + parsed = parser.parse(hello_args, req, location="form") + resp.body = json.dumps(parsed) + + +class EchoJSON: + def on_post(self, req, resp): + parsed = parser.parse(hello_args, req, location="json") + resp.body = json.dumps(parsed) + + +class EchoJSONOrForm: + def on_post(self, req, resp): + parsed = parser.parse(hello_args, req, location="json_or_form") + resp.body = json.dumps(parsed) + + +class EchoUseArgs: + @use_args(hello_args, location="query") def on_get(self, req, resp, args): resp.body = json.dumps(args) - on_post = on_get - -class EchoUseKwargs(object): - @use_kwargs(hello_args) +class EchoUseKwargs: + @use_kwargs(hello_args, location="query") def on_get(self, req, resp, name): resp.body = json.dumps({"name": name}) - on_post = on_get + +class EchoUseArgsValidated: + @use_args( + {"value": fields.Int()}, + validate=lambda args: args["value"] > 42, + location="form", + ) + def on_post(self, req, resp, args): + resp.body = json.dumps(args) -class EchoUseArgsValidated(object): - @use_args({"value": fields.Int()}, validate=lambda args: args["value"] > 42) - def on_get(self, req, resp, args): - resp.body = json.dumps(args) - - on_post = on_get +class EchoJSONIgnoreExtraData: + def on_post(self, req, resp): + resp.body = json.dumps(parser.parse(hello_exclude_schema, req)) -class EchoMulti(object): +class EchoMulti: def on_get(self, req, resp): + resp.body = json.dumps(parser.parse(hello_multiple, req, location="query")) + + +class EchoMultiForm: + def on_post(self, req, resp): + resp.body = json.dumps(parser.parse(hello_multiple, req, location="form")) + + +class EchoMultiJSON: + def on_post(self, req, resp): resp.body = json.dumps(parser.parse(hello_multiple, req)) - on_post = on_get + +class EchoManySchema: + def on_post(self, req, resp): + resp.body = json.dumps(parser.parse(hello_many_schema, req)) -class EchoManySchema(object): - def on_get(self, req, resp): - resp.body = json.dumps( - parser.parse(hello_many_schema, req, locations=("json",)) - ) - - on_post = on_get - - -class EchoUseArgsWithPathParam(object): - @use_args({"value": fields.Int()}) +class EchoUseArgsWithPathParam: + @use_args({"value": fields.Int()}, location="query") def on_get(self, req, resp, args, name): resp.body = json.dumps(args) -class EchoUseKwargsWithPathParam(object): - @use_kwargs({"value": fields.Int()}) +class EchoUseKwargsWithPathParam: + @use_kwargs({"value": fields.Int()}, location="query") def on_get(self, req, resp, value, name): resp.body = json.dumps({"value": value}) -class AlwaysError(object): +class AlwaysError: def on_get(self, req, resp): def always_fail(value): raise ma.ValidationError("something went wrong") @@ -100,23 +117,28 @@ on_post = on_get -class EchoHeaders(object): +class EchoHeaders: def on_get(self, req, resp): - resp.body = json.dumps(parser.parse(hello_args, req, locations=("headers",))) + class HeaderSchema(ma.Schema): + NAME = fields.Str(missing="World") + + resp.body = json.dumps( + parser.parse(HeaderSchema(**exclude_kwargs), req, location="headers") + ) -class EchoCookie(object): +class EchoCookie: def on_get(self, req, resp): - resp.body = json.dumps(parser.parse(hello_args, req, locations=("cookies",))) + resp.body = json.dumps(parser.parse(hello_args, req, location="cookies")) -class EchoNested(object): +class EchoNested: def on_post(self, req, resp): args = {"name": fields.Nested({"first": fields.Str(), "last": fields.Str()})} resp.body = json.dumps(parser.parse(args, req)) -class EchoNestedMany(object): +class EchoNestedMany: def on_post(self, req, resp): args = { "users": fields.Nested( @@ -127,15 +149,15 @@ def use_args_hook(args, context_key="args", **kwargs): - def hook(req, resp, params): + def hook(req, resp, resource, params): parsed_args = parser.parse(args, req=req, **kwargs) req.context[context_key] = parsed_args return hook -@falcon.before(use_args_hook(hello_args)) -class EchoUseArgsHook(object): +@falcon.before(use_args_hook(hello_args, location="query")) +class EchoUseArgsHook: def on_get(self, req, resp): resp.body = json.dumps(req.context["args"]) @@ -143,11 +165,16 @@ def create_app(): app = falcon.API() app.add_route("/echo", Echo()) - app.add_route("/echo_query", EchoQuery()) + 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()) + app.add_route("/echo_ignoring_extra_data", EchoJSONIgnoreExtraData()) app.add_route("/echo_multi", EchoMulti()) + app.add_route("/echo_multi_form", EchoMultiForm()) + app.add_route("/echo_multi_json", EchoMultiJSON()) app.add_route("/echo_many_schema", EchoManySchema()) app.add_route("/echo_use_args_with_path_param/{name}", EchoUseArgsWithPathParam()) app.add_route( diff --git a/tests/apps/flask_app.py b/tests/apps/flask_app.py index 019cb9f..232e715 100644 --- a/tests/apps/flask_app.py +++ b/tests/apps/flask_app.py @@ -1,11 +1,10 @@ -from webargs.core import json from flask import Flask, jsonify as J, Response, request from flask.views import MethodView - import marshmallow as ma + from webargs import fields from webargs.flaskparser import parser, use_args, use_kwargs -from webargs.core import MARSHMALLOW_VERSION_INFO +from webargs.core import json, MARSHMALLOW_VERSION_INFO class TestAppConfig: @@ -23,57 +22,90 @@ strict_kwargs = {"strict": True} if MARSHMALLOW_VERSION_INFO[0] < 3 else {} hello_many_schema = HelloSchema(many=True, **strict_kwargs) +# variant which ignores unknown fields +exclude_kwargs = ( + {"strict": True} if MARSHMALLOW_VERSION_INFO[0] < 3 else {"unknown": ma.EXCLUDE} +) +hello_exclude_schema = HelloSchema(**exclude_kwargs) + app = Flask(__name__) app.config.from_object(TestAppConfig) -@app.route("/echo", methods=["GET", "POST"]) +@app.route("/echo", methods=["GET"]) def echo(): - return J(parser.parse(hello_args)) - - -@app.route("/echo_query") -def echo_query(): - return J(parser.parse(hello_args, request, locations=("query",))) - - -@app.route("/echo_use_args", methods=["GET", "POST"]) -@use_args(hello_args) + return J(parser.parse(hello_args, location="query")) + + +@app.route("/echo_form", methods=["POST"]) +def echo_form(): + return J(parser.parse(hello_args, location="form")) + + +@app.route("/echo_json", methods=["POST"]) +def echo_json(): + return J(parser.parse(hello_args, location="json")) + + +@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): return J(args) -@app.route("/echo_use_args_validated", methods=["GET", "POST"]) -@use_args({"value": fields.Int()}, validate=lambda args: args["value"] > 42) +@app.route("/echo_use_args_validated", methods=["POST"]) +@use_args( + {"value": fields.Int()}, validate=lambda args: args["value"] > 42, location="form" +) def echo_use_args_validated(args): return J(args) -@app.route("/echo_use_kwargs", methods=["GET", "POST"]) -@use_kwargs(hello_args) +@app.route("/echo_ignoring_extra_data", methods=["POST"]) +def echo_json_ignore_extra_data(): + return J(parser.parse(hello_exclude_schema)) + + +@app.route("/echo_use_kwargs", methods=["GET"]) +@use_kwargs(hello_args, location="query") def echo_use_kwargs(name): return J({"name": name}) -@app.route("/echo_multi", methods=["GET", "POST"]) +@app.route("/echo_multi", methods=["GET"]) def multi(): + return J(parser.parse(hello_multiple, location="query")) + + +@app.route("/echo_multi_form", methods=["POST"]) +def multi_form(): + return J(parser.parse(hello_multiple, location="form")) + + +@app.route("/echo_multi_json", methods=["POST"]) +def multi_json(): return J(parser.parse(hello_multiple)) @app.route("/echo_many_schema", methods=["GET", "POST"]) def many_nested(): - arguments = parser.parse(hello_many_schema, locations=("json",)) + arguments = parser.parse(hello_many_schema) return Response(json.dumps(arguments), content_type="application/json") @app.route("/echo_use_args_with_path_param/") -@use_args({"value": fields.Int()}) +@use_args({"value": fields.Int()}, location="query") def echo_use_args_with_path(args, name): return J(args) @app.route("/echo_use_kwargs_with_path_param/") -@use_kwargs({"value": fields.Int()}) +@use_kwargs({"value": fields.Int()}, location="query") def echo_use_kwargs_with_path(name, value): return J({"value": value}) @@ -89,18 +121,20 @@ @app.route("/echo_headers") def echo_headers(): - return J(parser.parse(hello_args, locations=("headers",))) + # the "exclude schema" must be used in this case because WSGI headers may + # be populated with many fields not sent by the caller + return J(parser.parse(hello_exclude_schema, location="headers")) @app.route("/echo_cookie") def echo_cookie(): - return J(parser.parse(hello_args, request, locations=("cookies",))) + return J(parser.parse(hello_args, request, location="cookies")) @app.route("/echo_file", methods=["POST"]) def echo_file(): args = {"myfile": fields.Field()} - result = parser.parse(args, locations=("files",)) + result = parser.parse(args, location="files") fp = result["myfile"] content = fp.read().decode("utf8") return J({"myfile": content}) @@ -108,11 +142,11 @@ @app.route("/echo_view_arg/") def echo_view_arg(view_arg): - return J(parser.parse({"view_arg": fields.Int()}, locations=("view_args",))) + return J(parser.parse({"view_arg": fields.Int()}, location="view_args")) @app.route("/echo_view_arg_use_args/") -@use_args({"view_arg": fields.Int(location="view_args")}) +@use_args({"view_arg": fields.Int()}, location="view_args") def echo_view_arg_with_use_args(args, **kwargs): return J(args) @@ -177,4 +211,16 @@ def handle_error(err): if err.code == 422: assert isinstance(err.data["schema"], ma.Schema) - return J(err.data["messages"]), err.code + + if MARSHMALLOW_VERSION_INFO[0] >= 3: + return J(err.data["messages"]), err.code + + # on marshmallow2, validation errors for nested schemas can fail to encode: + # https://github.com/marshmallow-code/marshmallow/issues/493 + # to workaround this, convert integer keys to strings + def tweak_data(value): + if not isinstance(value, dict): + return value + return {str(k): v for k, v in value.items()} + + return J({k: tweak_data(v) for k, v in err.data["messages"].items()}), err.code diff --git a/tests/apps/pyramid_app.py b/tests/apps/pyramid_app.py index 438ca72..6404b46 100644 --- a/tests/apps/pyramid_app.py +++ b/tests/apps/pyramid_app.py @@ -1,12 +1,10 @@ -from webargs.core import json - from pyramid.config import Configurator from pyramid.httpexceptions import HTTPBadRequest import marshmallow as ma from webargs import fields from webargs.pyramidparser import parser, use_args, use_kwargs -from webargs.core import MARSHMALLOW_VERSION_INFO +from webargs.core import json, MARSHMALLOW_VERSION_INFO hello_args = {"name": fields.Str(missing="World", validate=lambda n: len(n) >= 3)} hello_multiple = {"name": fields.List(fields.Str())} @@ -19,10 +17,24 @@ strict_kwargs = {"strict": True} if MARSHMALLOW_VERSION_INFO[0] < 3 else {} hello_many_schema = HelloSchema(many=True, **strict_kwargs) +# variant which ignores unknown fields +exclude_kwargs = ( + {"strict": True} if MARSHMALLOW_VERSION_INFO[0] < 3 else {"unknown": ma.EXCLUDE} +) +hello_exclude_schema = HelloSchema(**exclude_kwargs) + def echo(request): + return parser.parse(hello_args, request, location="query") + + +def echo_form(request): + return parser.parse(hello_args, request, location="form") + + +def echo_json(request): try: - return parser.parse(hello_args, request) + return parser.parse(hello_args, request, location="json") except json.JSONDecodeError: error = HTTPBadRequest() error.body = json.dumps(["Invalid JSON."]).encode("utf-8") @@ -30,39 +42,69 @@ 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) + except json.JSONDecodeError: + error = HTTPBadRequest() + error.body = json.dumps(["Invalid JSON."]).encode("utf-8") + error.content_type = "application/json" + raise error + + def echo_query(request): - return parser.parse(hello_args, request, locations=("query",)) - - -@use_args(hello_args) + return parser.parse(hello_args, request, location="query") + + +@use_args(hello_args, location="query") def echo_use_args(request, args): return args -@use_args({"value": fields.Int()}, validate=lambda args: args["value"] > 42) +@use_args( + {"value": fields.Int()}, validate=lambda args: args["value"] > 42, location="form" +) def echo_use_args_validated(request, args): return args -@use_kwargs(hello_args) +@use_kwargs(hello_args, location="query") def echo_use_kwargs(request, name): return {"name": name} def echo_multi(request): + return parser.parse(hello_multiple, request, location="query") + + +def echo_multi_form(request): + return parser.parse(hello_multiple, request, location="form") + + +def echo_multi_json(request): return parser.parse(hello_multiple, request) def echo_many_schema(request): - return parser.parse(hello_many_schema, request, locations=("json",)) - - -@use_args({"value": fields.Int()}) + return parser.parse(hello_many_schema, request) + + +@use_args({"value": fields.Int()}, location="query") def echo_use_args_with_path_param(request, args): return args -@use_kwargs({"value": fields.Int()}) +@use_kwargs({"value": fields.Int()}, location="query") def echo_use_kwargs_with_path_param(request, value): return {"value": value} @@ -76,16 +118,16 @@ def echo_headers(request): - return parser.parse(hello_args, request, locations=("headers",)) + return parser.parse(hello_exclude_schema, request, location="headers") def echo_cookie(request): - return parser.parse(hello_args, request, locations=("cookies",)) + return parser.parse(hello_args, request, location="cookies") def echo_file(request): args = {"myfile": fields.Field()} - result = parser.parse(args, request, locations=("files",)) + result = parser.parse(args, request, location="files") myfile = result["myfile"] content = myfile.file.read().decode("utf8") return {"myfile": content} @@ -104,14 +146,14 @@ def echo_matchdict(request): - return parser.parse({"mymatch": fields.Int()}, request, locations=("matchdict",)) - - -class EchoCallable(object): + return parser.parse({"mymatch": fields.Int()}, request, location="matchdict") + + +class EchoCallable: def __init__(self, request): self.request = request - @use_args({"value": fields.Int()}) + @use_args({"value": fields.Int()}, location="query") def __call__(self, args): return args @@ -127,11 +169,17 @@ config = Configurator() 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) add_route(config, "/echo_use_args_validated", echo_use_args_validated) add_route(config, "/echo_use_kwargs", echo_use_kwargs) add_route(config, "/echo_multi", echo_multi) + add_route(config, "/echo_multi_form", echo_multi_form) + add_route(config, "/echo_multi_json", echo_multi_json) add_route(config, "/echo_many_schema", echo_many_schema) add_route( config, "/echo_use_args_with_path_param/{name}", echo_use_args_with_path_param diff --git a/tests/compat.py b/tests/compat.py deleted file mode 100644 index c0c7545..0000000 --- a/tests/compat.py +++ /dev/null @@ -1,16 +0,0 @@ -# -*- coding: utf-8 -*- -# flake8: noqa -import sys - -PY2 = int(sys.version[0]) == 2 - -if PY2: - text_type = unicode - binary_type = str - string_types = (str, unicode) - basestring = basestring -else: - text_type = str - binary_type = bytes - string_types = (str,) - basestring = (str, bytes) diff --git a/tests/test_core.py b/tests/test_core.py index 4f22523..a9a32e9 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,24 +1,28 @@ -# -*- coding: utf-8 -*- import itertools -import mock -import sys import datetime import pytest -from marshmallow import Schema, post_load, class_registry, validates_schema +from marshmallow import Schema, post_load, pre_load, class_registry, validates_schema from werkzeug.datastructures import MultiDict as WerkMultiDict from django.utils.datastructures import MultiValueDict as DjMultiDict from bottle import MultiDict as BotMultiDict -from webargs import fields, missing, ValidationError +from webargs import fields, ValidationError from webargs.core import ( Parser, - get_value, dict2schema, is_json, get_mimetype, MARSHMALLOW_VERSION_INFO, ) +from webargs.multidictproxy import MultiDictProxy + +try: + # Python 3.5 + import mock +except ImportError: + # Python 3.6+ + from unittest import mock strict_kwargs = {"strict": True} if MARSHMALLOW_VERSION_INFO[0] < 3 else {} @@ -28,20 +32,20 @@ def __init__(self, status_code, headers): self.status_code = status_code self.headers = headers - super(MockHTTPError, self).__init__(self, "HTTP Error occurred") + super().__init__(self, "HTTP Error occurred") class MockRequestParser(Parser): """A minimal parser implementation that parses mock requests.""" - def parse_querystring(self, req, name, field): - return get_value(req.query, name, field) - - def parse_json(self, req, name, field): - return get_value(req.json, name, field) - - def parse_cookies(self, req, name, field): - return get_value(req.cookies, name, field) + def load_querystring(self, req, schema): + return MultiDictProxy(req.query, schema) + + def load_json(self, req, schema): + return req.json + + def load_cookies(self, req, schema): + return req.cookies @pytest.yield_fixture(scope="function") @@ -60,65 +64,73 @@ # Parser tests -@mock.patch("webargs.core.Parser.parse_json") -def test_parse_json_called_by_parse_arg(parse_json, web_request): - field = fields.Field() +@mock.patch("webargs.core.Parser.load_json") +def test_load_json_called_by_parse_default(load_json, web_request): + schema = dict2schema({"foo": fields.Field()})() + load_json.return_value = {"foo": 1} p = Parser() - p.parse_arg("foo", field, web_request) - parse_json.assert_called_with(web_request, "foo", field) - - -@mock.patch("webargs.core.Parser.parse_querystring") -def test_parse_querystring_called_by_parse_arg(parse_querystring, web_request): - field = fields.Field() - p = Parser() - p.parse_arg("foo", field, web_request) - assert parse_querystring.called_once() - - -@mock.patch("webargs.core.Parser.parse_form") -def test_parse_form_called_by_parse_arg(parse_form, web_request): - field = fields.Field() - p = Parser() - p.parse_arg("foo", field, web_request) - assert parse_form.called_once() - - -@mock.patch("webargs.core.Parser.parse_json") -def test_parse_json_not_called_when_json_not_a_location(parse_json, web_request): - field = fields.Field() - p = Parser() - p.parse_arg("foo", field, web_request, locations=("form", "querystring")) - assert parse_json.call_count == 0 - - -@mock.patch("webargs.core.Parser.parse_headers") -def test_parse_headers_called_when_headers_is_a_location(parse_headers, web_request): - field = fields.Field() - p = Parser() - p.parse_arg("foo", field, web_request) - assert parse_headers.call_count == 0 - p.parse_arg("foo", field, web_request, locations=("headers",)) - parse_headers.assert_called() - - -@mock.patch("webargs.core.Parser.parse_cookies") -def test_parse_cookies_called_when_cookies_is_a_location(parse_cookies, web_request): - field = fields.Field() - p = Parser() - p.parse_arg("foo", field, web_request) - assert parse_cookies.call_count == 0 - p.parse_arg("foo", field, web_request, locations=("cookies",)) - parse_cookies.assert_called() - - -@mock.patch("webargs.core.Parser.parse_json") -def test_parse(parse_json, web_request): - parse_json.return_value = 42 + p.parse(schema, web_request) + load_json.assert_called_with(web_request, schema) + + +@pytest.mark.parametrize( + "location", ["querystring", "form", "headers", "cookies", "files"] +) +def test_load_nondefault_called_by_parse_with_location(location, web_request): + with mock.patch( + "webargs.core.Parser.load_{}".format(location) + ) as mock_loadfunc, mock.patch("webargs.core.Parser.load_json") as load_json: + mock_loadfunc.return_value = {} + load_json.return_value = {} + p = Parser() + + # ensure that without location=..., the loader is not called (json is + # called) + p.parse({"foo": fields.Field()}, web_request) + assert mock_loadfunc.call_count == 0 + assert load_json.call_count == 1 + + # but when location=... is given, the loader *is* called and json is + # not called + p.parse({"foo": fields.Field()}, web_request, location=location) + assert mock_loadfunc.call_count == 1 + # it was already 1, should not go up + assert load_json.call_count == 1 + + +def test_parse(parser, web_request): + web_request.json = {"username": 42, "password": 42} argmap = {"username": fields.Field(), "password": fields.Field()} - p = Parser() - ret = p.parse(argmap, web_request) + ret = parser.parse(argmap, web_request) assert {"username": 42, "password": 42} == ret + + +@pytest.mark.skipif( + MARSHMALLOW_VERSION_INFO[0] < 3, reason="unknown=... added in marshmallow3" +) +def test_parse_with_unknown_behavior_specified(parser, web_request): + # This is new in webargs 6.x ; it's the way you can "get back" the behavior + # of webargs 5.x in which extra args are ignored + from marshmallow import EXCLUDE, INCLUDE, RAISE + + web_request.json = {"username": 42, "password": 42, "fjords": 42} + + class CustomSchema(Schema): + username = fields.Field() + password = fields.Field() + + # with no unknown setting or unknown=RAISE, it blows up + with pytest.raises(ValidationError, match="Unknown field."): + parser.parse(CustomSchema(), web_request) + with pytest.raises(ValidationError, match="Unknown field."): + parser.parse(CustomSchema(unknown=RAISE), web_request) + + # with unknown=EXCLUDE the data is omitted + ret = parser.parse(CustomSchema(unknown=EXCLUDE), web_request) + assert {"username": 42, "password": 42} == ret + # with unknown=INCLUDE it is added even though it isn't part of the schema + ret = parser.parse(CustomSchema(unknown=INCLUDE), web_request) + assert {"username": 42, "password": 42, "fjords": 42} == ret def test_parse_required_arg_raises_validation_error(parser, web_request): @@ -142,13 +154,10 @@ assert result == {"first": "Steve", "last": None} -@mock.patch("webargs.core.Parser.parse_json") -def test_parse_required_arg(parse_json, web_request): - arg = fields.Field(required=True) - parse_json.return_value = 42 - p = Parser() - result = p.parse_arg("foo", arg, web_request, locations=("json",)) - assert result == 42 +def test_parse_required_arg(parser, web_request): + web_request.json = {"foo": 42} + result = parser.parse({"foo": fields.Field(required=True)}, web_request) + assert result == {"foo": 42} def test_parse_required_list(parser, web_request): @@ -156,7 +165,9 @@ args = {"foo": fields.List(fields.Field(), required=True)} with pytest.raises(ValidationError) as excinfo: parser.parse(args, web_request) - assert excinfo.value.messages["foo"][0] == "Missing data for required field." + assert ( + excinfo.value.messages["json"]["foo"][0] == "Missing data for required field." + ) # Regression test for https://github.com/marshmallow-code/webargs/issues/107 @@ -171,7 +182,7 @@ args = {"foo": fields.List(fields.Field(), allow_none=False)} with pytest.raises(ValidationError) as excinfo: parser.parse(args, web_request) - assert excinfo.value.messages["foo"][0] == "Field may not be null." + assert excinfo.value.messages["json"]["foo"][0] == "Field may not be null." def test_parse_empty_list(parser, web_request): @@ -186,21 +197,21 @@ assert parser.parse(args, web_request) == {} -def test_default_locations(): - assert set(Parser.DEFAULT_LOCATIONS) == set(["json", "querystring", "form"]) +def test_default_location(): + assert Parser.DEFAULT_LOCATION == "json" def test_missing_with_default(parser, web_request): web_request.json = {} args = {"val": fields.Field(missing="pizza")} - result = parser.parse(args, web_request, locations=("json",)) + result = parser.parse(args, web_request) assert result["val"] == "pizza" def test_default_can_be_none(parser, web_request): web_request.json = {} args = {"val": fields.Field(missing=None, allow_none=True)} - result = parser.parse(args, web_request, locations=("json",)) + result = parser.parse(args, web_request) assert result["val"] is None @@ -211,143 +222,130 @@ "p": fields.Int( missing=1, validate=lambda p: p > 0, - error=u"La page demandée n'existe pas", + error="La page demandée n'existe pas", location="querystring", ) } assert parser.parse(args, web_request) == {"p": 1} -def test_value_error_raised_if_parse_arg_called_with_invalid_location(web_request): +def test_value_error_raised_if_parse_called_with_invalid_location(parser, web_request): field = fields.Field() + with pytest.raises(ValueError, match="Invalid location argument: invalidlocation"): + parser.parse({"foo": field}, web_request, location="invalidlocation") + + +@mock.patch("webargs.core.Parser.handle_error") +def test_handle_error_called_when_parsing_raises_error(handle_error, web_request): + def always_fail(*args, **kwargs): + raise ValidationError("error occurred") + p = Parser() - with pytest.raises(ValueError) as excinfo: - p.parse_arg("foo", field, web_request, locations=("invalidlocation", "headers")) - assert "Invalid locations arguments: {0}".format(["invalidlocation"]) in str( - excinfo - ) - - -def test_value_error_raised_if_invalid_location_on_field(web_request, parser): - with pytest.raises(ValueError) as excinfo: - parser.parse({"foo": fields.Field(location="invalidlocation")}, web_request) - assert "Invalid locations arguments: {0}".format(["invalidlocation"]) in str( - excinfo - ) - - -@mock.patch("webargs.core.Parser.handle_error") -@mock.patch("webargs.core.Parser.parse_json") -def test_handle_error_called_when_parsing_raises_error( - parse_json, handle_error, web_request -): - val_err = ValidationError("error occurred") - parse_json.side_effect = val_err - p = Parser() - p.parse({"foo": fields.Field()}, web_request, locations=("json",)) - handle_error.assert_called - parse_json.side_effect = ValidationError("another exception") - p.parse({"foo": fields.Field()}, web_request, locations=("json",)) + assert handle_error.call_count == 0 + p.parse({"foo": fields.Field()}, web_request, validate=always_fail) + assert handle_error.call_count == 1 + p.parse({"foo": fields.Field()}, web_request, validate=always_fail) assert handle_error.call_count == 2 def test_handle_error_reraises_errors(web_request): p = Parser() with pytest.raises(ValidationError): - p.handle_error(ValidationError("error raised"), web_request, Schema()) - - -@mock.patch("webargs.core.Parser.parse_headers") -def test_locations_as_init_arguments(parse_headers, web_request): - p = Parser(locations=("headers",)) + p.handle_error( + ValidationError("error raised"), + web_request, + Schema(), + error_status_code=422, + error_headers={}, + ) + + +@mock.patch("webargs.core.Parser.load_headers") +def test_location_as_init_argument(load_headers, web_request): + p = Parser(location="headers") + load_headers.return_value = {} p.parse({"foo": fields.Field()}, web_request) - assert parse_headers.called - - -@mock.patch("webargs.core.Parser.parse_files") -def test_parse_files(parse_files, web_request): - p = Parser() - p.parse({"foo": fields.Field()}, web_request, locations=("files",)) - assert parse_files.called - - -@mock.patch("webargs.core.Parser.parse_json") -def test_custom_error_handler(parse_json, web_request): + assert load_headers.called + + +def test_custom_error_handler(web_request): class CustomError(Exception): pass - def error_handler(error, req, schema, status_code, headers): + def error_handler(error, req, schema, *, error_status_code, error_headers): assert isinstance(schema, Schema) raise CustomError(error) - parse_json.side_effect = ValidationError("parse_json failed") + def failing_validate_func(args): + raise ValidationError("parsing failed") + + class MySchema(Schema): + foo = fields.Int() + + myschema = MySchema(**strict_kwargs) + web_request.json = {"foo": "hello world"} + p = Parser(error_handler=error_handler) with pytest.raises(CustomError): - p.parse({"foo": fields.Field()}, web_request) - - -@mock.patch("webargs.core.Parser.parse_json") -def test_custom_error_handler_decorator(parse_json, web_request): + p.parse(myschema, web_request, validate=failing_validate_func) + + +def test_custom_error_handler_decorator(web_request): class CustomError(Exception): pass - parse_json.side_effect = ValidationError("parse_json failed") - + mock_schema = mock.Mock(spec=Schema) + mock_schema.strict = True + mock_schema.load.side_effect = ValidationError("parsing json failed") parser = Parser() @parser.error_handler - def handle_error(error, req, schema, status_code, headers): + def handle_error(error, req, schema, *, error_status_code, error_headers): assert isinstance(schema, Schema) raise CustomError(error) with pytest.raises(CustomError): - parser.parse({"foo": fields.Field()}, web_request) - - -def test_custom_location_handler(web_request): + parser.parse(mock_schema, web_request) + + +def test_custom_location_loader(web_request): web_request.data = {"foo": 42} parser = Parser() - @parser.location_handler("data") - def parse_data(req, name, arg): - return req.data.get(name, missing) - - result = parser.parse({"foo": fields.Int()}, web_request, locations=("data",)) + @parser.location_loader("data") + def load_data(req, schema): + return req.data + + result = parser.parse({"foo": fields.Int()}, web_request, location="data") assert result["foo"] == 42 -def test_custom_location_handler_with_data_key(web_request): +def test_custom_location_loader_with_data_key(web_request): web_request.data = {"X-Foo": 42} parser = Parser() - @parser.location_handler("data") - def parse_data(req, name, arg): - return req.data.get(name, missing) + @parser.location_loader("data") + def load_data(req, schema): + return req.data data_key_kwarg = { "load_from" if (MARSHMALLOW_VERSION_INFO[0] < 3) else "data_key": "X-Foo" } result = parser.parse( - {"x_foo": fields.Int(**data_key_kwarg)}, web_request, locations=("data",) + {"x_foo": fields.Int(**data_key_kwarg)}, web_request, location="data" ) assert result["x_foo"] == 42 -def test_full_input_validation(web_request): +def test_full_input_validation(parser, web_request): web_request.json = {"foo": 41, "bar": 42} - parser = MockRequestParser() args = {"foo": fields.Int(), "bar": fields.Int()} with pytest.raises(ValidationError): # Test that `validate` receives dictionary of args - parser.parse( - args, - web_request, - locations=("json",), - validate=lambda args: args["foo"] > args["bar"], - ) + parser.parse(args, web_request, validate=lambda args: args["foo"] > args["bar"]) def test_full_input_validation_with_multiple_validators(web_request, parser): @@ -362,34 +360,30 @@ args = {"a": fields.Int(), "b": fields.Int()} web_request.json = {"a": 2, "b": 1} validators = [validate1, validate2] - with pytest.raises(ValidationError) as excinfo: - parser.parse(args, web_request, locations=("json",), validate=validators) - assert "b must be > a" in str(excinfo) + with pytest.raises(ValidationError, match="b must be > a"): + parser.parse(args, web_request, validate=validators) web_request.json = {"a": 1, "b": 2} - with pytest.raises(ValidationError) as excinfo: - parser.parse(args, web_request, locations=("json",), validate=validators) - assert "a must be > b" in str(excinfo) - - -def test_required_with_custom_error(web_request): - web_request.json = {} - parser = MockRequestParser() + with pytest.raises(ValidationError, match="a must be > b"): + parser.parse(args, web_request, validate=validators) + + +def test_required_with_custom_error(parser, web_request): + web_request.json = {} args = { "foo": fields.Str(required=True, error_messages={"required": "We need foo"}) } with pytest.raises(ValidationError) as excinfo: # Test that `validate` receives dictionary of args - parser.parse(args, web_request, locations=("json",)) - - assert "We need foo" in excinfo.value.messages["foo"] + parser.parse(args, web_request) + + assert "We need foo" in excinfo.value.messages["json"]["foo"] if MARSHMALLOW_VERSION_INFO[0] < 3: assert "foo" in excinfo.value.field_names -def test_required_with_custom_error_and_validation_error(web_request): +def test_required_with_custom_error_and_validation_error(parser, web_request): web_request.json = {"foo": ""} - parser = MockRequestParser() args = { "foo": fields.Str( required="We need foo", @@ -399,7 +393,7 @@ } with pytest.raises(ValidationError) as excinfo: # Test that `validate` receives dictionary of args - parser.parse(args, web_request, locations=("json",)) + parser.parse(args, web_request) assert "foo required length is 3" in excinfo.value.args[0]["foo"] if MARSHMALLOW_VERSION_INFO[0] < 3: @@ -410,27 +404,19 @@ def validate(val): return False - text = u"øœ∑∆∑" + text = "øœ∑∆∑" web_request.json = {"text": text} parser = MockRequestParser() args = {"text": fields.Str()} with pytest.raises(ValidationError) as excinfo: - parser.parse(args, web_request, locations=("json",), validate=validate) - assert excinfo.value.messages == ["Invalid value."] + parser.parse(args, web_request, validate=validate) + assert excinfo.value.messages == {"json": ["Invalid value."]} def test_invalid_argument_for_validate(web_request, parser): with pytest.raises(ValueError) as excinfo: parser.parse({}, web_request, validate="notcallable") assert "not a callable or list of callables." in excinfo.value.args[0] - - -def test_get_value_basic(): - assert get_value({"foo": 42}, "foo", False) == 42 - assert get_value({"foo": 42}, "bar", False) is missing - assert get_value({"foos": ["a", "b"]}, "foos", True) == ["a", "b"] - # https://github.com/marshmallow-code/webargs/pull/30 - assert get_value({"foos": ["a", "b"]}, "bar", True) is missing def create_bottle_multi_dict(): @@ -448,9 +434,24 @@ @pytest.mark.parametrize("input_dict", multidicts) -def test_get_value_multidict(input_dict): - field = fields.List(fields.Str()) - assert get_value(input_dict, "foos", field) == ["a", "b"] +def test_multidict_proxy(input_dict): + class ListSchema(Schema): + foos = fields.List(fields.Str()) + + class StrSchema(Schema): + foos = fields.Str() + + # this MultiDictProxy is aware that "foos" is a list field and will + # therefore produce a list with __getitem__ + list_wrapped_multidict = MultiDictProxy(input_dict, ListSchema()) + + # this MultiDictProxy is under the impression that "foos" is just a string + # and it should return "a" or "b" + # the decision between "a" and "b" in this case belongs to the framework + str_wrapped_multidict = MultiDictProxy(input_dict, StrSchema()) + + assert list_wrapped_multidict["foos"] == ["a", "b"] + assert str_wrapped_multidict["foos"] in ("a", "b") def test_parse_with_data_key(web_request): @@ -461,7 +462,7 @@ "load_from" if (MARSHMALLOW_VERSION_INFO[0] < 3) else "data_key": "Content-Type" } args = {"content_type": fields.Field(**data_key_kwargs)} - parsed = parser.parse(args, web_request, locations=("json",)) + parsed = parser.parse(args, web_request) assert parsed == {"content_type": "application/json"} @@ -475,7 +476,7 @@ parser = MockRequestParser() args = {"content_type": fields.Field(load_from="Content-Type")} - parsed = parser.parse(args, web_request, locations=("json",)) + parsed = parser.parse(args, web_request) assert parsed == {"content_type": "application/json"} @@ -488,9 +489,10 @@ } args = {"content_type": fields.Str(**data_key_kwargs)} with pytest.raises(ValidationError) as excinfo: - parser.parse(args, web_request, locations=("json",)) - assert "Content-Type" in excinfo.value.messages - assert excinfo.value.messages["Content-Type"] == ["Not a valid string."] + parser.parse(args, web_request) + assert "json" in excinfo.value.messages + assert "Content-Type" in excinfo.value.messages["json"] + assert excinfo.value.messages["json"]["Content-Type"] == ["Not a valid string."] def test_parse_nested_with_data_key(web_request): @@ -501,7 +503,7 @@ } args = {"nested_arg": fields.Nested({"right": fields.Field(**data_key_kwarg)})} - parsed = parser.parse(args, web_request, locations=("json",)) + parsed = parser.parse(args, web_request) assert parsed == {"nested_arg": {"right": "OK"}} @@ -518,7 +520,7 @@ ) } - parsed = parser.parse(args, web_request, locations=("json",)) + parsed = parser.parse(args, web_request) assert parsed == {"nested_arg": {"found": None}} @@ -528,7 +530,7 @@ web_request.json = {"nested_arg": {}} args = {"nested_arg": fields.Nested({"miss": fields.Field(missing="")})} - parsed = parser.parse(args, web_request, locations=("json",)) + parsed = parser.parse(args, web_request) assert parsed == {"nested_arg": {"miss": ""}} @@ -559,8 +561,8 @@ web_request.json = {"username": "foo"} web_request.query = {"page": 42} - @parser.use_args(query_args, web_request, locations=("query",)) - @parser.use_args(json_args, web_request, locations=("json",)) + @parser.use_args(query_args, web_request, location="query") + @parser.use_args(json_args, web_request) def viewfunc(query_parsed, json_parsed): return {"json": json_parsed, "query": query_parsed} @@ -575,8 +577,8 @@ web_request.json = {"username": "foo"} web_request.query = {"page": 42} - @parser.use_kwargs(query_args, web_request, locations=("query",)) - @parser.use_kwargs(json_args, web_request, locations=("json",)) + @parser.use_kwargs(query_args, web_request, location="query") + @parser.use_kwargs(json_args, web_request) def viewfunc(page, username): return {"json": {"username": username}, "query": {"page": page}} @@ -597,24 +599,26 @@ def test_list_allowed_missing(web_request, parser): args = {"name": fields.List(fields.Str())} - web_request.json = {"fakedata": True} + web_request.json = {} result = parser.parse(args, web_request) assert result == {} def test_int_list_allowed_missing(web_request, parser): args = {"name": fields.List(fields.Int())} - web_request.json = {"fakedata": True} + web_request.json = {} result = parser.parse(args, web_request) assert result == {} def test_multiple_arg_required_with_int_conversion(web_request, parser): args = {"ids": fields.List(fields.Int(), required=True)} - web_request.json = {"fakedata": True} + web_request.json = {} with pytest.raises(ValidationError) as excinfo: parser.parse(args, web_request) - assert excinfo.value.messages == {"ids": ["Missing data for required field."]} + assert excinfo.value.messages == { + "json": {"ids": ["Missing data for required field."]} + } def test_parse_with_callable(web_request, parser): @@ -648,7 +652,7 @@ strict = True @post_load - def request_data(self, item): + def request_data(self, item, **kwargs): item["data"] = self.context["request"].data return item @@ -740,12 +744,6 @@ assert viewfunc() == {"email": "foo@bar.com", "password": "bar"} - # Must skip on older versions of python due to - # https://github.com/pytest-dev/pytest/issues/840 - @pytest.mark.skipif( - sys.version_info < (3, 4), - reason="Skipping due to a bug in pytest's warning recording", - ) @pytest.mark.skipif( MARSHMALLOW_VERSION_INFO[0] >= 3, reason='"strict" parameter is removed in marshmallow 3', @@ -758,10 +756,22 @@ assert "strict=True" in str(warning.message) def test_use_kwargs_stacked(self, web_request, parser): + if MARSHMALLOW_VERSION_INFO[0] >= 3: + from marshmallow import EXCLUDE + + class PageSchema(Schema): + page = fields.Int() + + pageschema = PageSchema(unknown=EXCLUDE) + userschema = self.UserSchema(unknown=EXCLUDE) + else: + pageschema = {"page": fields.Int()} + userschema = self.UserSchema(**strict_kwargs) + web_request.json = {"email": "foo@bar.com", "password": "bar", "page": 42} - @parser.use_kwargs({"page": fields.Int()}, web_request) - @parser.use_kwargs(self.UserSchema(**strict_kwargs), web_request) + @parser.use_kwargs(pageschema, web_request) + @parser.use_kwargs(userschema, web_request) def viewfunc(email, password, page): return {"email": email, "password": password, "page": page} @@ -780,23 +790,23 @@ strict = True @validates_schema(pass_original=True) - def validate_schema(self, data, original_data): + def validate_schema(self, data, original_data, **kwargs): assert "location" not in original_data return True web_request.json = {"name": "Eric Cartman"} - res = parser.parse(UserSchema, web_request, locations=("json",)) + res = parser.parse(UserSchema, web_request) assert res == {"name": "Eric Cartman"} -def test_use_args_with_custom_locations_in_parser(web_request, parser): +def test_use_args_with_custom_location_in_parser(web_request, parser): custom_args = {"foo": fields.Str()} web_request.json = {} - parser.locations = ("custom",) - - @parser.location_handler("custom") - def parse_custom(req, name, arg): - return "bar" + parser.location = "custom" + + @parser.location_loader("custom") + def load_custom(schema, req): + return {"foo": "bar"} @parser.use_args(custom_args, web_request) def viewfunc(args): @@ -838,32 +848,59 @@ dumped = schema.dump(parsed) data = dumped.data if MARSHMALLOW_VERSION_INFO[0] < 3 else dumped - assert data["ids"] == [1, 2, 3] - - -def test_delimited_list_as_string(web_request, parser): - web_request.json = {"ids": "1,2,3"} + assert data["ids"] == "1,2,3" + + +@pytest.mark.skipif( + MARSHMALLOW_VERSION_INFO[0] < 3, reason="fields.Tuple added in marshmallow3" +) +def test_delimited_tuple_default_delimiter(web_request, parser): + """ + Test load and dump from DelimitedTuple, including the use of a datetime + type (similar to a DelimitedList test below) which confirms that we aren't + relying on __str__, but are properly de/serializing the included fields + """ + web_request.json = {"ids": "1,2,2020-05-04"} schema_cls = dict2schema( - {"ids": fields.DelimitedList(fields.Int(), as_string=True)} + { + "ids": fields.DelimitedTuple( + (fields.Int, fields.Int, fields.DateTime(format="%Y-%m-%d")) + ) + } ) schema = schema_cls() parsed = parser.parse(schema, web_request) - assert parsed["ids"] == [1, 2, 3] - - dumped = schema.dump(parsed) - data = dumped.data if MARSHMALLOW_VERSION_INFO[0] < 3 else dumped - assert data["ids"] == "1,2,3" - - -def test_delimited_list_as_string_v2(web_request, parser): + assert parsed["ids"] == (1, 2, datetime.datetime(2020, 5, 4)) + + data = schema.dump(parsed) + assert data["ids"] == "1,2,2020-05-04" + + +@pytest.mark.skipif( + MARSHMALLOW_VERSION_INFO[0] < 3, reason="fields.Tuple added in marshmallow3" +) +def test_delimited_tuple_incorrect_arity(web_request, parser): + web_request.json = {"ids": "1,2"} + schema_cls = dict2schema( + {"ids": fields.DelimitedTuple((fields.Int, fields.Int, fields.Int))} + ) + schema = schema_cls() + + with pytest.raises(ValidationError): + parser.parse(schema, web_request) + + +def test_delimited_list_with_datetime(web_request, parser): + """ + Test that DelimitedList(DateTime(format=...)) correctly parses and dumps + dates to and from strings -- indicates that we're doing proper + serialization of values in dump() and not just relying on __str__ producing + correct results + """ web_request.json = {"dates": "2018-11-01,2018-11-02"} schema_cls = dict2schema( - { - "dates": fields.DelimitedList( - fields.DateTime(format="%Y-%m-%d"), as_string=True - ) - } + {"dates": fields.DelimitedList(fields.DateTime(format="%Y-%m-%d"))} ) schema = schema_cls() @@ -886,14 +923,55 @@ parsed = parser.parse(schema, web_request) assert parsed["ids"] == [1, 2, 3] - -def test_delimited_list_load_list(web_request, parser): + dumped = schema.dump(parsed) + data = dumped.data if MARSHMALLOW_VERSION_INFO[0] < 3 else dumped + assert data["ids"] == "1|2|3" + + +@pytest.mark.skipif( + MARSHMALLOW_VERSION_INFO[0] < 3, reason="fields.Tuple added in marshmallow3" +) +def test_delimited_tuple_custom_delimiter(web_request, parser): + web_request.json = {"ids": "1|2"} + schema_cls = dict2schema( + {"ids": fields.DelimitedTuple((fields.Int, fields.Int), delimiter="|")} + ) + schema = schema_cls() + + parsed = parser.parse(schema, web_request) + assert parsed["ids"] == (1, 2) + + data = schema.dump(parsed) + assert data["ids"] == "1|2" + + +def test_delimited_list_load_list_errors(web_request, parser): web_request.json = {"ids": [1, 2, 3]} schema_cls = dict2schema({"ids": fields.DelimitedList(fields.Int())}) schema = schema_cls() - parsed = parser.parse(schema, web_request) - assert parsed["ids"] == [1, 2, 3] + with pytest.raises(ValidationError) as excinfo: + parser.parse(schema, web_request) + exc = excinfo.value + assert isinstance(exc, ValidationError) + errors = exc.args[0] + assert errors["ids"] == ["Not a valid delimited list."] + + +@pytest.mark.skipif( + MARSHMALLOW_VERSION_INFO[0] < 3, reason="fields.Tuple added in marshmallow3" +) +def test_delimited_tuple_load_list_errors(web_request, parser): + web_request.json = {"ids": [1, 2]} + schema_cls = dict2schema({"ids": fields.DelimitedTuple((fields.Int, fields.Int))}) + schema = schema_cls() + + with pytest.raises(ValidationError) as excinfo: + parser.parse(schema, web_request) + exc = excinfo.value + assert isinstance(exc, ValidationError) + errors = exc.args[0] + assert errors["ids"] == ["Not a valid delimited tuple."] # Regresion test for https://github.com/marshmallow-code/webargs/issues/149 @@ -904,7 +982,20 @@ with pytest.raises(ValidationError) as excinfo: parser.parse(schema, web_request) - assert excinfo.value.messages == {"ids": ["Not a valid list."]} + assert excinfo.value.messages == {"json": {"ids": ["Not a valid delimited list."]}} + + +@pytest.mark.skipif( + MARSHMALLOW_VERSION_INFO[0] < 3, reason="fields.Tuple added in marshmallow3" +) +def test_delimited_tuple_passed_invalid_type(web_request, parser): + web_request.json = {"ids": 1} + schema_cls = dict2schema({"ids": fields.DelimitedTuple((fields.Int,))}) + schema = schema_cls() + + with pytest.raises(ValidationError) as excinfo: + parser.parse(schema, web_request) + assert excinfo.value.messages == {"json": {"ids": ["Not a valid delimited tuple."]}} def test_missing_list_argument_not_in_parsed_result(web_request, parser): @@ -919,19 +1010,9 @@ def test_type_conversion_with_multiple_required(web_request, parser): web_request.json = {} args = {"ids": fields.List(fields.Int(), required=True)} - with pytest.raises(ValidationError) as excinfo: + msg = "Missing data for required field." + with pytest.raises(ValidationError, match=msg): parser.parse(args, web_request) - assert "Missing data for required field." in str(excinfo) - - -def test_arg_location_param(web_request, parser): - web_request.json = {"foo": 24} - web_request.cookies = {"foo": 42} - args = {"foo": fields.Field(location="cookies")} - - parsed = parser.parse(args, web_request) - - assert parsed["foo"] == 42 def test_validation_errors_in_validator_are_passed_to_handle_error(parser, web_request): @@ -983,8 +1064,6 @@ assert schema.fields["id"].required if MARSHMALLOW_VERSION_INFO[0] < 3: assert schema.opts.strict is True - else: - assert schema.opts.register is False # Regression test for https://github.com/marshmallow-code/webargs/issues/101 @@ -1033,9 +1112,7 @@ class MockRequestParserWithErrorHandler(MockRequestParser): - def handle_error( - self, error, req, schema, error_status_code=None, error_headers=None - ): + def handle_error(self, error, req, schema, *, error_status_code, error_headers): assert isinstance(error, ValidationError) assert isinstance(schema, Schema) raise MockHTTPError(error_status_code, error_headers) @@ -1052,3 +1129,36 @@ error = excinfo.value assert error.status_code == 418 assert error.headers == {"X-Foo": "bar"} + + +@mock.patch("webargs.core.Parser.load_json") +def test_custom_schema_class(load_json, web_request): + class CustomSchema(Schema): + @pre_load + def pre_load(self, data, **kwargs): + data["value"] += " world" + return data + + load_json.return_value = {"value": "hello"} + argmap = {"value": fields.Str()} + p = Parser(schema_class=CustomSchema) + ret = p.parse(argmap, web_request) + assert ret == {"value": "hello world"} + + +@mock.patch("webargs.core.Parser.load_json") +def test_custom_default_schema_class(load_json, web_request): + class CustomSchema(Schema): + @pre_load + def pre_load(self, data, **kwargs): + data["value"] += " world" + return data + + class CustomParser(Parser): + DEFAULT_SCHEMA_CLASS = CustomSchema + + load_json.return_value = {"value": "hello"} + argmap = {"value": fields.Str()} + p = CustomParser() + ret = p.parse(argmap, web_request) + assert ret == {"value": "hello world"} diff --git a/tests/test_djangoparser.py b/tests/test_djangoparser.py index 5b8497a..b4c866a 100644 --- a/tests/test_djangoparser.py +++ b/tests/test_djangoparser.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import pytest from tests.apps.django_app.base.wsgi import application @@ -23,12 +20,12 @@ def test_parsing_in_class_based_view(self, testapp): assert testapp.get("/echo_cbv?name=Fred").json == {"name": "Fred"} - assert testapp.post("/echo_cbv", {"name": "Fred"}).json == {"name": "Fred"} + assert testapp.post_json("/echo_cbv", {"name": "Fred"}).json == {"name": "Fred"} def test_use_args_in_class_based_view(self, testapp): res = testapp.get("/echo_use_args_cbv?name=Fred") assert res.json == {"name": "Fred"} - res = testapp.post("/echo_use_args_cbv", {"name": "Fred"}) + res = testapp.post_json("/echo_use_args_cbv", {"name": "Fred"}) assert res.json == {"name": "Fred"} def test_use_args_in_class_based_view_with_path_param(self, testapp): diff --git a/tests/test_falconparser.py b/tests/test_falconparser.py index 7162a0d..4d2f15c 100644 --- a/tests/test_falconparser.py +++ b/tests/test_falconparser.py @@ -1,5 +1,5 @@ -# -*- coding: utf-8 -*- import pytest +import falcon.testing from webargs.testing import CommonTestCase from tests.apps.falcon_app import create_app @@ -16,13 +16,37 @@ def test_use_args_hook(self, testapp): assert testapp.get("/echo_use_args_hook?name=Fred").json == {"name": "Fred"} + # https://github.com/marshmallow-code/webargs/issues/427 + def test_parse_json_with_nonutf8_chars(self, testapp): + res = testapp.post( + "/echo_json", + 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."]} + # https://github.com/sloria/webargs/issues/329 def test_invalid_json(self, testapp): res = testapp.post( - "/echo", + "/echo_json", '{"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."]} + + # Falcon converts headers to all-caps + def test_parsing_headers(self, testapp): + res = testapp.get("/echo_headers", headers={"name": "Fred"}) + assert res.json == {"NAME": "Fred"} + + # `falcon.testing.TestClient.simulate_request` parses request with `wsgiref` + def test_body_parsing_works_with_simulate(self): + app = self.create_app() + client = falcon.testing.TestClient(app) + res = client.simulate_post("/echo_json", json={"name": "Fred"},) + assert res.json == {"name": "Fred"} diff --git a/tests/test_flaskparser.py b/tests/test_flaskparser.py index 12bb1b4..f8ccf36 100644 --- a/tests/test_flaskparser.py +++ b/tests/test_flaskparser.py @@ -1,17 +1,20 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals -import mock - from werkzeug.exceptions import HTTPException import pytest from flask import Flask -from webargs import fields, ValidationError, missing +from webargs import fields, ValidationError, missing, dict2schema from webargs.flaskparser import parser, abort from webargs.core import MARSHMALLOW_VERSION_INFO, json from .apps.flask_app import app from webargs.testing import CommonTestCase + +try: + # Python 3.5 + import mock +except ImportError: + # Python 3.6+ + from unittest import mock class TestFlaskParser(CommonTestCase): @@ -25,30 +28,38 @@ def test_parsing_invalid_view_arg(self, testapp): res = testapp.get("/echo_view_arg/foo", expect_errors=True) assert res.status_code == 422 - assert res.json == {"view_arg": ["Not a valid integer."]} + assert res.json == {"view_args": {"view_arg": ["Not a valid integer."]}} def test_use_args_with_view_args_parsing(self, testapp): res = testapp.get("/echo_view_arg_use_args/42") assert res.json == {"view_arg": 42} def test_use_args_on_a_method_view(self, testapp): - res = testapp.post("/echo_method_view_use_args", {"val": 42}) + res = testapp.post_json("/echo_method_view_use_args", {"val": 42}) assert res.json == {"val": 42} def test_use_kwargs_on_a_method_view(self, testapp): - res = testapp.post("/echo_method_view_use_kwargs", {"val": 42}) + res = testapp.post_json("/echo_method_view_use_kwargs", {"val": 42}) assert res.json == {"val": 42} def test_use_kwargs_with_missing_data(self, testapp): - res = testapp.post("/echo_use_kwargs_missing", {"username": "foo"}) + res = testapp.post_json("/echo_use_kwargs_missing", {"username": "foo"}) assert res.json == {"username": "foo"} # regression test for https://github.com/marshmallow-code/webargs/issues/145 def test_nested_many_with_data_key(self, testapp): - res = testapp.post_json("/echo_nested_many_data_key", {"x_field": [{"id": 42}]}) - # https://github.com/marshmallow-code/marshmallow/pull/714 + post_with_raw_fieldname_args = ( + "/echo_nested_many_data_key", + {"x_field": [{"id": 42}]}, + ) + # under marshmallow 2 this is allowed and works if MARSHMALLOW_VERSION_INFO[0] < 3: + res = testapp.post_json(*post_with_raw_fieldname_args) assert res.json == {"x_field": [{"id": 42}]} + # but under marshmallow3 , only data_key is checked, field name is ignored + else: + res = testapp.post_json(*post_with_raw_fieldname_args, expect_errors=True) + assert res.status_code == 422 res = testapp.post_json("/echo_nested_many_data_key", {"X-Field": [{"id": 24}]}) assert res.json == {"x_field": [{"id": 24}]} @@ -72,18 +83,21 @@ content_type="application/json", ): parser.parse(argmap) - mock_abort.assert_called + mock_abort.assert_called() abort_args, abort_kwargs = mock_abort.call_args assert abort_args[0] == 422 expected_msg = "Invalid value." - assert abort_kwargs["messages"]["value"] == [expected_msg] + assert abort_kwargs["messages"]["json"]["value"] == [expected_msg] assert type(abort_kwargs["exc"]) == ValidationError -def test_parse_form_returns_missing_if_no_form(): +@pytest.mark.parametrize("mimetype", [None, "application/json"]) +def test_load_json_returns_missing_if_no_data(mimetype): req = mock.Mock() - req.form.get.side_effect = AttributeError("no form") - assert parser.parse_form(req, "foo", fields.Field()) is missing + req.mimetype = mimetype + req.get_data.return_value = "" + schema = dict2schema({"foo": fields.Field()})() + assert parser.load_json(req, schema) is missing def test_abort_with_message(): diff --git a/tests/test_py3/test_aiohttpparser.py b/tests/test_py3/test_aiohttpparser.py index d3de2fb..06a4622 100644 --- a/tests/test_py3/test_aiohttpparser.py +++ b/tests/test_py3/test_aiohttpparser.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - import asyncio import webtest import webtest_aiohttp @@ -38,16 +36,17 @@ # regression test for https://github.com/marshmallow-code/webargs/issues/165 def test_multiple_args(self, testapp): - res = testapp.post_json( - "/echo_multiple_args", {"first": "1", "last": "2", "_ignore": 0} - ) + res = testapp.post_json("/echo_multiple_args", {"first": "1", "last": "2"}) assert res.json == {"first": "1", "last": "2"} # regression test for https://github.com/marshmallow-code/webargs/issues/145 def test_nested_many_with_data_key(self, testapp): - res = testapp.post_json("/echo_nested_many_data_key", {"x_field": [{"id": 42}]}) # https://github.com/marshmallow-code/marshmallow/pull/714 + # on marshmallow 2, the field name can also be used if MARSHMALLOW_VERSION_INFO[0] < 3: + res = testapp.post_json( + "/echo_nested_many_data_key", {"x_field": [{"id": 42}]} + ) assert res.json == {"x_field": [{"id": 42}]} res = testapp.post_json("/echo_nested_many_data_key", {"X-Field": [{"id": 24}]}) diff --git a/tests/test_py3/test_aiohttpparser_async_functions.py b/tests/test_py3/test_aiohttpparser_async_functions.py index a0437c4..4b732e1 100644 --- a/tests/test_py3/test_aiohttpparser_async_functions.py +++ b/tests/test_py3/test_aiohttpparser_async_functions.py @@ -11,16 +11,16 @@ async def echo_parse(request): - parsed = await parser.parse(hello_args, request) + parsed = await parser.parse(hello_args, request, location="query") return json_response(parsed) -@use_args(hello_args) +@use_args(hello_args, location="query") async def echo_use_args(request, args): return json_response(args) -@use_kwargs(hello_args) +@use_kwargs(hello_args, location="query") async def echo_use_kwargs(request, name): return json_response({"name": name}) diff --git a/tests/test_pyramidparser.py b/tests/test_pyramidparser.py index 2f68c7a..fb8577c 100644 --- a/tests/test_pyramidparser.py +++ b/tests/test_pyramidparser.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- from webargs.testing import CommonTestCase diff --git a/tests/test_tornadoparser.py b/tests/test_tornadoparser.py index 84e5b19..50e281b 100644 --- a/tests/test_tornadoparser.py +++ b/tests/test_tornadoparser.py @@ -1,291 +1,210 @@ -# -*- coding: utf-8 -*- - -from webargs.core import json +import marshmallow as ma +import pytest +import tornado.concurrent +import tornado.http1connection +import tornado.httpserver +import tornado.httputil +import tornado.ioloop +import tornado.web +from tornado.testing import AsyncHTTPTestCase +from webargs import fields, missing +from webargs.core import MARSHMALLOW_VERSION_INFO, json, parse_json +from webargs.tornadoparser import ( + WebArgsTornadoMultiDictProxy, + parser, + use_args, + use_kwargs, +) + +from urllib.parse import urlencode try: - from urllib.parse import urlencode -except ImportError: # PY2 - from urllib import urlencode # type: ignore - -import mock -import pytest - -import marshmallow as ma - -import tornado.web -import tornado.httputil -import tornado.httpserver -import tornado.http1connection -import tornado.concurrent -import tornado.ioloop -from tornado.testing import AsyncHTTPTestCase - -from webargs import fields, missing -from webargs.tornadoparser import parser, use_args, use_kwargs, get_value -from webargs.core import parse_json + # Python 3.5 + import mock +except ImportError: + # Python 3.6+ + from unittest import mock + name = "name" value = "value" -def test_get_value_basic(): - field, multifield = fields.Field(), fields.List(fields.Str()) - assert get_value({"foo": 42}, "foo", field) == 42 - assert get_value({"foo": 42}, "bar", field) is missing - assert get_value({"foos": ["a", "b"]}, "foos", multifield) == ["a", "b"] - # https://github.com/marshmallow-code/webargs/pull/30 - assert get_value({"foos": ["a", "b"]}, "bar", multifield) is missing - - -class TestQueryArgs(object): - def setup_method(self, method): - parser.clear_cache() - +class AuthorSchema(ma.Schema): + name = fields.Str(missing="World", validate=lambda n: len(n) >= 3) + works = fields.List(fields.Str()) + + +strict_kwargs = {"strict": True} if MARSHMALLOW_VERSION_INFO[0] < 3 else {} +author_schema = AuthorSchema(**strict_kwargs) + + +def test_tornado_multidictproxy(): + for dictval, fieldname, expected in ( + ({"name": "Sophocles"}, "name", "Sophocles"), + ({"name": "Sophocles"}, "works", missing), + ({"works": ["Antigone", "Oedipus Rex"]}, "works", ["Antigone", "Oedipus Rex"]), + ({"works": ["Antigone", "Oedipus at Colonus"]}, "name", missing), + ): + proxy = WebArgsTornadoMultiDictProxy(dictval, author_schema) + assert proxy.get(fieldname) == expected + + +class TestQueryArgs: def test_it_should_get_single_values(self): - query = [(name, value)] - field = fields.Field() + query = [("name", "Aeschylus")] request = make_get_request(query) - - result = parser.parse_querystring(request, name, field) - - assert result == value + result = parser.load_querystring(request, author_schema) + assert result["name"] == "Aeschylus" def test_it_should_get_multiple_values(self): - query = [(name, value), (name, value)] - field = fields.List(fields.Field()) + query = [("works", "Agamemnon"), ("works", "Nereids")] request = make_get_request(query) - - result = parser.parse_querystring(request, name, field) - - assert result == [value, value] + result = parser.load_querystring(request, author_schema) + assert result["works"] == ["Agamemnon", "Nereids"] def test_it_should_return_missing_if_not_present(self): query = [] - field = fields.Field() - field2 = fields.List(fields.Int()) request = make_get_request(query) - - result = parser.parse_querystring(request, name, field) - result2 = parser.parse_querystring(request, name, field2) - - assert result is missing - assert result2 is missing - - def test_it_should_return_empty_list_if_multiple_and_not_present(self): - query = [] - field = fields.List(fields.Field()) - request = make_get_request(query) - - result = parser.parse_querystring(request, name, field) - - assert result is missing + result = parser.load_querystring(request, author_schema) + assert result["name"] is missing + assert result["works"] is missing class TestFormArgs: - def setup_method(self, method): - parser.clear_cache() - def test_it_should_get_single_values(self): - query = [(name, value)] - field = fields.Field() + query = [("name", "Aristophanes")] request = make_form_request(query) - - result = parser.parse_form(request, name, field) - - assert result == value + result = parser.load_form(request, author_schema) + assert result["name"] == "Aristophanes" def test_it_should_get_multiple_values(self): - query = [(name, value), (name, value)] - field = fields.List(fields.Field()) + query = [("works", "The Wasps"), ("works", "The Frogs")] request = make_form_request(query) - - result = parser.parse_form(request, name, field) - - assert result == [value, value] + result = parser.load_form(request, author_schema) + assert result["works"] == ["The Wasps", "The Frogs"] def test_it_should_return_missing_if_not_present(self): query = [] - field = fields.Field() request = make_form_request(query) - - result = parser.parse_form(request, name, field) - - assert result is missing - - def test_it_should_return_empty_list_if_multiple_and_not_present(self): - query = [] - field = fields.List(fields.Field()) - request = make_form_request(query) - - result = parser.parse_form(request, name, field) - - assert result is missing - - -class TestJSONArgs(object): - def setup_method(self, method): - parser.clear_cache() - + result = parser.load_form(request, author_schema) + assert result["name"] is missing + assert result["works"] is missing + + +class TestJSONArgs: def test_it_should_get_single_values(self): - query = {name: value} - field = fields.Field() + query = {"name": "Euripides"} request = make_json_request(query) - result = parser.parse_json(request, name, field) - - assert result == value + result = parser.load_json(request, author_schema) + assert result["name"] == "Euripides" def test_parsing_request_with_vendor_content_type(self): - query = {name: value} - field = fields.Field() + query = {"name": "Euripides"} request = make_json_request( query, content_type="application/vnd.api+json; charset=UTF-8" ) - result = parser.parse_json(request, name, field) - - assert result == value + result = parser.load_json(request, author_schema) + assert result["name"] == "Euripides" def test_it_should_get_multiple_values(self): - query = {name: [value, value]} - field = fields.List(fields.Field()) + query = {"works": ["Medea", "Electra"]} request = make_json_request(query) - result = parser.parse_json(request, name, field) - - assert result == [value, value] + result = parser.load_json(request, author_schema) + assert result["works"] == ["Medea", "Electra"] def test_it_should_get_multiple_nested_values(self): - query = {name: [{"id": 1, "name": "foo"}, {"id": 2, "name": "bar"}]} - field = fields.List( - fields.Nested({"id": fields.Field(), "name": fields.Field()}) - ) + class CustomSchema(ma.Schema): + works = fields.List( + fields.Nested({"author": fields.Str(), "workname": fields.Str()}) + ) + + custom_schema = CustomSchema(**strict_kwargs) + + query = { + "works": [ + {"author": "Euripides", "workname": "Hecuba"}, + {"author": "Aristophanes", "workname": "The Birds"}, + ] + } request = make_json_request(query) - result = parser.parse_json(request, name, field) - assert result == [{"id": 1, "name": "foo"}, {"id": 2, "name": "bar"}] + result = parser.load_json(request, custom_schema) + assert result["works"] == [ + {"author": "Euripides", "workname": "Hecuba"}, + {"author": "Aristophanes", "workname": "The Birds"}, + ] + + def test_it_should_not_include_fieldnames_if_not_present(self): + query = {} + request = make_json_request(query) + result = parser.load_json(request, author_schema) + assert result == {} + + def test_it_should_handle_type_error_on_load_json(self): + # but this is different from the test above where the payload was valid + # and empty -- missing vs {} + request = make_request( + body=tornado.concurrent.Future(), + headers={"Content-Type": "application/json"}, + ) + result = parser.load_json(request, author_schema) + assert result is missing + + def test_it_should_handle_value_error_on_parse_json(self): + request = make_request("this is json not") + result = parser.load_json(request, author_schema) + assert result is missing + + +class TestHeadersArgs: + def test_it_should_get_single_values(self): + query = {"name": "Euphorion"} + request = make_request(headers=query) + result = parser.load_headers(request, author_schema) + assert result["name"] == "Euphorion" + + def test_it_should_get_multiple_values(self): + query = {"works": ["Prometheus Bound", "Prometheus Unbound"]} + request = make_request(headers=query) + result = parser.load_headers(request, author_schema) + assert result["works"] == ["Prometheus Bound", "Prometheus Unbound"] def test_it_should_return_missing_if_not_present(self): - query = {} - field = fields.Field() - request = make_json_request(query) - result = parser.parse_json(request, name, field) - - assert result is missing - - def test_it_should_return_empty_list_if_multiple_and_not_present(self): - query = {} - field = fields.List(fields.Field()) - request = make_json_request(query) - result = parser.parse_json(request, name, field) - - assert result is missing - - def test_it_should_handle_type_error_on_parse_json(self): - field = fields.Field() - request = make_request( - body=tornado.concurrent.Future, headers={"Content-Type": "application/json"} - ) - result = parser.parse_json(request, name, field) - assert parser._cache["json"] == {} - assert result is missing - - def test_it_should_handle_value_error_on_parse_json(self): - field = fields.Field() - request = make_request("this is json not") - result = parser.parse_json(request, name, field) - assert parser._cache["json"] == {} - assert result is missing - - -class TestHeadersArgs(object): - def setup_method(self, method): - parser.clear_cache() - + request = make_request() + result = parser.load_headers(request, author_schema) + assert result["name"] is missing + assert result["works"] is missing + + +class TestFilesArgs: def test_it_should_get_single_values(self): - query = {name: value} - field = fields.Field() - request = make_request(headers=query) - - result = parser.parse_headers(request, name, field) - - assert result == value + query = [("name", "Sappho")] + request = make_files_request(query) + result = parser.load_files(request, author_schema) + assert result["name"] == "Sappho" def test_it_should_get_multiple_values(self): - query = {name: [value, value]} - field = fields.List(fields.Field()) - request = make_request(headers=query) - - result = parser.parse_headers(request, name, field) - - assert result == [value, value] - - def test_it_should_return_missing_if_not_present(self): - field = fields.Field(multiple=False) - request = make_request() - - result = parser.parse_headers(request, name, field) - - assert result is missing - - def test_it_should_return_empty_list_if_multiple_and_not_present(self): - query = {} - field = fields.List(fields.Field()) - request = make_request(headers=query) - - result = parser.parse_headers(request, name, field) - - assert result is missing - - -class TestFilesArgs(object): - def setup_method(self, method): - parser.clear_cache() - - def test_it_should_get_single_values(self): - query = [(name, value)] - field = fields.Field() + query = [("works", "Sappho 31"), ("works", "Ode to Aphrodite")] request = make_files_request(query) - - result = parser.parse_files(request, name, field) - - assert result == value - - def test_it_should_get_multiple_values(self): - query = [(name, value), (name, value)] - field = fields.List(fields.Field()) - request = make_files_request(query) - - result = parser.parse_files(request, name, field) - - assert result == [value, value] + result = parser.load_files(request, author_schema) + assert result["works"] == ["Sappho 31", "Ode to Aphrodite"] def test_it_should_return_missing_if_not_present(self): query = [] - field = fields.Field() request = make_files_request(query) - - result = parser.parse_files(request, name, field) - - assert result is missing - - def test_it_should_return_empty_list_if_multiple_and_not_present(self): - query = [] - field = fields.List(fields.Field()) - request = make_files_request(query) - - result = parser.parse_files(request, name, field) - - assert result is missing - - -class TestErrorHandler(object): + result = parser.load_files(request, author_schema) + assert result["name"] is missing + assert result["works"] is missing + + +class TestErrorHandler: def test_it_should_raise_httperror_on_failed_validation(self): args = {"foo": fields.Field(validate=lambda x: False)} with pytest.raises(tornado.web.HTTPError): parser.parse(args, make_json_request({"foo": 42})) -class TestParse(object): - def setup_method(self, method): - parser.clear_cache() - +class TestParse: def test_it_should_parse_query_arguments(self): attrs = {"string": fields.Field(), "integer": fields.List(fields.Int())} @@ -293,22 +212,11 @@ [("string", "value"), ("integer", "1"), ("integer", "2")] ) - parsed = parser.parse(attrs, request) + parsed = parser.parse(attrs, request, location="query") assert parsed["integer"] == [1, 2] assert parsed["string"] == value - def test_parsing_clears_cache(self): - request = make_json_request({"string": "value", "integer": [1, 2]}) - string_result = parser.parse_json(request, "string", fields.Str()) - assert string_result == "value" - assert "json" in parser._cache - assert "string" in parser._cache["json"] - assert "integer" in parser._cache["json"] - attrs = {"string": fields.Str(), "integer": fields.List(fields.Int())} - parser.parse(attrs, request) - assert parser._cache == {} - def test_it_should_parse_form_arguments(self): attrs = {"string": fields.Field(), "integer": fields.List(fields.Int())} @@ -316,7 +224,7 @@ [("string", "value"), ("integer", "1"), ("integer", "2")] ) - parsed = parser.parse(attrs, request) + parsed = parser.parse(attrs, request, location="form") assert parsed["integer"] == [1, 2] assert parsed["string"] == value @@ -348,7 +256,7 @@ request = make_request(headers={"string": "value", "integer": ["1", "2"]}) - parsed = parser.parse(attrs, request, locations=["headers"]) + parsed = parser.parse(attrs, request, location="headers") assert parsed["string"] == value assert parsed["integer"] == [1, 2] @@ -360,7 +268,7 @@ [("string", "value"), ("integer", "1"), ("integer", "2")] ) - parsed = parser.parse(attrs, request, locations=["cookies"]) + parsed = parser.parse(attrs, request, location="cookies") assert parsed["string"] == value assert parsed["integer"] == [2] @@ -372,7 +280,7 @@ [("string", "value"), ("integer", "1"), ("integer", "2")] ) - parsed = parser.parse(attrs, request, locations=["files"]) + parsed = parser.parse(attrs, request, location="files") assert parsed["string"] == value assert parsed["integer"] == [1, 2] @@ -382,24 +290,21 @@ request = make_json_request({}) - with pytest.raises(tornado.web.HTTPError) as excinfo: + msg = "Missing data for required field." + with pytest.raises(tornado.web.HTTPError, match=msg): parser.parse(args, request) - assert "Missing data for required field." in str(excinfo) def test_it_should_parse_multiple_arg_required(self): args = {"foo": fields.List(fields.Int(), required=True)} request = make_json_request({}) - with pytest.raises(tornado.web.HTTPError) as excinfo: + msg = "Missing data for required field." + with pytest.raises(tornado.web.HTTPError, match=msg): parser.parse(args, request) - assert "Missing data for required field." in str(excinfo) - - -class TestUseArgs(object): - def setup_method(self, method): - parser.clear_cache() - + + +class TestUseArgs: def test_it_should_pass_parsed_as_first_argument(self): - class Handler(object): + class Handler: request = make_json_request({"key": "value"}) @use_args({"key": fields.Field()}) @@ -414,7 +319,7 @@ assert result is True def test_it_should_pass_parsed_as_kwargs_arguments(self): - class Handler(object): + class Handler: request = make_json_request({"key": "value"}) @use_kwargs({"key": fields.Field()}) @@ -429,7 +334,7 @@ assert result is True def test_it_should_be_validate_arguments_when_validator_is_passed(self): - class Handler(object): + class Handler: request = make_json_request({"foo": 41}) @use_kwargs({"foo": fields.Int()}, validate=lambda args: args["foo"] > 42) @@ -487,8 +392,8 @@ def make_request(uri=None, body=None, headers=None, files=None): - uri = uri if uri is not None else u"" - body = body if body is not None else u"" + uri = uri if uri is not None else "" + body = body if body is not None else "" method = "POST" if body else "GET" # Need to make a mock connection right now because Tornado 4.0 requires a # remote_ip in the context attribute. 4.1 addresses this, and this @@ -497,7 +402,7 @@ mock_connection = mock.Mock(spec=tornado.http1connection.HTTP1Connection) mock_connection.context = mock.Mock() mock_connection.remote_ip = None - content_type = headers.get("Content-Type", u"") if headers else u"" + content_type = headers.get("Content-Type", "") if headers else "" request = tornado.httputil.HTTPServerRequest( method=method, uri=uri, @@ -520,9 +425,21 @@ class EchoHandler(tornado.web.RequestHandler): ARGS = {"name": fields.Str()} - @use_args(ARGS) + @use_args(ARGS, location="query") def get(self, args): self.write(args) + + +class EchoFormHandler(tornado.web.RequestHandler): + ARGS = {"name": fields.Str()} + + @use_args(ARGS, location="form") + def post(self, args): + self.write(args) + + +class EchoJSONHandler(tornado.web.RequestHandler): + ARGS = {"name": fields.Str()} @use_args(ARGS) def post(self, args): @@ -532,13 +449,18 @@ class EchoWithParamHandler(tornado.web.RequestHandler): ARGS = {"name": fields.Str()} - @use_args(ARGS) + @use_args(ARGS, location="query") def get(self, id, args): self.write(args) echo_app = tornado.web.Application( - [(r"/echo", EchoHandler), (r"/echo_with_param/(\d+)", EchoWithParamHandler)] + [ + (r"/echo", EchoHandler), + (r"/echo_form", EchoFormHandler), + (r"/echo_json", EchoJSONHandler), + (r"/echo_with_param/(\d+)", EchoWithParamHandler), + ] ) @@ -548,7 +470,7 @@ def test_post(self): res = self.fetch( - "/echo", + "/echo_json", method="POST", headers={"Content-Type": "application/json"}, body=json.dumps({"name": "Steve"}), @@ -556,7 +478,7 @@ json_body = parse_json(res.body) assert json_body["name"] == "Steve" res = self.fetch( - "/echo", + "/echo_json", method="POST", headers={"Content-Type": "application/json"}, body=json.dumps({}), @@ -588,7 +510,7 @@ def post(self, args): self.write(args) - @use_kwargs(ARGS) + @use_kwargs(ARGS, location="query") def get(self, name): self.write({"status": "success"}) diff --git a/tests/test_webapp2parser.py b/tests/test_webapp2parser.py index 3fcb20b..d68593c 100644 --- a/tests/test_webapp2parser.py +++ b/tests/test_webapp2parser.py @@ -1,17 +1,15 @@ -# -*- coding: utf-8 -*- """Tests for the webapp2 parser""" -try: - from urllib.parse import urlencode -except ImportError: # PY2 - from urllib import urlencode # type: ignore +from urllib.parse import urlencode from webargs.core import json import pytest +import marshmallow as ma from marshmallow import fields, ValidationError import webtest import webapp2 from webargs.webapp2parser import parser +from webargs.core import MARSHMALLOW_VERSION_INFO hello_args = {"name": fields.Str(missing="World")} @@ -25,32 +23,43 @@ } +class HelloSchema(ma.Schema): + name = fields.Str(missing="World", validate=lambda n: len(n) >= 3) + + +# variant which ignores unknown fields +exclude_kwargs = ( + {"strict": True} if MARSHMALLOW_VERSION_INFO[0] < 3 else {"unknown": ma.EXCLUDE} +) +hello_exclude_schema = HelloSchema(**exclude_kwargs) + + def test_parse_querystring_args(): request = webapp2.Request.blank("/echo?name=Fred") - assert parser.parse(hello_args, req=request) == {"name": "Fred"} + assert parser.parse(hello_args, req=request, location="query") == {"name": "Fred"} def test_parse_querystring_multiple(): expected = {"name": ["steve", "Loria"]} request = webapp2.Request.blank("/echomulti?name=steve&name=Loria") - assert parser.parse(hello_multiple, req=request) == expected + assert parser.parse(hello_multiple, req=request, location="query") == expected def test_parse_form(): expected = {"name": "Joe"} request = webapp2.Request.blank("/echo", POST=expected) - assert parser.parse(hello_args, req=request) == expected + assert parser.parse(hello_args, req=request, location="form") == expected def test_parse_form_multiple(): expected = {"name": ["steve", "Loria"]} request = webapp2.Request.blank("/echo", POST=urlencode(expected, doseq=True)) - assert parser.parse(hello_multiple, req=request) == expected + assert parser.parse(hello_multiple, req=request, location="form") == expected def test_parsing_form_default(): request = webapp2.Request.blank("/echo", POST="") - assert parser.parse(hello_args, req=request) == {"name": "World"} + assert parser.parse(hello_args, req=request, location="form") == {"name": "World"} def test_parse_json(): @@ -59,6 +68,15 @@ "/echo", POST=json.dumps(expected), headers={"content-type": "application/json"} ) assert parser.parse(hello_args, req=request) == expected + + +def test_parse_json_content_type_mismatch(): + request = webapp2.Request.blank( + "/echo_json", + POST=json.dumps({"name": "foo"}), + headers={"content-type": "application/x-www-form-urlencoded"}, + ) + assert parser.parse(hello_args, req=request) == {"name": "World"} def test_parse_invalid_json(): @@ -95,13 +113,15 @@ request = webapp2.Request.blank( "/", headers={"Cookie": response.headers["Set-Cookie"]} ) - assert parser.parse(hello_args, req=request, locations=("cookies",)) == expected + assert parser.parse(hello_args, req=request, location="cookies") == expected def test_parsing_headers(): expected = {"name": "Fred"} request = webapp2.Request.blank("/", headers=expected) - assert parser.parse(hello_args, req=request, locations=("headers",)) == expected + assert ( + parser.parse(hello_exclude_schema, req=request, location="headers") == expected + ) def test_parse_files(): @@ -110,14 +130,14 @@ """ class Handler(webapp2.RequestHandler): - @parser.use_args({"myfile": fields.List(fields.Field())}, locations=("files",)) + @parser.use_args({"myfile": fields.List(fields.Field())}, location="files") def post(self, args): self.response.content_type = "application/json" def _value(f): return f.getvalue().decode("utf-8") - data = dict((i.filename, _value(i.file)) for i in args["myfile"]) + data = {i.filename: _value(i.file) for i in args["myfile"]} self.response.write(json.dumps(data)) app = webapp2.WSGIApplication([("/", Handler)]) @@ -130,13 +150,13 @@ def test_exception_on_validation_error(): request = webapp2.Request.blank("/", POST={"num": "3"}) with pytest.raises(ValidationError): - parser.parse(hello_validate, req=request) + parser.parse(hello_validate, req=request, location="form") def test_validation_error_with_message(): request = webapp2.Request.blank("/", POST={"num": "3"}) with pytest.raises(ValidationError) as exc: - parser.parse(hello_validate, req=request) + parser.parse(hello_validate, req=request, location="form") assert "Houston, we've had a problem." in exc.value @@ -148,4 +168,4 @@ request = webapp2.Request.blank("/echo", POST=expected) app = webapp2.WSGIApplication([]) app.set_globals(app, request) - assert parser.parse(hello_args) == expected + assert parser.parse(hello_args, location="form") == expected diff --git a/tox.ini b/tox.ini index 4ac9841..3e67e11 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,9 @@ [tox] envlist= lint - py{27,35,36,37}-marshmallow{2,3} + py{35,36,37,38}-marshmallow2 + py{35,36,37,38}-marshmallow3 + py38-marshmallowdev docs [testenv] @@ -9,28 +11,25 @@ deps = marshmallow2: marshmallow==2.15.2 marshmallow3: marshmallow>=3.0.0rc2,<4.0.0 -commands = - py27: pytest --ignore=tests/test_py3/ {posargs} - py{35,36,37}: pytest {posargs} + marshmallowdev: https://github.com/marshmallow-code/marshmallow/archive/dev.tar.gz +commands = pytest {posargs} [testenv:lint] -deps = pre-commit~=1.14 +deps = pre-commit~=1.20 skip_install = true commands = pre-commit run --all-files [testenv:docs] -deps = -rdocs/requirements.txt -extras = +extras = docs commands = sphinx-build docs/ docs/_build {posargs} ; Below tasks are for development only (not run in CI) [testenv:watch-docs] deps = - -rdocs/requirements.txt sphinx-autobuild -extras = -commands = sphinx-autobuild --open-browser docs/ docs/_build {posargs} -z webargs +extras = docs +commands = sphinx-autobuild --open-browser docs/ docs/_build {posargs} -z src/webargs -s 2 [testenv:watch-readme] deps = restview diff --git a/webargs/__init__.py b/webargs/__init__.py deleted file mode 100755 index f1e6048..0000000 --- a/webargs/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -# -*- coding: utf-8 -*- -from marshmallow.utils import missing - -# Make marshmallow's validation functions importable from webargs -from marshmallow import validate - -from webargs.core import dict2schema, ValidationError -from webargs import fields - -__version__ = "5.1.2" -__author__ = "Steven Loria" -__license__ = "MIT" - - -__all__ = ("dict2schema", "ValidationError", "fields", "missing", "validate") diff --git a/webargs/aiohttpparser.py b/webargs/aiohttpparser.py deleted file mode 100644 index 63ac30e..0000000 --- a/webargs/aiohttpparser.py +++ /dev/null @@ -1,176 +0,0 @@ -"""aiohttp request argument parsing module. - -Example: :: - - import asyncio - from aiohttp import web - - from webargs import fields - from webargs.aiohttpparser import use_args - - - hello_args = { - 'name': fields.Str(required=True) - } - @asyncio.coroutine - @use_args(hello_args) - def index(request, args): - return web.Response( - body='Hello {}'.format(args['name']).encode('utf-8') - ) - - app = web.Application() - app.router.add_route('GET', '/', index) -""" -import typing - -from aiohttp import web -from aiohttp.web import Request -from aiohttp import web_exceptions -from marshmallow import Schema, ValidationError -from marshmallow.fields import Field - -from webargs import core -from webargs.core import json -from webargs.asyncparser import AsyncParser - - -def is_json_request(req: Request) -> bool: - content_type = req.content_type - return core.is_json(content_type) - - -class HTTPUnprocessableEntity(web.HTTPClientError): - status_code = 422 - - -# Mapping of status codes to exception classes -# Adapted from werkzeug -exception_map = {422: HTTPUnprocessableEntity} - - -def _find_exceptions() -> None: - for name in web_exceptions.__all__: - obj = getattr(web_exceptions, name) - try: - is_http_exception = issubclass(obj, web_exceptions.HTTPException) - except TypeError: - is_http_exception = False - if not is_http_exception or obj.status_code is None: - continue - old_obj = exception_map.get(obj.status_code, None) - if old_obj is not None and issubclass(obj, old_obj): - continue - exception_map[obj.status_code] = obj - - -# Collect all exceptions from aiohttp.web_exceptions -_find_exceptions() -del _find_exceptions - - -class AIOHTTPParser(AsyncParser): - """aiohttp request argument parser.""" - - __location_map__ = dict( - match_info="parse_match_info", **core.Parser.__location_map__ - ) - - def parse_querystring(self, req: Request, name: str, field: Field) -> typing.Any: - """Pull a querystring value from the request.""" - return core.get_value(req.query, name, field) - - async def parse_form(self, req: Request, name: str, field: Field) -> typing.Any: - """Pull a form value from the request.""" - post_data = self._cache.get("post") - if post_data is None: - self._cache["post"] = await req.post() - return core.get_value(self._cache["post"], name, field) - - async def parse_json(self, req: Request, name: str, field: Field) -> typing.Any: - """Pull a json value from the request.""" - json_data = self._cache.get("json") - if json_data is None: - if not (req.body_exists and is_json_request(req)): - return core.missing - try: - json_data = await req.json(loads=json.loads) - except json.JSONDecodeError as e: - if e.doc == "": - return core.missing - else: - return self.handle_invalid_json_error(e, req) - self._cache["json"] = json_data - return core.get_value(json_data, name, field, allow_many_nested=True) - - def parse_headers(self, req: Request, name: str, field: Field) -> typing.Any: - """Pull a value from the header data.""" - return core.get_value(req.headers, name, field) - - def parse_cookies(self, req: Request, name: str, field: Field) -> typing.Any: - """Pull a value from the cookiejar.""" - return core.get_value(req.cookies, name, field) - - def parse_files(self, req: Request, name: str, field: Field) -> None: - raise NotImplementedError( - "parse_files is not implemented. You may be able to use parse_form for " - "parsing upload data." - ) - - def parse_match_info(self, req: Request, name: str, field: Field) -> typing.Any: - """Pull a value from the request's ``match_info``.""" - return core.get_value(req.match_info, name, field) - - def get_request_from_view_args( - self, view: typing.Callable, args: typing.Iterable, kwargs: typing.Mapping - ) -> Request: - """Get request object from a handler function or method. Used internally by - ``use_args`` and ``use_kwargs``. - """ - req = None - for arg in args: - if isinstance(arg, web.Request): - req = arg - break - elif isinstance(arg, web.View): - req = arg.request - break - assert isinstance(req, web.Request), "Request argument not found for handler" - return req - - def handle_error( - self, - error: ValidationError, - req: Request, - schema: Schema, - error_status_code: typing.Union[int, None] = None, - error_headers: typing.Union[typing.Mapping[str, str], None] = None, - ) -> "typing.NoReturn": - """Handle ValidationErrors and return a JSON response of error messages - to the client. - """ - error_class = exception_map.get( - error_status_code or self.DEFAULT_VALIDATION_STATUS - ) - if not error_class: - raise LookupError("No exception for {0}".format(error_status_code)) - headers = error_headers - raise error_class( - body=json.dumps(error.messages).encode("utf-8"), - headers=headers, - content_type="application/json", - ) - - def handle_invalid_json_error( - self, error: json.JSONDecodeError, req: Request, *args, **kwargs - ) -> "typing.NoReturn": - error_class = exception_map[400] - messages = {"json": ["Invalid JSON body."]} - raise error_class( - body=json.dumps(messages).encode("utf-8"), content_type="application/json" - ) - - -parser = AIOHTTPParser() -use_args = parser.use_args # type: typing.Callable -use_kwargs = parser.use_kwargs # type: typing.Callable diff --git a/webargs/asyncparser.py b/webargs/asyncparser.py deleted file mode 100644 index c8032a4..0000000 --- a/webargs/asyncparser.py +++ /dev/null @@ -1,221 +0,0 @@ -"""Asynchronous request parser. Compatible with Python>=3.5.""" -import asyncio -import functools -import inspect -import typing -from collections.abc import Mapping - -from marshmallow import Schema, ValidationError -from marshmallow.fields import Field -import marshmallow as ma -from marshmallow.utils import missing - -from webargs import core - -Request = typing.TypeVar("Request") -ArgMap = typing.Union[Schema, typing.Mapping[str, Field]] -Validate = typing.Union[typing.Callable, typing.Iterable[typing.Callable]] - - -class AsyncParser(core.Parser): - """Asynchronous variant of `webargs.core.Parser`, where parsing methods may be - either coroutines or regular methods. - """ - - async def _parse_request( - self, schema: Schema, req: Request, locations: typing.Iterable - ) -> typing.Union[dict, list]: - if schema.many: - assert ( - "json" in locations - ), "schema.many=True is only supported for JSON location" - # The ad hoc Nested field is more like a workaround or a helper, - # and it servers its purpose fine. However, if somebody has a desire - # to re-design the support of bulk-type arguments, go ahead. - parsed = await self.parse_arg( - name="json", - field=ma.fields.Nested(schema, many=True), - req=req, - locations=locations, - ) - if parsed is missing: - parsed = [] - else: - argdict = schema.fields - parsed = {} - for argname, field_obj in argdict.items(): - if core.MARSHMALLOW_VERSION_INFO[0] < 3: - parsed_value = await self.parse_arg( - argname, field_obj, req, locations - ) - # If load_from is specified on the field, try to parse from that key - if parsed_value is missing and field_obj.load_from: - parsed_value = await self.parse_arg( - field_obj.load_from, field_obj, req, locations - ) - argname = field_obj.load_from - else: - argname = field_obj.data_key or argname - parsed_value = await self.parse_arg( - argname, field_obj, req, locations - ) - if parsed_value is not missing: - parsed[argname] = parsed_value - return parsed - - # TODO: Lots of duplication from core.Parser here. Rethink. - async def parse( - self, - argmap: ArgMap, - req: Request = None, - locations: typing.Iterable = None, - validate: Validate = None, - error_status_code: typing.Union[int, None] = None, - error_headers: typing.Union[typing.Mapping[str, str], None] = None, - ) -> typing.Union[typing.Mapping, None]: - """Coroutine variant of `webargs.core.Parser`. - - Receives the same arguments as `webargs.core.Parser.parse`. - """ - req = req if req is not None else self.get_default_request() - assert req is not None, "Must pass req object" - data = None - validators = core._ensure_list_of_callables(validate) - schema = self._get_schema(argmap, req) - try: - parsed = await self._parse_request( - schema=schema, req=req, locations=locations or self.locations - ) - result = schema.load(parsed) - data = result.data if core.MARSHMALLOW_VERSION_INFO[0] < 3 else result - self._validate_arguments(data, validators) - except ma.exceptions.ValidationError as error: - await self._on_validation_error( - error, req, schema, error_status_code, error_headers - ) - finally: - self.clear_cache() - return data - - async def _on_validation_error( - self, - error: ValidationError, - req: Request, - schema: Schema, - error_status_code: typing.Union[int, None], - error_headers: typing.Union[typing.Mapping[str, str], None] = None, - ) -> None: - error_handler = self.error_callback or self.handle_error - await error_handler(error, req, schema, error_status_code, error_headers) - - def use_args( - self, - argmap: ArgMap, - req: typing.Optional[Request] = None, - locations: typing.Iterable = None, - as_kwargs: bool = False, - validate: Validate = None, - error_status_code: typing.Optional[int] = None, - error_headers: typing.Union[typing.Mapping[str, str], None] = None, - ) -> typing.Callable[..., typing.Callable]: - """Decorator that injects parsed arguments into a view function or method. - - Receives the same arguments as `webargs.core.Parser.use_args`. - """ - locations = locations or self.locations - request_obj = req - # Optimization: If argmap is passed as a dictionary, we only need - # to generate a Schema once - if isinstance(argmap, Mapping): - argmap = core.dict2schema(argmap)() - - def decorator(func: typing.Callable) -> typing.Callable: - req_ = request_obj - - if inspect.iscoroutinefunction(func): - - @functools.wraps(func) - async def wrapper(*args, **kwargs): - req_obj = req_ - - if not req_obj: - req_obj = self.get_request_from_view_args(func, args, kwargs) - # NOTE: At this point, argmap may be a Schema, callable, or dict - parsed_args = await self.parse( - argmap, - req=req_obj, - locations=locations, - validate=validate, - error_status_code=error_status_code, - error_headers=error_headers, - ) - if as_kwargs: - kwargs.update(parsed_args or {}) - return await func(*args, **kwargs) - else: - # Add parsed_args after other positional arguments - new_args = args + (parsed_args,) - return await func(*new_args, **kwargs) - - else: - - @functools.wraps(func) # type: ignore - def wrapper(*args, **kwargs): - req_obj = req_ - - if not req_obj: - req_obj = self.get_request_from_view_args(func, args, kwargs) - # NOTE: At this point, argmap may be a Schema, callable, or dict - parsed_args = yield from self.parse( # type: ignore - argmap, - req=req_obj, - locations=locations, - validate=validate, - error_status_code=error_status_code, - error_headers=error_headers, - ) - if as_kwargs: - kwargs.update(parsed_args) - return func(*args, **kwargs) # noqa: B901 - else: - # Add parsed_args after other positional arguments - new_args = args + (parsed_args,) - return func(*new_args, **kwargs) - - return wrapper - - return decorator - - def use_kwargs(self, *args, **kwargs) -> typing.Callable: - """Decorator that injects parsed arguments into a view function or method. - - Receives the same arguments as `webargs.core.Parser.use_kwargs`. - - """ - return super().use_kwargs(*args, **kwargs) - - async def parse_arg( - self, name: str, field: Field, req: Request, locations: typing.Iterable = None - ) -> typing.Any: - location = field.metadata.get("location") - if location: - locations_to_check = self._validated_locations([location]) - else: - locations_to_check = self._validated_locations(locations or self.locations) - - for location in locations_to_check: - value = await self._get_value(name, field, req=req, location=location) - # Found the value; validate and return it - if value is not core.missing: - return value - return core.missing - - async def _get_value( - self, name: str, argobj: Field, req: Request, location: str - ) -> typing.Any: - function = self._get_handler(location) - if asyncio.iscoroutinefunction(function): - value = await function(req, name, argobj) - else: - value = function(req, name, argobj) - return value diff --git a/webargs/bottleparser.py b/webargs/bottleparser.py deleted file mode 100644 index a03fb11..0000000 --- a/webargs/bottleparser.py +++ /dev/null @@ -1,90 +0,0 @@ -# -*- coding: utf-8 -*- -"""Bottle request argument parsing module. - -Example: :: - - from bottle import route, run - from marshmallow import fields - from webargs.bottleparser import use_args - - hello_args = { - 'name': fields.Str(missing='World') - } - @route('/', method='GET', apply=use_args(hello_args)) - def index(args): - return 'Hello ' + args['name'] - - if __name__ == '__main__': - run(debug=True) -""" -import bottle - -from webargs import core -from webargs.core import json - - -class BottleParser(core.Parser): - """Bottle.py request argument parser.""" - - def parse_querystring(self, req, name, field): - """Pull a querystring value from the request.""" - return core.get_value(req.query, name, field) - - def parse_form(self, req, name, field): - """Pull a form value from the request.""" - return core.get_value(req.forms, name, field) - - def parse_json(self, req, name, field): - """Pull a json value from the request.""" - json_data = self._cache.get("json") - if json_data is None: - try: - self._cache["json"] = json_data = req.json - except AttributeError: - return core.missing - except json.JSONDecodeError as e: - if e.doc == "": - return core.missing - else: - return self.handle_invalid_json_error(e, req) - if json_data is None: - return core.missing - return core.get_value(json_data, name, field, allow_many_nested=True) - - def parse_headers(self, req, name, field): - """Pull a value from the header data.""" - return core.get_value(req.headers, name, field) - - def parse_cookies(self, req, name, field): - """Pull a value from the cookiejar.""" - return req.get_cookie(name) - - def parse_files(self, req, name, field): - """Pull a file from the request.""" - return core.get_value(req.files, name, field) - - def handle_error(self, error, req, schema, error_status_code, error_headers): - """Handles errors during parsing. Aborts the current request with a - 400 error. - """ - status_code = error_status_code or self.DEFAULT_VALIDATION_STATUS - raise bottle.HTTPError( - status=status_code, - body=error.messages, - headers=error_headers, - exception=error, - ) - - def handle_invalid_json_error(self, error, req, *args, **kwargs): - raise bottle.HTTPError( - status=400, body={"json": ["Invalid JSON body."]}, exception=error - ) - - def get_default_request(self): - """Override to use bottle's thread-local request object by default.""" - return bottle.request - - -parser = BottleParser() -use_args = parser.use_args -use_kwargs = parser.use_kwargs diff --git a/webargs/core.py b/webargs/core.py deleted file mode 100755 index bfe2c2f..0000000 --- a/webargs/core.py +++ /dev/null @@ -1,576 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -import functools -import inspect -import logging -import warnings -from distutils.version import LooseVersion - -try: - from collections.abc import Mapping -except ImportError: - from collections import Mapping - -try: - import simplejson as json -except ImportError: - import json # type: ignore - -import marshmallow as ma -from marshmallow import ValidationError -from marshmallow.compat import iteritems -from marshmallow.utils import missing, is_collection - -logger = logging.getLogger(__name__) - - -__all__ = [ - "ValidationError", - "dict2schema", - "is_multiple", - "Parser", - "get_value", - "missing", - "parse_json", -] - -MARSHMALLOW_VERSION_INFO = tuple(LooseVersion(ma.__version__).version) # type: tuple - -DEFAULT_VALIDATION_STATUS = 422 # type: int - - -def _callable_or_raise(obj): - """Makes sure an object is callable if it is not ``None``. If not - callable, a ValueError is raised. - """ - if obj and not callable(obj): - raise ValueError("{0!r} is not callable.".format(obj)) - else: - return obj - - -def dict2schema(dct): - """Generate a `marshmallow.Schema` class given a dictionary of - `Fields `. - """ - attrs = dct.copy() - - class Meta(object): - if MARSHMALLOW_VERSION_INFO[0] < 3: - strict = True - else: - register = False - - attrs["Meta"] = Meta - return type(str(""), (ma.Schema,), attrs) - - -def is_multiple(field): - """Return whether or not `field` handles repeated/multi-value arguments.""" - return isinstance(field, ma.fields.List) and not hasattr(field, "delimiter") - - -def get_mimetype(content_type): - return content_type.split(";")[0].strip() if content_type else None - - -# Adapted from werkzeug: -# https://github.com/mitsuhiko/werkzeug/blob/master/werkzeug/wrappers.py -def is_json(mimetype): - """Indicates if this mimetype is JSON or not. By default a request - is considered to include JSON data if the mimetype is - ``application/json`` or ``application/*+json``. - """ - if not mimetype: - return False - if ";" in mimetype: # Allow Content-Type header to be passed - mimetype = get_mimetype(mimetype) - if mimetype == "application/json": - return True - if mimetype.startswith("application/") and mimetype.endswith("+json"): - return True - return False - - -def get_value(data, name, field, allow_many_nested=False): - """Get a value from a dictionary. Handles ``MultiDict`` types when - ``multiple=True``. If the value is not found, return `missing`. - - :param object data: Mapping (e.g. `dict`) or list-like instance to - pull the value from. - :param str name: Name of the key. - :param bool multiple: Whether to handle multiple values. - :param bool allow_many_nested: Whether to allow a list of nested objects - (it is valid only for JSON format, so it is set to True in ``parse_json`` - methods). - """ - missing_value = missing - if allow_many_nested and isinstance(field, ma.fields.Nested) and field.many: - if is_collection(data): - return data - - if not hasattr(data, "get"): - return missing_value - - multiple = is_multiple(field) - val = data.get(name, missing_value) - if multiple and val is not missing: - if hasattr(data, "getlist"): - return data.getlist(name) - elif hasattr(data, "getall"): - return data.getall(name) - elif isinstance(val, (list, tuple)): - return val - if val is None: - return None - else: - return [val] - return val - - -def parse_json(s, encoding="utf-8"): - if isinstance(s, bytes): - s = s.decode(encoding) - return json.loads(s) - - -def _ensure_list_of_callables(obj): - if obj: - if isinstance(obj, (list, tuple)): - validators = obj - elif callable(obj): - validators = [obj] - else: - raise ValueError( - "{0!r} is not a callable or list of callables.".format(obj) - ) - else: - validators = [] - return validators - - -class Parser(object): - """Base parser class that provides high-level implementation for parsing - a request. - - Descendant classes must provide lower-level implementations for parsing - different locations, e.g. ``parse_json``, ``parse_querystring``, etc. - - :param tuple locations: Default locations to parse. - :param callable error_handler: Custom error handler function. - """ - - DEFAULT_LOCATIONS = ("querystring", "form", "json") - #: Default status code to return for validation errors - DEFAULT_VALIDATION_STATUS = DEFAULT_VALIDATION_STATUS - #: Default error message for validation errors - DEFAULT_VALIDATION_MESSAGE = "Invalid value." - - #: Maps location => method name - __location_map__ = { - "json": "parse_json", - "querystring": "parse_querystring", - "query": "parse_querystring", - "form": "parse_form", - "headers": "parse_headers", - "cookies": "parse_cookies", - "files": "parse_files", - } - - def __init__(self, locations=None, error_handler=None): - self.locations = locations or self.DEFAULT_LOCATIONS - self.error_callback = _callable_or_raise(error_handler) - #: A short-lived cache to store results from processing request bodies. - self._cache = {} - - def _validated_locations(self, locations): - """Ensure that the given locations argument is valid. - - :raises: ValueError if a given locations includes an invalid location. - """ - # The set difference between the given locations and the available locations - # will be the set of invalid locations - valid_locations = set(self.__location_map__.keys()) - given = set(locations) - invalid_locations = given - valid_locations - if len(invalid_locations): - msg = "Invalid locations arguments: {0}".format(list(invalid_locations)) - raise ValueError(msg) - return locations - - def _get_handler(self, location): - # Parsing function to call - # May be a method name (str) or a function - func = self.__location_map__.get(location) - if func: - if inspect.isfunction(func): - function = func - else: - function = getattr(self, func) - else: - raise ValueError('Invalid location: "{0}"'.format(location)) - return function - - def _get_value(self, name, argobj, req, location): - function = self._get_handler(location) - return function(req, name, argobj) - - def parse_arg(self, name, field, req, locations=None): - """Parse a single argument from a request. - - .. note:: - This method does not perform validation on the argument. - - :param str name: The name of the value. - :param marshmallow.fields.Field field: The marshmallow `Field` for the request - parameter. - :param req: The request object to parse. - :param tuple locations: The locations ('json', 'querystring', etc.) where - to search for the value. - :return: The unvalidated argument value or `missing` if the value cannot - be found on the request. - """ - location = field.metadata.get("location") - if location: - locations_to_check = self._validated_locations([location]) - else: - locations_to_check = self._validated_locations(locations or self.locations) - - for location in locations_to_check: - value = self._get_value(name, field, req=req, location=location) - # Found the value; validate and return it - if value is not missing: - return value - return missing - - def _parse_request(self, schema, req, locations): - """Return a parsed arguments dictionary for the current request.""" - if schema.many: - assert ( - "json" in locations - ), "schema.many=True is only supported for JSON location" - # The ad hoc Nested field is more like a workaround or a helper, - # and it servers its purpose fine. However, if somebody has a desire - # to re-design the support of bulk-type arguments, go ahead. - parsed = self.parse_arg( - name="json", - field=ma.fields.Nested(schema, many=True), - req=req, - locations=locations, - ) - if parsed is missing: - parsed = [] - else: - argdict = schema.fields - parsed = {} - for argname, field_obj in iteritems(argdict): - if MARSHMALLOW_VERSION_INFO[0] < 3: - parsed_value = self.parse_arg(argname, field_obj, req, locations) - # If load_from is specified on the field, try to parse from that key - if parsed_value is missing and field_obj.load_from: - parsed_value = self.parse_arg( - field_obj.load_from, field_obj, req, locations - ) - argname = field_obj.load_from - else: - argname = field_obj.data_key or argname - parsed_value = self.parse_arg(argname, field_obj, req, locations) - if parsed_value is not missing: - parsed[argname] = parsed_value - return parsed - - def _on_validation_error( - self, error, req, schema, error_status_code, error_headers - ): - error_handler = self.error_callback or self.handle_error - error_handler(error, req, schema, error_status_code, error_headers) - - def _validate_arguments(self, data, validators): - for validator in validators: - if validator(data) is False: - msg = self.DEFAULT_VALIDATION_MESSAGE - raise ValidationError(msg, data=data) - - def _get_schema(self, argmap, req): - """Return a `marshmallow.Schema` for the given argmap and request. - - :param argmap: Either a `marshmallow.Schema`, `dict` - of argname -> `marshmallow.fields.Field` pairs, or a callable that returns - a `marshmallow.Schema` instance. - :param req: The request object being parsed. - :rtype: marshmallow.Schema - """ - if isinstance(argmap, ma.Schema): - schema = argmap - elif isinstance(argmap, type) and issubclass(argmap, ma.Schema): - schema = argmap() - elif callable(argmap): - schema = argmap(req) - else: - schema = dict2schema(argmap)() - if MARSHMALLOW_VERSION_INFO[0] < 3 and not schema.strict: - warnings.warn( - "It is highly recommended that you set strict=True on your schema " - "so that the parser's error handler will be invoked when expected.", - UserWarning, - ) - return schema - - def parse( - self, - argmap, - req=None, - locations=None, - validate=None, - error_status_code=None, - error_headers=None, - ): - """Main request parsing method. - - :param argmap: Either a `marshmallow.Schema`, a `dict` - of argname -> `marshmallow.fields.Field` pairs, or a callable - which accepts a request and returns a `marshmallow.Schema`. - :param req: The request object to parse. - :param tuple locations: Where on the request to search for values. - Can include one or more of ``('json', 'querystring', 'form', - 'headers', 'cookies', 'files')``. - :param callable validate: Validation function or list of validation functions - that receives the dictionary of parsed arguments. Validator either returns a - boolean or raises a :exc:`ValidationError`. - :param int error_status_code: Status code passed to error handler functions when - a `ValidationError` is raised. - :param dict error_headers: Headers passed to error handler functions when a - a `ValidationError` is raised. - - :return: A dictionary of parsed arguments - """ - req = req if req is not None else self.get_default_request() - assert req is not None, "Must pass req object" - data = None - validators = _ensure_list_of_callables(validate) - schema = self._get_schema(argmap, req) - try: - parsed = self._parse_request( - schema=schema, req=req, locations=locations or self.locations - ) - result = schema.load(parsed) - data = result.data if MARSHMALLOW_VERSION_INFO[0] < 3 else result - self._validate_arguments(data, validators) - except ma.exceptions.ValidationError as error: - self._on_validation_error( - error, req, schema, error_status_code, error_headers - ) - finally: - self.clear_cache() - return data - - def clear_cache(self): - """Invalidate the parser's cache.""" - self._cache = {} - return None - - def get_default_request(self): - """Optional override. Provides a hook for frameworks that use thread-local - request objects. - """ - return None - - def get_request_from_view_args(self, view, args, kwargs): - """Optional override. Returns the request object to be parsed, given a view - function's args and kwargs. - - Used by the `use_args` and `use_kwargs` to get a request object from a - view's arguments. - - :param callable view: The view function or method being decorated by - `use_args` or `use_kwargs` - :param tuple args: Positional arguments passed to ``view``. - :param dict kwargs: Keyword arguments passed to ``view``. - """ - return None - - def use_args( - self, - argmap, - req=None, - locations=None, - as_kwargs=False, - validate=None, - error_status_code=None, - error_headers=None, - ): - """Decorator that injects parsed arguments into a view function or method. - - Example usage with Flask: :: - - @app.route('/echo', methods=['get', 'post']) - @parser.use_args({'name': fields.Str()}) - def greet(args): - return 'Hello ' + args['name'] - - :param argmap: Either a `marshmallow.Schema`, a `dict` - of argname -> `marshmallow.fields.Field` pairs, or a callable - which accepts a request and returns a `marshmallow.Schema`. - :param tuple locations: Where on the request to search for values. - :param bool as_kwargs: Whether to insert arguments as keyword arguments. - :param callable validate: Validation function that receives the dictionary - of parsed arguments. If the function returns ``False``, the parser - will raise a :exc:`ValidationError`. - :param int error_status_code: Status code passed to error handler functions when - a `ValidationError` is raised. - :param dict error_headers: Headers passed to error handler functions when a - a `ValidationError` is raised. - """ - locations = locations or self.locations - request_obj = req - # Optimization: If argmap is passed as a dictionary, we only need - # to generate a Schema once - if isinstance(argmap, Mapping): - argmap = dict2schema(argmap)() - - def decorator(func): - req_ = request_obj - - @functools.wraps(func) - def wrapper(*args, **kwargs): - req_obj = req_ - - if not req_obj: - req_obj = self.get_request_from_view_args(func, args, kwargs) - # NOTE: At this point, argmap may be a Schema, or a callable - parsed_args = self.parse( - argmap, - req=req_obj, - locations=locations, - validate=validate, - error_status_code=error_status_code, - error_headers=error_headers, - ) - if as_kwargs: - kwargs.update(parsed_args) - return func(*args, **kwargs) - else: - # Add parsed_args after other positional arguments - new_args = args + (parsed_args,) - return func(*new_args, **kwargs) - - wrapper.__wrapped__ = func - return wrapper - - return decorator - - def use_kwargs(self, *args, **kwargs): - """Decorator that injects parsed arguments into a view function or method - as keyword arguments. - - This is a shortcut to :meth:`use_args` with ``as_kwargs=True``. - - Example usage with Flask: :: - - @app.route('/echo', methods=['get', 'post']) - @parser.use_kwargs({'name': fields.Str()}) - def greet(name): - return 'Hello ' + name - - Receives the same ``args`` and ``kwargs`` as :meth:`use_args`. - """ - kwargs["as_kwargs"] = True - return self.use_args(*args, **kwargs) - - def location_handler(self, name): - """Decorator that registers a function for parsing a request location. - The wrapped function receives a request, the name of the argument, and - the corresponding `Field ` object. - - Example: :: - - from webargs import core - parser = core.Parser() - - @parser.location_handler("name") - def parse_data(request, name, field): - return request.data.get(name) - - :param str name: The name of the location to register. - """ - - def decorator(func): - self.__location_map__[name] = func - return func - - return decorator - - def error_handler(self, func): - """Decorator that registers a custom error handling function. The - function should receive the raised error, request object, - `marshmallow.Schema` instance used to parse the request, error status code, - and headers to use for the error response. Overrides - the parser's ``handle_error`` method. - - Example: :: - - from webargs import flaskparser - - parser = flaskparser.FlaskParser() - - - class CustomError(Exception): - pass - - - @parser.error_handler - def handle_error(error, req, schema, status_code, headers): - raise CustomError(error.messages) - - :param callable func: The error callback to register. - """ - self.error_callback = func - return func - - # Abstract Methods - - def parse_json(self, req, name, arg): - """Pull a JSON value from a request object or return `missing` if the - value cannot be found. - """ - return missing - - def parse_querystring(self, req, name, arg): - """Pull a value from the query string of a request object or return `missing` if - the value cannot be found. - """ - return missing - - def parse_form(self, req, name, arg): - """Pull a value from the form data of a request object or return - `missing` if the value cannot be found. - """ - return missing - - def parse_headers(self, req, name, arg): - """Pull a value from the headers or return `missing` if the value - cannot be found. - """ - return missing - - def parse_cookies(self, req, name, arg): - """Pull a cookie value from the request or return `missing` if the value - cannot be found. - """ - return missing - - def parse_files(self, req, name, arg): - """Pull a file from the request or return `missing` if the value file - cannot be found. - """ - return missing - - def handle_error( - self, error, req, schema, error_status_code=None, error_headers=None - ): - """Called if an error occurs while parsing args. By default, just logs and - raises ``error``. - """ - logger.error(error) - raise error diff --git a/webargs/djangoparser.py b/webargs/djangoparser.py deleted file mode 100644 index fd5cc11..0000000 --- a/webargs/djangoparser.py +++ /dev/null @@ -1,85 +0,0 @@ -# -*- coding: utf-8 -*- -"""Django request argument parsing. - -Example usage: :: - - from django.views.generic import View - from django.http import HttpResponse - from marshmallow import fields - from webargs.djangoparser import use_args - - hello_args = { - 'name': fields.Str(missing='World') - } - - class MyView(View): - - @use_args(hello_args) - def get(self, args, request): - return HttpResponse('Hello ' + args['name']) -""" -from webargs import core -from webargs.core import json - - -class DjangoParser(core.Parser): - """Django request argument parser. - - .. warning:: - - :class:`DjangoParser` does not override - :meth:`handle_error `, so your Django - views are responsible for catching any :exc:`ValidationErrors` raised by - the parser and returning the appropriate `HTTPResponse`. - """ - - def parse_querystring(self, req, name, field): - """Pull the querystring value from the request.""" - return core.get_value(req.GET, name, field) - - def parse_form(self, req, name, field): - """Pull the form value from the request.""" - return core.get_value(req.POST, name, field) - - def parse_json(self, req, name, field): - """Pull a json value from the request body.""" - json_data = self._cache.get("json") - if json_data is None: - try: - self._cache["json"] = json_data = core.parse_json(req.body) - except AttributeError: - return core.missing - except json.JSONDecodeError as e: - if e.doc == "": - return core.missing - else: - return self.handle_invalid_json_error(e, req) - return core.get_value(json_data, name, field, allow_many_nested=True) - - def parse_cookies(self, req, name, field): - """Pull the value from the cookiejar.""" - return core.get_value(req.COOKIES, name, field) - - def parse_headers(self, req, name, field): - raise NotImplementedError( - "Header parsing not supported by {0}".format(self.__class__.__name__) - ) - - def parse_files(self, req, name, field): - """Pull a file from the request.""" - return core.get_value(req.FILES, name, field) - - def get_request_from_view_args(self, view, args, kwargs): - # The first argument is either `self` or `request` - try: # self.request - return args[0].request - except AttributeError: # first arg is request - return args[0] - - def handle_invalid_json_error(self, error, req, *args, **kwargs): - raise error - - -parser = DjangoParser() -use_args = parser.use_args -use_kwargs = parser.use_kwargs diff --git a/webargs/falconparser.py b/webargs/falconparser.py deleted file mode 100644 index b8c5ec7..0000000 --- a/webargs/falconparser.py +++ /dev/null @@ -1,165 +0,0 @@ -# -*- coding: utf-8 -*- -"""Falcon request argument parsing module. -""" -import falcon -from falcon.util.uri import parse_query_string - -from webargs import core -from webargs.core import json - -HTTP_422 = "422 Unprocessable Entity" - -# Mapping of int status codes to string status -status_map = {422: HTTP_422} - - -# Collect all exceptions from falcon.status_codes -def _find_exceptions(): - for name in filter(lambda n: n.startswith("HTTP"), dir(falcon.status_codes)): - status = getattr(falcon.status_codes, name) - status_code = int(status.split(" ")[0]) - status_map[status_code] = status - - -_find_exceptions() -del _find_exceptions - - -def is_json_request(req): - content_type = req.get_header("Content-Type") - return content_type and core.is_json(content_type) - - -def parse_json_body(req): - if req.content_length in (None, 0): - # Nothing to do - return {} - if is_json_request(req): - body = req.stream.read() - if body: - try: - return core.parse_json(body) - except json.JSONDecodeError as e: - if e.doc == "": - return core.missing - else: - raise - return {} - - -# NOTE: Adapted from falcon.request.Request._parse_form_urlencoded -def parse_form_body(req): - if ( - req.content_type is not None - and "application/x-www-form-urlencoded" in req.content_type - ): - body = req.stream.read() - try: - body = body.decode("ascii") - except UnicodeDecodeError: - body = None - req.log_error( - "Non-ASCII characters found in form body " - "with Content-Type of " - "application/x-www-form-urlencoded. Body " - "will be ignored." - ) - - if body: - return parse_query_string( - body, keep_blank_qs_values=req.options.keep_blank_qs_values - ) - return {} - - -class HTTPError(falcon.HTTPError): - """HTTPError that stores a dictionary of validation error messages. - """ - - def __init__(self, status, errors, *args, **kwargs): - self.errors = errors - super(HTTPError, self).__init__(status, *args, **kwargs) - - def to_dict(self, *args, **kwargs): - """Override `falcon.HTTPError` to include error messages in responses.""" - ret = super(HTTPError, self).to_dict(*args, **kwargs) - if self.errors is not None: - ret["errors"] = self.errors - return ret - - -class FalconParser(core.Parser): - """Falcon request argument parser.""" - - def parse_querystring(self, req, name, field): - """Pull a querystring value from the request.""" - return core.get_value(req.params, name, field) - - def parse_form(self, req, name, field): - """Pull a form value from the request. - - .. note:: - - The request stream will be read and left at EOF. - """ - form = self._cache.get("form") - if form is None: - self._cache["form"] = form = parse_form_body(req) - return core.get_value(form, name, field) - - 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 = self._cache.get("json_data") - if json_data is None: - try: - self._cache["json_data"] = json_data = parse_json_body(req) - except json.JSONDecodeError as e: - return self.handle_invalid_json_error(e, req) - return core.get_value(json_data, name, field, allow_many_nested=True) - - def parse_headers(self, req, name, field): - """Pull a header value from the request.""" - # Use req.get_headers rather than req.headers for performance - return req.get_header(name, required=False) or core.missing - - def parse_cookies(self, req, name, field): - """Pull a cookie value from the request.""" - cookies = self._cache.get("cookies") - if cookies is None: - self._cache["cookies"] = cookies = req.cookies - return core.get_value(cookies, name, field) - - def get_request_from_view_args(self, view, args, kwargs): - """Get request from a resource method's arguments. Assumes that - request is the second argument. - """ - req = args[1] - assert isinstance(req, falcon.Request), "Argument is not a falcon.Request" - return req - - def parse_files(self, req, name, field): - raise NotImplementedError( - "Parsing files not yet supported by {0}".format(self.__class__.__name__) - ) - - def handle_error(self, error, req, schema, error_status_code, error_headers): - """Handles errors during parsing.""" - status = status_map.get(error_status_code or self.DEFAULT_VALIDATION_STATUS) - if status is None: - raise LookupError("Status code {0} not supported".format(error_status_code)) - raise HTTPError(status, errors=error.messages, headers=error_headers) - - def handle_invalid_json_error(self, error, req, *args, **kwargs): - status = status_map[400] - messages = {"json": ["Invalid JSON body."]} - raise HTTPError(status, errors=messages) - - -parser = FalconParser() -use_args = parser.use_args -use_kwargs = parser.use_kwargs diff --git a/webargs/fields.py b/webargs/fields.py deleted file mode 100644 index e3948d7..0000000 --- a/webargs/fields.py +++ /dev/null @@ -1,74 +0,0 @@ -# -*- coding: utf-8 -*- -"""Field classes. - -Includes all fields from `marshmallow.fields` in addition to a custom -`Nested` field and `DelimitedList`. - -All fields can optionally take a special `location` keyword argument, which -tells webargs where to parse the request argument from. :: - - args = { - 'active': fields.Bool(location='query') - 'content_type': fields.Str(data_key='Content-Type', - location='headers') - } - -Note: `data_key` replaced `load_from` in marshmallow 3. -When using marshmallow 2, use `load_from`. -""" -import marshmallow as ma - -from webargs.core import dict2schema - -__all__ = ["Nested", "DelimitedList"] -# Expose all fields from marshmallow.fields. -# We do this instead of 'from marshmallow.fields import *' because webargs -# has its own subclass of Nested -for each in (field_name for field_name in ma.fields.__all__ if field_name != "Nested"): - __all__.append(each) - globals()[each] = getattr(ma.fields, each) - - -class Nested(ma.fields.Nested): - """Same as `marshmallow.fields.Nested`, except can be passed a dictionary as - the first argument, which will be converted to a `marshmallow.Schema`. - """ - - def __init__(self, nested, *args, **kwargs): - if isinstance(nested, dict): - nested = dict2schema(nested) - super(Nested, self).__init__(nested, *args, **kwargs) - - -class DelimitedList(ma.fields.List): - """Same as `marshmallow.fields.List`, except can load from either a list or - a delimited string (e.g. "foo,bar,baz"). - - :param Field cls_or_instance: A field class or instance. - :param str delimiter: Delimiter between values. - :param bool as_string: Dump values to string. - """ - - delimiter = "," - - def __init__(self, cls_or_instance, delimiter=None, as_string=False, **kwargs): - self.delimiter = delimiter or self.delimiter - self.as_string = as_string - super(DelimitedList, self).__init__(cls_or_instance, **kwargs) - - def _serialize(self, value, attr, obj): - ret = super(DelimitedList, self)._serialize(value, attr, obj) - if self.as_string: - return self.delimiter.join(format(each) for each in ret) - return ret - - def _deserialize(self, value, attr, data): - try: - ret = ( - value - if ma.utils.is_iterable_but_not_string(value) - else value.split(self.delimiter) - ) - except AttributeError: - self.fail("invalid") - return super(DelimitedList, self)._deserialize(ret, attr, data) diff --git a/webargs/flaskparser.py b/webargs/flaskparser.py deleted file mode 100644 index ea5a24f..0000000 --- a/webargs/flaskparser.py +++ /dev/null @@ -1,120 +0,0 @@ -# -*- coding: utf-8 -*- -"""Flask request argument parsing module. - -Example: :: - - from flask import Flask - - from webargs import fields - from webargs.flaskparser import use_args - - app = Flask(__name__) - - hello_args = { - 'name': fields.Str(required=True) - } - - @app.route('/') - @use_args(hello_args) - def index(args): - return 'Hello ' + args['name'] -""" -import flask -from werkzeug.exceptions import HTTPException - -from webargs import core -from webargs.core import json - - -def abort(http_status_code, exc=None, **kwargs): - """Raise a HTTPException for the given http_status_code. Attach any keyword - arguments to the exception for later processing. - - From Flask-Restful. See NOTICE file for license information. - """ - try: - flask.abort(http_status_code) - except HTTPException as err: - err.data = kwargs - err.exc = exc - raise err - - -def is_json_request(req): - return core.is_json(req.mimetype) - - -class FlaskParser(core.Parser): - """Flask request argument parser.""" - - __location_map__ = dict(view_args="parse_view_args", **core.Parser.__location_map__) - - def parse_view_args(self, req, name, field): - """Pull a value from the request's ``view_args``.""" - return core.get_value(req.view_args, name, field) - - def parse_json(self, req, name, field): - """Pull a json value from the request.""" - json_data = self._cache.get("json") - if json_data is None: - # We decode the json manually here instead of - # using req.get_json() so that we can handle - # JSONDecodeErrors consistently - data = req.get_data(cache=True) - try: - self._cache["json"] = json_data = core.parse_json(data) - except json.JSONDecodeError as e: - if e.doc == "": - return core.missing - else: - return self.handle_invalid_json_error(e, req) - return core.get_value(json_data, name, field, allow_many_nested=True) - - def parse_querystring(self, req, name, field): - """Pull a querystring value from the request.""" - return core.get_value(req.args, name, field) - - def parse_form(self, req, name, field): - """Pull a form value from the request.""" - try: - return core.get_value(req.form, name, field) - except AttributeError: - pass - return core.missing - - def parse_headers(self, req, name, field): - """Pull a value from the header data.""" - return core.get_value(req.headers, name, field) - - def parse_cookies(self, req, name, field): - """Pull a value from the cookiejar.""" - return core.get_value(req.cookies, name, field) - - def parse_files(self, req, name, field): - """Pull a file from the request.""" - return core.get_value(req.files, name, field) - - def handle_error(self, error, req, schema, error_status_code, error_headers): - """Handles errors during parsing. Aborts the current HTTP request and - responds with a 422 error. - """ - status_code = error_status_code or self.DEFAULT_VALIDATION_STATUS - abort( - status_code, - exc=error, - messages=error.messages, - schema=schema, - headers=error_headers, - ) - - def handle_invalid_json_error(self, error, req, *args, **kwargs): - abort(400, exc=error, messages={"json": ["Invalid JSON body."]}) - - def get_default_request(self): - """Override to use Flask's thread-local request objec by default""" - return flask.request - - -parser = FlaskParser() -use_args = parser.use_args -use_kwargs = parser.use_kwargs diff --git a/webargs/pyramidparser.py b/webargs/pyramidparser.py deleted file mode 100644 index aecff32..0000000 --- a/webargs/pyramidparser.py +++ /dev/null @@ -1,173 +0,0 @@ -# -*- coding: utf-8 -*- -"""Pyramid request argument parsing. - -Example usage: :: - - from wsgiref.simple_server import make_server - from pyramid.config import Configurator - from pyramid.response import Response - from marshmallow import fields - from webargs.pyramidparser import use_args - - hello_args = { - 'name': fields.Str(missing='World') - } - - @use_args(hello_args) - def hello_world(request, args): - return Response('Hello ' + args['name']) - - if __name__ == '__main__': - config = Configurator() - config.add_route('hello', '/') - config.add_view(hello_world, route_name='hello') - app = config.make_wsgi_app() - server = make_server('0.0.0.0', 6543, app) - server.serve_forever() -""" -import collections -import functools - -from webob.multidict import MultiDict -from pyramid.httpexceptions import exception_response - -from marshmallow.compat import text_type -from webargs import core -from webargs.core import json - - -class PyramidParser(core.Parser): - """Pyramid request argument parser.""" - - __location_map__ = dict(matchdict="parse_matchdict", **core.Parser.__location_map__) - - def parse_querystring(self, req, name, field): - """Pull a querystring value from the request.""" - return core.get_value(req.GET, name, field) - - def parse_form(self, req, name, field): - """Pull a form value from the request.""" - return core.get_value(req.POST, name, field) - - def parse_json(self, req, name, field): - """Pull a json value from the request.""" - json_data = self._cache.get("json") - if json_data is None: - try: - self._cache["json"] = json_data = core.parse_json(req.body, req.charset) - except json.JSONDecodeError as e: - if e.doc == "": - return core.missing - else: - return self.handle_invalid_json_error(e, req) - if json_data is None: - return core.missing - return core.get_value(json_data, name, field, allow_many_nested=True) - - def parse_cookies(self, req, name, field): - """Pull the value from the cookiejar.""" - return core.get_value(req.cookies, name, field) - - def parse_headers(self, req, name, field): - """Pull a value from the header data.""" - return core.get_value(req.headers, name, field) - - def parse_files(self, req, name, field): - """Pull a file from the request.""" - files = ((k, v) for k, v in req.POST.items() if hasattr(v, "file")) - return core.get_value(MultiDict(files), name, field) - - def parse_matchdict(self, req, name, field): - """Pull a value from the request's `matchdict`.""" - return core.get_value(req.matchdict, name, field) - - def handle_error(self, error, req, schema, error_status_code, error_headers): - """Handles errors during parsing. Aborts the current HTTP request and - responds with a 400 error. - """ - status_code = error_status_code or self.DEFAULT_VALIDATION_STATUS - response = exception_response( - status_code, - detail=text_type(error), - headers=error_headers, - content_type="application/json", - ) - body = json.dumps(error.messages) - response.body = body.encode("utf-8") if isinstance(body, text_type) else body - raise response - - def handle_invalid_json_error(self, error, req, *args, **kwargs): - messages = {"json": ["Invalid JSON body."]} - response = exception_response( - 400, detail=text_type(messages), content_type="application/json" - ) - body = json.dumps(messages) - response.body = body.encode("utf-8") if isinstance(body, text_type) else body - raise response - - def use_args( - self, - argmap, - req=None, - locations=core.Parser.DEFAULT_LOCATIONS, - as_kwargs=False, - validate=None, - error_status_code=None, - error_headers=None, - ): - """Decorator that injects parsed arguments into a view callable. - Supports the *Class-based View* pattern where `request` is saved as an instance - attribute on a view class. - - :param dict argmap: Either a `marshmallow.Schema`, a `dict` - of argname -> `marshmallow.fields.Field` pairs, or a callable - which accepts a request and returns a `marshmallow.Schema`. - :param req: The request object to parse. Pulled off of the view by default. - :param tuple locations: Where on the request to search for values. - :param bool as_kwargs: Whether to insert arguments as keyword arguments. - :param callable validate: Validation function that receives the dictionary - of parsed arguments. If the function returns ``False``, the parser - will raise a :exc:`ValidationError`. - :param int error_status_code: Status code passed to error handler functions when - a `ValidationError` is raised. - :param dict error_headers: Headers passed to error handler functions when a - a `ValidationError` is raised. - """ - locations = locations or self.locations - # Optimization: If argmap is passed as a dictionary, we only need - # to generate a Schema once - if isinstance(argmap, collections.Mapping): - argmap = core.dict2schema(argmap)() - - def decorator(func): - @functools.wraps(func) - def wrapper(obj, *args, **kwargs): - # The first argument is either `self` or `request` - try: # get self.request - request = req or obj.request - except AttributeError: # first arg is request - request = obj - # NOTE: At this point, argmap may be a Schema, callable, or dict - parsed_args = self.parse( - argmap, - req=request, - locations=locations, - validate=validate, - error_status_code=error_status_code, - error_headers=error_headers, - ) - if as_kwargs: - kwargs.update(parsed_args) - return func(obj, *args, **kwargs) - else: - return func(obj, parsed_args, *args, **kwargs) - - wrapper.__wrapped__ = func - return wrapper - - return decorator - - -parser = PyramidParser() -use_args = parser.use_args -use_kwargs = parser.use_kwargs diff --git a/webargs/testing.py b/webargs/testing.py deleted file mode 100644 index 2d07684..0000000 --- a/webargs/testing.py +++ /dev/null @@ -1,207 +0,0 @@ -# -*- coding: utf-8 -*- -"""Utilities for testing. Includes a base test class -for testing parsers. - -.. warning:: - - Methods and functions in this module may change without - warning and without a major version change. -""" -import pytest -import webtest - -from webargs.core import json - - -class CommonTestCase(object): - """Base test class that defines test methods for common functionality across all - parsers. Subclasses must define `create_app`, which returns a WSGI-like app. - """ - - def create_app(self): - """Return a WSGI app""" - raise NotImplementedError("Must define create_app()") - - def create_testapp(self, app): - return webtest.TestApp(app) - - def before_create_app(self): - pass - - def after_create_app(self): - pass - - @pytest.fixture(scope="class") - def testapp(self): - self.before_create_app() - yield self.create_testapp(self.create_app()) - self.after_create_app() - - def test_parse_querystring_args(self, testapp): - assert testapp.get("/echo?name=Fred").json == {"name": "Fred"} - - def test_parse_querystring_with_query_location_specified(self, testapp): - assert testapp.get("/echo_query?name=Steve").json == {"name": "Steve"} - - def test_parse_form(self, testapp): - assert testapp.post("/echo", {"name": "Joe"}).json == {"name": "Joe"} - - def test_parse_json(self, testapp): - assert testapp.post_json("/echo", {"name": "Fred"}).json == {"name": "Fred"} - - 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 == {"name": "World"} - - def test_parse_json_with_charset(self, testapp): - res = testapp.post( - "/echo", - json.dumps({"name": "Steve"}), - content_type="application/json;charset=UTF-8", - ) - assert res.json == {"name": "Steve"} - - def test_parse_json_with_vendor_media_type(self, testapp): - res = testapp.post( - "/echo", - json.dumps({"name": "Steve"}), - content_type="application/vnd.api+json;charset=UTF-8", - ) - assert res.json == {"name": "Steve"} - - def test_parse_json_ignores_extra_data(self, testapp): - assert testapp.post_json("/echo", {"extra": "data"}).json == {"name": "World"} - - def test_parse_json_blank(self, testapp): - assert testapp.post_json("/echo", None).json == {"name": "World"} - - def test_parse_json_ignore_unexpected_int(self, testapp): - assert testapp.post_json("/echo", 1).json == {"name": "World"} - - def test_parse_json_ignore_unexpected_list(self, testapp): - assert testapp.post_json("/echo", [{"extra": "data"}]).json == {"name": "World"} - - def test_parse_json_many_schema_invalid_input(self, testapp): - res = testapp.post_json( - "/echo_many_schema", [{"name": "a"}], expect_errors=True - ) - assert res.status_code == 422 - - def test_parse_json_many_schema(self, testapp): - res = testapp.post_json("/echo_many_schema", [{"name": "Steve"}]).json - assert res == [{"name": "Steve"}] - - def test_parse_json_many_schema_ignore_malformed_data(self, testapp): - assert testapp.post_json("/echo_many_schema", {"extra": "data"}).json == [] - - def test_parsing_form_default(self, testapp): - assert testapp.post("/echo", {}).json == {"name": "World"} - - def test_parse_querystring_multiple(self, testapp): - expected = {"name": ["steve", "Loria"]} - assert testapp.get("/echo_multi?name=steve&name=Loria").json == expected - - def test_parse_form_multiple(self, testapp): - expected = {"name": ["steve", "Loria"]} - assert ( - testapp.post("/echo_multi", {"name": ["steve", "Loria"]}).json == expected - ) - - def test_parse_json_list(self, testapp): - expected = {"name": ["Steve"]} - assert testapp.post_json("/echo_multi", {"name": "Steve"}).json == expected - - def test_parse_json_with_nonascii_chars(self, testapp): - text = u"øˆƒ£ºº∆ƒˆ∆" - assert testapp.post_json("/echo", {"name": text}).json == {"name": text} - - def test_validation_error_returns_422_response(self, testapp): - res = testapp.post("/echo", {"name": "b"}, expect_errors=True) - assert res.status_code == 422 - - def test_user_validation_error_returns_422_response_by_default(self, testapp): - res = testapp.post_json("/error", {"text": "foo"}, expect_errors=True) - assert res.status_code == 422 - - def test_use_args_decorator(self, testapp): - assert testapp.get("/echo_use_args?name=Fred").json == {"name": "Fred"} - - def test_use_args_with_path_param(self, testapp): - url = "/echo_use_args_with_path_param/foo" - res = testapp.get(url + "?value=42") - assert res.json == {"value": 42} - - def test_use_args_with_validation(self, testapp): - result = testapp.post("/echo_use_args_validated", {"value": 43}) - assert result.status_code == 200 - result = testapp.post( - "/echo_use_args_validated", {"value": 41}, expect_errors=True - ) - assert result.status_code == 422 - - def test_use_kwargs_decorator(self, testapp): - assert testapp.get("/echo_use_kwargs?name=Fred").json == {"name": "Fred"} - - def test_use_kwargs_with_path_param(self, testapp): - url = "/echo_use_kwargs_with_path_param/foo" - res = testapp.get(url + "?value=42") - assert res.json == {"value": 42} - - def test_parsing_headers(self, testapp): - res = testapp.get("/echo_headers", headers={"name": "Fred"}) - assert res.json == {"name": "Fred"} - - def test_parsing_cookies(self, testapp): - testapp.set_cookie("name", "Steve") - res = testapp.get("/echo_cookie") - assert res.json == {"name": "Steve"} - - def test_parse_nested_json(self, testapp): - res = testapp.post_json( - "/echo_nested", {"name": {"first": "Steve", "last": "Loria"}} - ) - assert res.json == {"name": {"first": "Steve", "last": "Loria"}} - - def test_parse_nested_many_json(self, testapp): - in_data = {"users": [{"id": 1, "name": "foo"}, {"id": 2, "name": "bar"}]} - res = testapp.post_json("/echo_nested_many", in_data) - assert res.json == in_data - - # Regression test for https://github.com/marshmallow-code/webargs/issues/120 - def test_parse_nested_many_missing(self, testapp): - in_data = {} - res = testapp.post_json("/echo_nested_many", in_data) - assert res.json == {} - - def test_parse_json_if_no_json(self, testapp): - res = testapp.post("/echo") - assert res.json == {"name": "World"} - - def test_parse_files(self, testapp): - res = testapp.post( - "/echo_file", {"myfile": webtest.Upload("README.rst", b"data")} - ) - assert res.json == {"myfile": "data"} - - # https://github.com/sloria/webargs/pull/297 - def test_empty_json(self, testapp): - res = testapp.post( - "/echo", - "", - headers={"Accept": "application/json", "Content-Type": "application/json"}, - ) - assert res.status_code == 200 - assert res.json == {"name": "World"} - - # https://github.com/sloria/webargs/issues/329 - def test_invalid_json(self, testapp): - res = testapp.post( - "/echo", - '{"foo": "bar", }', - headers={"Accept": "application/json", "Content-Type": "application/json"}, - expect_errors=True, - ) - assert res.status_code == 400 - assert res.json == {"json": ["Invalid JSON body."]} diff --git a/webargs/tornadoparser.py b/webargs/tornadoparser.py deleted file mode 100644 index cc0f1a3..0000000 --- a/webargs/tornadoparser.py +++ /dev/null @@ -1,157 +0,0 @@ -# -*- coding: utf-8 -*- -"""Tornado request argument parsing module. - -Example: :: - - import tornado.web - from marshmallow import fields - from webargs.tornadoparser import use_args - - class HelloHandler(tornado.web.RequestHandler): - - @use_args({'name': fields.Str(missing='World')}) - def get(self, args): - response = {'message': 'Hello {}'.format(args['name'])} - self.write(response) -""" -import tornado.web -from tornado.escape import _unicode - -from marshmallow.compat import basestring -from webargs import core -from webargs.core import json - - -class HTTPError(tornado.web.HTTPError): - """`tornado.web.HTTPError` that stores validation errors.""" - - def __init__(self, *args, **kwargs): - self.messages = kwargs.pop("messages", {}) - self.headers = kwargs.pop("headers", None) - super(HTTPError, self).__init__(*args, **kwargs) - - -def parse_json_body(req): - """Return the decoded JSON body from the request.""" - content_type = req.headers.get("Content-Type") - if content_type and core.is_json(content_type): - try: - return core.parse_json(req.body) - except TypeError: - pass - except json.JSONDecodeError as e: - if e.doc == "": - return core.missing - else: - raise - return {} - - -# From tornado.web.RequestHandler.decode_argument -def decode_argument(value, name=None): - """Decodes an argument from the request. - """ - try: - return _unicode(value) - except UnicodeDecodeError: - raise HTTPError(400, "Invalid unicode in %s: %r" % (name or "url", value[:40])) - - -def get_value(d, name, field): - """Handle gets from 'multidicts' made of lists - - It handles cases: ``{"key": [value]}`` and ``{"key": value}`` - """ - multiple = core.is_multiple(field) - value = d.get(name, core.missing) - if value is core.missing: - return core.missing - if multiple and value is not core.missing: - return [ - decode_argument(v, name) if isinstance(v, basestring) else v for v in value - ] - ret = value - if value and isinstance(value, (list, tuple)): - ret = value[0] - if isinstance(ret, basestring): - return decode_argument(ret, name) - else: - return ret - - -class TornadoParser(core.Parser): - """Tornado request argument parser.""" - - def __init__(self, *args, **kwargs): - super(TornadoParser, self).__init__(*args, **kwargs) - self.json = None - - def parse_json(self, req, name, field): - """Pull a json value from the request.""" - json_data = self._cache.get("json") - if json_data is None: - try: - self._cache["json"] = json_data = parse_json_body(req) - except json.JSONDecodeError as e: - return self.handle_invalid_json_error(e, req) - if json_data is None: - return core.missing - return core.get_value(json_data, name, field, allow_many_nested=True) - - def parse_querystring(self, req, name, field): - """Pull a querystring value from the request.""" - return get_value(req.query_arguments, name, field) - - def parse_form(self, req, name, field): - """Pull a form value from the request.""" - return get_value(req.body_arguments, name, field) - - def parse_headers(self, req, name, field): - """Pull a value from the header data.""" - return get_value(req.headers, name, field) - - def parse_cookies(self, req, name, field): - """Pull a value from the header data.""" - cookie = req.cookies.get(name) - - if cookie is not None: - return [cookie.value] if core.is_multiple(field) else cookie.value - else: - return [] if core.is_multiple(field) else None - - def parse_files(self, req, name, field): - """Pull a file from the request.""" - return get_value(req.files, name, field) - - def handle_error(self, error, req, schema, error_status_code, error_headers): - """Handles errors during parsing. Raises a `tornado.web.HTTPError` - with a 400 error. - """ - status_code = error_status_code or self.DEFAULT_VALIDATION_STATUS - if status_code == 422: - reason = "Unprocessable Entity" - else: - reason = None - raise HTTPError( - status_code, - log_message=str(error.messages), - reason=reason, - messages=error.messages, - headers=error_headers, - ) - - def handle_invalid_json_error(self, error, req, *args, **kwargs): - raise HTTPError( - 400, - log_message="Invalid JSON body.", - reason="Bad Request", - messages={"json": ["Invalid JSON body."]}, - ) - - def get_request_from_view_args(self, view, args, kwargs): - return args[0].request - - -parser = TornadoParser() -use_args = parser.use_args -use_kwargs = parser.use_kwargs diff --git a/webargs/webapp2parser.py b/webargs/webapp2parser.py deleted file mode 100644 index 9da1558..0000000 --- a/webargs/webapp2parser.py +++ /dev/null @@ -1,80 +0,0 @@ -# -*- coding: utf-8 -*- -"""Webapp2 request argument parsing module. - -Example: :: - - import webapp2 - - from marshmallow import fields - from webargs.webobparser import use_args - - hello_args = { - 'name': fields.Str(missing='World') - } - - class MainPage(webapp2.RequestHandler): - - @use_args(hello_args) - def get_args(self, args): - self.response.write('Hello, {name}!'.format(name=args['name'])) - - @use_kwargs(hello_args) - def get_kwargs(self, name=None): - self.response.write('Hello, {name}!'.format(name=name)) - - app = webapp2.WSGIApplication([ - webapp2.Route(r'/hello', MainPage, handler_method='get_args'), - webapp2.Route(r'/hello_dict', MainPage, handler_method='get_kwargs'), - ], debug=True) -""" -import webapp2 -import webob.multidict - -from webargs import core -from webargs.core import json - - -class Webapp2Parser(core.Parser): - """webapp2 request argument parser.""" - - def parse_json(self, req, name, field): - """Pull a json value from the request.""" - json_data = self._cache.get("json") - if json_data is None: - try: - self._cache["json"] = json_data = core.parse_json(req.body) - except json.JSONDecodeError as e: - if e.doc == "": - return core.missing - else: - raise - return core.get_value(json_data, name, field, allow_many_nested=True) - - def parse_querystring(self, req, name, field): - """Pull a querystring value from the request.""" - return core.get_value(req.GET, name, field) - - def parse_form(self, req, name, field): - """Pull a form value from the request.""" - return core.get_value(req.POST, name, field) - - def parse_cookies(self, req, name, field): - """Pull the value from the cookiejar.""" - return core.get_value(req.cookies, name, field) - - def parse_headers(self, req, name, field): - """Pull a value from the header data.""" - return core.get_value(req.headers, name, field) - - def parse_files(self, req, name, field): - """Pull a file from the request.""" - files = ((k, v) for k, v in req.POST.items() if hasattr(v, "file")) - return core.get_value(webob.multidict.MultiDict(files), name, field) - - def get_default_request(self): - return webapp2.get_request() - - -parser = Webapp2Parser() -use_args = parser.use_args -use_kwargs = parser.use_kwargs