Skip to content

Commit 50141e0

Browse files
committed
hooks: implementation of the hooks API
1 parent b618aad commit 50141e0

9 files changed

Lines changed: 298 additions & 1 deletion

File tree

doc/api.rst

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,19 @@ by the user.
100100

101101
.. autoclass:: pytest_httpserver.httpserver.RequestHandlerList
102102
:members:
103+
104+
105+
pytest_httpserver.hooks
106+
-----------------------
107+
108+
.. automodule:: pytest_httpserver.hooks
109+
110+
111+
.. autoclass:: pytest_httpserver.hooks.Chain
112+
:members:
113+
114+
.. autoclass:: pytest_httpserver.hooks.Delay
115+
:members:
116+
117+
.. autoclass:: pytest_httpserver.hooks.Garbage
118+
:members:

doc/howto.rst

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -612,3 +612,49 @@ fixture so you can keep the original single-threaded behavior.
612612
that all threads are properly cleaned up and you want to wait for them,
613613
consider using the second option (:ref:`Creating a different httpserver fixture`)
614614
described above.
615+
616+
617+
Adding side effects
618+
-------------------
619+
620+
Sometimes there's a need to add side effects to the handling of the requests.
621+
Such side effect could be adding some amount of delay to the serving or adding
622+
some garbage to response data.
623+
624+
While these can be achieved by using
625+
:py:meth:`pytest_httpserver.RequestHandler.respond_with_handler` where you can
626+
implement your own function to serve the request, *pytest-httpserver* provides a
627+
hooks API where you can add side effects to request handlers such as
628+
:py:meth:`pytest_httpserver.RequestHandler.respond_with_json` and others.
629+
This allows to use the existing API of registering handlers.
630+
631+
Example:
632+
633+
.. literalinclude :: ../tests/examples/test_howto_hooks.py
634+
:language: python
635+
636+
:py:mod:`pytest_httpserver.hooks` module provides some pre-defined hooks to
637+
use.
638+
639+
You can implement your own hook as well. The requirement is to have a callable
640+
object (a function) ``Callable[[Request, Response], Response]``. In details:
641+
642+
* Parameter :py:class:`werkzeug.wrappers.Request` which represents the request
643+
sent by the client.
644+
645+
* Parameter :py:class:`werkzeug.wrappers.Response` which represents the response
646+
made by the handler.
647+
648+
* Returns a :py:class:`werkzeug.wrappers.Response` object which represents the
649+
response will be returned to the client.
650+
651+
652+
Example:
653+
654+
.. literalinclude :: ../tests/examples/test_howto_custom_hooks.py
655+
:language: python
656+
657+
``with_hook`` can be called multiple times, in this case *pytest-httpserver*
658+
will register the hooks, and hooks will be called sequentially, one by one. Each
659+
hook will receive the response what the previous hook returned, and the last
660+
hook called will return the final response which will be sent back to the client.

pytest_httpserver/hooks.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""
2+
Hooks for pytest-httpserver
3+
"""
4+
5+
import os
6+
import time
7+
from typing import Callable
8+
9+
from werkzeug import Request
10+
from werkzeug import Response
11+
12+
13+
class Chain:
14+
"""
15+
Combine multiple hooks into one callable object
16+
17+
Hooks specified will be called one by one.
18+
19+
Each hook will receive the response object made by the previous hook,
20+
similar to reduce.
21+
"""
22+
23+
def __init__(self, *args: Callable[[Request, Response], Response]):
24+
"""
25+
:param *args: callable objects specified in the same order they should
26+
be called.
27+
"""
28+
self._hooks = args
29+
30+
def __call__(self, request: Request, response: Response) -> Response:
31+
"""
32+
Calls the callable object one by one. The second and further callable
33+
objects receive the response returned by the previous one, while the
34+
first one receives the original response object.
35+
"""
36+
for hook in self._hooks:
37+
response = hook(request, response)
38+
return response
39+
40+
41+
class Delay:
42+
"""
43+
Delays returning the response
44+
"""
45+
46+
def __init__(self, seconds: float):
47+
"""
48+
:param seconds: seconds to sleep before returning the response
49+
"""
50+
self._seconds = seconds
51+
52+
def _sleep(self):
53+
"""
54+
Sleeps for the seconds specified in the constructor
55+
"""
56+
time.sleep(self._seconds)
57+
58+
def __call__(self, _request: Request, response: Response) -> Response:
59+
"""
60+
Delays returning the response object for the time specified in the
61+
constructor. Returns the original response unmodified.
62+
"""
63+
self._sleep()
64+
return response
65+
66+
67+
class Garbage:
68+
def __init__(self, prefix_size: int = 0, suffix_size: int = 0):
69+
"""
70+
Adds random bytes to the beginning or to the end of the response data.
71+
72+
:param prefix_size: amount of random bytes to be added to the beginning
73+
of the response data
74+
75+
:param suffix_size: amount of random bytes to be added to the end
76+
of the response data
77+
78+
"""
79+
assert prefix_size >= 0, "prefix_size should be positive integer"
80+
assert suffix_size >= 0, "suffix_size should be positive integer"
81+
self._prefix_size = prefix_size
82+
self._suffix_size = suffix_size
83+
84+
def _get_garbage_bytes(self, size: int) -> bytes:
85+
"""
86+
Returns the specified amount of random bytes.
87+
88+
:param size: amount of bytes to return
89+
"""
90+
return os.urandom(size)
91+
92+
def __call__(self, _request: Request, response: Response) -> Response:
93+
"""
94+
Adds random bytes to the beginning or to the end of the response data.
95+
96+
New random bytes will be generated for every call.
97+
98+
Returns the modified response object.
99+
"""
100+
prefix = self._get_garbage_bytes(self._prefix_size)
101+
suffix = self._get_garbage_bytes(self._suffix_size)
102+
response.set_data(prefix + response.get_data() + suffix)
103+
return response

pytest_httpserver/httpserver.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -529,6 +529,11 @@ class RequestHandler(RequestHandlerBase):
529529
def __init__(self, matcher: RequestMatcher):
530530
self.matcher = matcher
531531
self.request_handler: Callable[[Request], Response] | None = None
532+
self._hooks: list[Callable[[Request, Response], Response]] = []
533+
534+
def with_hook(self, hook: Callable[[Request, Response], Response]):
535+
self._hooks.append(hook)
536+
return self
532537

533538
def respond(self, request: Request) -> Response:
534539
"""
@@ -546,7 +551,11 @@ def respond(self, request: Request) -> Response:
546551
"Matching request handler found but no response defined: {} {}".format(request.method, request.path)
547552
)
548553
else:
549-
return self.request_handler(request)
554+
response = self.request_handler(request)
555+
556+
for hook in self._hooks:
557+
response = hook(request, response)
558+
return response
550559

551560
def respond_with_handler(self, func: Callable[[Request], Response]):
552561
"""
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
features:
3+
- |
4+
Hooks API
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import requests
2+
from werkzeug.wrappers import Request
3+
from werkzeug.wrappers import Response
4+
5+
from pytest_httpserver import HTTPServer
6+
7+
8+
def my_hook(_request: Request, response: Response) -> Response:
9+
# add a new header value to the response
10+
response.headers["X-Example"] = "Example"
11+
return response
12+
13+
14+
def test_custom_hook(httpserver: HTTPServer):
15+
httpserver.expect_request("/foo").with_hook(my_hook).respond_with_data(b"OK")
16+
17+
assert requests.get(httpserver.url_for("/foo")).headers["X-Example"] == "Example"

tests/examples/test_howto_hooks.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import requests
2+
3+
from pytest_httpserver import HTTPServer
4+
from pytest_httpserver.hooks import Delay
5+
6+
7+
def test_delay(httpserver: HTTPServer):
8+
# this adds 0.5 seconds delay to the server response
9+
httpserver.expect_request("/foo").with_hook(Delay(0.5)).respond_with_json({"example": "foo"})
10+
11+
assert requests.get(httpserver.url_for("/foo")).json() == {"example": "foo"}

tests/test_hooks.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
import pytest
6+
import requests
7+
8+
from pytest_httpserver.hooks import Chain
9+
from pytest_httpserver.hooks import Delay
10+
from pytest_httpserver.hooks import Garbage
11+
12+
if TYPE_CHECKING:
13+
from werkzeug.wrappers import Request
14+
from werkzeug.wrappers import Response
15+
16+
from pytest_httpserver import HTTPServer
17+
18+
19+
class MyDelay(Delay):
20+
def __init__(self, *args, **kwargs):
21+
super().__init__(*args, **kwargs)
22+
self.evidence: int | None = None
23+
24+
def _sleep(self):
25+
assert self.evidence is None, "_sleep should be called only once"
26+
self.evidence = self._seconds
27+
28+
29+
def my_hook(_request: Request, response: Response) -> Response:
30+
response.set_data(response.get_data() + b"-SUFFIX")
31+
return response
32+
33+
34+
def test_hook(httpserver: HTTPServer):
35+
36+
httpserver.expect_request("/foo").with_hook(my_hook).respond_with_data("OK")
37+
38+
assert requests.get(httpserver.url_for("/foo")).text == "OK-SUFFIX"
39+
40+
41+
def test_delay_hook(httpserver: HTTPServer):
42+
delay = MyDelay(10)
43+
httpserver.expect_request("/foo").with_hook(delay).respond_with_data("OK")
44+
assert requests.get(httpserver.url_for("/foo")).text == "OK"
45+
assert delay.evidence == 10
46+
47+
48+
def test_garbage_hook(httpserver: HTTPServer):
49+
httpserver.expect_request("/prefix").with_hook(Garbage(prefix_size=128)).respond_with_data("OK")
50+
httpserver.expect_request("/suffix").with_hook(Garbage(suffix_size=128)).respond_with_data("OK")
51+
httpserver.expect_request("/both").with_hook(Garbage(prefix_size=128, suffix_size=128)).respond_with_data("OK")
52+
httpserver.expect_request("/large_prefix").with_hook(Garbage(prefix_size=10 * 1024 * 1024)).respond_with_data("OK")
53+
54+
resp_content = requests.get(httpserver.url_for("/prefix")).content
55+
assert len(resp_content) == 130
56+
assert resp_content[128:] == b"OK"
57+
58+
resp_content = requests.get(httpserver.url_for("/large_prefix")).content
59+
assert len(resp_content) == 10 * 1024 * 1024 + 2
60+
assert resp_content[10 * 1024 * 1024 :] == b"OK"
61+
62+
resp_content = requests.get(httpserver.url_for("/suffix")).content
63+
assert len(resp_content) == 130
64+
assert resp_content[:2] == b"OK"
65+
66+
resp_content = requests.get(httpserver.url_for("/both")).content
67+
assert len(resp_content) == 258
68+
assert resp_content[128:130] == b"OK"
69+
70+
with pytest.raises(AssertionError, match="prefix_size should be positive integer"):
71+
Garbage(-10)
72+
73+
with pytest.raises(AssertionError, match="suffix_size should be positive integer"):
74+
Garbage(10, -10)
75+
76+
77+
def test_chain(httpserver: HTTPServer):
78+
delay = MyDelay(10)
79+
httpserver.expect_request("/foo").with_hook(Chain(delay, Garbage(128))).respond_with_data("OK")
80+
assert len(requests.get(httpserver.url_for("/foo")).content) == 130
81+
assert delay.evidence == 10
82+
83+
84+
def test_multiple_hooks(httpserver: HTTPServer):
85+
delay = MyDelay(10)
86+
httpserver.expect_request("/foo").with_hook(delay).with_hook(Garbage(128)).respond_with_data("OK")
87+
assert len(requests.get(httpserver.url_for("/foo")).content) == 130
88+
assert delay.evidence == 10

tests/test_release.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ def test_wheel_no_extra_contents(build: Build, version: str):
155155
assert package_contents == {
156156
"__init__.py",
157157
"blocking_httpserver.py",
158+
"hooks.py",
158159
"httpserver.py",
159160
"py.typed",
160161
"pytest_plugin.py",
@@ -196,6 +197,7 @@ def test_sdist_contents(build: Build, version: str):
196197
"pytest_httpserver": {
197198
"__init__.py",
198199
"blocking_httpserver.py",
200+
"hooks.py",
199201
"httpserver.py",
200202
"py.typed",
201203
"pytest_plugin.py",
@@ -207,6 +209,7 @@ def test_sdist_contents(build: Build, version: str):
207209
"test_blocking_httpserver.py",
208210
"test_handler_errors.py",
209211
"test_headers.py",
212+
"test_hooks.py",
210213
"test_ip_protocols.py",
211214
"test_json_matcher.py",
212215
"test_log_querying.py",

0 commit comments

Comments
 (0)