Repository: falconry/falcon
Pull request #1246 (2018-04-23 14:56:53)

feat(validators): Validate responses as well as requests


Patch:
diff --git a/falcon/media/validators/jsonschema.py b/falcon/media/validators/jsonschema.py
index 8db51e7..bee44f0 100644
--- a/falcon/media/validators/jsonschema.py
+++ b/falcon/media/validators/jsonschema.py
@@ -8,7 +8,7 @@ except ImportError:
     pass
 
 
-def validate(schema):
+def validate(req_schema=None, resp_schema=None):
     """Decorator for validating ``req.media`` using JSON Schema.
 
     This decorator provides standard JSON Schema validation via the
@@ -20,10 +20,16 @@ def validate(schema):
         The `jsonschema`` package must be installed separately in order to use
         this decorator, as Falcon does not install it by default.
 
+        See `json-schema.org <http://json-schema.org/>`_ for more
+        information on defining a compatible dictionary.
+
     Args:
-        schema (dict): A dictionary that follows the JSON Schema specification.
-            See `json-schema.org <http://json-schema.org/>`_ for more
-            information on defining a compatible dictionary.
+        req_schema (dict, optional): A dictionary that follows the JSON
+            Schema specification. The request will be validated against this
+            schema.
+        resp_schema (dict, optional): A dictionary that follows the JSON
+            Schema specification. The response will be validated against this
+            schema.
 
     Example:
         .. code:: python
@@ -41,14 +47,34 @@ def validate(schema):
 
     def decorator(func):
         def wrapper(self, req, resp, *args, **kwargs):
-            try:
-                jsonschema.validate(req.media, schema, format_checker=jsonschema.FormatChecker())
-            except jsonschema.ValidationError as e:
-                raise falcon.HTTPBadRequest(
-                    'Failed data validation',
-                    description=e.message
-                )
-
-            return func(self, req, resp, *args, **kwargs)
+            if req_schema is not None:
+                try:
+                    jsonschema.validate(
+                        req.media, req_schema,
+                        format_checker=jsonschema.FormatChecker()
+                    )
+                except jsonschema.ValidationError as e:
+                    raise falcon.HTTPBadRequest(
+                        'Request data failed validation',
+                        description=e.message
+                    )
+
+            result = func(self, req, resp, *args, **kwargs)
+
+            if resp_schema is not None:
+                try:
+                    jsonschema.validate(
+                        resp.media, resp_schema,
+                        format_checker=jsonschema.FormatChecker()
+                    )
+                except jsonschema.ValidationError:
+                    raise falcon.HTTPInternalServerError(
+                        'Response data failed validation'
+                        # Do not return 'e.message' in the response to
+                        # prevent info about possible internal response
+                        # formatting bugs from leaking out to users.
+                    )
+
+            return result
         return wrapper
     return decorator
diff --git a/requirements/docs b/requirements/docs
index 0c74084..517f731 100644
--- a/requirements/docs
+++ b/requirements/docs
@@ -2,5 +2,5 @@ docutils
 jinja2
 markupsafe
 pygments
-sphinx>=1.4.4
+sphinx>=1.4.4,!=1.7.3
 sphinx_rtd_theme
diff --git a/tox.ini b/tox.ini
index c1dfdd0..35ae7a3 100644
--- a/tox.ini
+++ b/tox.ini
@@ -83,6 +83,7 @@ deps = -r{toxinidir}/requirements/tests
 basepython = python2.7
 deps = {[with-debug-tools]deps}
        funcsigs
+       jsonschema
 
 [testenv:py3_debug]
 basepython = python3.6


Test patch:
diff --git a/tests/test_validators.py b/tests/test_validators.py
index 79245b9..97b813b 100644
--- a/tests/test_validators.py
+++ b/tests/test_validators.py
@@ -6,6 +6,7 @@ except ImportError:
 import pytest
 
 import falcon
+from falcon import testing
 from falcon.media import validators
 
 basic_schema = {
@@ -25,31 +26,94 @@ skip_missing_dep = pytest.mark.skipif(
 )
 
 
-class SampleResource(object):
-    @validators.jsonschema.validate(basic_schema)
-    def on_get(self, req, resp):
+class Resource(object):
+    @validators.jsonschema.validate(req_schema=basic_schema)
+    def request_validated(self, req, resp):
         assert req.media is not None
+        return resp
 
+    @validators.jsonschema.validate(resp_schema=basic_schema)
+    def response_validated(self, req, resp):
+        assert resp.media is not None
+        return resp
 
-class RequestStub(object):
+    @validators.jsonschema.validate(req_schema=basic_schema, resp_schema=basic_schema)
+    def both_validated(self, req, resp):
+        assert req.media is not None
+        assert resp.media is not None
+        return req, resp
+
+    @validators.jsonschema.validate(req_schema=basic_schema, resp_schema=basic_schema)
+    def on_put(self, req, resp):
+        assert req.media is not None
+        resp.media = GoodData.media
+
+
+class GoodData(object):
     media = {'message': 'something'}
 
 
+class BadData(object):
+    media = {}
+
+
 @skip_missing_dep
-def test_jsonschema_validation_success():
-    req = RequestStub()
+def test_req_schema_validation_success():
+    data = GoodData()
+    assert Resource().request_validated(GoodData(), data) is data
 
-    res = SampleResource()
-    assert res.on_get(req, None) is None
+
+@skip_missing_dep
+def test_req_schema_validation_failure():
+    with pytest.raises(falcon.HTTPBadRequest) as excinfo:
+        Resource().request_validated(BadData(), None)
+
+    assert excinfo.value.description == '\'message\' is a required property'
+
+
+@skip_missing_dep
+def test_resp_schema_validation_success():
+    data = GoodData()
+    assert Resource().response_validated(GoodData(), data) is data
 
 
 @skip_missing_dep
-def test_jsonschema_validation_failure():
-    req = RequestStub()
-    req.media = {}
+def test_resp_schema_validation_failure():
+    with pytest.raises(falcon.HTTPInternalServerError) as excinfo:
+        Resource().response_validated(GoodData(), BadData())
+
+    assert excinfo.value.title == 'Response data failed validation'
+
+
+@skip_missing_dep
+def test_both_schemas_validation_success():
+    req_data = GoodData()
+    resp_data = GoodData()
+
+    result = Resource().both_validated(req_data, resp_data)
+
+    assert result[0] is req_data
+    assert result[1] is resp_data
+
+    client = testing.TestClient(falcon.API())
+    client.app.add_route('/test', Resource())
+    result = client.simulate_put('/test', json=GoodData.media)
+    assert result.json == resp_data.media
+
+
+@skip_missing_dep
+def test_both_schemas_validation_failure():
+    with pytest.raises(falcon.HTTPInternalServerError) as excinfo:
+        Resource().both_validated(GoodData(), BadData())
+
+    assert excinfo.value.title == 'Response data failed validation'
+
+    with pytest.raises(falcon.HTTPBadRequest) as excinfo:
+        Resource().both_validated(BadData(), GoodData())
 
-    res = SampleResource()
-    with pytest.raises(falcon.HTTPBadRequest) as err:
-        res.on_get(req, None)
+    assert excinfo.value.title == 'Request data failed validation'
 
-    assert err.value.description == '\'message\' is a required property'
+    client = testing.TestClient(falcon.API())
+    client.app.add_route('/test', Resource())
+    result = client.simulate_put('/test', json=BadData.media)
+    assert result.status_code == 400