Skip to content

Commit 25e9214

Browse files
committed
httpserver: json matcher
Add the new parameter 'json' to the expect* functions: expect_request expect_oneshot_request expect_ordered_request This will match the body of the request, in a way that the request body will be first loaded as json, then it will be compared to the value specified for the parameter. Fixes #41.
1 parent c008af6 commit 25e9214

5 files changed

Lines changed: 173 additions & 7 deletions

File tree

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ the pre-defined expected http requests and their responses.
1616

1717
### Example
1818

19+
#### Handling a simple GET request
1920
```python
2021
def test_my_client(httpserver): # httpserver is a pytest fixture which starts the server
2122
# set up the server to serve /foobar with the json
@@ -24,6 +25,16 @@ def test_my_client(httpserver): # httpserver is a pytest fixture which starts th
2425
assert requests.get(httpserver.url_for("/foobar")).json() == {'foo': 'bar'}
2526
```
2627

28+
#### Handing a POST request with an expected json body
29+
```python
30+
def test_json_request(httpserver): # httpserver is a pytest fixture which starts the server
31+
# set up the server to serve /foobar with the json
32+
httpserver.expect_request("/foobar", method="POST", json={"id": 12, "name": "foo"}).respond_with_json({"foo": "bar"})
33+
# check that the request is served
34+
assert requests.post(httpserver.url_for("/foobar"), json={"id": 12, "name": "foo"}).json() == {'foo': 'bar'}
35+
```
36+
37+
2738
You can also use the library without pytest. There's a with statement to ensure that the server is stopped.
2839

2940

doc/howto.rst

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,51 @@ order of the parameters in the ``Authorization`` header does not matter.
153153
assert response.text == 'OK'
154154
155155
156+
JSON matching
157+
-------------
158+
159+
Matching the request data can be done in two different ways. One way is to
160+
provide a python string (or bytes object) whose value will be compared to the
161+
request body.
162+
163+
When the request contains a json, matching to will be error prone as an object
164+
can be represented as json in different ways, for example when different length
165+
of indentation is used.
166+
167+
To match the body as json, you need to add the python data structure (which
168+
could be dict, list or anything which can be the result of `json.loads()` call).
169+
The request's body will be loaded as json and the result will be compared to the
170+
provided object. If the request's body cannot be loaded as json, the matcher
171+
will fail and *pytest-httpserver* will proceed with the next registered matcher.
172+
173+
Example:
174+
175+
.. code:: python
176+
177+
def test_json_matcher(httpserver: HTTPServer):
178+
httpserver.expect_request("/foo", json={"foo": "bar"}).respond_with_data("Hello world!")
179+
resp = requests.get(httpserver.url_for("/foo"), json={"foo": "bar"})
180+
assert resp.status_code == 200
181+
assert resp.text == "Hello world!"
182+
183+
184+
.. note::
185+
JSON requests usually come with ``Content-Type: application/json`` header.
186+
*pytest-httpserver* provides the *headers* parameter to match the headers of
187+
the request, however matching json body does not imply matching the
188+
*Content-Type* header. If matching the header is intended, specify the expected
189+
*Content-Type* header and its value to the headers parameter.
190+
191+
.. note::
192+
*json* and *data* parameters are mutually exclusive so both of then cannot
193+
be specified as in such case the behavior is ambiguous.
194+
195+
.. note::
196+
The request body is decoded by using the *data_encoding* parameter, which is
197+
default to *utf-8*. If the request comes in a different encoding, and the
198+
decoding fails, the request won't match with the expected json.
199+
200+
156201
Advanced header matching
157202
------------------------
158203

pytest_httpserver/httpserver.py

Lines changed: 71 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,30 @@
88
from enum import Enum
99
from contextlib import suppress, contextmanager
1010
from copy import copy
11-
from typing import Callable, Mapping, Optional, Union, Pattern
11+
from typing import Any, Callable, Mapping, Optional, Union, Pattern
1212
from ssl import SSLContext
1313
import abc
1414

1515
from werkzeug.http import parse_authorization_header
1616
from werkzeug.serving import make_server
17-
from werkzeug.wrappers import Request, Response
17+
from werkzeug.wrappers import Request
18+
from werkzeug.wrappers import Response
19+
1820
import werkzeug.urls
1921
from werkzeug.datastructures import MultiDict
2022

2123
URI_DEFAULT = ""
2224
METHOD_ALL = "__ALL"
2325

2426

27+
class Undefined:
28+
def __repr__(self):
29+
return "<UNDEFINED>"
30+
31+
32+
UNDEFINED = Undefined()
33+
34+
2535
class Error(Exception):
2636
"""
2737
Base class for all exception defined in this package.
@@ -273,12 +283,17 @@ def __init__(
273283
data_encoding: str = "utf-8",
274284
headers: Optional[Mapping[str, str]] = None,
275285
query_string: Union[None, QueryMatcher, str, bytes, Mapping] = None,
276-
header_value_matcher: Optional[HeaderValueMatcher] = None):
286+
header_value_matcher: Optional[HeaderValueMatcher] = None,
287+
json: Any = UNDEFINED):
288+
289+
if json is not UNDEFINED and data is not None:
290+
raise ValueError("data and json parameters are mutually exclusive")
277291

278292
self.uri = uri
279293
self.method = method
280294
self.query_string = query_string
281295
self.query_matcher = _create_query_matcher(self.query_string)
296+
self.json = json
282297

283298
if headers is None:
284299
self.headers = {}
@@ -289,6 +304,7 @@ def __init__(
289304
data = data.encode(data_encoding)
290305

291306
self.data = data
307+
self.data_encoding = data_encoding
292308

293309
self.header_value_matcher = HeaderValueMatcher() if header_value_matcher is None else header_value_matcher
294310

@@ -299,7 +315,8 @@ def __repr__(self):
299315

300316
class_name = self.__class__.__name__
301317
retval = "<{} ".format(class_name)
302-
retval += "uri={uri!r} method={method!r} query_string={query_string!r} headers={headers!r} data={data!r}>".format_map(self.__dict__)
318+
retval += "uri={uri!r} method={method!r} query_string={query_string!r} headers={headers!r} data={data!r} json={json!r}>".format_map(
319+
self.__dict__)
303320
return retval
304321

305322
def match_data(self, request: Request) -> bool:
@@ -335,6 +352,30 @@ def match_uri(self, request: Request) -> bool:
335352

336353
return self.uri == URI_DEFAULT or path == self.uri
337354

355+
def match_json(self, request: Request) -> bool:
356+
"""
357+
Matches the request data as json.
358+
359+
Load the request data as json and compare it to self.json which is a
360+
json-serializable data structure (eg. a dict or list).
361+
362+
:param request: the HTTP request
363+
:return: `True` when the data is matched or no matching is required. `False` otherwise.
364+
"""
365+
if self.json is UNDEFINED:
366+
return True
367+
368+
try:
369+
# do the decoding here as python 3.5 requires string and does not
370+
# accept bytes
371+
json_received = json.loads(request.data.decode(self.data_encoding))
372+
except json.JSONDecodeError:
373+
return False
374+
except UnicodeDecodeError:
375+
return False
376+
377+
return json_received == self.json
378+
338379
def difference(self, request: Request) -> list:
339380
"""
340381
Calculates the difference between the matcher and the request.
@@ -371,6 +412,8 @@ def difference(self, request: Request) -> list:
371412
if not self.match_data(request):
372413
retval.append(("data", request.data, self.data))
373414

415+
if not self.match_json(request):
416+
retval.append(("json", request.data, self.json))
374417
return retval
375418

376419
def match(self, request: Request) -> bool:
@@ -614,7 +657,8 @@ def expect_request(
614657
headers: Optional[Mapping[str, str]] = None,
615658
query_string: Union[None, QueryMatcher, str, bytes, Mapping] = None,
616659
header_value_matcher: Optional[HeaderValueMatcher] = None,
617-
handler_type: HandlerType = HandlerType.PERMANENT) -> RequestHandler:
660+
handler_type: HandlerType = HandlerType.PERMANENT,
661+
json: Any = UNDEFINED) -> RequestHandler:
618662
"""
619663
Create and register a request handler.
620664
@@ -647,8 +691,13 @@ def expect_request(
647691
object from werkzeug.
648692
:param header_value_matcher: :py:class:`HeaderValueMatcher` that matches values of headers.
649693
:param handler_type: type of handler
694+
:param json: a python object (eg. a dict) whose value will be compared to the request body after it
695+
is loaded as json. If load fails, this matcher will be failed also. *Content-Type* is not checked.
696+
If that's desired, add it to the headers parameter.
650697
651698
:return: Created and register :py:class:`RequestHandler`.
699+
700+
Parameters `json` and `data` are mutually exclusive.
652701
"""
653702

654703
matcher = self.create_matcher(
@@ -659,6 +708,7 @@ def expect_request(
659708
headers=headers,
660709
query_string=query_string,
661710
header_value_matcher=header_value_matcher,
711+
json=json,
662712
)
663713
request_handler = RequestHandler(matcher)
664714
if handler_type == HandlerType.PERMANENT:
@@ -677,7 +727,8 @@ def expect_oneshot_request(
677727
data_encoding: str = "utf-8",
678728
headers: Optional[Mapping[str, str]] = None,
679729
query_string: Union[None, QueryMatcher, str, bytes, Mapping] = None,
680-
header_value_matcher: Optional[HeaderValueMatcher] = None) -> RequestHandler:
730+
header_value_matcher: Optional[HeaderValueMatcher] = None,
731+
json: Any = UNDEFINED) -> RequestHandler:
681732
"""
682733
Create and register a oneshot request handler.
683734
@@ -698,8 +749,13 @@ def expect_oneshot_request(
698749
value will be used. If multiple values needed to be handled, use ``MultiDict``
699750
object from werkzeug.
700751
:param header_value_matcher: :py:class:`HeaderValueMatcher` that matches values of headers.
752+
:param json: a python object (eg. a dict) whose value will be compared to the request body after it
753+
is loaded as json. If load fails, this matcher will be failed also. *Content-Type* is not checked.
754+
If that's desired, add it to the headers parameter.
701755
702756
:return: Created and register :py:class:`RequestHandler`.
757+
758+
Parameters `json` and `data` are mutually exclusive.
703759
"""
704760

705761
return self.expect_request(
@@ -711,6 +767,7 @@ def expect_oneshot_request(
711767
query_string=query_string,
712768
header_value_matcher=header_value_matcher,
713769
handler_type=HandlerType.ONESHOT,
770+
json=json,
714771
)
715772

716773
def expect_ordered_request(
@@ -721,7 +778,8 @@ def expect_ordered_request(
721778
data_encoding: str = "utf-8",
722779
headers: Optional[Mapping[str, str]] = None,
723780
query_string: Union[None, QueryMatcher, str, bytes, Mapping] = None,
724-
header_value_matcher: Optional[HeaderValueMatcher] = None) -> RequestHandler:
781+
header_value_matcher: Optional[HeaderValueMatcher] = None,
782+
json: Any = UNDEFINED) -> RequestHandler:
725783
"""
726784
Create and register a ordered request handler.
727785
@@ -742,8 +800,13 @@ def expect_ordered_request(
742800
value will be used. If multiple values needed to be handled, use ``MultiDict``
743801
object from werkzeug.
744802
:param header_value_matcher: :py:class:`HeaderValueMatcher` that matches values of headers.
803+
:param json: a python object (eg. a dict) whose value will be compared to the request body after it
804+
is loaded as json. If load fails, this matcher will be failed also. *Content-Type* is not checked.
805+
If that's desired, add it to the headers parameter.
745806
746807
:return: Created and register :py:class:`RequestHandler`.
808+
809+
Parameters `json` and `data` are mutually exclusive.
747810
"""
748811

749812
return self.expect_request(
@@ -755,6 +818,7 @@ def expect_ordered_request(
755818
query_string=query_string,
756819
header_value_matcher=header_value_matcher,
757820
handler_type=HandlerType.ORDERED,
821+
json=json,
758822
)
759823

760824
def thread_target(self):
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
features:
3+
- |
4+
It is now possible to specify a JSON-serializable python value (such as
5+
dict, list, etc) and match the request to it as JSON. The request's body
6+
is loaded as JSON and it will be compared to the expected value.

tests/test_json_matcher.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
2+
import json
3+
from pytest_httpserver import HTTPServer
4+
5+
import requests
6+
import pytest
7+
8+
9+
def test_json_matcher(httpserver: HTTPServer):
10+
httpserver.expect_request("/foo", json={"foo": "bar"}).respond_with_data("Hello world!")
11+
assert requests.get(httpserver.url_for("/foo")).status_code == 500
12+
resp = requests.get(httpserver.url_for("/foo"), json={"foo": "bar"})
13+
assert resp.status_code == 200
14+
assert resp.text == "Hello world!"
15+
assert requests.get(httpserver.url_for("/foo"), json={"foo": "bar", "foo2": "bar2"}).status_code == 500
16+
17+
18+
def test_json_matcher_with_none(httpserver: HTTPServer):
19+
httpserver.expect_request("/foo", json=None).respond_with_data("Hello world!")
20+
resp = requests.get(httpserver.url_for("/foo"), data=json.dumps(None), headers={"content-type": "application/json"})
21+
assert resp.status_code == 200
22+
assert resp.text == "Hello world!"
23+
24+
25+
def test_json_matcher_without_content_type(httpserver: HTTPServer):
26+
httpserver.expect_request("/foo", json={"foo": "bar"}).respond_with_data("Hello world!")
27+
assert requests.get(httpserver.url_for("/foo"), json={"foo": "bar"}).status_code == 200
28+
assert requests.get(httpserver.url_for("/foo"), data=json.dumps({"foo": "bar"})).status_code == 200
29+
30+
31+
def test_json_matcher_with_invalid_json(httpserver: HTTPServer):
32+
httpserver.expect_request("/foo", json={"foo": "bar"}).respond_with_data("Hello world!")
33+
assert requests.get(httpserver.url_for("/foo"), data="invalid-json").status_code == 500
34+
assert requests.get(httpserver.url_for("/foo"), data='{"invalid": "json"').status_code == 500
35+
assert requests.get(httpserver.url_for("/foo"), data=b"non-text\x1f\x8b").status_code == 500
36+
37+
38+
def test_data_and_json_mutually_exclusive(httpserver: HTTPServer):
39+
with pytest.raises(ValueError):
40+
httpserver.expect_request("/foo", json={}, data="foo")

0 commit comments

Comments
 (0)