Skip to content

Commit 195fe3b

Browse files
committed
httpserver: add query matcher feature
If query_string is specified as a Mapping (dict), the query string of the request will be parsed and will be matched with the dictionary specified. When a key has multiple values in the request (eg. "foo=bar&foo=baz"), the first item will be used. If it is desired to match all the values from the same key, use werkzeug.datastructures.MultiDict instance, as it can hold multiple values for the same key.
1 parent c3d4b76 commit 195fe3b

3 files changed

Lines changed: 151 additions & 12 deletions

File tree

pytest_httpserver/httpserver.py

Lines changed: 83 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,14 @@
99
from copy import copy
1010
from typing import Callable, Mapping, Optional, Union
1111
from ssl import SSLContext
12+
import urllib.parse
13+
import abc
1214

1315
from werkzeug.http import parse_authorization_header
1416
from werkzeug.serving import make_server
1517
from werkzeug.wrappers import Request, Response
18+
import werkzeug.urls
19+
from werkzeug.datastructures import MultiDict
1620

1721
URI_DEFAULT = ""
1822
METHOD_ALL = "__ALL"
@@ -123,6 +127,83 @@ def __call__(self, header_name: str, actual: str, expected: str) -> bool:
123127
)
124128

125129

130+
class QueryMatcher(abc.ABC):
131+
def match(self, request_query_string: bytes) -> bool:
132+
values = self.get_comparing_values(request_query_string)
133+
return values[0] == values[1]
134+
135+
@abc.abstractmethod
136+
def get_comparing_values(self, request_query_string: bytes) -> tuple:
137+
pass
138+
139+
class StringQueryMatcher(QueryMatcher):
140+
def __init__(self, query_string: Union[bytes, str]):
141+
if query_string is not None and not isinstance(query_string, (str, bytes)):
142+
raise TypeError("query_string must be a string, or a bytes-like object")
143+
144+
self.query_string = query_string
145+
146+
def get_comparing_values(self, request_query_string: bytes) -> tuple:
147+
if self.query_string is not None:
148+
if isinstance(self.query_string, str):
149+
query_string = self.query_string.encode()
150+
elif isinstance(self.query_string, bytes):
151+
query_string = self.query_string
152+
else:
153+
raise TypeError("query_string must be a string, or a bytes-like object")
154+
155+
return (request_query_string, query_string)
156+
157+
158+
class MappingQueryMatcher(QueryMatcher):
159+
def __init__(self, query_dict: [Mapping, MultiDict]):
160+
self.query_dict = query_dict
161+
162+
def get_comparing_values(self, request_query_string: bytes) -> tuple:
163+
query = werkzeug.urls.url_decode(request_query_string)
164+
if isinstance(self.query_dict, MultiDict):
165+
return (query, self.query_dict)
166+
else:
167+
return (query.to_dict(), dict(self.query_dict))
168+
169+
170+
class BooleanQueryMatcher(QueryMatcher):
171+
def __init__(self, result: bool):
172+
self.result = result
173+
174+
def get_comparing_values(self, request_query_string):
175+
if self.result:
176+
return (True, True)
177+
else:
178+
return (True, False)
179+
180+
181+
def _get_dict_type(d: Mapping, default=bytes):
182+
try:
183+
first_key = next(iter(d.keys()))
184+
key_type = type(first_key)
185+
except StopIteration:
186+
key_type = default
187+
188+
return key_type
189+
190+
191+
def _create_query_matcher(query_string: Union[None, QueryMatcher, str, bytes, Mapping]) -> QueryMatcher:
192+
if isinstance(query_string, QueryMatcher):
193+
return query_string
194+
195+
if query_string is None:
196+
return BooleanQueryMatcher(True)
197+
198+
if isinstance(query_string, (str, bytes)):
199+
return StringQueryMatcher(query_string)
200+
201+
if isinstance(query_string, Mapping):
202+
return MappingQueryMatcher(query_string)
203+
204+
raise TypeError("Unable to cast this type to QueryMatcher: {!r}".format(type(query_string)))
205+
206+
126207
class RequestMatcher:
127208
"""
128209
Matcher object for the incoming request.
@@ -151,12 +232,10 @@ def __init__(
151232
query_string: Union[None, bytes, str] = None,
152233
header_value_matcher: Optional[HeaderValueMatcher] = None):
153234

154-
if query_string is not None and not isinstance(query_string, (str, bytes)):
155-
raise TypeError("query_string must be a string, or a bytes-like object")
156-
157235
self.uri = uri
158236
self.method = method
159237
self.query_string = query_string
238+
self.query_matcher = _create_query_matcher(self.query_string)
160239

161240
if headers is None:
162241
self.headers = {}
@@ -211,15 +290,7 @@ def difference(self, request: Request) -> list:
211290
if self.method != METHOD_ALL and self.method != request.method:
212291
retval.append(("method", request.method, self.method))
213292

214-
if self.query_string is not None:
215-
if isinstance(self.query_string, str):
216-
query_string = self.query_string.encode()
217-
elif isinstance(self.query_string, bytes):
218-
query_string = self.query_string
219-
else:
220-
raise TypeError("query_string must be a string, or a bytes-like object")
221-
222-
if self.query_string is not None and query_string != request.query_string:
293+
if not self.query_matcher.match(request.query_string):
223294
retval.append(("query_string", request.query_string, self.query_string))
224295

225296
request_headers = {}

tests/test_querymatcher.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
from pytest_httpserver.httpserver import StringQueryMatcher, BooleanQueryMatcher, MappingQueryMatcher
2+
from werkzeug.datastructures import MultiDict
3+
4+
5+
def assert_match(qm, query_string):
6+
values = qm.get_comparing_values(query_string)
7+
assert values[0] == values[1]
8+
9+
10+
def assert_not_match(qm, query_string):
11+
values = qm.get_comparing_values(query_string)
12+
assert values[0] != values[1]
13+
14+
15+
def test_qm_string():
16+
qm = StringQueryMatcher("k1=v1&k2=v2")
17+
assert_match(qm, b"k1=v1&k2=v2")
18+
assert_not_match(qm, b"k2=v2&k1=v1")
19+
20+
21+
def test_qm_bytes():
22+
qm = StringQueryMatcher(b"k1=v1&k2=v2")
23+
assert_match(qm, b"k1=v1&k2=v2")
24+
assert_not_match(qm, b"k2=v2&k1=v1")
25+
26+
27+
def test_qm_boolean():
28+
qm = BooleanQueryMatcher(True)
29+
assert_match(qm, b"k1=v1")
30+
31+
32+
def test_qm_mapping_string():
33+
qm = MappingQueryMatcher({"k1": "v1"})
34+
assert_match(qm, b"k1=v1")
35+
36+
37+
def test_qm_mapping_unordered():
38+
qm = MappingQueryMatcher({"k1": "v1", "k2": "v2"})
39+
assert_match(qm, b"k1=v1&k2=v2")
40+
assert_match(qm, b"k2=v2&k1=v1")
41+
42+
43+
def test_qm_mapping_first_value():
44+
qm = MappingQueryMatcher({"k1": "v1"})
45+
assert_match(qm, b"k1=v1&k1=v2")
46+
47+
qm = MappingQueryMatcher({"k1": "v2"})
48+
assert_match(qm, b"k1=v2&k1=v1")
49+
50+
51+
def test_qm_mapping_multiple_values():
52+
md = MultiDict([("k1", "v1"), ("k1", "v2")])
53+
qm = MappingQueryMatcher(md)
54+
assert_match(qm, b"k1=v1&k1=v2")

tests/test_querystring.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,17 @@ def test_querystring_bytes(httpserver: HTTPServer):
2121
httpserver.check_assertions()
2222
assert response.text == "example_response"
2323
assert response.status_code == 200
24+
25+
def test_querystring_dict(httpserver: HTTPServer):
26+
httpserver.expect_request("/foobar", query_string={"k1": "v1", "k2": "v2"}, method="GET").respond_with_data(
27+
"example_response"
28+
)
29+
response = requests.get(httpserver.url_for("/foobar?k1=v1&k2=v2"))
30+
httpserver.check_assertions()
31+
assert response.text == "example_response"
32+
assert response.status_code == 200
33+
34+
response = requests.get(httpserver.url_for("/foobar?k2=v2&k1=v1"))
35+
httpserver.check_assertions()
36+
assert response.text == "example_response"
37+
assert response.status_code == 200

0 commit comments

Comments
 (0)