Skip to content

Commit 681d588

Browse files
committed
feat: adds Request and Response Hooks
1 parent 0ae5af0 commit 681d588

13 files changed

+308
-10
lines changed

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# CHANGELOG
22

3+
## Next Release
4+
5+
- Adds new `RequestHook` and `ResponseHook` events. (un)subscribe to them with the new `subscribe_to_request_hook`, `subscribe_to_response_hook`, `unsubscribe_from_request_hook`, or `unsubscribe_from_response_hook` methods of an `EasyPostClient`
6+
37
## v8.0.0 (2023-06-06)
48

59
See our [Upgrade Guide](UPGRADE_GUIDE.md#upgrading-from-7x-to-80) for more details.

Makefile

+2-2
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ black-check:
2020
build:
2121
$(VIRTUAL_BIN)/python -m build
2222

23-
## clean - Remove the virtual environment and clear out .pyc files
23+
## clean - Clean the project
2424
clean:
25-
rm -rf $(VIRTUAL_ENV) dist/ build/ *.egg-info/ .pytest_cache .mypy_cache
25+
rm -rf $(VIRTUAL_ENV) dist/ *.egg-info/ .*cache htmlcov *.lcov .coverage
2626
find . -name '*.pyc' -delete
2727

2828
## coverage - Test the project and generate an HTML coverage report

README.md

+18
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,24 @@ bought_shipment = client.shipment.buy(shipment.id, rate=shipment.lowest_rate())
6262
print(bought_shipment)
6363
```
6464

65+
### HTTP Hooks
66+
67+
Users can subscribe to HTTP requests and responses via the `RequestHook` and `ResponseHook` objects. To do so, pass a function to the `subscribe_to_request_hook` or `subscribe_to_response_hook` methods of an `EasyPostClient` object:
68+
69+
```python
70+
def custom_function(**kwargs):
71+
"""Pass your code here, data about the request/response is contained within `kwargs`."""
72+
print(f"Received a request with the status code of: {kwargs.get('http_status')}")
73+
74+
client = easypost.EasyPostClient(os.getenv('EASYPOST_API_KEY'))
75+
76+
client.subscribe_to_response_hook(custom_function)
77+
78+
# Make your API calls here, your custom_function will trigger once a response is received
79+
```
80+
81+
You can also unsubscribe your functions in a similar manner by using the `unsubscribe_from_request_hook` and `unsubscribe_from_response_hook` methods of a client object.
82+
6583
## Documentation
6684

6785
API documentation can be found at: <https://easypost.com/docs/api>.

easypost/easypost_client.py

+24
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
SUPPORT_EMAIL,
66
TIMEOUT,
77
)
8+
from easypost.hooks import (
9+
RequestHook,
10+
ResponseHook,
11+
)
812
from easypost.services import (
913
AddressService,
1014
BatchService,
@@ -75,6 +79,10 @@ def __init__(
7579
self.user = UserService(self)
7680
self.webhook = WebhookService(self)
7781

82+
# Hooks
83+
self._request_event = RequestHook()
84+
self._response_event = ResponseHook()
85+
7886
# use urlfetch as request_lib on google app engine, otherwise use requests
7987
self._request_lib = None
8088
try:
@@ -105,3 +113,19 @@ def __init__(
105113
else:
106114
if major_version < 1:
107115
raise ImportError(INVALID_REQUESTS_VERSION_ERROR.format(SUPPORT_EMAIL))
116+
117+
def subscribe_to_request_hook(self, function):
118+
"""Subscribe functions to run when a request event occurs."""
119+
self._request_event += function
120+
121+
def unsubscribe_from_request_hook(self, function):
122+
"""Unsubscribe functions from running when a request even occurs."""
123+
self._request_event -= function
124+
125+
def subscribe_to_response_hook(self, function):
126+
"""Subscribe functions to run when a response event occurs."""
127+
self._response_event += function
128+
129+
def unsubscribe_from_response_hook(self, function):
130+
"""Unsubscribe functions from running when a response even occurs."""
131+
self._response_event -= function

easypost/hooks/__init__.py

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# flake8: noqa
2+
from easypost.hooks.event_hook import EventHook
3+
from easypost.hooks.request_hook import RequestHook
4+
from easypost.hooks.response_hook import ResponseHook

easypost/hooks/event_hook.py

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
class EventHook:
2+
"""The parent event that occurs when a hook is triggered."""
3+
4+
def __init__(self):
5+
self._event_handlers = []
6+
7+
def __iadd__(self, handler):
8+
self._event_handlers.append(handler)
9+
return self
10+
11+
def __isub__(self, handler):
12+
self._event_handlers.remove(handler)
13+
return self
14+
15+
def __call__(self, *args, **kwargs):
16+
for event_handler in self._event_handlers:
17+
event_handler(*args, **kwargs)

easypost/hooks/request_hook.py

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from easypost.hooks import EventHook
2+
3+
4+
class RequestHook(EventHook):
5+
"""An event that gets triggered when an HTTP request begins."""
6+
7+
pass

easypost/hooks/response_hook.py

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from easypost.hooks import EventHook
2+
3+
4+
class ResponseHook(EventHook):
5+
"""An event that gets triggered when an HTTP response is returned."""
6+
7+
pass

easypost/requestor.py

+36-7
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import json
33
import platform
44
import time
5+
import uuid
56
from enum import Enum
67
from json import JSONDecodeError
78
from typing import (
@@ -200,17 +201,40 @@ def request_raw(
200201
"User-Agent": user_agent,
201202
}
202203

204+
request_timestamp = datetime.datetime.now(datetime.timezone.utc)
205+
request_uuid = uuid.uuid4()
206+
self._client._request_event(
207+
method=method,
208+
path=abs_url,
209+
headers=headers,
210+
data=params,
211+
request_timestamp=request_timestamp,
212+
request_uuid=request_uuid,
213+
)
214+
203215
if self._client._request_lib == "urlfetch":
204-
http_body, http_status = self.urlfetch_request(
216+
http_body, http_status, http_headers = self.urlfetch_request(
205217
method=method, abs_url=abs_url, headers=headers, params=params
206218
)
207219
elif self._client._request_lib == "requests":
208-
http_body, http_status = self.requests_request(
220+
http_body, http_status, http_headers = self.requests_request(
209221
method=method, abs_url=abs_url, headers=headers, params=params
210222
)
211223
else:
212224
raise EasyPostError(INVALID_REQUEST_LIB_ERROR.format(self._client._request_lib, SUPPORT_EMAIL))
213225

226+
response_timestamp = datetime.datetime.now(datetime.timezone.utc)
227+
self._client._response_event(
228+
http_status=http_status,
229+
method=method,
230+
path=abs_url,
231+
headers=http_headers,
232+
response_body=http_body,
233+
request_timestamp=request_timestamp,
234+
response_timestamp=response_timestamp,
235+
request_uuid=request_uuid,
236+
)
237+
214238
return http_body, http_status
215239

216240
def interpret_response(self, http_body: str, http_status: int) -> Dict[str, Any]:
@@ -235,7 +259,7 @@ def requests_request(
235259
abs_url: str,
236260
headers: Dict[str, Any],
237261
params: Dict[str, Any],
238-
) -> Tuple[str, int]:
262+
) -> Tuple[str, int, Dict[str, Any]]:
239263
"""Make a request by using the `request` library."""
240264
if method in [RequestMethod.GET, RequestMethod.DELETE]:
241265
url_params = params
@@ -261,20 +285,21 @@ def requests_request(
261285
)
262286
http_body = result.text
263287
http_status = result.status_code
288+
http_headers = result.headers
264289
except requests.exceptions.Timeout:
265290
raise TimeoutError(TIMEOUT_ERROR)
266291
except Exception as e:
267292
raise HttpError(COMMUNICATION_ERROR.format(SUPPORT_EMAIL, e))
268293

269-
return http_body, http_status
294+
return http_body, http_status, http_headers
270295

271296
def urlfetch_request(
272297
self,
273298
method: RequestMethod,
274299
abs_url: str,
275300
headers: Dict[str, Any],
276301
params: Dict[str, Any],
277-
) -> Tuple[str, int]:
302+
) -> Tuple[str, int, Dict[str, Any]]:
278303
"""Make a request by using the `urlfetch` library."""
279304
fetch_args = {
280305
"method": method.value,
@@ -300,7 +325,7 @@ def urlfetch_request(
300325
except Exception as e:
301326
raise HttpError(COMMUNICATION_ERROR.format(SUPPORT_EMAIL, e))
302327

303-
return result.content, result.status_code
328+
return result.content, result.status_code, result.headers
304329

305330
def handle_api_error(self, http_status: int, http_body: str, response: Dict[str, Any]) -> None:
306331
"""Handles API errors returned from the EasyPost API."""
@@ -318,7 +343,11 @@ def handle_api_error(self, http_status: int, http_body: str, response: Dict[str,
318343

319344
error_type = STATUS_CODE_TO_ERROR_MAPPING.get(http_status, UnknownApiError)
320345

321-
raise error_type(message=error.get("message", ""), http_status=http_status, http_body=http_body)
346+
raise error_type(
347+
message=error.get("message", ""),
348+
http_status=http_status,
349+
http_body=http_body,
350+
)
322351

323352
def _utf8(self, value: Union[str, bytes]) -> str:
324353
# Python3's str(bytestring) returns "b'bytestring'" so we use .decode instead

tests/cassettes/test_request_hooks.yaml

+74
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/cassettes/test_response_hooks.yaml

+75
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)