Skip to content

Commit 4bc9c5d

Browse files
authored
Fix matching request data when files are provided (#252)
1 parent 58ad17e commit 4bc9c5d

File tree

5 files changed

+154
-13
lines changed

5 files changed

+154
-13
lines changed

docs/api.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -309,9 +309,9 @@ respx.post("https://example.org/", content__contains="bar")
309309
```
310310

311311
### Data
312-
Matches request *form data*, using [eq](#eq) as default lookup.
312+
Matches request *form data*, excluding files, using [eq](#eq) as default lookup.
313313
> Key: `data`
314-
> Lookups: [eq](#eq)
314+
> Lookups: [eq](#eq), [contains](#contains)
315315
``` python
316316
respx.post("https://example.org/", data={"foo": "bar"})
317317
```

respx/patterns.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525

2626
import httpx
2727

28+
from respx.utils import MultiItems, decode_data
29+
2830
from .types import (
2931
URL as RawURL,
3032
CookieTypes,
@@ -536,14 +538,16 @@ def hash(self, value: Union[str, List, Dict]) -> str:
536538
return jsonlib.dumps(value, sort_keys=True)
537539

538540

539-
class Data(ContentMixin, Pattern):
540-
lookups = (Lookup.EQUAL,)
541+
class Data(MultiItemsMixin, Pattern):
542+
lookups = (Lookup.EQUAL, Lookup.CONTAINS)
541543
key = "data"
542-
value: bytes
544+
value: MultiItems
545+
546+
def clean(self, value: Dict) -> MultiItems:
547+
return MultiItems(value)
543548

544-
def clean(self, value: Dict) -> bytes:
545-
request = httpx.Request("POST", "/", data=value)
546-
data = request.read()
549+
def parse(self, request: httpx.Request) -> Any:
550+
data, _ = decode_data(request)
547551
return data
548552

549553

respx/utils.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import email
2+
from email.message import Message
3+
from typing import List, Tuple, cast
4+
from urllib.parse import parse_qsl
5+
6+
import httpx
7+
8+
9+
class MultiItems(dict):
10+
def get_list(self, key: str) -> List[str]:
11+
try:
12+
return [self[key]]
13+
except KeyError: # pragma: no cover
14+
return []
15+
16+
def multi_items(self) -> List[Tuple[str, str]]:
17+
return list(self.items())
18+
19+
20+
def _parse_multipart_form_data(
21+
content: bytes, *, content_type: str, encoding: str
22+
) -> Tuple[MultiItems, MultiItems]:
23+
form_data = b"\r\n".join(
24+
(
25+
b"MIME-Version: 1.0",
26+
b"Content-Type: " + content_type.encode(encoding),
27+
b"\r\n" + content,
28+
)
29+
)
30+
data = MultiItems()
31+
files = MultiItems()
32+
for payload in email.message_from_bytes(form_data).get_payload():
33+
payload = cast(Message, payload)
34+
name = payload.get_param("name", header="Content-Disposition")
35+
filename = payload.get_filename()
36+
content_type = payload.get_content_type()
37+
value = payload.get_payload(decode=True)
38+
assert isinstance(value, bytes)
39+
if content_type.startswith("text/") and filename is None:
40+
# Text field
41+
data[name] = value.decode(payload.get_content_charset() or "utf-8")
42+
else:
43+
# File field
44+
files[name] = filename, value
45+
46+
return data, files
47+
48+
49+
def _parse_urlencoded_data(content: bytes, *, encoding: str) -> MultiItems:
50+
return MultiItems(
51+
(key, value)
52+
for key, value in parse_qsl(content.decode(encoding), keep_blank_values=True)
53+
)
54+
55+
56+
def decode_data(request: httpx.Request) -> Tuple[MultiItems, MultiItems]:
57+
content = request.read()
58+
content_type = request.headers.get("Content-Type", "")
59+
60+
if content_type.startswith("multipart/form-data"):
61+
data, files = _parse_multipart_form_data(
62+
content,
63+
content_type=content_type,
64+
encoding=request.headers.encoding,
65+
)
66+
else:
67+
data = _parse_urlencoded_data(
68+
content,
69+
encoding=request.headers.encoding,
70+
)
71+
files = MultiItems()
72+
73+
return data, files

tests/test_api.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,15 @@ def test_json_post_body():
263263
assert get_route.called
264264

265265

266+
def test_data_post_body():
267+
with respx.mock:
268+
url = "https://foo.bar/"
269+
route = respx.post(url, data={"foo": "bar"}) % 201
270+
response = httpx.post(url, data={"foo": "bar"}, files={"file": b"..."})
271+
assert response.status_code == 201
272+
assert route.called
273+
274+
266275
async def test_raising_content(client):
267276
async with MockRouter() as respx_mock:
268277
url = "https://foo.bar/"

tests/test_patterns.py

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -323,14 +323,69 @@ def test_content_pattern(lookup, content, expected):
323323

324324

325325
@pytest.mark.parametrize(
326-
("lookup", "data", "expected"),
326+
("lookup", "data", "request_data", "expected"),
327327
[
328-
(Lookup.EQUAL, {"foo": "bar", "ham": "spam"}, True),
328+
(
329+
Lookup.EQUAL,
330+
{"foo": "bar", "ham": "spam"},
331+
None,
332+
True,
333+
),
334+
(
335+
Lookup.EQUAL,
336+
{"foo": "bar", "ham": "spam"},
337+
{"ham": "spam", "foo": "bar"},
338+
True,
339+
),
340+
(
341+
Lookup.EQUAL,
342+
{"uni": "äpple", "mixed": "Gehäusegröße"},
343+
None,
344+
True,
345+
),
346+
(
347+
Lookup.EQUAL,
348+
{"blank_value": ""},
349+
None,
350+
True,
351+
),
352+
(
353+
Lookup.EQUAL,
354+
{"x": "a"},
355+
{"x": "b"},
356+
False,
357+
),
358+
(
359+
Lookup.EQUAL,
360+
{"foo": "bar"},
361+
{"foo": "bar", "ham": "spam"},
362+
False,
363+
),
364+
(
365+
Lookup.CONTAINS,
366+
{"foo": "bar"},
367+
{"foo": "bar", "ham": "spam"},
368+
True,
369+
),
329370
],
330371
)
331-
def test_data_pattern(lookup, data, expected):
332-
request = httpx.Request("POST", "https://foo.bar/", data=data)
333-
match = Data(data, lookup=lookup).match(request)
372+
def test_data_pattern(lookup, data, request_data, expected):
373+
request_with_data = httpx.Request(
374+
"POST",
375+
"https://foo.bar/",
376+
data=request_data or data,
377+
)
378+
request_with_data_and_files = httpx.Request(
379+
"POST",
380+
"https://foo.bar/",
381+
data=request_data or data,
382+
files={"upload-file": ("report.xls", b"<...>", "application/vnd.ms-excel")},
383+
)
384+
385+
match = Data(data, lookup=lookup).match(request_with_data)
386+
assert bool(match) is expected
387+
388+
match = Data(data, lookup=lookup).match(request_with_data_and_files)
334389
assert bool(match) is expected
335390

336391

0 commit comments

Comments
 (0)