From 603ce1dbd14d9c4fac167a32e7aaf0f4897c584c Mon Sep 17 00:00:00 2001 From: mayureshsonawane17 Date: Tue, 6 Oct 2026 00:13:49 +0530 Subject: [PATCH] feat(matchers): add ignore_order option to json_params_matcher (#756) --- README.rst | 23 ++++++++++++ responses/matchers.py | 47 ++++++++++++++++++++++-- responses/tests/test_matchers.py | 62 ++++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 8170aedc..ce290496 100644 --- a/README.rst +++ b/README.rst @@ -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 ^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/responses/matchers.py b/responses/matchers.py index 15df1884..a44c8e61 100644 --- a/responses/matchers.py +++ b/responses/matchers.py @@ -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 = "" @@ -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. @@ -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 @@ -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 if not valid: reason = f"request.body doesn't match: {json_body} doesn't match {json_params}" diff --git a/responses/tests/test_matchers.py b/responses/tests/test_matchers.py index 4f67810c..3e07ba4d 100644 --- a/responses/tests/test_matchers.py +++ b/responses/tests/test_matchers.py @@ -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():