Skip to content

Commit

Permalink
Merge pull request #475 from ergo/master
Browse files Browse the repository at this point in the history
Add Marshmallow validation support to Cornice
  • Loading branch information
leplatrem committed Mar 24, 2018
2 parents 494f35f + 4248073 commit fcda515
Show file tree
Hide file tree
Showing 7 changed files with 576 additions and 13 deletions.
11 changes: 11 additions & 0 deletions cornice/validators/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,24 @@
headers_validator as colander_headers_validator,
path_validator as colander_path_validator,
querystring_validator as colander_querystring_validator)
from cornice.validators._marshmallow import (
validator as marshmallow_validator,
body_validator as marshmallow_body_validator,
headers_validator as marshmallow_headers_validator,
path_validator as marshmallow_path_validator,
querystring_validator as marshmallow_querystring_validator)


__all__ = ['colander_validator',
'colander_body_validator',
'colander_headers_validator',
'colander_path_validator',
'colander_querystring_validator',
'marshmallow_validator',
'marshmallow_body_validator',
'marshmallow_headers_validator',
'marshmallow_path_validator',
'marshmallow_querystring_validator',
'extract_cstruct',
'DEFAULT_VALIDATORS',
'DEFAULT_FILTERS']
Expand Down
165 changes: 165 additions & 0 deletions cornice/validators/_marshmallow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this file,
# You can obtain one at http://mozilla.org/MPL/2.0/.

from six import with_metaclass


def _generate_marshmallow_validator(location):
"""
Generate a marshmallow validator for data from the given location.
:param location: The location in the request to find the data to be
validated, such as "body" or "querystring".
:type location: str
:return: Returns a callable that will validate the request at the given
location.
:rtype: callable
"""

def _validator(request, schema=None, deserializer=None, **kwargs):
"""
Validate the location against the schema defined on the service.
The content of the location is deserialized, validated and stored in
the ``request.validated`` attribute.
.. note::
If no schema is defined, this validator does nothing.
:param request: Current request
:type request: :class:`~pyramid:pyramid.request.Request`
:param schema: The marshmallow schema
:param deserializer: Optional deserializer, defaults to
:func:`cornice.validators.extract_cstruct`
"""
import marshmallow

if schema is None:
return

class ValidatedField(marshmallow.fields.Field):
def _deserialize(self, value, attr, data):
deserialized = schema.load(value)
# marshmallow 2.x returns a tuple, 3/x will always throw
# and returns just data
if isinstance(deserialized, tuple):
deserialized, errors = deserialized[0], deserialized[1]
# this should cover both non-strict and strict forms
if errors:
raise marshmallow.ValidationError(
errors) # pragma: no cover
return deserialized

class Meta(object):
strict = True
ordered = True

class RequestSchemaMeta(type):
"""
A metaclass that will inject a location class attribute into
RequestSchema.
"""

def __new__(cls, name, bases, class_attrs):
"""
Instantiate the RequestSchema class.
:param name: The name of the class we are instantiating. Will
be "RequestSchema".
:type name: str
:param bases: The class's superclasses.
:type bases: tuple
:param dct: The class's class attributes.
:type dct: dict
"""

class_attrs[location] = ValidatedField(
required=True, load_from=location)
class_attrs['Meta'] = Meta
return type(name, bases, class_attrs)

class RequestSchema(with_metaclass(
RequestSchemaMeta, marshmallow.Schema)):
"""A schema to validate the request's location attributes."""
pass

validator(request, RequestSchema(), deserializer, **kwargs)
request.validated = request.validated.get(location, {})

return _validator


body_validator = _generate_marshmallow_validator('body')
headers_validator = _generate_marshmallow_validator('header')
path_validator = _generate_marshmallow_validator('path')
querystring_validator = _generate_marshmallow_validator('querystring')


def _message_normalizer(exc, no_field_name="_schema"):
"""
Normally `normalize_messages` will exist on `ValidationError` but pre 2.10
versions don't have it
:param exc:
:param no_field_name:
:return:
"""
if isinstance(exc.messages, dict):
return exc.messages
if len(exc.field_names) == 0:
return {no_field_name: exc.messages}
return dict((name, exc.messages) for name in exc.field_names)


def validator(request, schema=None, deserializer=None, **kwargs):
"""
Validate the full request against the schema defined on the service.
Each attribute of the request is deserialized, validated and stored in the
``request.validated`` attribute
(eg. body in ``request.validated['body']``).
.. note::
If no schema is defined, this validator does nothing.
:param request: Current request
:type request: :class:`~pyramid:pyramid.request.Request`
:param schema: The marshmallow schema
:param deserializer: Optional deserializer, defaults to
:func:`cornice.validators.extract_cstruct`
"""
import marshmallow
from cornice.validators import extract_cstruct

if deserializer is None:
deserializer = extract_cstruct

if schema is None:
return

cstruct = deserializer(request)
try:
deserialized = schema.load(cstruct)
# marshmallow 2.x returns a tuple, 3/x will always throw
# and returns just data
if isinstance(deserialized, tuple):
deserialized, errors = deserialized[0], deserialized[1]
# this should cover both non-strict and strict forms
if errors:
raise marshmallow.ValidationError(errors)
except marshmallow.ValidationError as err:
# translate = request.localizer.translate
normalized_errors = _message_normalizer(err)
for location, details in normalized_errors.items():
location = location if location != '_schema' else ''
if hasattr(details, 'items'):
for subfield, msg in details.items():
request.errors.add(location, subfield, msg)
else:
request.errors.add(location, location, details)
else:
request.validated.update(deserialized)
6 changes: 5 additions & 1 deletion docs/source/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@ Validation
.. autofunction:: cornice.validators.colander_path_validator
.. autofunction:: cornice.validators.colander_querystring_validator
.. autofunction:: cornice.validators.colander_validator

.. autofunction:: cornice.validators.marshmallow_body_validator
.. autofunction:: cornice.validators.marshmallow_headers_validator
.. autofunction:: cornice.validators.marshmallow_path_validator
.. autofunction:: cornice.validators.marshmallow_querystring_validator
.. autofunction:: cornice.validators.marshmallow_validator

Errors
======
Expand Down
80 changes: 74 additions & 6 deletions docs/source/schema.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ As you would do for a database table, you define some fields and
their type, and make sure that incoming requests comply.

There are many schema libraries in the Python ecosystem you can
use. The most known ones are Colander & formencode.
use. The most known ones are Colander, Marshmallow & formencode.

You can do schema validation using either those libraries or either
custom code.
Expand Down Expand Up @@ -64,6 +64,35 @@ To describe a schema, using Colander and Cornice, here is how you can do:
username = request.validated['username']
return {'success': True}
Using Marshmallow
=================

Marshmallow (https://marshmallow.readthedocs.io/en/latest/)
is an ORM/ODM/framework-agnostic library for converting complex
datatypes, such as objects, to and from native Python datatypes that can also
be used with Cornice validation hooks.

Cornice provides a helper to ease Marshmallow integration.

To describe a schema, using Marshmallow and Cornice, here is how you can do:

.. code-block:: python
import marshmallow
from cornice import Service
from cornice.validators import marshmallow_body_validator
class SignupSchema(marshmallow.Schema):
username = marshmallow.fields.String(required=True)
@signup.post(schema=SignupSchema(), validators=(marshmallow_body_validator,))
def signup_post(request):
username = request.validated['username']
return {'success': True}
Dynamic schemas
~~~~~~~~~~~~~~~

Expand All @@ -77,9 +106,9 @@ Example:
def dynamic_schema(request):
if request.method == 'POST':
schema = foo_schema
schema = foo_schema()
elif request.method == 'PUT':
schema = bar_schema
schema = bar_schema()
return schema
Expand All @@ -94,7 +123,8 @@ Example:
In addition to ``colander_body_validator()`` as demonstrated above, there are also three more
similar validators, ``colander_headers_validator()``, ``colander_path_validator()``, and
``colander_querystring_validator()``, which validate the given ``Schema`` against the headers, path,
``colander_querystring_validator()`` (and similarly named ``marshmallow_*``
functions), which validate the given ``Schema`` against the headers, path,
or querystring parameters, respectively.


Expand All @@ -111,6 +141,7 @@ The ``request.validated`` hences reflects this additional level.

.. code-block:: python
# colander
from cornice.validators import colander_validator
class Querystring(colander.MappingSchema):
Expand All @@ -131,11 +162,34 @@ The ``request.validated`` hences reflects this additional level.
referrer = request.validated['querystring']['referrer']
return {'success': True}
# marshmallow
from cornice.validators import marshmallow_validator
class Querystring(marshmallow.Schema):
referrer = marshmallow.fields.String()
class Payload(marshmallow.Schema):
username = marshmallow.fields.String(validate=[
marshmallow.validate.Length(min=3)
], required=True)
class SignupSchema(marshmallow.Schema):
body = marshmallow.fields.Nested(Payload())
querystring = marshmallow.fields.Nested(Querystring())
@signup.post(schema=SignupSchema(), validators=(marshmallow_validator,))
def signup_post(request):
username = request.validated['body']['username']
referrer = request.validated['querystring']['referrer']
return {'success': True}
This allows to have validation at the schema level that validates data from several
places on the request:

.. code-block:: python
# colander
class SignupSchema(colander.MappingSchema):
body = Payload()
querystring = Querystring()
Expand All @@ -144,14 +198,28 @@ places on the request:
appstruct = super(SignupSchema, self).deserialize(cstruct)
username = appstruct['body']['username']
referrer = appstruct['querystring'].get('referrer')
if username == referred:
if username == referrer:
self.raise_invalid('Referrer cannot be the same as username')
return appstruct
# marshmallow
class SignupSchema(marshmallow.Schema):
body = marshmallow.fields.Nested(Payload())
querystring = marshmallow.fields.Nested(Querystring())
@marshmallow.validates_schema(skip_on_field_errors=True)
def validate_multiple_fields(self, data):
username = data['body'].get('username')
referrer = data['querystring'].get('referrer')
if username == referrer:
raise marshmallow.ValidationError(
'Referrer cannot be the same as username')
Cornice provides built-in support for JSON and HTML forms
(``application/x-www-form-urlencoded``) input validation using the provided
colander validators.
validators.

If you need to validate other input formats, such as XML, you need to
implement your own deserializer and pass it to the service.
Expand Down
1 change: 1 addition & 0 deletions tests/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
Sphinx
WebTest
colander>=1.0b1
marshmallow>=2.0.0,<3.0.0
coverage
mock
nose
Expand Down

0 comments on commit fcda515

Please sign in to comment.