Skip to content
Merged
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
25 changes: 25 additions & 0 deletions doc/howto.rst
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,31 @@ If a parameter appears more than once, a dictionary matches its first value,
including an empty value. A ``werkzeug.datastructures.MultiDict`` matches all
values, including empty ones.

Percent encoding
~~~~~~~~~~~~~~~~

A string or bytes value for ``query_string`` is matched byte-for-byte against
the query received by the server. A string is encoded as UTF-8, but it is not
URL-quoted or percent-decoded. Supply the encoded query without the leading
``?``. For example, ``"q=hello%20world"`` matches that exact spelling, not
``"q=hello+world"``.

A dictionary or ``MultiDict`` instead matches the parsed query parameters.
The request's names and values are percent-decoded, and ``+`` becomes a space.
The expected mapping is used as-is, so supply decoded names and values:
``{"q": "hello world"}`` matches both ``?q=hello%20world`` and
``?q=hello+world``. This also avoids depending on how your HTTP client encodes
spaces or orders parameters with distinct names. Repeated values for the same
name still follow the dictionary and ``MultiDict`` rules described above.

To match a literal plus sign, use ``{"q": "a+b"}`` with a request containing
``?q=a%2Bb``. Similarly, ``{"q": "hello%20world"}`` expects the literal
characters ``%20``, so it matches ``?q=hello%2520world``, not
``?q=hello%20world``. These rules apply to parameter names as well as values.

.. literalinclude :: ../tests/examples/test_howto_query_params_encoding.py
:language: python


Matching form data
------------------
Expand Down
6 changes: 6 additions & 0 deletions pytest_httpserver/httpserver.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,8 @@ def __init__(self, query_string: bytes | str) -> None:
If string is specified, it will be encoded by the encode() method.
The query must not start with '?' but will be exactly (byte-by-byte) equal
the actual query string of the incoming request.
Supply the URL-encoded query: no percent-decoding or URL-quoting is performed,
so a space encoded as '%20' is different from one encoded as '+'.
"""
if not isinstance(query_string, (str, bytes)):
raise TypeError("query_string must be a string, or a bytes-like object")
Expand Down Expand Up @@ -269,6 +271,10 @@ def __init__(self, query_dict: Mapping[str, str] | MultiDict[str, str]) -> None:
If you want to match multiple values, use a MultiDict object from werkzeug, which
represents multiple values for one key. Empty values are kept, including
parameters without an equals sign. An empty value is distinct from a missing key.
The request's names and values are percent-decoded, with '+' decoded as a space.
Supply decoded names and values in the expected mapping, which is used as-is.
For example, {'q': 'hello world'} matches both 'q=hello%20world' and
'q=hello+world'; {'q': 'hello%20world'} instead matches 'q=hello%2520world'.
"""
self.query_dict = query_dict

Expand Down
24 changes: 24 additions & 0 deletions tests/examples/test_howto_query_params_encoding.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import requests


def test_encoded_query_string(httpserver):
httpserver.expect_request("/search", query_string="q=hello%20world").respond_with_data("OK")

assert requests.get(httpserver.url_for("/search") + "?q=hello%20world").status_code == 200
httpserver.check()


def test_decoded_query_mapping(httpserver):
httpserver.expect_request("/search", query_string={"q": "hello world"}).respond_with_data("OK")

assert requests.get(httpserver.url_for("/search") + "?q=hello%20world").status_code == 200
assert requests.get(httpserver.url_for("/search"), params={"q": "hello world"}).status_code == 200
httpserver.check()


def test_literal_plus_and_percent(httpserver):
httpserver.expect_request("/search", query_string={"q": "a+b", "literal": "hello%20world"}).respond_with_data("OK")

response = requests.get(httpserver.url_for("/search") + "?q=a%2Bb&literal=hello%2520world")
assert response.status_code == 200
httpserver.check()
16 changes: 16 additions & 0 deletions tests/test_querymatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,22 @@ def test_qm_bytes():
assert_not_match(qm, b"k2=v2&k1=v1")


@pytest.mark.parametrize("expected", ["q=hello%20world", b"q=hello%20world"])
def test_qm_string_preserves_percent_encoding(expected):
qm = StringQueryMatcher(expected)
assert_match(qm, b"q=hello%20world")
assert_not_match(qm, b"q=hello+world")
assert_not_match(qm, b"q=hello world")


@pytest.mark.parametrize("mapping_type", [dict, MultiDict])
def test_qm_mapping_decodes_names_and_values(mapping_type):
qm = MappingQueryMatcher(mapping_type({"search term": "hello world", "literal": "a+b%20"}))
assert_match(qm, b"search%20term=hello%20world&literal=a%2Bb%2520")
assert_match(qm, b"literal=a%2Bb%2520&search+term=hello+world")
assert_not_match(qm, b"search+term=hello+world&literal=a+b%20")


def test_qm_boolean():
qm = BooleanQueryMatcher(result=True)
assert_match(qm, b"k1=v1")
Expand Down
Loading