Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,29 @@ Matching JSON encoded data can be done with ``matchers.json_params_matcher()``.
)


To ignore the ordering of elements inside JSON lists/arrays, pass ``ignore_order=True``:

.. code-block:: python

@responses.activate
def test_order_insensitive_json():
responses.post(
url="http://example.com/items",
body="OK",
match=[
matchers.json_params_matcher(
{"tags": ["python", "testing", "api"]}, ignore_order=True
)
],
)
# Matches regardless of array item order
requests.post(
"http://example.com/items",
json={"tags": ["api", "python", "testing"]},
)



Query Parameters Matcher
^^^^^^^^^^^^^^^^^^^^^^^^

Expand Down
47 changes: 45 additions & 2 deletions responses/matchers.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,38 @@ def _filter_dict_recursively(
return filtered_dict


def _compare_json(
matcher_val: Any, request_val: Any, *, ignore_order: bool = False
) -> bool:
if ignore_order:
if isinstance(matcher_val, list) and isinstance(request_val, list):
if len(matcher_val) != len(request_val):
return False
matched = [False] * len(request_val)
for m_item in matcher_val:
found = False
for idx, r_item in enumerate(request_val):
if not matched[idx] and _compare_json(
m_item, r_item, ignore_order=True
):
matched[idx] = True
found = True
break
if not found:
return False
return True
elif isinstance(matcher_val, dict) and isinstance(request_val, dict):
if set(matcher_val.keys()) != set(request_val.keys()):
return False
for k in matcher_val:
if not _compare_json(
matcher_val[k], request_val[k], ignore_order=True
):
return False
return True
return matcher_val == request_val


def body_matcher(params: str, *, allow_blank: bool = False) -> Callable[..., Any]:
def match(request: PreparedRequest) -> Tuple[bool, str]:
reason = ""
Expand Down Expand Up @@ -110,7 +142,10 @@ def match(request: PreparedRequest) -> Tuple[bool, str]:


def json_params_matcher(
params: Optional[Union[Mapping[str, Any], List[Any]]], *, strict_match: bool = True
params: Optional[Union[Mapping[str, Any], List[Any]]],
*,
strict_match: bool = True,
ignore_order: bool = False,
) -> Callable[..., Any]:
"""Matches JSON encoded data of request body.

Expand All @@ -123,6 +158,9 @@ def json_params_matcher(
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.
ignore_order : bool, default=False
If set to ``True``, list elements in JSON structures are compared
without regard to their ordering.


Returns
Expand Down Expand Up @@ -152,7 +190,12 @@ def match(request: PreparedRequest) -> Tuple[bool, str]:
# filter down to just the params specified in the matcher
json_body = _filter_dict_recursively(json_body, json_params)

valid = params is None if request_body is None else json_params == json_body
if params is None:
valid = request_body is None
elif ignore_order:
valid = _compare_json(json_params, json_body, ignore_order=True)
else:
valid = json_params == json_body

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inverted empty-body matching condition

Medium Severity

Refactoring the match condition inverted the None check. json_params_matcher with params=None no longer accepts an empty JSON object body, and params={} now matches a missing request body. Both differ from the previous request_body is None short-circuit.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 603ce1d. Configure here.


if not valid:
reason = f"request.body doesn't match: {json_body} doesn't match {json_params}"
Expand Down
62 changes: 62 additions & 0 deletions responses/tests/test_matchers.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,68 @@ def test_json_params_matcher_body_is_gzipped():
assert result == (True, "")


def test_json_params_matcher_ignore_order():
# Top-level list reordered
mock_req = Mock(body='["C", "A", "B"]')
matcher = matchers.json_params_matcher(["A", "B", "C"], ignore_order=True)
assert matcher(mock_req) == (True, "")

# Default ignore_order=False fails on different order
matcher_default = matchers.json_params_matcher(["A", "B", "C"])
assert matcher_default(mock_req)[0] is False

# Nested lists inside dictionary
mock_nested = Mock(body='{"tags": ["python", "api"], "ids": [3, 1, 2]}')
matcher_nested = matchers.json_params_matcher(
{"tags": ["api", "python"], "ids": [1, 2, 3]}, ignore_order=True
)
assert matcher_nested(mock_nested) == (True, "")

# List of nested dictionaries in different order
mock_dict_list = Mock(
body='[{"id": 2, "name": "b"}, {"id": 1, "name": "a"}]'
)
matcher_dict_list = matchers.json_params_matcher(
[{"id": 1, "name": "a"}, {"id": 2, "name": "b"}], ignore_order=True
)
assert matcher_dict_list(mock_dict_list) == (True, "")

# Duplicate elements must match multiplicity
mock_dup = Mock(body="[1, 2, 2]")
matcher_dup = matchers.json_params_matcher([1, 1, 2], ignore_order=True)
assert matcher_dup(mock_dup)[0] is False

# Length mismatch fails
mock_len = Mock(body="[1, 2, 3]")
matcher_len = matchers.json_params_matcher([1, 2], ignore_order=True)
assert matcher_len(mock_len)[0] is False


def test_json_params_matcher_ignore_order_integration():
@responses.activate(assert_all_requests_are_fired=True)
def run():
responses.add(
method=responses.POST,
url="http://example.com/api",
body="OK",
match=[
matchers.json_params_matcher(
{"items": [10, 20, 30], "group": "test"},
ignore_order=True,
)
],
)

resp = requests.post(
"http://example.com/api",
json={"group": "test", "items": [30, 10, 20]},
)
assert_response(resp, "OK")

run()
assert_reset()


def test_urlencoded_params_matcher_blank():
@responses.activate
def run():
Expand Down