Repository: cdent/gabbi

Issue:
jsonhandler: read data from disk improvements
UPDATE: dropping the yaml loading from this issue and updating the querying of the subset of data from disk to enable JSONPath support like the left hand side does already for the jsonhandler. For example, $.versions[?status = "current"].version: <@endpoint.json:[?status = "current"].version

reading data from disk can be done today with the jsonhandler by using the '<@myfile.json' we would like to improve this to work on larger files by allowing a user to specify a subset. Also being able to read from a yaml source would be nice.

1. allow yaml to be loaded from disk this should be easy since json is supposed to be a subset of it. 
    - a foreseen problem I see with this is the test loader grabs all *.yaml files, we can get around this without modifying the test loader by naming our test yaml files with a .yml extension

2. Allow a user to specify a subset to load. It appears to be a simple change. I think we can use the ‘:’ character (or whatever we agree on) to split on the accessors for the dictionary and apply those to the self.loads(info) call like so https://github.com/trevormccasland/gabbi/commit/5a17517bb52fe6d4206cee42e63bca9d73ee8f35 ( I forgot to swap out the json.loads with yaml, ignore the point in the commit message :/ )
    - Our problem is the management of the json files, we would prefer that we could load our site deployment information in a yaml format and use those values for some of our api responses without having to create a json file for every test.

The following is a simple example, the contents of the files for our use case will be much larger and contains information that is related to how our apis are deployed and do not necessarily correlate to a single api response.

Example:
  endpoint.json contents:
    '[{"status": "current", "version": 1.0}]'

  response_json_paths:
    $.versions[0][‘status’]: <@endpoint.json:[0][status]

Discussion:
trevormccasland: I think a slight improvement can be made on the split operator by providing the second argument as a 1. This will make keys that include the ':' character safe to use in the program. When I update it, should I provide a test too?
trevormccasland: I definitely have the time, I was planning on submitting another issue / pull request for the yaml-based test.
cdent: > I definitely have the time, I was planning on submitting another issue / pull request for the yaml-based test.

Just to be clear I mean a yaml file in `gabbi/tests/gabbits_intercept/` that uses the syntax developedin this patch.
trevormccasland: Yeah that does make it clear! I'll add a yaml test their too.

Patch:
diff --git a/docs/source/jsonpath.rst b/docs/source/jsonpath.rst
index 317603b..3d00663 100644
--- a/docs/source/jsonpath.rst
+++ b/docs/source/jsonpath.rst
@@ -78,6 +78,46 @@ or::
 
 Examples like this can be found in one of gabbi's `own tests`_.
 
+When reading from disk you can apply the same JSONPath by adding a ':' to the
+end of your file name. This allows you to store multiple API responses into
+a JSON file to reduce file management when constructing your tests.
+
+.. highlight:: json
+
+Given JSON data as follows::
+
+    {
+        "values": [{
+            "pets": [{
+                "type": "cat",
+                "sound": "meow"
+            }, {
+                "type": "dog",
+                "sound": "woof"
+            }]
+        }, {
+            "people": [{
+                "name": "chris",
+                "id": 1
+            }, {
+                "name": "justin",
+                "id": 2
+            }]
+        }]
+    }
+
+.. highlight:: yaml
+
+You can write your tests like the following::
+
+    response_json_paths:
+        $.pets: <@pets.json
+        $.pets[?type = "cat"].sound: <@values.json:$.values[0].pets[?type = "cat"].sound
+
+Although placing more than one API response into a single JSON file may seem
+convenient, keep in mind there is a tradeoff in readability that should not
+be overlooked before implementing this technique.
+
 There are more JSONPath examples in :doc:`example` and in the
 `jsonpath_rw`_ and `jsonpath_rw_ext`_ documentation.
 
diff --git a/gabbi/handlers/jsonhandler.py b/gabbi/handlers/jsonhandler.py
index da371c4..607eb20 100644
--- a/gabbi/handlers/jsonhandler.py
+++ b/gabbi/handlers/jsonhandler.py
@@ -76,29 +76,45 @@ class JSONHandler(base.ContentHandler):
     def action(self, test, path, value=None):
         """Test json_paths against json data."""
         # Do template expansion in the left hand side.
-        path = test.replace_template(path)
+        lhs_path = test.replace_template(path)
+        rhs_path = rhs_match = None
         try:
-            match = self.extract_json_path_value(
-                test.response_data, path)
+            lhs_match = self.extract_json_path_value(
+                test.response_data, lhs_path)
         except AttributeError:
             raise AssertionError('unable to extract JSON from test results')
         except ValueError:
-            raise AssertionError('json path %s cannot match %s' %
-                                 (path, test.response_data))
+            raise AssertionError('left hand side json path %s cannot match '
+                                 '%s' % (path, test.response_data))
 
         # read data from disk if the value starts with '<@'
-        if isinstance(value, str) and value.startswith('<@'):
+        if isinstance(value, six.string_types) and value.startswith('<@'):
+            # Do template expansion in the rhs if rhs_path is provided.
+            if ':' in value:
+                value, rhs_path = value.split(':$', 1)
+                rhs_path = test.replace_template('$' + rhs_path)
             info = test.load_data_file(value.replace('<@', '', 1))
             info = six.text_type(info, 'UTF-8')
             value = self.loads(info)
+            if rhs_path:
+                try:
+                    rhs_match = self.extract_json_path_value(value, rhs_path)
+                except AttributeError:
+                    raise AssertionError('unable to extract JSON from data on '
+                                         'disk')
+                except ValueError:
+                    raise AssertionError('right hand side json path %s cannot '
+                                         'match %s' % (rhs_path, info))
 
         # If expected is a string, check to see if it is a regex.
         is_regex = (isinstance(value, six.string_types) and
                     value.startswith('/') and
                     value.endswith('/') and
                     len(value) > 1)
-        expected = test.replace_template(value, escape_regex=is_regex)
-        if is_regex:
+        expected = (rhs_match or
+                    test.replace_template(value, escape_regex=is_regex))
+        match = lhs_match
+        if is_regex and not rhs_match:
             expected = expected[1:-1]
             # match may be a number so stringify
             match = six.text_type(match)


Test patch:
diff --git a/gabbi/tests/gabbits_handlers/values.json b/gabbi/tests/gabbits_handlers/values.json
new file mode 100644
index 0000000..abee7f9
--- /dev/null
+++ b/gabbi/tests/gabbits_handlers/values.json
@@ -0,0 +1,19 @@
+{
+    "values": [{
+        "pets": [{
+            "type": "cat",
+            "sound": "meow"
+        }, {
+            "type": "dog",
+            "sound": "woof"
+        }]
+    }, {
+        "people": [{
+            "name": "chris",
+            "id": 1
+        }, {
+            "name": "justin",
+            "id": 2
+        }]
+    }]
+}
diff --git a/gabbi/tests/gabbits_intercept/data-right-side.yaml b/gabbi/tests/gabbits_intercept/data-right-side.yaml
new file mode 100644
index 0000000..10fb4bd
--- /dev/null
+++ b/gabbi/tests/gabbits_intercept/data-right-side.yaml
@@ -0,0 +1,25 @@
+# Test loading expected data from file on disk with JSONPath
+#
+defaults:
+  request_headers:
+      content-type: application/json
+  verbose: True
+
+tests:
+    - name: json encoded value from disk
+      POST: /
+      data: <@data.json
+      response_json_paths:
+          $.foo['bár']: <@data.json:$.foo['bár']
+
+    - name: json parital from disk
+      POST: /
+      data: <@cat.json
+      response_json_paths:
+          $: <@pets.json:$[?type = "cat"]
+
+    - name: json partial both sides
+      POST: /
+      data: <@pets.json
+      response_json_paths:
+          $[?type = "cat"].sound: <@values.json:$.values[0].pets[?type = "cat"].sound
diff --git a/gabbi/tests/gabbits_intercept/values.json b/gabbi/tests/gabbits_intercept/values.json
new file mode 100644
index 0000000..abee7f9
--- /dev/null
+++ b/gabbi/tests/gabbits_intercept/values.json
@@ -0,0 +1,19 @@
+{
+    "values": [{
+        "pets": [{
+            "type": "cat",
+            "sound": "meow"
+        }, {
+            "type": "dog",
+            "sound": "woof"
+        }]
+    }, {
+        "people": [{
+            "name": "chris",
+            "id": 1
+        }, {
+            "name": "justin",
+            "id": 2
+        }]
+    }]
+}
diff --git a/gabbi/tests/test_handlers.py b/gabbi/tests/test_handlers.py
index 72593a6..77a50ec 100644
--- a/gabbi/tests/test_handlers.py
+++ b/gabbi/tests/test_handlers.py
@@ -14,6 +14,7 @@
 """
 
 import json
+import os
 import unittest
 
 from gabbi import case
@@ -268,6 +269,39 @@ class HandlersTest(unittest.TestCase):
             self.assertIn("response_json_paths in 'omega test'",
                           str(exc))
 
+    def test_response_json_paths_from_disk_json_path(self):
+        handler = jsonhandler.JSONHandler()
+        lhs = '$.pets[?type = "cat"].sound'
+        rhs = '$.values[0].pets[?type = "cat"].sound'
+        self.test.test_directory = os.path.dirname(__file__)
+        self.test.test_data = {'response_json_paths': {
+            lhs: '<@gabbits_handlers/values.json:' + rhs,
+        }}
+        self.test.response_data = {
+            "pets": [
+                {"type": "cat", "sound": "meow"},
+                {"type": "dog", "sound": "woof"}
+            ]
+        }
+        self._assert_handler(handler)
+
+    def test_response_json_paths_from_disk_json_path_fail(self):
+        handler = jsonhandler.JSONHandler()
+        lhs = '$.pets[?type = "cat"].sound'
+        rhs = '$.values[0].pets[?type = "bad"].sound'
+        self.test.test_directory = os.path.dirname(__file__)
+        self.test.test_data = {'response_json_paths': {
+            lhs: '<@gabbits_handlers/values.json:' + rhs,
+        }}
+        self.test.response_data = {
+            "pets": [
+                {"type": "cat", "sound": "meow"},
+                {"type": "dog", "sound": "woof"}
+            ]
+        }
+        with self.assertRaises(AssertionError):
+            self._assert_handler(handler)
+
     def test_response_headers(self):
         handler = core.HeadersResponseHandler()
         self.test.response = {'content-type': 'text/plain'}