Repository navigation
Inconsistent behaviour in query string matching with spaces #370
Copy link
Copy link
Open
Description
It seems like the type (string vs dict) of query_string affects the behaviour of the resulting matcher when the query string in the request has percent-encoding. Specifically, a string typed argument to query_string must match exactly the query string in the request. However, a dict typed argument to query_string must match the request's query string after unquoting.
Reproduced with pytest-httpserver version 1.1.0
test file:
import unittest
import pytest_httpserver
import requests
class TestRepro(unittest.TestCase):
def test_query_string_with_spaces_string_fails(self):
with pytest_httpserver.HTTPServer() as httpserver:
httpserver.expect_request("/test", query_string="foo=bar baz").respond_with_data("OK")
url = httpserver.url_for("/test") + "?foo=bar baz"
requests.get(url)
httpserver.check_assertions()
def test_query_string_is_encoded_string_passes(self):
with pytest_httpserver.HTTPServer() as httpserver:
httpserver.expect_request("/test", query_string="foo=bar%20baz").respond_with_data("OK")
url = httpserver.url_for("/test") + "?foo=bar baz"
requests.get(url)
httpserver.check_assertions()
def test_query_string_with_spaces_dict_passes(self):
with pytest_httpserver.HTTPServer() as httpserver:
httpserver.expect_request("/test", query_string={"foo": "bar baz"}).respond_with_data("OK")
url = httpserver.url_for("/test") + "?foo=bar baz"
requests.get(url)
httpserver.check_assertions()
def test_query_string_is_encoded_dict_fails(self):
with pytest_httpserver.HTTPServer() as httpserver:
httpserver.expect_request("/test", query_string={"foo": "bar%20baz"}).respond_with_data("OK")
url = httpserver.url_for("/test") + "?foo=bar baz"
requests.get(url)
httpserver.check_assertions()Output:
$ python -m unittest repro.py
127.0.0.1 - - [02/Oct/2024 16:36:41] "GET /test?foo=bar%20baz HTTP/1.1" 500 -
F127.0.0.1 - - [02/Oct/2024 16:36:41] "GET /test?foo=bar%20baz HTTP/1.1" 200 -
.127.0.0.1 - - [02/Oct/2024 16:36:41] "GET /test?foo=bar%20baz HTTP/1.1" 200 -
.127.0.0.1 - - [02/Oct/2024 16:36:41] "GET /test?foo=bar%20baz HTTP/1.1" 500 -
F
======================================================================
FAIL: test_query_string_is_encoded_dict_fails (repro.TestRepro.test_query_string_is_encoded_dict_fails)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/home/wmorrison/code/aws_lambda/repro.py", line 37, in test_query_string_is_encoded_dict_fails
httpserver.check_assertions()
File "/home/wmorrison/.local/share/pyenv/versions/3.11.5/envs/3.11.5-enspired@aws_lambda/lib/python3.11/site-packages/pytest_httpserver/httpserver.py", line 818, in check_assertions
raise AssertionError(assertion)
AssertionError: No handler found for request <Request 'http://localhost:38771/test?foo=bar%20baz' [GET]> with data b''.Ordered matchers:
none
Oneshot matchers:
none
Persistent matchers:
<RequestMatcher uri='/test' method='__ALL' query_string={'foo': 'bar%20baz'} headers={} data=None json=<UNDEFINED>>
======================================================================
FAIL: test_query_string_with_spaces_string_fails (repro.TestRepro.test_query_string_with_spaces_string_fails)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/home/wmorrison/code/aws_lambda/repro.py", line 13, in test_query_string_with_spaces_string_fails
httpserver.check_assertions()
File "/home/wmorrison/.local/share/pyenv/versions/3.11.5/envs/3.11.5-enspired@aws_lambda/lib/python3.11/site-packages/pytest_httpserver/httpserver.py", line 818, in check_assertions
raise AssertionError(assertion)
AssertionError: No handler found for request <Request 'http://localhost:35185/test?foo=bar%20baz' [GET]> with data b''.Ordered matchers:
none
Oneshot matchers:
none
Persistent matchers:
<RequestMatcher uri='/test' method='__ALL' query_string='foo=bar baz' headers={} data=None json=<UNDEFINED>>
----------------------------------------------------------------------
Ran 4 tests in 0.014s
FAILED (failures=2)
Metadata
Metadata
Assignees
Labels
No labels
Activity
csernazs commentedon Oct 3, 2024
hi @WillMorrisonEnspi ,
I think this behavior is consistent.
I may misunderstood your issue, but here's my view:
==after encoding.This behaviour is partially documented (and unfortunatelly missing from the built html doc, that will be fixed):
String matching: https://github.com/csernazs/pytest-httpserver/blob/master/pytest_httpserver/httpserver.py#L188
Mapping matching: https://github.com/csernazs/pytest-httpserver/blob/master/pytest_httpserver/httpserver.py#L188
Also, there's a howto about this: https://pytest-httpserver.readthedocs.io/en/latest/howto.html#matching-query-parameters
Here, you specified an already quoted value in the dict, so it got double-quoted in pytest-httpserver:
Here, you specified an unquoted string, which then did't match with the quoted one sent by requests as server compares it with
==.pytest-httpserver provides you an API to define your own query matcher, so the first failed case can be solved by this:
The other case can be solved by this:
Changing querystring matcher in pytest-httpserver to handle your case would be an API breaking, and it would break the current cases.
Zsolt
WillMorrisonEnspi commentedon Oct 3, 2024
This is exactly the part I find inconsistent. Strings must already be quoted, while dict values should not already be quoted.
I did read through the docs, but this was not explained there. The String matcher documents what happens when you pass string vs bytes, and says that the bytes must match the incoming request exactly (that's correct and matches the behaviour from my reproduction test). However, the Mapping matcher docs don't mention anything about quoting. I would read this as
value handling should behave identically to the String matcher", but it doesn't as there is an additional percent-decoding of the query string introduced by the
parse_qslcall.I was also thrown off by the fact that the assertion messages from the Mapping matcher don't show the actual level of percent decoding happening. Compare:
AssertionError: No handler found for request <Request 'http://localhost:38771/test?foo=bar%20baz' [GET]> with data b''and the registered matcher
<RequestMatcher uri='/test' method='__ALL' query_string={'foo': 'bar%20baz'} headers={} data=None json=<UNDEFINED>>Perhaps the Mapping matcher's
__repr__should be updated to show the percent-decoded values if the request is also being percent decoded before comparison.This is a bit tricky to handle, I agree. Options below.
Footnotes
This is already sort of the case. A Mapping matcher value with no percent encoding in it will match requests with either one or no levels of encoding. E.g.
{"foo": "bar baz"}will match a request that is either?foo=bar%20bazor one that is?foo=bar baz. ↩csernazs commentedon Oct 3, 2024
The main idea behind this is that pytest-httpserver cannot quote this:
I also think that interpreting the
&is ambiguous as it is not telling whether the&and others should be quoted or not.One might says it is:
Other might say:
You can say that
&and=are not quoted, though. But then how one can specify&as a value? If they specify it with quoted, then that value will be un-quoted.Yes, I agree document needs to be extended with quoting details.
Thats a good idea.
If existing tests provide
%in their values, then those will start to fail.If you have a test with
{"foo": "%20bar"}then it will fail as%20barwill be handled differently.I think this can be done.
The matcher interface is designed for this, to provide more flexibility. I agree writing
QuotedDictMatcher({"foo": "bar%20baz"})makes the API less human-friendly, but IMHO this would be the way.MappingQueryMatchercould also have an optional kwarg flag, specifying the rules for quoting.Alternatively there would be a
always_quoteparameter, having 3 possible values:That would be the easiest for sure, but the question is how this can be implemented in a way that:
Welp, this also introduces ambiguity, if one wants to check if their client properly quotes the query parameters, then this won't check that.
Zsolt
WillMorrisonEnspi commentedon Oct 3, 2024
Summarizing the conversation so far.
StringQueryMatchershould not change, for the reasons you mentioned around ambiguity about&and=characters that would otherwise arise.MappingQueryMatchershould be updated to mention the percent-decoding that happens to the request's query string before matching, and that this means that the mapping's values should not be percent encoded.__repr__of theRequestMatcher(or theAssertionErrormessage) should be updated to make it clearer why an unhandled request was not matched.MappingQueryMatcherto disambiguate which percent-encoding behaviour is used. The current best suggestion (IMO) is to introduce a new query matcher which requires that the level of percent encoding matches exactly between the mapping values and request query strings, but is still order-independent.If 2. and 3. had already been implemented I would not have filed this bug (because I'd figure out that the behaviour is just the documented API), so I don't consider 4. to be required to resolve this issue. However, at some point I might want to write a test that checks if a client properly quotes query parameters independently of their order, so 4. is still a good feature to add.
csernazs commentedon Oct 3, 2024
Yes, that's correct, I agree with all the points. Thanks for the summary!
I'll do 2..4 points sometime in the near future.
Zsolt