Repository: getsentry/responses
Pull request #319 (2020-04-12 17:19:14)

Add post params with custom matchers
This approach lets users match on POSTed parameters - it includes two matchers for urlencoded and JSON encoded parameters and leaves open the possibility of matching on params encoded in other ways:

```
responses.add(
    method=responses.POST,
    url="http://example.com/",
    body="one",
    post_params_matcher=responses.json_params_matcher(
        {"page": {"name": "first", "type": "json",}}
    ),
)
responses.add(
    method=responses.POST,
    url="http://example.com/",
    body="two",
    post_params_matcher=responses.urlencoded_params_matcher(
        {"page": "second", "type": "urlencoded"}
    ),
)
```

codecov[bot]: # [Codecov](https://codecov.io/gh/getsentry/responses/pull/319?src=pr&el=h1) Report
> Merging [#319](https://codecov.io/gh/getsentry/responses/pull/319?src=pr&el=desc) into [master](https://codecov.io/gh/getsentry/responses/commit/7626a55ef5aee71a29a60c396757ea4241952c55&el=desc) will **increase** coverage by `0.59%`.
> The diff coverage is `98.11%`.

[![Impacted file tree graph](https://codecov.io/gh/getsentry/responses/pull/319/graphs/tree.svg?width=650&height=150&src=pr&token=x2tAGR7rxE)](https://codecov.io/gh/getsentry/responses/pull/319?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #319      +/-   ##
==========================================
+ Coverage   95.37%   95.97%   +0.59%     
==========================================
  Files           3        3              
  Lines        1124     1192      +68     
==========================================
+ Hits         1072     1144      +72     
+ Misses         52       48       -4     
```


| [Impacted Files](https://codecov.io/gh/getsentry/responses/pull/319?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [responses.py](https://codecov.io/gh/getsentry/responses/pull/319/diff?src=pr&el=tree#diff-cmVzcG9uc2VzLnB5) | `97.56% <95.83%> (+1.12%)` | :arrow_up: |
| [test\_responses.py](https://codecov.io/gh/getsentry/responses/pull/319/diff?src=pr&el=tree#diff-dGVzdF9yZXNwb25zZXMucHk=) | `97.63% <100.00%> (+0.16%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/getsentry/responses/pull/319?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/getsentry/responses/pull/319?src=pr&el=footer). Last update [7626a55...5c72926](https://codecov.io/gh/getsentry/responses/pull/319?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

markstory: I like the direction you've taken here. I think the more extensible post data matcher is a great idea. Do you think we could extend it another step and generalize matchers more? For example

```python
# Match a json body
responses.add(
  responses.POST,
  url='http://example.com/api/people',
  body='oh no',
  match=[responses.match_json_body(a='b', c='d')]
)

# Match a urlencoded body
responses.add(
  responses.POST,
  url='http://example.com/api/people',
  body='oh no',
  match=[responses.match_urlencoded_body(a='b', c='d')]
)
```

This would enable userland code to add custom matchers, and furthermore we could refactor the internal url + content-type matching to use this same matcher flow.
samrussell: I think I understand - we have a generic `match` parameter that takes some number of matchers, and that keeps the interface more open for expansion in future? I think I'll need to add some more test work around it, but the only change to the code is just renaming the parameter and making take a list of matchers instead of a single matcher, is that right?

Patch:
diff --git a/responses.py b/responses.py
index d5b4325..4a97b2c 100644
--- a/responses.py
+++ b/responses.py
@@ -53,6 +53,11 @@ except AttributeError:
     # Python 3.7
     Pattern = re.Pattern
 
+try:
+    from json.decoder import JSONDecodeError
+except ImportError:
+    JSONDecodeError = ValueError
+
 UNSET = object()
 
 Call = namedtuple("Call", ["request", "response"])
@@ -229,17 +234,37 @@ def _handle_body(body):
 _unspecified = object()
 
 
+def urlencoded_params_matcher(params):
+    def match(request_body):
+        return sorted(params.items()) == sorted(parse_qsl(request_body))
+
+    return match
+
+
+def json_params_matcher(params):
+    def match(request_body):
+        try:
+            if isinstance(request_body, bytes):
+                request_body = request_body.decode("utf-8")
+            return params == json_module.loads(request_body)
+        except JSONDecodeError:
+            return False
+
+    return match
+
+
 class BaseResponse(object):
     content_type = None
     headers = None
 
     stream = False
 
-    def __init__(self, method, url, match_querystring=_unspecified):
+    def __init__(self, method, url, match_querystring=_unspecified, match=[]):
         self.method = method
         # ensure the url has a default path set if the url is a string
         self.url = _ensure_url_default_path(url)
         self.match_querystring = self._should_match_querystring(match_querystring)
+        self.match = match
         self.call_count = 0
 
     def __eq__(self, other):
@@ -302,6 +327,13 @@ class BaseResponse(object):
         else:
             return False
 
+    def _body_matches(self, match, request_body):
+        for matcher in match:
+            if not matcher(request_body):
+                return False
+
+        return True
+
     def get_headers(self):
         headers = HTTPHeaderDict()  # Duplicate headers are legal
         if self.content_type is not None:
@@ -320,6 +352,9 @@ class BaseResponse(object):
         if not self._url_matches(self.url, request.url, self.match_querystring):
             return False
 
+        if not self._body_matches(self.match, request.body):
+            return False
+
         return True
 
 


Test patch:
diff --git a/test_responses.py b/test_responses.py
index 906f237..e2d3f48 100644
--- a/test_responses.py
+++ b/test_responses.py
@@ -1184,3 +1184,47 @@ def test_assert_call_count(url):
 
     run()
     assert_reset()
+
+
+def test_request_matches_post_params():
+    @responses.activate
+    def run():
+        responses.add(
+            method=responses.POST,
+            url="http://example.com/",
+            body="one",
+            match=[
+                responses.json_params_matcher(
+                    {"page": {"name": "first", "type": "json"}}
+                )
+            ],
+        )
+        responses.add(
+            method=responses.POST,
+            url="http://example.com/",
+            body="two",
+            match=[
+                responses.urlencoded_params_matcher(
+                    {"page": "second", "type": "urlencoded"}
+                )
+            ],
+        )
+
+        resp = requests.request(
+            "POST",
+            "http://example.com/",
+            headers={"Content-Type": "x-www-form-urlencoded"},
+            data={"page": "second", "type": "urlencoded"},
+        )
+        assert_response(resp, "two")
+
+        resp = requests.request(
+            "POST",
+            "http://example.com/",
+            headers={"Content-Type": "application/json"},
+            json={"page": {"name": "first", "type": "json"}},
+        )
+        assert_response(resp, "one")
+
+    run()
+    assert_reset()