Repository: getsentry/responses

Issue:
Using urlencoded_params_matcher for matching query parameters
I'm getting started with `responses` (version `0.12.1`), and I'm trying to match query parameters. From the README, my understanding was that I could use `urlencoded_params_matcher` for that, but it seems that this (and the `match` argument of `add`) only works for POST data, but not for query args.

This is probably intended, but the documentation could be a bit clearer here.

It seems that the right way of doing what I want is to specify the query args in the URL. This is cumbersome due to the encoding, and if you mock a lot of URLs with slightly different query arg values then it gets confusing quickly. I would prefer to pass a dict instead whose content is automatically encoded (e.g. via `urllib.parse.urlencode`). Something like

```python
responses.add(
    method='GET',
    url='https://some-host/some-endpoint',
    params={
        'foo': 'bar',
        'x': 'y',
    },
)
```

which would be roughly equivalent to

```python
url = https://some-host/some-endpoint',
params = {
    'foo': 'bar',
    'x': 'y',
}
full_url = url + '?' + urlencode(params)  # construction should be more robust
responses.add(
    method='GET',
    url=full_url,
)
```

Comments:

Patch:
diff --git a/.gitignore b/.gitignore
index 6bd96e5..3534373 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,8 @@
 .coverage
 .DS_Store
 .idea
+.env
+venv
 *.db
 *.egg-info
 *.pyc
diff --git a/README.rst b/README.rst
index 20f5f27..212923c 100644
--- a/README.rst
+++ b/README.rst
@@ -142,6 +142,7 @@ other formats.
 
     import responses
     import requests
+    from responses import matchers
 
     @responses.activate
     def test_calc_api():
@@ -150,15 +151,44 @@ other formats.
             url='http://calc.com/sum',
             body="4",
             match=[
-                responses.urlencoded_params_matcher({"left": "1", "right": "3"})
+                matchers.urlencoded_params_matcher({"left": "1", "right": "3"})
             ]
         )
         requests.post("http://calc.com/sum", data={"left": 1, "right": 3})
 
-Matching JSON encoded data can be done with ``responses.json_params_matcher()``.
+Matching JSON encoded data can be done with ``matchers.json_params_matcher()``.
 If your application uses other encodings you can build your own matcher that
 returns ``True`` or ``False`` if the request parameters match. Your matcher can
-expect a ``request_body`` parameter to be provided by responses.
+expect a ``request`` parameter to be provided by responses.
+
+Similarly, you can use the ``matchers.query_param_matcher`` function to match
+against the ``params`` request parameter.
+Note, you must set ``match_querystring=False``
+
+.. code-block:: python
+
+    import responses
+    import requests
+    from responses import matchers
+
+    @responses.activate
+    def test_calc_api():
+        url = "http://example.com/test"
+        params = {"hello": "world", "I am": "a big test"}
+        responses.add(
+            method=responses.GET,
+            url=url,
+            body="test",
+            match=[matchers.query_param_matcher(params)],
+            match_querystring=False,
+        )
+
+        resp = requests.get(url, params=params)
+
+        constructed_url = r"http://example.com/test?I+am=a+big+test&hello=world"
+        assert resp.url == constructed_url
+        assert resp.request.url == constructed_url
+        assert resp.request.params == params
 
 Dynamic Responses
 -----------------
diff --git a/responses/__init__.py b/responses/__init__.py
index 68fd7a3..139e670 100644
--- a/responses/__init__.py
+++ b/responses/__init__.py
@@ -15,6 +15,9 @@ from requests.adapters import HTTPAdapter
 from requests.exceptions import ConnectionError
 from requests.sessions import REDIRECT_STATI
 from requests.utils import cookiejar_from_dict
+from responses.matchers import json_params_matcher as _json_params_matcher
+from responses.matchers import urlencoded_params_matcher as _urlencoded_params_matcher
+from warnings import warn
 
 try:
     from collections.abc import Sequence, Sized
@@ -59,11 +62,6 @@ 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"])
@@ -73,6 +71,22 @@ _real_send = HTTPAdapter.send
 logger = logging.getLogger("responses")
 
 
+def urlencoded_params_matcher(params):
+    warn(
+        "Function is deprecated. Use 'from responses.matchers import urlencoded_params_matcher'",
+        DeprecationWarning,
+    )
+    return _urlencoded_params_matcher(params)
+
+
+def json_params_matcher(params):
+    warn(
+        "Function is deprecated. Use 'from responses.matchers import json_params_matcher'",
+        DeprecationWarning,
+    )
+    return _json_params_matcher(params)
+
+
 def _is_string(s):
     return isinstance(s, six.string_types)
 
@@ -240,33 +254,6 @@ def _handle_body(body):
 _unspecified = object()
 
 
-def urlencoded_params_matcher(params):
-    def match(request_body):
-        return (
-            params is None
-            if request_body is None
-            else 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 is None
-                if request_body is None
-                else params == json_module.loads(request_body)
-            )
-        except JSONDecodeError:
-            return False
-
-    return match
-
-
 class BaseResponse(object):
     content_type = None
     headers = None
@@ -345,12 +332,14 @@ class BaseResponse(object):
         else:
             return False
 
-    def _body_matches(self, match, request_body):
+    @staticmethod
+    def _req_attr_matches(match, request):
         for matcher in match:
-            if not matcher(request_body):
-                return False
+            valid, reason = matcher(request)
+            if not valid:
+                return False, reason
 
-        return True
+        return True, ""
 
     def get_headers(self):
         headers = HTTPHeaderDict()  # Duplicate headers are legal
@@ -370,8 +359,9 @@ class BaseResponse(object):
         if not self._url_matches(self.url, request.url, self.match_querystring):
             return False, "URL does not match"
 
-        if not self._body_matches(self.match, request.body):
-            return False, "Parameters do not match"
+        valid, reason = self._req_attr_matches(self.match, request)
+        if not valid:
+            return False, "Parameters do not match. " + reason
 
         return True, ""
 
@@ -717,9 +707,9 @@ class RequestsMock(object):
         return params
 
     def _on_request(self, adapter, request, **kwargs):
+        request.params = self._parse_request_params(request.path_url)
         match, match_failed_reasons = self._find_match(request)
         resp_callback = self.response_callback
-        request.params = self._parse_request_params(request.path_url)
 
         if match is None:
             if any(
diff --git a/responses/__init__.pyi b/responses/__init__.pyi
index 571b5e1..dc3dbba 100644
--- a/responses/__init__.pyi
+++ b/responses/__init__.pyi
@@ -5,22 +5,24 @@ from typing import (
     Iterator,
     Mapping,
     Optional,
-    Pattern,
     NamedTuple,
     Protocol,
     TypeVar,
+    Dict,
+    List,
+    Tuple,
+    Union
 )
 from io import BufferedReader, BytesIO
 from re import Pattern
 from requests.adapters import HTTPResponse, PreparedRequest
 from requests.cookies import RequestsCookieJar
-from typing import Any, Callable, Dict, List, Optional, Tuple, Union
 from typing_extensions import Literal
 from unittest import mock as std_mock
 from urllib.parse import quote as quote
 from urllib3.response import HTTPHeaderDict
+from .matchers import urlencoded_params_matcher, json_params_matcher
 
-JSONDecodeError = ValueError
 
 def _clean_unicode(url: str) -> str: ...
 def _cookies_from_headers(headers: Dict[str, str]) -> RequestsCookieJar: ...
@@ -36,12 +38,7 @@ def _is_string(s: Union[Pattern[str], str]) -> bool: ...
 def get_wrapped(
     func: Callable[..., Any], responses: RequestsMock
 ) -> Callable[..., Any]: ...
-def json_params_matcher(
-    params: Optional[Dict[str, Any]]
-) -> Callable[..., Any]: ...
-def urlencoded_params_matcher(
-    params: Optional[Dict[str, str]]
-) -> Callable[..., Any]: ...
+
 
 class Call(NamedTuple):
     request: PreparedRequest
@@ -75,7 +72,7 @@ class BaseResponse:
     ) -> None: ...
     def __eq__(self, other: Any) -> bool: ...
     def __ne__(self, other: Any) -> bool: ...
-    def _body_matches(
+    def _req_attr_matches(
         self, match: List[Callable[..., Any]], request_body: Optional[Union[bytes, str]]
     ) -> bool: ...
     def _should_match_querystring(
diff --git a/responses/matchers.py b/responses/matchers.py
new file mode 100644
index 0000000..8508c96
--- /dev/null
+++ b/responses/matchers.py
@@ -0,0 +1,87 @@
+import six
+import json as json_module
+
+if six.PY2:
+    from urlparse import parse_qsl
+else:
+    from urllib.parse import parse_qsl
+
+try:
+    from json.decoder import JSONDecodeError
+except ImportError:
+    JSONDecodeError = ValueError
+
+
+def urlencoded_params_matcher(params):
+    """
+    Matches URL encoded data
+    :param params: (dict) data provided to 'data' arg of request
+    :return: (func) matcher
+    """
+
+    def match(request):
+        request_body = request.body
+        valid = (
+            params is None
+            if request_body is None
+            else sorted(params.items()) == sorted(parse_qsl(request_body))
+        )
+        if not valid:
+            return False, "%s doesn't match %s" % (request_body, params)
+
+        return valid, ""
+
+    return match
+
+
+def json_params_matcher(params):
+    """
+    Matches JSON encoded data
+    :param params: (dict) JSON data provided to 'json' arg of request
+    :return: (func) matcher
+    """
+
+    def match(request):
+        request_body = request.body
+        try:
+            if isinstance(request_body, bytes):
+                request_body = request_body.decode("utf-8")
+            valid = (
+                params is None
+                if request_body is None
+                else params == json_module.loads(request_body)
+            )
+            if not valid:
+                return False, "%s doesn't match %s" % (request_body, params)
+
+            return valid, ""
+        except JSONDecodeError:
+            return False, "JSONDecodeError: Cannot parse request.body"
+
+    return match
+
+
+def query_param_matcher(params):
+    """
+    Matcher to match 'params' argument in request
+    :param params: (dict), same as provided to request
+    :return: (func) matcher
+    """
+
+    def match(request):
+        request_params = request.params
+        valid = (
+            params is None
+            if request_params is None
+            else sorted(params.items()) == sorted(request_params.items())
+        )
+
+        if not valid:
+            return False, "%s doesn't match %s" % (
+                sorted(request_params.items()),
+                sorted(params.items()),
+            )
+
+        return valid, ""
+
+    return match
diff --git a/responses/matchers.pyi b/responses/matchers.pyi
new file mode 100644
index 0000000..8851303
--- /dev/null
+++ b/responses/matchers.pyi
@@ -0,0 +1,21 @@
+from typing import (
+    Any,
+    Callable,
+    Optional,
+    Dict,
+)
+
+JSONDecodeError = ValueError
+
+
+def json_params_matcher(
+    params: Optional[Dict[str, Any]]
+) -> Callable[..., Any]: ...
+
+def urlencoded_params_matcher(
+    params: Optional[Dict[str, str]]
+) -> Callable[..., Any]: ...
+
+def query_param_matcher(
+    params: Optional[Dict[str, str]]
+) -> Callable[..., Any]: ...


Test patch:
diff --git a/responses/test_responses.py b/responses/test_responses.py
index 4821f56..1c89a81 100644
--- a/responses/test_responses.py
+++ b/responses/test_responses.py
@@ -3,15 +3,18 @@
 from __future__ import absolute_import, print_function, division, unicode_literals
 
 import inspect
+import os
 import re
 import six
 from io import BufferedReader, BytesIO
+from sys import version_info
 
 import pytest
 import requests
 import responses
 from requests.exceptions import ConnectionError, HTTPError
-from responses import BaseResponse, Response
+from responses import BaseResponse, Response, matchers
+
 
 try:
     from mock import patch, Mock
@@ -530,8 +533,8 @@ def test_callback():
     }
     url = "http://example.com/"
 
-    def request_callback(request):
-        return (status, headers, body)
+    def request_callback(_request):
+        return status, headers, body
 
     @responses.activate
     def run():
@@ -573,7 +576,7 @@ def test_callback_exception_body():
     url = "http://example.com/"
 
     def request_callback(request):
-        return (200, {}, body)
+        return 200, {}, body
 
     @responses.activate
     def run():
@@ -595,8 +598,8 @@ def test_callback_no_content_type():
     headers = {"foo": "bar"}
     url = "http://example.com/"
 
-    def request_callback(request):
-        return (status, headers, body)
+    def request_callback(_request):
+        return status, headers, body
 
     @responses.activate
     def run():
@@ -887,17 +890,22 @@ def test_response_callback():
     assert_reset()
 
 
+@pytest.mark.skipif(six.PY2, reason="re.compile works differntly in PY2")
 def test_response_filebody():
     """ Adds the possibility to use actual (binary) files as responses """
 
     def run():
+        current_file = os.path.abspath(__file__)
         with responses.RequestsMock() as m:
-            with open("README.rst", "r") as out:
+            with open(current_file, "r") as out:
                 m.add(responses.GET, "http://example.com", body=out.read(), stream=True)
                 resp = requests.get("http://example.com")
-            with open("README.rst", "r") as out:
+            with open(current_file, "r") as out:
                 assert resp.text == out.read()
 
+    run()
+    assert_reset()
+
 
 def test_assert_all_requests_are_fired():
     def request_callback(request):
@@ -1304,26 +1312,25 @@ def test_assert_call_count(url):
 
 def test_request_matches_post_params():
     @responses.activate
-    def run():
+    def run(deprecated):
+        if deprecated:
+            json_params_matcher = getattr(responses, "json_params_matcher")
+            urlencoded_params_matcher = getattr(responses, "urlencoded_params_matcher")
+        else:
+            json_params_matcher = matchers.json_params_matcher
+            urlencoded_params_matcher = matchers.urlencoded_params_matcher
+
         responses.add(
             method=responses.POST,
             url="http://example.com/",
             body="one",
-            match=[
-                responses.json_params_matcher(
-                    {"page": {"name": "first", "type": "json"}}
-                )
-            ],
+            match=[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"}
-                )
-            ],
+            match=[urlencoded_params_matcher({"page": "second", "type": "urlencoded"})],
         )
 
         resp = requests.request(
@@ -1342,8 +1349,9 @@ def test_request_matches_post_params():
         )
         assert_response(resp, "one")
 
-    run()
-    assert_reset()
+    for depr in [True, False]:
+        run(deprecated=depr)
+        assert_reset()
 
 
 def test_request_matches_empty_body():
@@ -1353,14 +1361,14 @@ def test_request_matches_empty_body():
             method=responses.POST,
             url="http://example.com/",
             body="one",
-            match=[responses.json_params_matcher(None)],
+            match=[matchers.json_params_matcher(None)],
         )
 
         responses.add(
             method=responses.POST,
             url="http://example.com/",
             body="two",
-            match=[responses.urlencoded_params_matcher(None)],
+            match=[matchers.urlencoded_params_matcher(None)],
         )
 
         resp = requests.request("POST", "http://example.com/")
@@ -1377,6 +1385,39 @@ def test_request_matches_empty_body():
     assert_reset()
 
 
+def test_request_matches_params():
+    @responses.activate
+    def run():
+        url = "http://example.com/test"
+        params = {"hello": "world", "I am": "a big test"}
+        responses.add(
+            method=responses.GET,
+            url=url,
+            body="test",
+            match=[matchers.query_param_matcher(params)],
+            match_querystring=False,
+        )
+
+        # exchange parameter places for the test
+        params = {
+            "I am": "a big test",
+            "hello": "world",
+        }
+        resp = requests.get(url, params=params)
+
+        if six.PY3 and version_info[1] >= 7:
+            # only after py 3.7 dictionaries are ordered, so we can check URL
+            constructed_url = r"http://example.com/test?I+am=a+big+test&hello=world"
+            assert resp.url == constructed_url
+            assert resp.request.url == constructed_url
+
+        resp_params = getattr(resp.request, "params")
+        assert resp_params == params
+
+    run()
+    assert_reset()
+
+
 def test_fail_request_error():
     @responses.activate
     def run():
@@ -1385,15 +1426,68 @@ def test_fail_request_error():
         responses.add(
             "POST",
             "http://example.com",
-            match=[responses.urlencoded_params_matcher({"foo": "bar"})],
+            match=[matchers.urlencoded_params_matcher({"foo": "bar"})],
+        )
+        responses.add(
+            "POST",
+            "http://example.com",
+            match=[matchers.json_params_matcher({"fail": "json"})],
         )
 
         with pytest.raises(ConnectionError) as excinfo:
             requests.post("http://example.com", data={"id": "bad"})
+
         msg = str(excinfo.value)
         assert "- POST http://example1.com/ URL does not match" in msg
         assert "- GET http://example.com/ Method does not match" in msg
-        assert "- POST http://example.com/ Parameters do not match" in msg
+
+        if six.PY3:
+            assert "Parameters do not match. id=bad doesn't match {'foo': 'bar'}" in msg
+        else:
+            assert (
+                "Parameters do not match. id=bad doesn't match {u'foo': u'bar'}" in msg
+            )
+
+        assert (
+            "Parameters do not match. JSONDecodeError: Cannot parse request.body" in msg
+        )
+
+        # test query parameters
+        responses.add(
+            "GET",
+            "http://111.com",
+            match=[matchers.query_param_matcher({"my": "params"})],
+        )
+
+        responses.add(
+            method=responses.GET,
+            url="http://111.com/",
+            body="two",
+            match=[matchers.json_params_matcher({"page": "one"})],
+        )
+
+        with pytest.raises(ConnectionError) as excinfo:
+            requests.get("http://111.com", params={"id": "bad"}, json={"page": "two"})
+
+        msg = str(excinfo.value)
+        if six.PY3:
+            assert (
+                "Parameters do not match. [('id', 'bad')] doesn't match [('my', 'params')]"
+                in msg
+            )
+            assert (
+                """Parameters do not match. {"page": "two"} doesn't match {'page': 'one'}"""
+                in msg
+            )
+        else:
+            assert (
+                "Parameters do not match. [('id', 'bad')] doesn't match [(u'my', u'params')]"
+                in msg
+            )
+            assert (
+                """Parameters do not match. {"page": "two"} doesn't match {u'page': u'one'}"""
+                in msg
+            )
 
     run()
     assert_reset()