Skip to content

Commit b996648

Browse files
authored
Merge pull request #300 from EasyPost/hooks
feat: adds http request and response hooks
2 parents af0814c + 50965cf commit b996648

File tree

11 files changed

+510
-16
lines changed

11 files changed

+510
-16
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
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 `subscribeToRequestHook`, `subscribeToResponseHook`, `unsubscribeFromRequestHook`, or `unsubscribeFromResponseHook` methods of an `EasyPostClient`
6+
37
## v6.7.0 (2023-06-06)
48

59
- Migrates carrier metadata to GA (now available via `client.carrierMetadata.retrieve`)

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,26 @@ $boughtShipment = $client->shipment->buy($shipment->id, $shipment->lowestRate())
6363
echo $boughtShipment;
6464
```
6565

66+
### HTTP Hooks
67+
68+
Users can subscribe to HTTP requests and responses via the `RequestHook` and `ResponseHook` objects. To do so, pass a function to the `subscribeToRequestHook` or `subscribeToResponseHook` methods of an `EasyPostClient` object:
69+
70+
```php
71+
function customFunction($args)
72+
{
73+
// Pass your code here, data about the request/response is contained within `$args`.
74+
echo "Received a request with the status code of: " . $args['http_status'];
75+
}
76+
77+
$client = new \EasyPost\EasyPostClient(getenv('EASYPOST_API_KEY'));
78+
79+
$client->subscribeToResponseHook('customFunction');
80+
81+
// Make your API calls here, your customFunction will trigger once a response is received
82+
```
83+
84+
You can also unsubscribe your functions in a similar manner by using the `unsubscribeFromRequestHook` and `unsubscribeFromResponseHook` methods of a client object.
85+
6686
## Documentation
6787

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

lib/EasyPost/EasyPostClient.php

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
use EasyPost\Constant\Constants;
66
use EasyPost\Exception\General\EasyPostException;
77
use EasyPost\Exception\General\MissingParameterException;
8+
use EasyPost\Hook\RequestHook;
9+
use EasyPost\Hook\ResponseHook;
810
use EasyPost\Service\AddressService;
911
use EasyPost\Service\BaseService;
1012
use EasyPost\Service\BatchService;
@@ -65,6 +67,8 @@ class EasyPostClient extends BaseService
6567
private $timeout;
6668
private $apiBase;
6769
private $mockingUtility;
70+
public $requestEvent;
71+
public $responseEvent;
6872

6973
/**
7074
* Constructor for an EasyPostClient.
@@ -85,6 +89,8 @@ public function __construct(
8589
$this->timeout = $timeout;
8690
$this->apiBase = $apiBase;
8791
$this->mockingUtility = $mockingUtility;
92+
$this->requestEvent = new RequestHook();
93+
$this->responseEvent = new ResponseHook();
8894

8995
if (!$this->apiKey) {
9096
throw new MissingParameterException(
@@ -187,4 +193,48 @@ public function getMockingUtility()
187193
{
188194
return $this->mockingUtility;
189195
}
196+
197+
/**
198+
* Subscribe functions to run when a request event occurs.
199+
*
200+
* @param callable $function
201+
* @return void
202+
*/
203+
public function subscribeToRequestHook($function)
204+
{
205+
$this->requestEvent->addHandler($function);
206+
}
207+
208+
/**
209+
* Unsubscribe functions from running when a request even occurs.
210+
*
211+
* @param callable $function
212+
* @return void
213+
*/
214+
public function unsubscribeFromRequestHook($function)
215+
{
216+
$this->requestEvent->removeHandler($function);
217+
}
218+
219+
/**
220+
* Subscribe functions to run when a response event occurs.
221+
*
222+
* @param callable $function
223+
* @return void
224+
*/
225+
public function subscribeToResponseHook($function)
226+
{
227+
$this->responseEvent->addHandler($function);
228+
}
229+
230+
/**
231+
* Unsubscribe functions from running when a response even occurs.
232+
*
233+
* @param callable $function
234+
* @return void
235+
*/
236+
public function unsubscribeFromResponseHook($function)
237+
{
238+
$this->responseEvent->removeHandler($function);
239+
}
190240
}

lib/EasyPost/Hook/EventHook.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
namespace EasyPost\Hook;
4+
5+
/**
6+
* The parent event that occurs when a hook is triggered.
7+
*/
8+
class EventHook
9+
{
10+
private $eventHandlers = [];
11+
12+
public function __invoke(...$args)
13+
{
14+
foreach ($this->eventHandlers as $eventHandler) {
15+
$eventHandler(...$args);
16+
}
17+
}
18+
19+
public function addHandler($handler)
20+
{
21+
$this->eventHandlers[] = $handler;
22+
return $this;
23+
}
24+
25+
public function removeHandler($handler)
26+
{
27+
$index = array_search($handler, $this->eventHandlers, true);
28+
if ($index !== false) {
29+
array_splice($this->eventHandlers, $index, 1);
30+
}
31+
return $this;
32+
}
33+
}

lib/EasyPost/Hook/RequestHook.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
namespace EasyPost\Hook;
4+
5+
/**
6+
* An event that gets triggered when an HTTP request begins.
7+
*/
8+
class RequestHook extends EventHook
9+
{
10+
}

lib/EasyPost/Hook/ResponseHook.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
namespace EasyPost\Hook;
4+
5+
/**
6+
* An event that gets triggered when an HTTP response is returned.
7+
*/
8+
class ResponseHook extends EventHook
9+
{
10+
}

lib/EasyPost/Http/Requestor.php

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
namespace EasyPost\Http;
44

5+
use DateTime;
6+
use DateTimeZone;
57
use EasyPost\Constant\Constants;
68
use EasyPost\EasyPostClient;
79
use EasyPost\EasypostObject;
@@ -179,6 +181,18 @@ private static function requestRaw($client, $method, $url, $params, $beta = fals
179181
'User-Agent' => 'EasyPost/v2 PhpClient/' . Constants::LIBRARY_VERSION . " PHP/$phpVersion OS/$osType OSVersion/$osVersion OSArch/$osArch", // phpcs:ignore
180182
];
181183

184+
$requestUuid = uniqid();
185+
date_default_timezone_set('UTC');
186+
$requestTimestamp = microtime(true);
187+
($client->requestEvent)([
188+
'method' => $method,
189+
'path' => $absoluteUrl,
190+
'headers' => $headers,
191+
'request_body' => $params,
192+
'request_timestamp' => $requestTimestamp,
193+
'request_uuid' => $requestUuid,
194+
]);
195+
182196
if ($client->mock()) {
183197
// If there are mock requests set, this client will ONLY make mock requests
184198
$mockingUtility = $client->getMockingUtility();
@@ -189,26 +203,38 @@ private static function requestRaw($client, $method, $url, $params, $beta = fals
189203

190204
$responseBody = $matchingRequest->responseInfo->body;
191205
$httpStatus = $matchingRequest->responseInfo->statusCode;
206+
$responseHeaders = [];
207+
} else {
208+
$guzzleClient = new Client();
209+
$requestOptions['headers'] = $headers;
210+
try {
211+
$response = $guzzleClient->request($method, $absoluteUrl, $requestOptions);
212+
} catch (\GuzzleHttp\Exception\ConnectException $error) {
213+
throw new HttpException(sprintf(Constants::COMMUNICATION_ERROR, 'EasyPost', $error->getMessage()));
214+
}
192215

193-
return [$responseBody, $httpStatus];
194-
}
195-
196-
$guzzleClient = new Client();
197-
$requestOptions['headers'] = $headers;
198-
try {
199-
$response = $guzzleClient->request($method, $absoluteUrl, $requestOptions);
200-
} catch (\GuzzleHttp\Exception\ConnectException $error) {
201-
throw new HttpException(sprintf(Constants::COMMUNICATION_ERROR, 'EasyPost', $error->getMessage()));
202-
}
216+
// Guzzle does not have a native way of catching timeout exceptions...
217+
// If we don't have a response at this point, it's likely due to a timeout.
218+
if (!isset($response)) {
219+
throw new TimeoutException(sprintf(Constants::NO_RESPONSE_ERROR, 'EasyPost'));
220+
}
203221

204-
// Guzzle does not have a native way of catching timeout exceptions...
205-
// If we don't have a response at this point, it's likely due to a timeout.
206-
if (!isset($response)) {
207-
throw new TimeoutException(sprintf(Constants::NO_RESPONSE_ERROR, 'EasyPost'));
222+
$responseBody = $response->getBody();
223+
$httpStatus = $response->getStatusCode();
224+
$responseHeaders = $response->getHeaders();
208225
}
209226

210-
$responseBody = $response->getBody();
211-
$httpStatus = $response->getStatusCode();
227+
$responseTimestamp = microtime(true);
228+
($client->responseEvent)([
229+
'http_status' => $httpStatus,
230+
'method' => $method,
231+
'path' => $absoluteUrl,
232+
'headers' => $responseHeaders,
233+
'response_body' => $responseBody,
234+
'request_timestamp' => $requestTimestamp,
235+
'response_timestamp' => $responseTimestamp,
236+
'request_uuid' => $requestUuid,
237+
]);
212238

213239
return [$responseBody, $httpStatus];
214240
}

test/EasyPost/HookTest.php

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<?php
2+
3+
namespace EasyPost\Test;
4+
5+
use DateTime;
6+
use EasyPost\EasyPostClient;
7+
8+
class HookTest extends \PHPUnit\Framework\TestCase
9+
{
10+
private static $client;
11+
12+
/**
13+
* Setup the testing environment for this file.
14+
*/
15+
public static function setUpBeforeClass(): void
16+
{
17+
TestUtil::setupVcrTests();
18+
self::$client = new EasyPostClient(getenv('EASYPOST_TEST_API_KEY'));
19+
}
20+
21+
/**
22+
* Cleanup the testing environment once finished.
23+
*/
24+
public static function tearDownAfterClass(): void
25+
{
26+
TestUtil::teardownVcrTests();
27+
}
28+
29+
/**
30+
* Make assertions about a request once the RequestHook fires.
31+
*/
32+
public function requestTest($args)
33+
{
34+
$this->assertEquals('post', $args['method']);
35+
$this->assertEquals('https://api.easypost.com/v2/parcels', $args['path']);
36+
$this->assertArrayHasKey('parcel', $args['request_body']);
37+
$this->assertArrayHasKey('Authorization', $args['headers']);
38+
$this->assertIsFloat($args['request_timestamp']);
39+
$this->assertEquals(13, strlen($args['request_uuid']));
40+
}
41+
42+
/**
43+
* Test that we fire a RequestHook prior to making an HTTP request.
44+
*/
45+
public function testRequestHooks()
46+
{
47+
TestUtil::setupCassette('hooks/request.yml');
48+
49+
self::$client->subscribeToRequestHook([$this, 'requestTest']);
50+
self::$client->parcel->create(Fixture::basicParcel());
51+
}
52+
53+
/**
54+
* Make assertions about a response once the ResponseHook fires.
55+
*/
56+
public function responseTest($args)
57+
{
58+
$this->assertEquals(201, $args['http_status']);
59+
$this->assertEquals('post', $args['method']);
60+
$this->assertEquals('https://api.easypost.com/v2/parcels', $args['path']);
61+
$this->assertNotNull(json_decode($args['response_body'], true)['object']);
62+
$this->assertArrayHasKey('location', $args['headers']);
63+
$this->assertTrue($args['response_timestamp'] > $args['request_timestamp']);
64+
$this->assertEquals(13, strlen($args['request_uuid']));
65+
}
66+
67+
/**
68+
* Test that we fire a ResponseHook after receiving an HTTP response.
69+
*/
70+
public function testResponseHooks()
71+
{
72+
TestUtil::setupCassette('hooks/response.yml');
73+
74+
self::$client->subscribeToResponseHook([$this, 'responseTest']);
75+
self::$client->parcel->create(Fixture::basicParcel());
76+
}
77+
78+
/**
79+
* This function should never run since we unsubscribe from HTTP hooks.
80+
*/
81+
public function failIfSubscribed()
82+
{
83+
throw new \Exception('Unsubscribing from HTTP hooks did not work as intended');
84+
}
85+
86+
/**
87+
* Test that we do not fire a hook once unsubscribed.
88+
*/
89+
public function testUnsubscribeHooks()
90+
{
91+
TestUtil::setupCassette('hooks/unsubscribe.yml');
92+
93+
self::$client->subscribeToRequestHook([$this, 'failIfSubscribed']);
94+
self::$client->unsubscribeFromRequestHook([$this, 'failIfSubscribed']);
95+
96+
self::$client->subscribeToResponseHook([$this, 'failIfSubscribed']);
97+
self::$client->unsubscribeFromResponseHook([$this, 'failIfSubscribed']);
98+
99+
self::$client->parcel->create(Fixture::basicParcel());
100+
}
101+
}

0 commit comments

Comments
 (0)