Repository: getsentry/responses

Issue:
json_params_matcher does not support general JSONs (e.g. lists of dicts)
### Describe the bug

The implementation of `json_params_matcher` does not support arbitrarily nested JSON objects, such as lists of dicts. 

It says so in the signature: 

`def json_params_matcher(params: Optional[Dict[str, Any]]) -> Callable[..., Any]:`

and the simple check of `params_dict == json_body` is not enough. Additionally, an Exception is raised in this section, as a dict is expected.

`
            if not valid:
                reason = "request.body doesn't match: {} doesn't match {}".format(
                    _create_key_val_str(json_body), _create_key_val_str(params_dict)
                )
`

### Additional context

I can provide a PR, but there are different ways to tackle this:

- Custom function to recursively check => no dependencies
- Adopting something like https://github.com/seperman/deepdiff or https://github.com/xlwings/jsondiff to support with the extraction of reasons for JSONs being not equal

As this might entail additional dependencies, I'd like to get your opinion on this before submitting a PR.

### Version of `responses`

0.21.0

### Steps to Reproduce

```python
import json
from unittest.mock import Mock

from responses.matchers import _create_key_val_str, json_params_matcher


json_a = [{"a": "b"}]
json_b = [{"a": "b", "c": "d"}]
mock_request = Mock(body=json.dumps(json_b))
result = json_params_matcher(json_a)(mock_request)
print(result)

```

### Expected Result

`result` should be (False, "<reason>"), no error is thrown

### Actual Result

AttributeError is raised: 'list' object has no attribute 'keys'

Discussion:
codecov[bot]: # [Codecov](https://app.codecov.io/gh/getsentry/responses/compare/569?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) Report
> Merging [#569](https://app.codecov.io/gh/getsentry/responses/compare/569?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) (3fb1ff7) into [master](https://codecov.io/gh/getsentry/responses/commit/d49f82958887b0f55479714319df22a47c4fd3bc?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) (d49f829) will **not change** coverage.
> The diff coverage is `100.00%`.

```diff
@@            Coverage Diff            @@
##            master      #569   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files            7         7           
  Lines         2644      2659   +15     
=========================================
+ Hits          2644      2659   +15     
```


| [Impacted Files](https://app.codecov.io/gh/getsentry/responses/compare/569?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/compare/569/diff?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/compare/569/diff?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%> (ø)` | |

------

[Continue to review full report at Codecov](https://app.codecov.io/gh/getsentry/responses/compare/569?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://app.codecov.io/gh/getsentry/responses/compare/569?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry). Last update [d49f829...3fb1ff7](https://app.codecov.io/gh/getsentry/responses/compare/569?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry).

beliaev-maksim: hi @chadwhitacre 

I think something is wrong with FOSSA pipeline. 22min for check is too much. I have already seen similar for other PRs. Can you please check?

Patch:
diff --git a/CHANGES b/CHANGES
index b4f4f93..a66f591 100644
--- a/CHANGES
+++ b/CHANGES
@@ -5,6 +5,7 @@
 * Add `passthrough` argument to `BaseResponse` object. See #557
 * Fix `registries` leak. See #563
 * Add support for the `loose` version of `json_params_matcher` via named argument `strict_match`. See #551
+* Add lists support as JSON objects in `json_params_matcher`. See #559
 * Added project links to pypi listing.
 
 0.21.0
diff --git a/mypy.ini b/mypy.ini
index 0b3462b..92e94fb 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -1,4 +1,5 @@
 [mypy]
+exclude = tests
 show_column_numbers=True
 show_error_codes = True
 
@@ -26,8 +27,3 @@ warn_unreachable=False
 
 strict_equality=True
 ignore_missing_imports=True
-
-[mypy-responses.tests.*]
-disallow_untyped_defs=False
-disallow_untyped_calls=False
-disallow_any_decorated=False
diff --git a/responses/matchers.py b/responses/matchers.py
index c7f2647..4880e50 100644
--- a/responses/matchers.py
+++ b/responses/matchers.py
@@ -97,16 +97,17 @@ def urlencoded_params_matcher(
 
 
 def json_params_matcher(
-    params: Optional[Dict[str, Any]], *, strict_match: bool = True
+    params: Optional[Union[Dict[str, Any], List[Any]]], *, strict_match: bool = True
 ) -> Callable[..., Any]:
     """Matches JSON encoded data of request body.
 
     Parameters
     ----------
-    params : dict
-        JSON data provided to 'json' arg of request or a part of it if used in
+    params : dict or list
+        JSON object provided to 'json' arg of request or a part of it if used in
         conjunction with ``strict_match=False``.
     strict_match : bool, default=True
+        Applied only when JSON object is a dictionary.
         If set to ``True``, validates that all keys of JSON object match.
         If set to ``False``, original request may contain additional keys.
 
@@ -121,22 +122,30 @@ def json_params_matcher(
     def match(request: PreparedRequest) -> Tuple[bool, str]:
         reason = ""
         request_body = request.body
-        params_dict = params or {}
+        json_params = (params or {}) if not isinstance(params, list) else params
         try:
             if isinstance(request_body, bytes):
                 request_body = request_body.decode("utf-8")
             json_body = json_module.loads(request_body) if request_body else {}
 
-            if not strict_match:
+            if (
+                not strict_match
+                and isinstance(json_body, dict)
+                and isinstance(json_params, dict)
+            ):
                 # filter down to just the params specified in the matcher
-                json_body = _filter_dict_recursively(json_body, params_dict)
+                json_body = _filter_dict_recursively(json_body, json_params)
 
-            valid = params is None if request_body is None else params_dict == json_body
+            valid = params is None if request_body is None else json_params == json_body
 
             if not valid:
-                reason = "request.body doesn't match: {} doesn't match {}".format(
-                    _create_key_val_str(json_body), _create_key_val_str(params_dict)
-                )
+                if isinstance(json_body, dict) and isinstance(json_params, dict):
+                    reason = "request.body doesn't match: {} doesn't match {}".format(
+                        _create_key_val_str(json_body), _create_key_val_str(json_params)
+                    )
+                else:
+                    reason = f"request.body doesn't match: {json_body} doesn't match {json_params}"
+
                 if not strict_match:
                     reason += (
                         "\nNote: You use non-strict parameters check, "


Test patch:
diff --git a/responses/tests/test_matchers.py b/responses/tests/test_matchers.py
index ed8c55d..217788b 100644
--- a/responses/tests/test_matchers.py
+++ b/responses/tests/test_matchers.py
@@ -1,3 +1,5 @@
+from unittest.mock import Mock
+
 import pytest
 import requests
 from requests.exceptions import ConnectionError
@@ -136,6 +138,25 @@ def test_json_params_matcher_not_strict_diff_values():
     assert_reset()
 
 
+def test_json_params_matcher_json_list():
+    json_a = [{"a": "b"}]
+    json_b = '[{"a": "b", "c": "d"}]'
+    mock_request = Mock(body=json_b)
+    result = matchers.json_params_matcher(json_a)(mock_request)
+    assert result == (
+        False,
+        "request.body doesn't match: [{'a': 'b', 'c': 'd'}] doesn't match [{'a': 'b'}]",
+    )
+
+
+def test_json_params_matcher_json_list_empty():
+    json_a = []
+    json_b = "[]"
+    mock_request = Mock(body=json_b)
+    result = matchers.json_params_matcher(json_a)(mock_request)
+    assert result == (True, "")
+
+
 def test_urlencoded_params_matcher_blank():
     @responses.activate
     def run():