Skip to content

Commit 76d03e7

Browse files
00200200csernazs
authored andcommitted
docs: explain percent encoding in query matchers
1 parent e9dcdb2 commit 76d03e7

4 files changed

Lines changed: 71 additions & 0 deletions

File tree

‎doc/howto.rst‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,31 @@ If a parameter appears more than once, a dictionary matches its first value,
5454
including an empty value. A ``werkzeug.datastructures.MultiDict`` matches all
5555
values, including empty ones.
5656

57+
Percent encoding
58+
~~~~~~~~~~~~~~~~
59+
60+
A string or bytes value for ``query_string`` is matched byte-for-byte against
61+
the query received by the server. A string is encoded as UTF-8, but it is not
62+
URL-quoted or percent-decoded. Supply the encoded query without the leading
63+
``?``. For example, ``"q=hello%20world"`` matches that exact spelling, not
64+
``"q=hello+world"``.
65+
66+
A dictionary or ``MultiDict`` instead matches the parsed query parameters.
67+
The request's names and values are percent-decoded, and ``+`` becomes a space.
68+
The expected mapping is used as-is, so supply decoded names and values:
69+
``{"q": "hello world"}`` matches both ``?q=hello%20world`` and
70+
``?q=hello+world``. This also avoids depending on how your HTTP client encodes
71+
spaces or orders parameters with distinct names. Repeated values for the same
72+
name still follow the dictionary and ``MultiDict`` rules described above.
73+
74+
To match a literal plus sign, use ``{"q": "a+b"}`` with a request containing
75+
``?q=a%2Bb``. Similarly, ``{"q": "hello%20world"}`` expects the literal
76+
characters ``%20``, so it matches ``?q=hello%2520world``, not
77+
``?q=hello%20world``. These rules apply to parameter names as well as values.
78+
79+
.. literalinclude :: ../tests/examples/test_howto_query_params_encoding.py
80+
:language: python
81+
5782
5883
Matching form data
5984
------------------

‎pytest_httpserver/httpserver.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,8 @@ def __init__(self, query_string: bytes | str) -> None:
238238
If string is specified, it will be encoded by the encode() method.
239239
The query must not start with '?' but will be exactly (byte-by-byte) equal
240240
the actual query string of the incoming request.
241+
Supply the URL-encoded query: no percent-decoding or URL-quoting is performed,
242+
so a space encoded as '%20' is different from one encoded as '+'.
241243
"""
242244
if not isinstance(query_string, (str, bytes)):
243245
raise TypeError("query_string must be a string, or a bytes-like object")
@@ -269,6 +271,10 @@ def __init__(self, query_dict: Mapping[str, str] | MultiDict[str, str]) -> None:
269271
If you want to match multiple values, use a MultiDict object from werkzeug, which
270272
represents multiple values for one key. Empty values are kept, including
271273
parameters without an equals sign. An empty value is distinct from a missing key.
274+
The request's names and values are percent-decoded, with '+' decoded as a space.
275+
Supply decoded names and values in the expected mapping, which is used as-is.
276+
For example, {'q': 'hello world'} matches both 'q=hello%20world' and
277+
'q=hello+world'; {'q': 'hello%20world'} instead matches 'q=hello%2520world'.
272278
"""
273279
self.query_dict = query_dict
274280

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import requests
2+
3+
4+
def test_encoded_query_string(httpserver):
5+
httpserver.expect_request("/search", query_string="q=hello%20world").respond_with_data("OK")
6+
7+
assert requests.get(httpserver.url_for("/search") + "?q=hello%20world").status_code == 200
8+
httpserver.check()
9+
10+
11+
def test_decoded_query_mapping(httpserver):
12+
httpserver.expect_request("/search", query_string={"q": "hello world"}).respond_with_data("OK")
13+
14+
assert requests.get(httpserver.url_for("/search") + "?q=hello%20world").status_code == 200
15+
assert requests.get(httpserver.url_for("/search"), params={"q": "hello world"}).status_code == 200
16+
httpserver.check()
17+
18+
19+
def test_literal_plus_and_percent(httpserver):
20+
httpserver.expect_request("/search", query_string={"q": "a+b", "literal": "hello%20world"}).respond_with_data("OK")
21+
22+
response = requests.get(httpserver.url_for("/search") + "?q=a%2Bb&literal=hello%2520world")
23+
assert response.status_code == 200
24+
httpserver.check()

‎tests/test_querymatcher.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,22 @@ def test_qm_bytes():
2828
assert_not_match(qm, b"k2=v2&k1=v1")
2929

3030

31+
@pytest.mark.parametrize("expected", ["q=hello%20world", b"q=hello%20world"])
32+
def test_qm_string_preserves_percent_encoding(expected):
33+
qm = StringQueryMatcher(expected)
34+
assert_match(qm, b"q=hello%20world")
35+
assert_not_match(qm, b"q=hello+world")
36+
assert_not_match(qm, b"q=hello world")
37+
38+
39+
@pytest.mark.parametrize("mapping_type", [dict, MultiDict])
40+
def test_qm_mapping_decodes_names_and_values(mapping_type):
41+
qm = MappingQueryMatcher(mapping_type({"search term": "hello world", "literal": "a+b%20"}))
42+
assert_match(qm, b"search%20term=hello%20world&literal=a%2Bb%2520")
43+
assert_match(qm, b"literal=a%2Bb%2520&search+term=hello+world")
44+
assert_not_match(qm, b"search+term=hello+world&literal=a+b%20")
45+
46+
3147
def test_qm_boolean():
3248
qm = BooleanQueryMatcher(result=True)
3349
assert_match(qm, b"k1=v1")

0 commit comments

Comments
 (0)