Skip to content

Commit 005ee98

Browse files
Match parsed form fields in request expectations
1 parent 7c6b723 commit 005ee98

8 files changed

Lines changed: 186 additions & 8 deletions

File tree

‎doc/howto.rst‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,32 @@ including an empty value. A ``werkzeug.datastructures.MultiDict`` matches all
5555
values, including empty ones.
5656

5757

58+
Matching form data
59+
------------------
60+
61+
Use ``data_form`` to match parsed form fields without depending on their encoded
62+
order or spelling:
63+
64+
.. code-block:: python
65+
66+
httpserver.expect_request(
67+
"/form", method="POST", data_form={"name": "Jane Doe", "flag": ""}
68+
).respond_with_data("ok")
69+
response = requests.post(
70+
httpserver.url_for("/form"), data={"flag": "", "name": "Jane Doe"}
71+
)
72+
assert response.text == "ok"
73+
74+
A dictionary matches the first value for each field. Use a
75+
``werkzeug.datastructures.MultiDict`` to match all values of repeated fields.
76+
Extra fields do not match, and an empty value is distinct from a missing field.
77+
78+
Werkzeug parses form fields using the request's ``Content-Type`` header.
79+
``data_form={}`` matches requests with no parsed form fields; use ``headers``
80+
if a particular content type is required. ``data_form`` is mutually exclusive
81+
with ``data`` and ``json``. The raw body remains available to response handlers.
82+
83+
5884
URI matching
5985
------------
6086

‎pytest_httpserver/blocking_httpserver.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ def assert_request(
8080
header_value_matcher: HeaderValueMatcher | None = None,
8181
json: Any = UNDEFINED,
8282
timeout: int = 30,
83+
*,
84+
data_form: Mapping[str, str] | None = None,
8385
) -> BlockingRequestHandler:
8486
"""
8587
Wait for an incoming request and check whether it matches according to the given parameters.
@@ -109,10 +111,13 @@ def assert_request(
109111
is loaded as json. If load fails, this matcher will be failed also. *Content-Type* is not checked.
110112
If that's desired, add it to the headers parameter.
111113
:param timeout: waiting time in seconds for an incoming request.
114+
:param data_form: expected form fields, parsed by werkzeug using the request's *Content-Type*.
115+
A mapping matches the first value for each field; a ``MultiDict`` matches all values.
116+
Mutually exclusive with `data` and `json`.
112117
113118
:return: Created and registered :py:class:`BlockingRequestHandler`.
114119
115-
Parameters `json` and `data` are mutually exclusive.
120+
Parameters `json`, `data`, and `data_form` are mutually exclusive.
116121
"""
117122

118123
matcher = self.create_matcher(
@@ -124,6 +129,7 @@ def assert_request(
124129
query_string=query_string,
125130
header_value_matcher=header_value_matcher,
126131
json=json,
132+
**({"data_form": data_form} if data_form is not None else {}),
127133
)
128134

129135
try:

‎pytest_httpserver/httpserver.py‎

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,7 @@ class RequestMatcherKwargs(TypedDict, total=False):
304304
query_string: QueryMatcher | str | bytes | Mapping[str, str] | None
305305
header_value_matcher: HVMATCHER_T | None
306306
json: Any
307+
data_form: Mapping[str, str] | None
307308

308309

309310
class RequestMatcher:
@@ -333,6 +334,9 @@ class RequestMatcher:
333334
:param json: a python object (eg. a dict) whose value will be compared to the request body after it
334335
is loaded as json. If load fails, this matcher will be failed also. *Content-Type* is not checked.
335336
If that's desired, add it to the headers parameter.
337+
:param data_form: expected form fields, parsed by werkzeug using the request's *Content-Type*.
338+
A mapping matches the first value for each field; a ``MultiDict`` matches all values.
339+
Mutually exclusive with `data` and `json`.
336340
"""
337341

338342
def __init__(
@@ -345,15 +349,20 @@ def __init__(
345349
query_string: QueryMatcher | str | bytes | Mapping[str, str] | None = None,
346350
header_value_matcher: HVMATCHER_T | None = None,
347351
json: Any = UNDEFINED,
352+
*,
353+
data_form: Mapping[str, str] | None = None,
348354
) -> None:
349355
if json is not UNDEFINED and data is not None:
350356
raise ValueError("data and json parameters are mutually exclusive")
357+
if data_form is not None and (data is not None or json is not UNDEFINED):
358+
raise ValueError("data_form is mutually exclusive with data and json")
351359

352360
self.uri = uri
353361
self.method = method
354362
self.query_string = query_string
355363
self.query_matcher = _create_query_matcher(self.query_string)
356364
self.json = json
365+
self.data_form = data_form
357366

358367
self.headers: Mapping[str, str] = {}
359368
if headers is not None:
@@ -379,9 +388,11 @@ def __repr__(self) -> str:
379388
retval = "<{} ".format(class_name)
380389
retval += (
381390
"uri={uri!r} method={method!r} query_string={query_string!r} "
382-
"headers={headers!r} data={data!r} json={json!r}>"
391+
"headers={headers!r} data={data!r} json={json!r}"
383392
).format_map(self.__dict__)
384-
return retval
393+
if self.data_form is not None:
394+
retval += f" data_form={self.data_form!r}"
395+
return retval + ">"
385396

386397
def match_data(self, request: Request) -> bool:
387398
"""
@@ -440,6 +451,17 @@ def match_json(self, request: Request) -> bool:
440451

441452
return bool(json_received == self.json)
442453

454+
def match_data_form(self, request: Request) -> bool:
455+
"""Match parsed form fields, or accept any form when no expectation is set."""
456+
if self.data_form is None:
457+
return True
458+
459+
# Cache the raw body before form parsing so response handlers can still read it.
460+
request.get_data()
461+
if isinstance(self.data_form, MultiDict):
462+
return bool(request.form == self.data_form)
463+
return request.form.to_dict() == dict(self.data_form)
464+
443465
def difference(self, request: Request) -> list[tuple[str, str, str | URIPattern]]:
444466
"""
445467
Calculates the difference between the matcher and the request.
@@ -478,6 +500,8 @@ def difference(self, request: Request) -> list[tuple[str, str, str | URIPattern]
478500

479501
if not self.match_json(request):
480502
retval.append(("json", request.data, self.json))
503+
if not self.match_data_form(request):
504+
retval.append(("data_form", request.form, self.data_form))
481505
return retval
482506

483507
def match(self, request: Request) -> bool:
@@ -603,9 +627,11 @@ def __repr__(self) -> str:
603627
retval = (
604628
f"<{class_name} uri={self.matcher.uri!r} method={self.matcher.method!r} "
605629
f"query_string={self.matcher.query_string!r} headers={self.matcher.headers!r} data={self.matcher.data!r} "
606-
f"json={self.matcher.json!r}>"
630+
f"json={self.matcher.json!r}"
607631
)
608-
return retval
632+
if self.matcher.data_form is not None:
633+
retval += f" data_form={self.matcher.data_form!r}"
634+
return retval + ">"
609635

610636

611637
class RequestHandlerList(list[RequestHandler]):
@@ -1087,6 +1113,8 @@ def expect_request(
10871113
header_value_matcher: HVMATCHER_T | None = None,
10881114
handler_type: HandlerType = HandlerType.PERMANENT,
10891115
json: Any = UNDEFINED,
1116+
*,
1117+
data_form: Mapping[str, str] | None = None,
10901118
) -> RequestHandler:
10911119
"""
10921120
Create and register a request handler.
@@ -1134,10 +1162,13 @@ def expect_request(
11341162
:param json: a python object (eg. a dict) whose value will be compared to the request body after it
11351163
is loaded as json. If load fails, this matcher will be failed also. *Content-Type* is not checked.
11361164
If that's desired, add it to the headers parameter.
1165+
:param data_form: expected form fields, parsed by werkzeug using the request's *Content-Type*.
1166+
A mapping matches the first value for each field; a ``MultiDict`` matches all values.
1167+
Mutually exclusive with `data` and `json`.
11371168
11381169
:return: Created and register :py:class:`RequestHandler`.
11391170
1140-
Parameters `json` and `data` are mutually exclusive.
1171+
Parameters `json`, `data`, and `data_form` are mutually exclusive.
11411172
"""
11421173

11431174
matcher = self.create_matcher(
@@ -1149,6 +1180,7 @@ def expect_request(
11491180
query_string=query_string,
11501181
header_value_matcher=header_value_matcher,
11511182
json=json,
1183+
**({"data_form": data_form} if data_form is not None else {}),
11521184
)
11531185
request_handler = RequestHandler(matcher)
11541186
if handler_type == HandlerType.PERMANENT:
@@ -1169,6 +1201,8 @@ def expect_oneshot_request(
11691201
query_string: QueryMatcher | str | bytes | Mapping[str, str] | None = None,
11701202
header_value_matcher: HVMATCHER_T | None = None,
11711203
json: Any = UNDEFINED,
1204+
*,
1205+
data_form: Mapping[str, str] | None = None,
11721206
) -> RequestHandler:
11731207
"""
11741208
Create and register a oneshot request handler.
@@ -1196,10 +1230,13 @@ def expect_oneshot_request(
11961230
:param json: a python object (eg. a dict) whose value will be compared to the request body after it
11971231
is loaded as json. If load fails, this matcher will be failed also. *Content-Type* is not checked.
11981232
If that's desired, add it to the headers parameter.
1233+
:param data_form: expected form fields, parsed by werkzeug using the request's *Content-Type*.
1234+
A mapping matches the first value for each field; a ``MultiDict`` matches all values.
1235+
Mutually exclusive with `data` and `json`.
11991236
12001237
:return: Created and register :py:class:`RequestHandler`.
12011238
1202-
Parameters `json` and `data` are mutually exclusive.
1239+
Parameters `json`, `data`, and `data_form` are mutually exclusive.
12031240
"""
12041241

12051242
return self.expect_request(
@@ -1212,6 +1249,7 @@ def expect_oneshot_request(
12121249
header_value_matcher=header_value_matcher,
12131250
handler_type=HandlerType.ONESHOT,
12141251
json=json,
1252+
**({"data_form": data_form} if data_form is not None else {}),
12151253
)
12161254

12171255
def expect_ordered_request(
@@ -1224,6 +1262,8 @@ def expect_ordered_request(
12241262
query_string: QueryMatcher | str | bytes | Mapping[str, str] | None = None,
12251263
header_value_matcher: HVMATCHER_T | None = None,
12261264
json: Any = UNDEFINED,
1265+
*,
1266+
data_form: Mapping[str, str] | None = None,
12271267
) -> RequestHandler:
12281268
"""
12291269
Create and register a ordered request handler.
@@ -1251,10 +1291,13 @@ def expect_ordered_request(
12511291
:param json: a python object (eg. a dict) whose value will be compared to the request body after it
12521292
is loaded as json. If load fails, this matcher will be failed also. *Content-Type* is not checked.
12531293
If that's desired, add it to the headers parameter.
1294+
:param data_form: expected form fields, parsed by werkzeug using the request's *Content-Type*.
1295+
A mapping matches the first value for each field; a ``MultiDict`` matches all values.
1296+
Mutually exclusive with `data` and `json`.
12541297
12551298
:return: Created and register :py:class:`RequestHandler`.
12561299
1257-
Parameters `json` and `data` are mutually exclusive.
1300+
Parameters `json`, `data`, and `data_form` are mutually exclusive.
12581301
"""
12591302

12601303
return self.expect_request(
@@ -1267,6 +1310,7 @@ def expect_ordered_request(
12671310
header_value_matcher=header_value_matcher,
12681311
handler_type=HandlerType.ORDERED,
12691312
json=json,
1313+
**({"data_form": data_form} if data_form is not None else {}),
12701314
)
12711315

12721316
def format_matchers(self) -> str:
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
features:
2+
- |
3+
Add the ``data_form`` keyword argument to request expectations and
4+
``BlockingHTTPServer.assert_request()`` for matching parsed form fields.
5+
A mapping matches each field's first value, while a Werkzeug ``MultiDict``
6+
matches all repeated values. Form expectations are mutually exclusive with
7+
``data`` and ``json`` expectations, and preserve the raw request body for
8+
response handlers.

‎tests/test_bake.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,17 @@ def test_bake_with_headers(httpserver: HTTPServer) -> None:
2020
assert response.json() == {"result": "ok"}
2121

2222

23+
def test_bake_with_form(httpserver: HTTPServer) -> None:
24+
server = httpserver.bake(method="POST", data_form={"foo": "bar"})
25+
server.expect_request("/form").respond_with_data("ok")
26+
27+
response = requests.post(server.url_for("/form"), data={"foo": "bar"})
28+
29+
assert response.status_code == 200
30+
assert response.text == "ok"
31+
httpserver.check()
32+
33+
2334
@pytest.mark.parametrize(
2435
("bake_chain", "expect_kwargs", "request_method"),
2536
[

‎tests/test_blocking_httpserver.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,19 @@ def test_behave_workflow(httpserver: BlockingHTTPServer):
6868
then_the_response_is_got_from(server_connection, response)
6969

7070

71+
def test_form_request(httpserver: BlockingHTTPServer):
72+
request = dict(method="POST", url=httpserver.url_for("/form"), data={"foo": "bar"})
73+
74+
with when_a_request_is_being_sent_to_the_server(request) as server_connection:
75+
handler = httpserver.assert_request("/form", method="POST", data_form={"foo": "bar"})
76+
handler.respond_with_data("ok")
77+
78+
response = server_connection.get(timeout=9)
79+
assert response.status_code == 200
80+
assert response.text == "ok"
81+
httpserver.check()
82+
83+
7184
def test_raises_assertion_error_when_request_does_not_match(httpserver: BlockingHTTPServer):
7285
request = dict(
7386
method="GET",

‎tests/test_form_matcher.py‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import pytest
2+
import requests
3+
from werkzeug import Request
4+
from werkzeug import Response
5+
from werkzeug.datastructures import MultiDict
6+
7+
from pytest_httpserver import HTTPServer
8+
from pytest_httpserver.httpserver import RequestMatcher
9+
10+
11+
@pytest.mark.parametrize("expect_method", ["expect_request", "expect_oneshot_request", "expect_ordered_request"])
12+
def test_form_matcher_request(httpserver: HTTPServer, expect_method: str):
13+
form = MultiDict([("tag", "first"), ("tag", "second"), ("name", "José + %"), ("flag", "")])
14+
expect = getattr(httpserver, expect_method)
15+
expect("/form", method="POST", data_form=form).respond_with_data("ok")
16+
17+
response = requests.post(httpserver.url_for("/form"), data=list(form.items(multi=True)))
18+
19+
assert response.status_code == 200
20+
assert response.text == "ok"
21+
httpserver.check()
22+
23+
24+
@pytest.mark.parametrize(
25+
("expected", "body", "matches"),
26+
[
27+
({"foo": "bar"}, "foo=bar&foo=other", True),
28+
({"foo": "other"}, "foo=bar&foo=other", False),
29+
({"foo": "bar"}, "foo=bar&extra=value", False),
30+
({"flag": ""}, "flag=", True),
31+
({"flag": ""}, "", False),
32+
({}, "", True),
33+
(MultiDict([("tag", "first"), ("tag", "second")]), "tag=first&tag=second", True),
34+
(MultiDict([("tag", "first"), ("tag", "second")]), "tag=first", False),
35+
],
36+
)
37+
def test_form_matcher_values(expected, body: str, matches: bool): # noqa: FBT001
38+
request = Request.from_values(method="POST", data=body, content_type="application/x-www-form-urlencoded")
39+
matcher = RequestMatcher("/", data_form=expected)
40+
41+
assert matcher.match(request) is matches
42+
if not matches:
43+
assert matcher.difference(request) == [("data_form", request.form, expected)]
44+
assert "data_form=" in repr(matcher)
45+
46+
47+
def test_form_matcher_needs_form_content_type():
48+
request = Request.from_values(method="POST", data="foo=bar", content_type="text/plain")
49+
assert not RequestMatcher("/", data_form={"foo": "bar"}).match(request)
50+
51+
52+
def test_form_matcher_preserves_request_body(httpserver: HTTPServer):
53+
def handler(request: Request) -> Response:
54+
assert request.form["foo"] == "bar"
55+
return Response(request.get_data())
56+
57+
httpserver.expect_request("/form", data_form={"foo": "bar"}).respond_with_handler(handler)
58+
59+
response = requests.post(httpserver.url_for("/form"), data={"foo": "bar"})
60+
61+
assert response.status_code == 200
62+
assert response.content == b"foo=bar"
63+
httpserver.check()
64+
65+
66+
@pytest.mark.parametrize("other_body", [{"data": "foo=bar"}, {"json": {}}])
67+
def test_form_matcher_body_parameters_mutually_exclusive(httpserver: HTTPServer, other_body):
68+
with pytest.raises(ValueError, match="mutually exclusive"):
69+
httpserver.expect_request("/form", data_form={}, **other_body)

‎tests/test_release.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ def test_sdist_contents(build: Build, version: str):
219219
"examples",
220220
"test_bake.py",
221221
"test_blocking_httpserver.py",
222+
"test_form_matcher.py",
222223
"test_handler_errors.py",
223224
"test_headers.py",
224225
"test_hooks.py",

0 commit comments

Comments
 (0)