Skip to content

Commit 5556761

Browse files
Merge pull request sendgrid#24 from lightbringer1991/sendgrid-php-259-allow-sending-api-requests-in-bulk
implements sending concurrent requests with curl multi
2 parents aa8dda8 + b240877 commit 5556761

File tree

3 files changed

+184
-26
lines changed

3 files changed

+184
-26
lines changed

.codeclimate.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ engines:
88
enabled: true
99
config:
1010
file_extensions: "php"
11+
standard: "PSR1,PSR2"
1112
ratings:
1213
paths:
1314
- "**.php"

lib/Client.php

Lines changed: 171 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ class Client
3636
protected $path;
3737
/** @var array */
3838
protected $curlOptions;
39+
/** @var bool $isConcurrentRequest */
40+
protected $isConcurrentRequest;
41+
/** @var array $savedRequests */
42+
protected $savedRequests;
3943
/** @var bool */
4044
protected $retryOnLimit;
4145

@@ -49,10 +53,10 @@ class Client
4953
/**
5054
* Initialize the client
5155
*
52-
* @param string $host the base url (e.g. https://api.sendgrid.com)
53-
* @param array $headers global request headers
54-
* @param string $version api version (configurable)
55-
* @param array $path holds the segments of the url path
56+
* @param string $host the base url (e.g. https://api.sendgrid.com)
57+
* @param array $headers global request headers
58+
* @param string $version api version (configurable)
59+
* @param array $path holds the segments of the url path
5660
*/
5761
public function __construct($host, $headers = [], $version = '/v3', $path = [])
5862
{
@@ -63,6 +67,8 @@ public function __construct($host, $headers = [], $version = '/v3', $path = [])
6367

6468
$this->curlOptions = [];
6569
$this->retryOnLimit = false;
70+
$this->isConcurrentRequest = false;
71+
$this->savedRequests = [];
6672
}
6773

6874
/**
@@ -125,6 +131,20 @@ public function setRetryOnLimit($retry)
125131
return $this;
126132
}
127133

134+
/**
135+
* set concurrent request flag
136+
*
137+
* @param bool $isConcurrent
138+
*
139+
* @return Client
140+
*/
141+
public function setIsConcurrentRequest($isConcurrent)
142+
{
143+
$this->isConcurrentRequest = $isConcurrent;
144+
145+
return $this;
146+
}
147+
128148
/**
129149
* @return array
130150
*/
@@ -150,43 +170,93 @@ private function buildUrl($queryParams = null)
150170
}
151171

152172
/**
153-
* Make the API call and return the response. This is separated into
154-
* it's own function, so we can mock it easily for testing.
155-
*
156-
* @param string $method the HTTP verb
157-
* @param string $url the final url to call
158-
* @param array $body request body
159-
* @param array $headers any additional request headers
160-
* @param bool $retryOnLimit should retry if rate limit is reach?
161-
*
162-
* @return Response object
163-
*/
164-
public function makeRequest($method, $url, $body = null, $headers = null, $retryOnLimit = false)
173+
* Creates curl options for a request
174+
* this function does not mutate any private variables
175+
*
176+
* @param string $method
177+
* @param array $body
178+
* @param array $headers
179+
* @return array
180+
*/
181+
private function createCurlOptions($method, $body = null, $headers = null)
165182
{
166-
$curl = curl_init($url);
167-
168183
$options = array_merge(
169184
[
170185
CURLOPT_RETURNTRANSFER => true,
171186
CURLOPT_HEADER => 1,
172187
CURLOPT_CUSTOMREQUEST => strtoupper($method),
173188
CURLOPT_SSL_VERIFYPEER => false,
174-
CURLOPT_FAILONERROR => false,
189+
CURLOPT_FAILONERROR => false
175190
],
176191
$this->curlOptions
177192
);
178193

179-
curl_setopt_array($curl, $options);
180-
181194
if (isset($headers)) {
182-
$this->headers = array_merge($this->headers, $headers);
195+
$headers = array_merge($this->headers, $headers);
196+
} else {
197+
$headers = [];
183198
}
199+
184200
if (isset($body)) {
185201
$encodedBody = json_encode($body);
186-
curl_setopt($curl, CURLOPT_POSTFIELDS, $encodedBody);
187-
$this->headers = array_merge($this->headers, ['Content-Type: application/json']);
202+
$options[CURLOPT_POSTFIELDS] = $encodedBody;
203+
$headers = array_merge($headers, ['Content-Type: application/json']);
188204
}
189-
curl_setopt($curl, CURLOPT_HTTPHEADER, $this->headers);
205+
$options[CURLOPT_HTTPHEADER] = $headers;
206+
207+
return $options;
208+
}
209+
210+
/**
211+
* @param array $requestData
212+
* e.g. ['method' => 'POST', 'url' => 'www.example.com', 'body' => 'test body', 'headers' => []]
213+
* @param bool $retryOnLimit
214+
*
215+
* @return array
216+
*/
217+
private function createSavedRequest($requestData, $retryOnLimit = false)
218+
{
219+
return array_merge($requestData, ['retryOnLimit' => $retryOnLimit]);
220+
}
221+
222+
/**
223+
* @param array $requests
224+
*
225+
* @return array
226+
*/
227+
private function createCurlMultiHandle($requests)
228+
{
229+
$channels = [];
230+
$multiHandle = curl_multi_init();
231+
232+
foreach ($requests as $id => $data) {
233+
$channels[$id] = curl_init($data['url']);
234+
$curlOpts = $this->createCurlOptions($data['method'], $data['body'], $data['headers']);
235+
curl_setopt_array($channels[$id], $curlOpts);
236+
curl_multi_add_handle($multiHandle, $channels[$id]);
237+
}
238+
239+
return [$channels, $multiHandle];
240+
}
241+
242+
/**
243+
* Make the API call and return the response. This is separated into
244+
* it's own function, so we can mock it easily for testing.
245+
*
246+
* @param string $method the HTTP verb
247+
* @param string $url the final url to call
248+
* @param array $body request body
249+
* @param array $headers any additional request headers
250+
* @param bool $retryOnLimit should retry if rate limit is reach?
251+
*
252+
* @return Response object
253+
*/
254+
public function makeRequest($method, $url, $body = null, $headers = null, $retryOnLimit = false)
255+
{
256+
$curl = curl_init($url);
257+
258+
$curlOpts = $this->createCurlOptions($method, $body, $headers);
259+
curl_setopt_array($curl, $curlOpts);
190260

191261
$response = curl_exec($curl);
192262
$headerSize = curl_getinfo($curl, CURLINFO_HEADER_SIZE);
@@ -212,6 +282,66 @@ public function makeRequest($method, $url, $body = null, $headers = null, $retry
212282
return $response;
213283
}
214284

285+
/**
286+
* Send all saved requests at once
287+
*
288+
* @param array $requests
289+
* @return Response[]
290+
*/
291+
public function makeAllRequests($requests = [])
292+
{
293+
if (empty($requests)) {
294+
$requests = $this->savedRequests;
295+
}
296+
list ($channels, $multiHandle) = $this->createCurlMultiHandle($requests);
297+
298+
// running all requests
299+
$isRunning = null;
300+
do {
301+
curl_multi_exec($multiHandle, $isRunning);
302+
} while ($isRunning);
303+
304+
// get response and close all handles
305+
$retryRequests = [];
306+
$responses = [];
307+
$sleepDurations = 0;
308+
foreach ($channels as $id => $ch) {
309+
$response = curl_multi_getcontent($ch);
310+
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
311+
$statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
312+
$responseBody = substr($response, $headerSize);
313+
314+
$responseHeaders = substr($response, 0, $headerSize);
315+
$responseHeaders = explode("\n", $responseHeaders);
316+
$responseHeaders = array_map('trim', $responseHeaders);
317+
318+
$response = new Response($statusCode, $responseBody, $responseHeaders);
319+
if (($statusCode === 429) && $requests[$id]['retryOnLimit']) {
320+
$headers = $response->headers(true);
321+
$sleepDurations = max($sleepDurations, $headers['X-Ratelimit-Reset'] - time());
322+
$requestData = [
323+
'method' => $requests[$id]['method'],
324+
'url' => $requests[$id]['url'],
325+
'body' => $requests[$id]['body'],
326+
'headers' =>$headers,
327+
];
328+
$retryRequests[] = $this->createSavedRequest($requestData, false);
329+
} else {
330+
$responses[] = $response;
331+
}
332+
333+
curl_multi_remove_handle($multiHandle, $ch);
334+
}
335+
curl_multi_close($multiHandle);
336+
337+
// retry requests
338+
if (!empty($retryRequests)) {
339+
sleep($sleepDurations > 0 ? $sleepDurations : 0);
340+
$responses = array_merge($responses, $this->makeAllRequests($retryRequests));
341+
}
342+
return $responses;
343+
}
344+
215345
/**
216346
* Add variable values to the url.
217347
* (e.g. /your/api/{variable_value}/call)
@@ -242,7 +372,7 @@ public function _($name = null)
242372
* @param string $name name of the dynamic method call or HTTP verb
243373
* @param array $args parameters passed with the method call
244374
*
245-
* @return Client|Response object
375+
* @return Client|Response|Response[]|null object
246376
*/
247377
public function __call($name, $args)
248378
{
@@ -253,12 +383,27 @@ public function __call($name, $args)
253383
return $this->_();
254384
}
255385

386+
// send all saved requests
387+
if (($name === 'send') && $this->isConcurrentRequest) {
388+
return $this->makeAllRequests();
389+
}
390+
256391
if (in_array($name, $this->methods, true)) {
257392
$body = isset($args[0]) ? $args[0] : null;
258393
$queryParams = isset($args[1]) ? $args[1] : null;
259394
$url = $this->buildUrl($queryParams);
260395
$headers = isset($args[2]) ? $args[2] : null;
261396
$retryOnLimit = isset($args[3]) ? $args[3] : $this->retryOnLimit;
397+
398+
if ($this->isConcurrentRequest) {
399+
// save request to be sent later
400+
$this->savedRequests[] = $this->createSavedRequest(
401+
['method' => $name, 'url' => $url, 'body' => $body, 'headers' => $headers],
402+
$retryOnLimit
403+
);
404+
return null;
405+
}
406+
262407
return $this->makeRequest($name, $url, $body, $headers, $retryOnLimit);
263408
}
264409

test/unit/ClientTest.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,4 +112,16 @@ public function testGetCurlOptions()
112112
$client = new Client('https://localhost:4010');
113113
$this->assertSame([], $client->getCurlOptions());
114114
}
115+
116+
public function testCurlMulti()
117+
{
118+
$client = new Client('https://localhost:4010');
119+
$client->setIsConcurrentRequest(true);
120+
$client->get(['name' => 'A New Hope']);
121+
$client->get(null, null, ['X-Mock: 200']);
122+
$client->get(null, ['limit' => 100, 'offset' => 0]);
123+
124+
// returns 3 response object
125+
$this->assertEquals(3, count($client->send()));
126+
}
115127
}

0 commit comments

Comments
 (0)