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
3 changes: 3 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ Unreleased
This also applies to headers returned by callbacks.
* Fixed ``urlencoded_params_matcher`` rejecting matching form data supplied
as bytes or a binary file-like object.
* Fixed ``urlencoded_params_matcher`` keeping only the last value of a key
that is sent more than once. Repeated keys are now matched against a list,
e.g. ``{"id": ["1", "2"]}``.
* Added ``responses.CallbackRequest`` so callback and matcher annotations can
include the ``params`` and ``req_kwargs`` attributes added by ``responses``.

Expand Down
21 changes: 18 additions & 3 deletions responses/matchers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from json.decoder import JSONDecodeError
from typing import Any
from typing import Callable
from typing import Dict
from typing import List
from typing import Mapping
from typing import MutableMapping
Expand Down Expand Up @@ -38,6 +39,19 @@ def _filter_dict_recursively(
return filtered_dict


def _parse_form_values(body: str, *, allow_blank: bool) -> Dict[str, Any]:
"""Parse URL encoded data, keeping every value of a repeated key in a list."""
values: Dict[str, Any] = {}
for key, value in parse_qsl(body, keep_blank_values=allow_blank):
if key not in values:
values[key] = value
elif isinstance(values[key], list):
values[key].append(value)
else:
values[key] = [values[key], value]
return values


def body_matcher(params: str, *, allow_blank: bool = False) -> Callable[..., Any]:
def match(request: PreparedRequest) -> Tuple[bool, str]:
reason = ""
Expand All @@ -54,15 +68,16 @@ def match(request: PreparedRequest) -> Tuple[bool, str]:


def urlencoded_params_matcher(
params: Optional[Mapping[str, str]],
params: Optional[Mapping[str, Union[str, List[str]]]],
*,
allow_blank: bool = False,
strict_match: bool = True,
) -> Callable[..., Any]:
"""
Matches URL encoded data

:param params: (dict) data provided to 'data' arg of request
:param params: (dict) data provided to 'data' arg of request. Use a list
for a key that is sent more than once, e.g. ``{"id": ["1", "2"]}``
:param allow_blank If true, blank values are accounted as empty strings
:param strict_match If true, all keys must match;
otherwise, partial matches allowed
Expand All @@ -75,7 +90,7 @@ def match(request: PreparedRequest) -> Tuple[bool, str]:
if isinstance(request_body, bytes):
request_body = request_body.decode("utf-8")
qsl_body: Mapping[Any, Any] = (
dict(parse_qsl(request_body, keep_blank_values=allow_blank))
_parse_form_values(request_body, allow_blank=allow_blank)
if request_body
else {}
)
Expand Down
42 changes: 42 additions & 0 deletions responses/tests/test_matchers.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,48 @@ def run():
assert_reset()


@pytest.mark.parametrize("strict_match", [False, True])
def test_urlencoded_params_matcher_repeated_keys(strict_match: bool) -> None:
@responses.activate
def run():
responses.add(
method=responses.POST,
url="http://example.com/",
body="all values",
match=[
matchers.urlencoded_params_matcher(
{"id": ["1", "2"], "page": "a"}, strict_match=strict_match
)
],
)
# only the last value of the repeated key must not match
responses.add(
method=responses.POST,
url="http://example.com/",
body="last value",
match=[
matchers.urlencoded_params_matcher(
{"id": "2", "page": "a"}, strict_match=strict_match
)
],
)

resp = requests.post(
"http://example.com/", data={"id": ["1", "2"], "page": "a"}
)
assert_response(resp, "all values")

# values of a repeated key are kept even when they are not adjacent
resp = requests.post("http://example.com/", data="id=1&page=a&id=2")
assert_response(resp, "all values")

resp = requests.post("http://example.com/", data={"id": "2", "page": "a"})
assert_response(resp, "last value")

run()
assert_reset()


@pytest.mark.parametrize("as_file", [False, True])
@pytest.mark.parametrize("strict_match", [False, True])
@pytest.mark.parametrize(
Expand Down