1
|
|
|
<?php |
2
|
|
|
|
3
|
|
|
declare(strict_types=1); |
4
|
|
|
|
5
|
|
|
namespace WebServCo\Framework\Http; |
6
|
|
|
|
7
|
|
|
use WebServCo\Framework\Exceptions\HttpClientException; |
8
|
|
|
|
9
|
|
|
final class CurlClient extends AbstractClient implements \WebServCo\Framework\Interfaces\HttpClientInterface |
10
|
|
|
{ |
11
|
|
|
|
12
|
|
|
/** |
13
|
|
|
* cURL |
14
|
|
|
* |
15
|
|
|
* @var resource |
16
|
|
|
*/ |
17
|
|
|
protected $curl; |
18
|
|
|
|
19
|
|
|
protected string $curlError; |
20
|
|
|
|
21
|
|
|
/** |
22
|
|
|
* Debug info |
23
|
|
|
* |
24
|
|
|
* @var array<string,string> |
25
|
|
|
*/ |
26
|
|
|
protected array $debugInfo; |
27
|
|
|
|
28
|
|
|
protected string $debugOutput; |
29
|
|
|
|
30
|
|
|
/** |
31
|
|
|
* debugStderr |
32
|
|
|
* |
33
|
|
|
* @var resource |
34
|
|
|
*/ |
35
|
|
|
protected $debugStderr; |
36
|
|
|
|
37
|
|
|
/** |
38
|
|
|
* Response header array |
39
|
|
|
* |
40
|
|
|
* @var array<int,string> |
41
|
|
|
*/ |
42
|
|
|
protected array $responseHeaderArray; |
43
|
|
|
|
44
|
|
|
/** |
45
|
|
|
* Response headers array |
46
|
|
|
* |
47
|
|
|
* @var array<int,array<int,string>> |
48
|
|
|
*/ |
49
|
|
|
protected array $responseHeadersArray; |
50
|
|
|
|
51
|
|
|
public function retrieve(string $url): Response |
52
|
|
|
{ |
53
|
|
|
$this->debugInit(); |
54
|
|
|
|
55
|
|
|
$this->curl = \curl_init(); |
|
|
|
|
56
|
|
|
if (!\is_resource($this->curl)) { |
57
|
|
|
// Not in the try/catch/finally block as there is nothing to close or debug at this point. |
58
|
|
|
throw new HttpClientException('Not a valid cURL resource.'); |
59
|
|
|
} |
60
|
|
|
|
61
|
|
|
try { |
62
|
|
|
$this->setCurlOptions($url); |
63
|
|
|
|
64
|
|
|
$this->debugSetOptions(); |
65
|
|
|
|
66
|
|
|
$this->handleRequestMethod(); |
67
|
|
|
|
68
|
|
|
$this->setRequestHeaders(); |
69
|
|
|
|
70
|
|
|
$this->processResponse(); |
71
|
|
|
|
72
|
|
|
$body = \trim($this->response); |
73
|
|
|
|
74
|
|
|
$this->processResponseHeaders(); |
75
|
|
|
|
76
|
|
|
$headers = \end($this->responseHeaders); |
77
|
|
|
return new Response( |
78
|
|
|
$body, // content |
79
|
|
|
$this->getHttpCode(), // statusCode |
80
|
|
|
\is_array($headers) ? $headers : [], // headers |
81
|
|
|
); |
82
|
|
|
} catch (HttpClientException $e) { |
83
|
|
|
// Re-throw same exception |
84
|
|
|
// try/catch/finally block used in order to close and debug curl handle in all situations |
85
|
|
|
throw new HttpClientException($e->getMessage(), $e); |
86
|
|
|
} finally { |
87
|
|
|
// Debug and close connection in any situation. |
88
|
|
|
|
89
|
|
|
$this->debugInfo = \curl_getinfo($this->curl); |
90
|
|
|
|
91
|
|
|
\curl_close($this->curl); |
92
|
|
|
|
93
|
|
|
$this->debugFinish(); |
94
|
|
|
} |
95
|
|
|
} |
96
|
|
|
|
97
|
|
|
protected function debugFinish(): bool |
98
|
|
|
{ |
99
|
|
|
if ($this->debug) { |
100
|
|
|
\fclose($this->debugStderr); |
101
|
|
|
$this->debugOutput = (string) \ob_get_clean(); |
102
|
|
|
|
103
|
|
|
$this->logger->debug('CURL INFO:', $this->debugInfo); |
104
|
|
|
$this->logger->debug('CURL VERBOSE:', ['debugOutput' => $this->debugOutput]); |
105
|
|
|
if ($this->requestData) { |
106
|
|
|
$this->logger->debug('REQUEST DATA:', ['requestData' => $this->requestData]); |
107
|
|
|
} |
108
|
|
|
if ($this->response) { |
109
|
|
|
$this->logger->debug('CURL RESPONSE:', ['response' => $this->response]); |
110
|
|
|
} |
111
|
|
|
|
112
|
|
|
return true; |
113
|
|
|
} |
114
|
|
|
return false; |
115
|
|
|
} |
116
|
|
|
|
117
|
|
|
protected function debugInit(): bool |
118
|
|
|
{ |
119
|
|
|
if ($this->debug) { |
120
|
|
|
\ob_start(); |
121
|
|
|
$debugStderr = \fopen('php://output', 'w'); |
122
|
|
|
if ($debugStderr) { |
|
|
|
|
123
|
|
|
$this->debugStderr = $debugStderr; |
124
|
|
|
return true; |
125
|
|
|
} |
126
|
|
|
} |
127
|
|
|
return false; |
128
|
|
|
} |
129
|
|
|
|
130
|
|
|
protected function debugSetOptions(): bool |
131
|
|
|
{ |
132
|
|
|
if ($this->debug) { |
133
|
|
|
//curl_setopt($this->curl, CURLINFO_HEADER_OUT, 1); /* verbose not working if this is enabled */ |
134
|
|
|
\curl_setopt($this->curl, \CURLOPT_VERBOSE, 1); |
135
|
|
|
\curl_setopt($this->curl, \CURLOPT_STDERR, $this->debugStderr); |
136
|
|
|
return true; |
137
|
|
|
} |
138
|
|
|
return false; |
139
|
|
|
} |
140
|
|
|
|
141
|
|
|
protected function getHttpCode(): int |
142
|
|
|
{ |
143
|
|
|
$httpCode = \curl_getinfo($this->curl, \CURLINFO_RESPONSE_CODE); |
144
|
|
|
if (!$httpCode) { |
145
|
|
|
throw new HttpClientException(\sprintf("Empty HTTP status code. cURL error: %s.", $this->curlError)); |
146
|
|
|
} |
147
|
|
|
return $httpCode; |
148
|
|
|
} |
149
|
|
|
|
150
|
|
|
protected function handleRequestMethod(): bool |
151
|
|
|
{ |
152
|
|
|
if (!\is_resource($this->curl)) { |
153
|
|
|
throw new HttpClientException('Not a valid resource.'); |
154
|
|
|
} |
155
|
|
|
|
156
|
|
|
switch ($this->method) { |
157
|
|
|
case Method::DELETE: |
158
|
|
|
case Method::POST: |
159
|
|
|
/* |
160
|
|
|
* This sets the header application/x-www-form-urlencoded |
161
|
|
|
* Use custom request instead and handle headers manually |
162
|
|
|
* curl_setopt($this->curl, CURLOPT_POST, true); |
163
|
|
|
*/ |
164
|
|
|
\curl_setopt($this->curl, \CURLOPT_CUSTOMREQUEST, $this->method); |
165
|
|
|
if ($this->requestData) { |
166
|
|
|
\curl_setopt($this->curl, \CURLOPT_POSTFIELDS, $this->requestData); |
167
|
|
|
if (\is_array($this->requestData)) { |
168
|
|
|
$this->setRequestHeader('Content-Type', 'multipart/form-data'); |
169
|
|
|
} else { |
170
|
|
|
$this->setRequestHeader('Content-Type', $this->requestContentType); |
171
|
|
|
// use strlen and not mb_strlen: "The length of the request body in octets (8-bit bytes)." |
172
|
|
|
$this->setRequestHeader('Content-Length', (string) \strlen($this->requestData)); |
173
|
|
|
} |
174
|
|
|
} |
175
|
|
|
break; |
176
|
|
|
case Method::HEAD: |
177
|
|
|
\curl_setopt($this->curl, \CURLOPT_NOBODY, true); |
178
|
|
|
break; |
179
|
|
|
} |
180
|
|
|
|
181
|
|
|
return true; |
182
|
|
|
} |
183
|
|
|
|
184
|
|
|
/** |
185
|
|
|
* @param resource $curlResource |
186
|
|
|
*/ |
187
|
|
|
// phpcs:ignore SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter |
188
|
|
|
protected function headerCallback($curlResource, string $headerData): int |
189
|
|
|
{ |
190
|
|
|
$headerDataTrimmed = \trim($headerData); |
191
|
|
|
if (!$headerDataTrimmed) { |
192
|
|
|
$this->responseHeadersArray[] = $this->responseHeaderArray; |
193
|
|
|
$this->responseHeaderArray = []; |
194
|
|
|
} |
195
|
|
|
$this->responseHeaderArray[] = $headerData; |
196
|
|
|
|
197
|
|
|
return \strlen($headerData); |
198
|
|
|
} |
199
|
|
|
|
200
|
|
|
/** |
201
|
|
|
* @param array<string,mixed> $headers |
202
|
|
|
* @return array<int,string> |
203
|
|
|
*/ |
204
|
|
|
protected function parseRequestHeaders(array $headers): array |
205
|
|
|
{ |
206
|
|
|
$data = []; |
207
|
|
|
foreach ($headers as $k => $v) { |
208
|
|
|
if (\is_array($v)) { |
209
|
|
|
foreach ($v as $item) { |
210
|
|
|
$data[] = \sprintf('%s: %s', $k, $item); |
211
|
|
|
} |
212
|
|
|
} else { |
213
|
|
|
$data[] = \sprintf('%s: %s', $k, $v); |
214
|
|
|
} |
215
|
|
|
} |
216
|
|
|
return $data; |
217
|
|
|
} |
218
|
|
|
|
219
|
|
|
/** |
220
|
|
|
* @param array<int,string> $responseHeadersArray |
221
|
|
|
* @return array<string,mixed> |
222
|
|
|
*/ |
223
|
|
|
protected function parseResponseHeadersArray(array $responseHeadersArray = []): array |
224
|
|
|
{ |
225
|
|
|
$headers = []; |
226
|
|
|
|
227
|
|
|
foreach ($responseHeadersArray as $index => $line) { |
228
|
|
|
if (0 === $index) { |
229
|
|
|
continue; /* we'll get the status code elsewhere */ |
230
|
|
|
} |
231
|
|
|
$parts = \explode(': ', $line, 2); |
232
|
|
|
if (!isset($parts[1])) { |
233
|
|
|
continue; // invalid header (missing colon) |
234
|
|
|
} |
235
|
|
|
[$key, $value] = $parts; |
236
|
|
|
if (isset($headers[$key])) { |
237
|
|
|
if (!\is_array($headers[$key])) { |
238
|
|
|
$headers[$key] = [$headers[$key]]; |
239
|
|
|
} |
240
|
|
|
// check cookies |
241
|
|
|
if ('Set-Cookie' === $key) { |
242
|
|
|
$parts = \explode('=', $value, 2); |
243
|
|
|
$cookieName = $parts[0]; |
244
|
|
|
if (\is_array($headers[$key])) { |
245
|
|
|
foreach ($headers[$key] as $cookieIndex => $existingCookie) { |
246
|
|
|
// check if we already have a cookie with the same name |
247
|
|
|
if (0 !== \mb_stripos($existingCookie, $cookieName)) { |
248
|
|
|
continue; |
249
|
|
|
} |
250
|
|
|
|
251
|
|
|
// remove previous cookie with the same name |
252
|
|
|
unset($headers[$key][$cookieIndex]); |
253
|
|
|
} |
254
|
|
|
} |
255
|
|
|
} |
256
|
|
|
$headers[$key][] = \trim($value); |
257
|
|
|
$headers[$key] = \array_values((array) $headers[$key]); // re-index array |
258
|
|
|
} else { |
259
|
|
|
$headers[$key][] = \trim($value); |
260
|
|
|
} |
261
|
|
|
} |
262
|
|
|
return $headers; |
263
|
|
|
} |
264
|
|
|
|
265
|
|
|
protected function processResponse(): bool |
266
|
|
|
{ |
267
|
|
|
$response = \curl_exec($this->curl); |
268
|
|
|
$this->curlError = \curl_error($this->curl); |
269
|
|
|
if (false === $response) { |
270
|
|
|
throw new HttpClientException(\sprintf("cURL error: %s.", $this->curlError)); |
271
|
|
|
} |
272
|
|
|
$this->response = (string) $response; |
273
|
|
|
return true; |
274
|
|
|
} |
275
|
|
|
|
276
|
|
|
protected function processResponseHeaders(): bool |
277
|
|
|
{ |
278
|
|
|
$this->responseHeaders = []; |
279
|
|
|
foreach ($this->responseHeadersArray as $item) { |
280
|
|
|
$this->responseHeaders[] = $this->parseResponseHeadersArray($item); |
281
|
|
|
} |
282
|
|
|
return true; |
283
|
|
|
} |
284
|
|
|
|
285
|
|
|
protected function setCurlOptions(string $url): bool |
286
|
|
|
{ |
287
|
|
|
if (!\is_resource($this->curl)) { |
288
|
|
|
throw new HttpClientException('Not a valid resource.'); |
289
|
|
|
} |
290
|
|
|
|
291
|
|
|
// set options |
292
|
|
|
\curl_setopt_array( |
293
|
|
|
$this->curl, |
294
|
|
|
[ |
295
|
|
|
\CURLOPT_RETURNTRANSFER => true, /* return instead of outputting */ |
296
|
|
|
\CURLOPT_URL => $url, |
297
|
|
|
\CURLOPT_HEADER => false, /* do not include the header in the output */ |
298
|
|
|
\CURLOPT_FOLLOWLOCATION => true, /* follow redirects */ |
299
|
|
|
\CURLOPT_CONNECTTIMEOUT => 60, // The number of seconds to wait while trying to connect. |
300
|
|
|
\CURLOPT_TIMEOUT => 60, // The maximum number of seconds to allow cURL functions to execute. |
301
|
|
|
], |
302
|
|
|
); |
303
|
|
|
// check if we should ignore ssl errors |
304
|
|
|
if ($this->skipSslVerification) { |
305
|
|
|
// stop cURL from verifying the peer's certificate |
306
|
|
|
\curl_setopt($this->curl, \CURLOPT_SSL_VERIFYPEER, false); |
307
|
|
|
// don't check the existence of a common name in the SSL peer certificate |
308
|
|
|
\curl_setopt($this->curl, \CURLOPT_SSL_VERIFYHOST, 0); |
309
|
|
|
} |
310
|
|
|
|
311
|
|
|
return true; |
312
|
|
|
} |
313
|
|
|
|
314
|
|
|
protected function setRequestHeaders(): bool |
315
|
|
|
{ |
316
|
|
|
if (!\is_resource($this->curl)) { |
317
|
|
|
throw new HttpClientException('Not a valid resource.'); |
318
|
|
|
} |
319
|
|
|
|
320
|
|
|
// set headers |
321
|
|
|
if ($this->requestHeaders) { |
|
|
|
|
322
|
|
|
\curl_setopt( |
323
|
|
|
$this->curl, |
324
|
|
|
\CURLOPT_HTTPHEADER, |
325
|
|
|
$this->parseRequestHeaders($this->requestHeaders), |
326
|
|
|
); |
327
|
|
|
} |
328
|
|
|
|
329
|
|
|
// Callback to process response headers |
330
|
|
|
\curl_setopt($this->curl, \CURLOPT_HEADERFUNCTION, [$this, 'headerCallback']); |
331
|
|
|
|
332
|
|
|
return true; |
333
|
|
|
} |
334
|
|
|
} |
335
|
|
|
|
Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.
For example, imagine you have a variable
$accountId
that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to theid
property of an instance of theAccount
class. This class holds a proper account, so the id value must no longer be false.Either this assignment is in error or a type check should be added for that assignment.