Repository: 4Catalyzer/flask-resty

Issue:
Support Marshmallow 3
[From here](https://marshmallow.readthedocs.io/en/latest/upgrading.html#schemas-are-always-strict):
> Schema().load and Schema().dump don’t return a (data, errors) tuple any more. Only data is returned.

Discussion:

Patch:
diff --git a/.travis.yml b/.travis.yml
index 708e4a9..00ffb59 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -4,16 +4,21 @@ services:
   - postgresql
 matrix:
   include:
-    - { python: "2.7",   env: "TOXENV=py-full DATABASE_URL=postgres://localhost/travis_ci_test" }
-    - { python: "3.5",   env: "TOXENV=py-full DATABASE_URL=postgres://localhost/travis_ci_test" }
-    - { python: "3.6",   env: "TOXENV=py-full DATABASE_URL=postgres://localhost/travis_ci_test" }
-    - { python: "3.7",   env: "TOXENV=py-full DATABASE_URL=postgres://localhost/travis_ci_test" }
+    - { python: "2.7",   env: "TOXENV=py-full-marshmallow2 DATABASE_URL=postgres://localhost/travis_ci_test" }
+    - { python: "3.5",   env: "TOXENV=py-full-marshmallow2 DATABASE_URL=postgres://localhost/travis_ci_test" }
+    - { python: "3.6",   env: "TOXENV=py-full-marshmallow2 DATABASE_URL=postgres://localhost/travis_ci_test" }
+    - { python: "3.7",   env: "TOXENV=py-full-marshmallow2 DATABASE_URL=postgres://localhost/travis_ci_test" }
 
-    - { python: "2.7",   env: "TOXENV=py-base" }
-    - { python: "3.6",   env: "TOXENV=py-base" }
+    - { python: "2.7",   env: "TOXENV=py-base-marshmallow2" }
+    - { python: "3.6",   env: "TOXENV=py-base-marshmallow2" }
 
-    - { python: "pypy2.7-6.0",  env: "TOXENV=py-full" }
-    - { python: "pypy3.5-6.0", env: "TOXENV=py-full" }
+    - { python: "2.7",   env: "TOXENV=py-full-marshmallow3" }
+    - { python: "3.6",   env: "TOXENV=py-full-marshmallow3" }
+    - { python: "2.7",   env: "TOXENV=py-base-marshmallow3" }
+    - { python: "3.6",   env: "TOXENV=py-base-marshmallow3" }
+
+    - { python: "pypy2.7-6.0", env: "TOXENV=py-full-marshmallow2" }
+    - { python: "pypy3.5-6.0", env: "TOXENV=py-full-marshmallow2" }
 
 cache:
   directories:
diff --git a/README.md b/README.md
index 91e22ef..3da8ccd 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# Flask-RESTy [![Travis][build-badge]][build] [![PyPI][pypi-badge]][pypi]
+# Flask-RESTy [![Travis][build-badge]][build] [![PyPI][pypi-badge]][pypi] [![marshmallow 2/3 compatible][marshmallow-badge]][marshmallow-upgrading]
 Building blocks for REST APIs for [Flask](http://flask.pocoo.org/).
 
 [![Codecov][codecov-badge]][codecov]
@@ -79,3 +79,6 @@ class WidgetSchema(TableSchema):
 
 [codecov-badge]: https://img.shields.io/codecov/c/github/4Catalyzer/flask-resty/master.svg
 [codecov]: https://codecov.io/gh/4Catalyzer/flask-resty
+
+[marshmallow-badge]: https://badgen.net/badge/marshmallow/2,3?list=1
+[marshmallow-upgrading]: https://marshmallow.readthedocs.io/en/latest/upgrading.html
diff --git a/flask_resty/compat.py b/flask_resty/compat.py
index 76a767c..4f86e88 100644
--- a/flask_resty/compat.py
+++ b/flask_resty/compat.py
@@ -1,8 +1,11 @@
 import sys
 
+import marshmallow
+
 # -----------------------------------------------------------------------------
 
 PY2 = int(sys.version_info[0]) == 2
+MA2 = int(marshmallow.__version__[0]) == 2
 
 # -----------------------------------------------------------------------------
 
@@ -10,3 +13,23 @@ if PY2:
     basestring = basestring  # noqa: F821
 else:
     basestring = (str, bytes)
+
+
+def _strict_run(method, obj_or_data, **kwargs):
+    result = method(obj_or_data, **kwargs)
+    if MA2:  # Make marshmallow 2 schemas behave like marshmallow 3
+        data, errors = result
+        if errors:
+            raise marshmallow.ValidationError(errors, data=data)
+    else:
+        data = result
+
+    return data
+
+
+def schema_load(schema, in_data, **kwargs):
+    return _strict_run(schema.load, in_data, **kwargs)
+
+
+def schema_dump(schema, obj, **kwargs):
+    return _strict_run(schema.dump, obj, **kwargs)
diff --git a/flask_resty/fields.py b/flask_resty/fields.py
index 1c2f370..e1ee7f2 100644
--- a/flask_resty/fields.py
+++ b/flask_resty/fields.py
@@ -1,19 +1,18 @@
-from marshmallow import fields, ValidationError
+from marshmallow import fields
 import marshmallow.utils
 
+from .compat import schema_load
+
 # -----------------------------------------------------------------------------
 
 
 class RelatedItem(fields.Nested):
-    def _deserialize(self, value, attr, data):
+    def _deserialize(self, value, *args, **kwargs):
         if self.many and not marshmallow.utils.is_collection(value):
             self.fail('type', input=value, type=value.__class__.__name__)
 
         # Do partial load of related item, as we only need the id.
-        data, errors = self.schema.load(value, partial=True)
-        if errors:
-            raise ValidationError(errors, data=data)
-        return data
+        return schema_load(self.schema, value, partial=True)
 
     def _validate_missing(self, value):
         # Do not display detailed error data on required fields in nested
diff --git a/flask_resty/view.py b/flask_resty/view.py
index fc6043b..a263919 100644
--- a/flask_resty/view.py
+++ b/flask_resty/view.py
@@ -2,7 +2,7 @@ import itertools
 
 import flask
 from flask.views import MethodView
-from marshmallow import fields
+from marshmallow import fields, ValidationError
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.orm import Load
 from sqlalchemy.orm.exc import NoResultFound
@@ -11,6 +11,7 @@ from werkzeug.exceptions import NotFound
 from . import meta
 from .authentication import NoOpAuthentication
 from .authorization import NoOpAuthorization
+from .compat import MA2, schema_dump, schema_load
 from .decorators import request_cached_property
 from .exceptions import ApiError
 from .spec import ApiViewDeclaration, ModelViewDeclaration
@@ -36,7 +37,7 @@ class ApiView(MethodView):
         return super(ApiView, self).dispatch_request(*args, **kwargs)
 
     def serialize(self, item, **kwargs):
-        return self.serializer.dump(item, **kwargs).data
+        return schema_dump(self.serializer, item, **kwargs)
 
     @settable_property
     def serializer(self):
@@ -111,11 +112,12 @@ class ApiView(MethodView):
         return data_raw
 
     def deserialize(self, data_raw, expected_id=None, **kwargs):
-        data, errors = self.deserializer.load(data_raw, **kwargs)
-        if errors:
+        try:
+            data = schema_load(self.deserializer, data_raw, **kwargs)
+        except ValidationError as e:
             raise ApiError(422, *(
                 self.format_validation_error(error)
-                for error in iter_validation_errors(errors)
+                for error in iter_validation_errors(e.messages)
             ))
 
         self.validate_request_id(data, expected_id)
@@ -170,8 +172,11 @@ class ApiView(MethodView):
         for field_name, field in self.args_schema.fields.items():
             if field_name in args:
                 args_key = field_name
-            elif field.load_from and field.load_from in args:
+            elif MA2 and field.load_from and field.load_from in args:
                 args_key = field.load_from
+            elif not MA2 and field.data_key and field.data_key in args:
+                args_key = field.data_key
+                field_name = field.data_key
             else:
                 continue
 
@@ -187,11 +192,12 @@ class ApiView(MethodView):
         return isinstance(field, fields.List)
 
     def deserialize_args(self, data_raw, **kwargs):
-        data, errors = self.args_schema.load(data_raw, **kwargs)
-        if errors:
+        try:
+            data = schema_load(self.args_schema, data_raw, **kwargs)
+        except ValidationError as e:
             raise ApiError(422, *(
                 self.format_parameter_validation_error(message, parameter)
-                for parameter, messages in errors.items()
+                for parameter, messages in e.messages.items()
                 for message in messages
             ))
 
diff --git a/tox.ini b/tox.ini
index 2a92706..8f5f3b3 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,9 +1,12 @@
 [tox]
-envlist = py{27,35,37}-{base,full}
+envlist = py{27,35,37}-{base,full}-marshmallow{2,3}
 
 [testenv]
 passenv = DATABASE_URL
 usedevelop = True
+deps =
+    marshmallow2: marshmallow>=2.2.0,<3.0.0
+    marshmallow3: marshmallow>=3.0.0rc5,<4.0.0
 extras =
     tests
     full: apispec,jwt


Test patch:
diff --git a/tests/test_args.py b/tests/test_args.py
index 827aa4c..900b617 100644
--- a/tests/test_args.py
+++ b/tests/test_args.py
@@ -2,6 +2,7 @@ from marshmallow import fields, Schema
 import pytest
 
 from flask_resty import Api, ApiView
+from flask_resty.compat import MA2
 from flask_resty.testing import assert_response
 
 # -----------------------------------------------------------------------------
@@ -13,10 +14,14 @@ def schemas():
         name = fields.String(required=True)
 
     class NameListSchema(Schema):
-        names = fields.List(fields.String(), load_from='name', required=True)
+        field_kwargs = {
+            'load_from' if MA2 else 'data_key': 'name',
+            'required': True,
+        }
+        names = fields.List(fields.String(), **field_kwargs)
 
     class NameDefaultSchema(Schema):
-        name = fields.String(required=True, missing='foo')
+        name = fields.String(missing='foo')
 
     return {
         'name': NameSchema(),
diff --git a/tests/test_fields.py b/tests/test_fields.py
index 559a4b8..7d76758 100644
--- a/tests/test_fields.py
+++ b/tests/test_fields.py
@@ -1,7 +1,8 @@
-from marshmallow import fields, Schema
+from marshmallow import fields, Schema, ValidationError
 import pytest
 
 from flask_resty import RelatedItem
+from flask_resty.compat import schema_dump, schema_load
 
 # -----------------------------------------------------------------------------
 
@@ -40,7 +41,7 @@ def error_messages():
 
 
 def test_dump_single(single_schema):
-    data, errors = single_schema.dump({
+    data = schema_dump(single_schema, {
         'child': {
             'id': 1,
             'name': "Foo",
@@ -53,11 +54,10 @@ def test_dump_single(single_schema):
             'name': "Foo",
         },
     }
-    assert not errors
 
 
 def test_dump_many(many_schema):
-    data, errors = many_schema.dump({
+    data = schema_dump(many_schema, {
         'children': [
             {
                 'id': 1,
@@ -82,11 +82,10 @@ def test_dump_many(many_schema):
             },
         ],
     }
-    assert not errors
 
 
 def test_load_single(single_schema):
-    data, errors = single_schema.load({
+    data = schema_load(single_schema, {
         'child': {
             'id': '1',
         },
@@ -97,11 +96,10 @@ def test_load_single(single_schema):
             'id': 1,
         },
     }
-    assert not errors
 
 
 def test_load_many(many_schema):
-    data, errors = many_schema.load({
+    data = schema_load(many_schema, {
         'children': [
             {
                 'id': '1',
@@ -122,29 +120,30 @@ def test_load_many(many_schema):
             },
         ],
     }
-    assert not errors
 
 
 # -----------------------------------------------------------------------------
 
 
 def test_error_load_single_missing(single_schema, error_messages):
-    data, errors = single_schema.load({})
+    with pytest.raises(ValidationError) as excinfo:
+        schema_load(single_schema, {})
 
-    assert not data
+    errors = excinfo.value.messages
     assert errors == {
         'child': [error_messages['required']],
     }
 
 
 def test_error_load_single_field_type(single_schema):
-    data, errors = single_schema.load({
-        'child': {
-            'id': 'foo',
-        },
-    })
+    with pytest.raises(ValidationError) as excinfo:
+        schema_load(single_schema, {
+            'child': {
+                'id': 'foo',
+            },
+        })
 
-    assert not data
+    errors = excinfo.value.messages
     assert errors == {
         'child': {
             'id': [fields.Integer().error_messages['invalid']],
@@ -153,22 +152,24 @@ def test_error_load_single_field_type(single_schema):
 
 
 def test_error_load_many_missing(many_schema, error_messages):
-    data, errors = many_schema.load({})
+    with pytest.raises(ValidationError) as excinfo:
+        schema_load(many_schema, {})
 
-    assert not data
+    errors = excinfo.value.messages
     assert errors == {
         'children': [error_messages['required']],
     }
 
 
 def test_error_load_many_type(many_schema, error_messages):
-    data, errors = many_schema.load({
-        'children': {
-            'id': 1,
-        },
-    })
+    with pytest.raises(ValidationError) as excinfo:
+        schema_load(many_schema, {
+            'children': {
+                'id': 1,
+            },
+        })
 
-    assert not data
+    errors = excinfo.value.messages
     assert errors == {
         'children': [error_messages['type']],
     }