Repository: getsentry/responses
Pull request #663 (2023-08-09 16:29:59)

[#651] regex compatability for headers matching
Attempt at #651.

- Changes in `header_matcher` function.
- Instead of using simple list equality, it now checks for either regex or simple string check.
- Added tests `test_request_matches_headers_regex`, `test_request_matches_headers_regex_strict_match`

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/getsentry/responses/pull/663?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) Report
Patch coverage: **`100.00%`** and no project coverage change.
> Comparison is base [(`ad22f54`)](https://app.codecov.io/gh/getsentry/responses/commit/ad22f54e941b95b8cde8c03e0b20a6dd1ffb4dde?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) 100.00% compared to head [(`a2e2da1`)](https://app.codecov.io/gh/getsentry/responses/pull/663?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) 100.00%.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff            @@
##            master      #663   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files            9         9           
  Lines         2880      2968   +88     
=========================================
+ Hits          2880      2968   +88     
```


| [Files Changed](https://app.codecov.io/gh/getsentry/responses/pull/663?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) | Coverage Δ | |
|---|---|---|
| [responses/matchers.py](https://app.codecov.io/gh/getsentry/responses/pull/663?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry#diff-cmVzcG9uc2VzL21hdGNoZXJzLnB5) | `100.00% <100.00%> (ø)` | |
| [responses/tests/test\_matchers.py](https://app.codecov.io/gh/getsentry/responses/pull/663?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry#diff-cmVzcG9uc2VzL3Rlc3RzL3Rlc3RfbWF0Y2hlcnMucHk=) | `100.00% <100.00%> (ø)` | |


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/getsentry/responses/pull/663?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry).


Patch:
diff --git a/responses/matchers.py b/responses/matchers.py
index 4880e50..e9fb372 100644
--- a/responses/matchers.py
+++ b/responses/matchers.py
@@ -1,4 +1,5 @@
 import json as json_module
+import re
 from json.decoder import JSONDecodeError
 from typing import Any
 from typing import Callable
@@ -402,6 +403,23 @@ def header_matcher(
     :return: (func) matcher
     """
 
+    def _compare_with_regex(request_headers: Union[Dict[Any, Any], Any]) -> bool:
+        if strict_match and len(request_headers) != len(headers):
+            return False
+
+        for k, v in headers.items():
+            if request_headers.get(k) is not None:
+                if isinstance(v, re.Pattern):
+                    if re.match(v, request_headers[k]) is None:
+                        return False
+                else:
+                    if not v == request_headers[k]:
+                        return False
+            elif strict_match:
+                return False
+
+        return True
+
     def match(request: PreparedRequest) -> Tuple[bool, str]:
         request_headers: Union[Dict[Any, Any], Any] = request.headers or {}
 
@@ -409,7 +427,7 @@ def header_matcher(
             # filter down to just the headers specified in the matcher
             request_headers = {k: v for k, v in request_headers.items() if k in headers}
 
-        valid = sorted(headers.items()) == sorted(request_headers.items())
+        valid = _compare_with_regex(request_headers)
 
         if not valid:
             return False, "Headers do not match: {} doesn't match {}".format(


Test patch:
diff --git a/responses/tests/test_matchers.py b/responses/tests/test_matchers.py
index 217788b..237b435 100644
--- a/responses/tests/test_matchers.py
+++ b/responses/tests/test_matchers.py
@@ -1,3 +1,4 @@
+import re
 from unittest.mock import Mock
 
 import pytest
@@ -782,3 +783,168 @@ def test_matchers_create_key_val_str():
         "a, {3: test, key1: val1, key2: 2}], test: val}"
     )
     assert conv_str == reference
+
+
+class TestHeaderWithRegex:
+    @property
+    def url(self):
+        return "http://example.com/"
+
+    def _register(self):
+        responses.add(
+            method=responses.GET,
+            url=self.url,
+            body="success",
+            match=[
+                matchers.header_matcher(
+                    {
+                        "Accept": "text/plain",
+                        "Message-Signature": re.compile(r'signature="\S+",created=\d+'),
+                    },
+                    strict_match=True,
+                )
+            ],
+        )
+
+    def test_request_matches_headers_regex(self):
+        @responses.activate
+        def run():
+            # this one can not use common _register method because different headers
+            responses.add(
+                method=responses.GET,
+                url=self.url,
+                json={"success": True},
+                match=[
+                    matchers.header_matcher(
+                        {
+                            "Message-Signature": re.compile(
+                                r'signature="\S+",created=\d+'
+                            ),
+                            "Authorization": "Bearer API_TOKEN",
+                        },
+                        strict_match=False,
+                    )
+                ],
+            )
+            # the actual request can contain extra headers (requests always adds some itself anyway)
+            resp = requests.get(
+                self.url,
+                headers={
+                    "Message-Signature": 'signature="abc",created=1243',
+                    "Authorization": "Bearer API_TOKEN",
+                },
+            )
+            assert_response(
+                resp, body='{"success": true}', content_type="application/json"
+            )
+
+        run()
+        assert_reset()
+
+    def test_request_matches_headers_regex_strict_match_regex_failed(self):
+        @responses.activate
+        def run():
+            self._register()
+            session = requests.Session()
+            # requests will add some extra headers of its own, so we have to use prepared requests
+            prepped = session.prepare_request(
+                requests.Request(
+                    method="GET",
+                    url=self.url,
+                )
+            )
+            prepped.headers.clear()
+            prepped.headers["Accept"] = "text/plain"
+            prepped.headers["Message-Signature"] = 'signature="123",created=abc'
+            with pytest.raises(ConnectionError) as excinfo:
+                session.send(prepped)
+            msg = str(excinfo.value)
+            assert (
+                "Headers do not match: {Accept: text/plain, Message-Signature: "
+                'signature="123",created=abc} '
+                "doesn't match {Accept: text/plain, Message-Signature: "
+                "re.compile('signature=\"\\\\S+\",created=\\\\d+')}"
+            ) in msg
+
+        run()
+        assert_reset()
+
+    def test_request_matches_headers_regex_strict_match_mismatched_field(self):
+        @responses.activate
+        def run():
+            self._register()
+            # requests will add some extra headers of its own, so we have to use prepared requests
+            session = requests.Session()
+            prepped = session.prepare_request(
+                requests.Request(
+                    method="GET",
+                    url=self.url,
+                )
+            )
+            prepped.headers.clear()
+            prepped.headers["Accept"] = "text/plain"
+            prepped.headers["Accept-Charset"] = "utf-8"
+            # "Accept-Charset" header will fail to match to "Message-Signature"
+            with pytest.raises(ConnectionError) as excinfo:
+                session.send(prepped)
+            msg = str(excinfo.value)
+            assert (
+                "Headers do not match: {Accept: text/plain, Accept-Charset: utf-8} "
+                "doesn't match {Accept: text/plain, Message-Signature: "
+                "re.compile('signature=\"\\\\S+\",created=\\\\d+')}"
+            ) in msg
+
+        run()
+        assert_reset()
+
+    def test_request_matches_headers_regex_strict_match_mismatched_number(self):
+        @responses.activate
+        def run():
+            self._register()
+            # requests will add some extra headers of its own, so we have to use prepared requests
+            session = requests.Session()
+            # include the "Accept-Charset" header, which will fail to match
+            prepped = session.prepare_request(
+                requests.Request(
+                    method="GET",
+                    url=self.url,
+                )
+            )
+            prepped.headers.clear()
+            prepped.headers["Accept"] = "text/plain"
+            prepped.headers["Accept-Charset"] = "utf-8"
+            prepped.headers["Message-Signature"] = 'signature="abc",created=1243'
+            with pytest.raises(ConnectionError) as excinfo:
+                session.send(prepped)
+            msg = str(excinfo.value)
+            assert (
+                "Headers do not match: {Accept: text/plain, Accept-Charset: utf-8, "
+                'Message-Signature: signature="abc",'
+                "created=1243} "
+                "doesn't match {Accept: text/plain, Message-Signature: "
+                "re.compile('signature=\"\\\\S+\",created=\\\\d+')}"
+            ) in msg
+
+        run()
+        assert_reset()
+
+    def test_request_matches_headers_regex_strict_match_positive(self):
+        @responses.activate
+        def run():
+            self._register()
+            # requests will add some extra headers of its own, so we have to use prepared requests
+            session = requests.Session()
+            prepped = session.prepare_request(
+                requests.Request(
+                    method="GET",
+                    url=self.url,
+                )
+            )
+            prepped.headers.clear()
+            prepped.headers["Accept"] = "text/plain"
+            prepped.headers["Message-Signature"] = 'signature="abc",created=1243'
+            resp = session.send(prepped)
+            assert_response(resp, body="success", content_type="text/plain")
+
+        run()
+        assert_reset()