diff --git a/doc/howto.rst b/doc/howto.rst index 91d9c2a..d2049a3 100644 --- a/doc/howto.rst +++ b/doc/howto.rst @@ -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 ------------------ diff --git a/pytest_httpserver/httpserver.py b/pytest_httpserver/httpserver.py index a1788ee..933e23b 100644 --- a/pytest_httpserver/httpserver.py +++ b/pytest_httpserver/httpserver.py @@ -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") @@ -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 diff --git a/tests/examples/test_howto_query_params_encoding.py b/tests/examples/test_howto_query_params_encoding.py new file mode 100644 index 0000000..a79ab3a --- /dev/null +++ b/tests/examples/test_howto_query_params_encoding.py @@ -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() diff --git a/tests/test_querymatcher.py b/tests/test_querymatcher.py index c64799b..56f7120 100644 --- a/tests/test_querymatcher.py +++ b/tests/test_querymatcher.py @@ -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")