Repository: taverntesting/tavern

Issue:
Should be able to apply regular expressions to JSON values not just the entire response
I'd like to be able to use the normal JSON validation for most of the response, but say that a certain field must match a given regular expression. When you look at things like newlines, quoting, escaping, and so on, a regular expression which really works well when given an arbitrary JSON expression is really messy.

For example, version numbers. You don't typically want to hard code version numbers in tests, but you would like to be able to check that the version number matches '[0-9]+\\.[0-9]+\\.[0-9]+' for example...

Of course, you could write a custom validation script for each regular expression, but that's yucky - and you still need to validate the other fields (not sure if you can easily do that using the current code or not...). But either way, validating a single field is at least equally useful as validating a whole response.

One could always add an optional argument like "field" which would tell you the JSON field to validate - using something a lot like the current code. Being able to do multiples of this would make this more useful. Having it not disable "normal" JSON field validation would also make it more useful.

Or maybe there's a way to do this already if you know what you're doing ;-).

Discussion:

Patch:
diff --git a/docs/source/basics.md b/docs/source/basics.md
index 1871637..37d6e79 100644
--- a/docs/source/basics.md
+++ b/docs/source/basics.md
@@ -1488,6 +1488,52 @@ which will pass if it matches _part_ of the thing being checked, or `!re_match`
 which will match _part_ of the thing being checked, as long as it is at the
 _beginning_ of the string. See the Python documentation for more details.
 
+Another way of doing this is to use the builtin `validate_regex` helper function.
+For example if we want to get a version that is returned in a 'meta' key in the
+format `v1.2.3-510c2665d771e1`:
+
+```yaml
+stages:
+- name: get a token by id
+  request:
+    url: "{host}/tokens/get"
+    method: GET
+    params:
+      id: 456
+  response:
+    status_code: 200
+    json:
+      code: abc123
+      id: 456
+      meta:
+        version: !anystr
+        hash: 456
+    save:
+      $ext:
+        function: tavern.testutils.helpers:validate_regex
+        extra_kwargs:
+          expression: "v(?P<version>[\d\.]+)-[\w\d]+"
+          in_jmespath: "meta.version"
+```
+
+This is a more flexible version of the helper which can also be used to save values
+as in the example. If a named matching group is used as shown above, the saved values
+ can then be accessed in subsequent stages by using the `regex.<group-name>` syntax, eg: 
+
+```yaml
+- name: Reuse thing specified in first request
+  request:
+    url: "{host}/get_version_info"
+    method: GET
+    params:
+      version: "{regex.version}"
+  response:
+    status_code: 200
+    json:
+      simple_version: "v{regex.version}"
+      made_on: "2020-02-21"
+```
+
 ## Type conversions
 
 [YAML](http://yaml.org/spec/1.1/current.html#id867381) has some magic variables
diff --git a/tavern/util/exceptions.py b/tavern/util/exceptions.py
index a10ea51..114281b 100644
--- a/tavern/util/exceptions.py
+++ b/tavern/util/exceptions.py
@@ -123,3 +123,7 @@ class InvalidExtBlockException(TavernException):
 
 class InvalidRetryException(TavernException):
     """Invalid spec for max_retries"""
+
+
+class RegexAccessError(TavernException):
+    """Error accessing a key via regex"""


Test patch:
diff --git a/tavern/testutils/helpers.py b/tavern/testutils/helpers.py
index ee78f9e..987cf3e 100644
--- a/tavern/testutils/helpers.py
+++ b/tavern/testutils/helpers.py
@@ -10,7 +10,7 @@ import jwt
 from tavern.schemas.files import verify_generic
 from tavern.testutils.jmesutils import actual_validation, validate_comparison
 from tavern.util import exceptions
-from tavern.util.dict_util import check_keys_match_recursive
+from tavern.util.dict_util import check_keys_match_recursive, recurse_access_key
 
 logger = logging.getLogger(__name__)
 
@@ -97,24 +97,53 @@ def validate_pykwalify(response, schema):
         verify_generic(to_verify, schema)
 
 
-def validate_regex(response, expression, header=None):
+def validate_regex(response, expression, *, header=None, in_jmespath=None):
     """Make sure the response matches a regex expression
 
     Args:
-        response (Response): requests.Response object
+        response (requests.Response): requests.Response object
         expression (str): Regex expression to use
         header (str): Match against a particular header instead of the body
+        in_jmespath (str): if present, jmespath to access before trying to match
 
     Returns:
         dict: dictionary of regex: boxed name capture groups
     """
+
+    if header and in_jmespath:
+        raise exceptions.BadSchemaError("Can only specify one of header or jmespath")
+
     if header:
         content = response.headers[header]
     else:
         content = response.text
 
-    match = re.search(expression, content) or False
-    assert match
+    if in_jmespath:
+        if not response.headers.get("content-type", "").startswith("application/json"):
+            logger.warning(
+                "Trying to use jmespath match but content type is not application/json"
+            )
+
+        try:
+            decoded = json.loads(content)
+        except json.JSONDecodeError as e:
+            raise exceptions.RegexAccessError(
+                "unable to decode json for regex match"
+            ) from e
+
+        content = recurse_access_key(decoded, in_jmespath)
+        if not isinstance(content, str):
+            raise exceptions.RegexAccessError(
+                "Successfully accessed {} from response, but it was a {} and not a string".format(
+                    in_jmespath, type(content)
+                )
+            )
+
+    logger.debug("Matching %s with %s", content, expression)
+
+    match = re.search(expression, content)
+    if match is None:
+        raise exceptions.RegexAccessError("No match for regex")
 
     return {"regex": Box(match.groupdict())}
 
diff --git a/tests/integration/test_regex.tavern.yaml b/tests/integration/test_regex.tavern.yaml
index bb5fc19..1ac9803 100644
--- a/tests/integration/test_regex.tavern.yaml
+++ b/tests/integration/test_regex.tavern.yaml
@@ -68,3 +68,36 @@ stages:
         extra_kwargs:
           expression: '(?<=Hello)[wW]orld\d+$'
           header: X-Integration-Value
+
+---
+
+test_name: Match something in part of the request
+
+stages:
+- name: simple match
+  request:
+    url: "{global_host}/echo"
+    method: POST
+    json:
+      fake: code=abc123&state=f
+      fake2: code=abc123&state=f
+      fake3: code=abc124&state=f
+  response:
+    status_code: 200
+    save:
+      $ext:
+        function: tavern.testutils.helpers:validate_regex
+        extra_kwargs:
+          expression: "code=(?P<code_token>.*)&state"
+          in_jmespath: "fake3"
+
+- name: Reuse thing specified in first request
+  request:
+    url: "{global_host}/echo"
+    method: POST
+    json:
+      fake: "{regex.code_token}"
+  response:
+    status_code: 200
+    json:
+      fake: abc124
diff --git a/tests/unit/test_helpers.py b/tests/unit/test_helpers.py
index b85771d..85048b0 100644
--- a/tests/unit/test_helpers.py
+++ b/tests/unit/test_helpers.py
@@ -1,3 +1,4 @@
+import json
 import sys
 import tempfile
 from textwrap import dedent
@@ -42,21 +43,40 @@ class TestRegex:
     def test_regex_no_match(self):
         response = FakeResponse("abchelloabc")
 
-        with pytest.raises(AssertionError):
+        with pytest.raises(exceptions.RegexAccessError):
             validate_regex(response, "(?P<greeting>hola)")
 
     def test_regex_match_header(self):
         response = FakeResponse("abchelloabc")
 
-        matched = validate_regex(response, "(?P<greeting>hello)", "test_header")
+        matched = validate_regex(response, "(?P<greeting>hello)", header="test_header")
 
         assert "greeting" in matched["regex"]
 
     def test_regex_no_match_header(self):
         response = FakeResponse("abchelloabc")
 
-        with pytest.raises(AssertionError):
-            validate_regex(response, "(?P<greeting>hola)", "test_header")
+        with pytest.raises(exceptions.RegexAccessError):
+            validate_regex(response, "(?P<greeting>hola)", header="test_header")
+
+    @pytest.mark.parametrize(
+        "match",
+        (
+            (r"val(?P<num>\d)", "path1", "val1"),
+            (r"val(?P<num>\d)", "path2[0]", "val2"),
+            (r"val(?P<num>\d)", "path3.sub", "val3"),
+        ),
+    )
+    def test_in_jmespath(self, match):
+        response = FakeResponse(
+            json.dumps({"path1": "val1", "path2": ["val2"], "path3": {"sub": "val3"}})
+        )
+
+        expression, path, expected = match
+
+        result = validate_regex(response, expression, in_jmespath=path)
+
+        assert result["regex"]["num"] == expected[-1]
 
 
 class TestRunAlone: