1 | <?php |
||
2 | |||
3 | declare(strict_types=1); |
||
4 | |||
5 | namespace WebServCo\Framework\Http; |
||
6 | |||
7 | use WebServCo\Framework\Exceptions\HttpClientException; |
||
8 | |||
9 | /** |
||
10 | * A HTTP client usieng cURL. |
||
11 | * |
||
12 | * PHP 8 compatibility notes. |
||
13 | * https://www.php.net/manual/en/migration80.incompatible.php |
||
14 | * "curl_init() will now return a CurlHandle object rather than a resource." |
||
15 | * "Return value checks using is_resource() should be replaced with checks for false." |
||
16 | * old code: |
||
17 | * if (!\is_resource($this->curl)) { |
||
18 | * Not using CurlHandle for the moment so that the code is still compatible with PHP 7.4 |
||
19 | */ |
||
20 | final class CurlClient extends AbstractClient implements \WebServCo\Framework\Interfaces\HttpClientInterface |
||
21 | { |
||
22 | /** |
||
23 | * cURL |
||
24 | * |
||
25 | * @var resource |
||
26 | */ |
||
27 | protected $curl; |
||
28 | |||
29 | protected string $curlError; |
||
30 | |||
31 | /** |
||
32 | * Debug info |
||
33 | * |
||
34 | * @var array<string,string> |
||
35 | */ |
||
36 | protected array $debugInfo; |
||
37 | |||
38 | protected string $debugOutput; |
||
39 | |||
40 | /** |
||
41 | * debugStderr |
||
42 | * |
||
43 | * @var resource |
||
44 | */ |
||
45 | protected $debugStderr; |
||
46 | |||
47 | /** |
||
48 | * Response header array |
||
49 | * |
||
50 | * @var array<int,string> |
||
51 | */ |
||
52 | protected array $responseHeaderArray; |
||
53 | |||
54 | /** |
||
55 | * Response headers array |
||
56 | * |
||
57 | * @var array<int,array<int,string>> |
||
58 | */ |
||
59 | protected array $responseHeadersArray; |
||
60 | |||
61 | public function retrieve(string $url): Response |
||
62 | { |
||
63 | if (!$url) { |
||
64 | throw new \InvalidArgumentException('URL is empty'); |
||
65 | } |
||
66 | |||
67 | $this->debugInit(); |
||
68 | |||
69 | $this->curl = \curl_init(); |
||
0 ignored issues
–
show
|
|||
70 | |||
71 | if (false === $this->curl) { |
||
72 | // Not in the try/catch/finally block as there is nothing to close or debug at this point. |
||
73 | throw new HttpClientException('Not a valid cURL resource.'); |
||
74 | } |
||
75 | |||
76 | try { |
||
77 | $this->setCurlOptions($url); |
||
78 | |||
79 | $this->debugSetOptions(); |
||
80 | |||
81 | $this->handleRequestMethod(); |
||
82 | |||
83 | $this->setRequestHeaders(); |
||
84 | |||
85 | $this->processResponse(); |
||
86 | |||
87 | $body = \trim($this->response); |
||
88 | |||
89 | $this->processResponseHeaders(); |
||
90 | |||
91 | $headers = \end($this->responseHeaders); |
||
92 | return new Response( |
||
93 | $body, // content |
||
94 | $this->getHttpCode(), // statusCode |
||
95 | \is_array($headers) ? $headers : [], // headers |
||
96 | ); |
||
97 | } catch (HttpClientException $e) { |
||
98 | // Re-throw same exception |
||
99 | // try/catch/finally block used in order to close and debug curl handle in all situations |
||
100 | throw new HttpClientException($e->getMessage(), $e); |
||
101 | } finally { |
||
102 | // Debug and close connection in any situation. |
||
103 | |||
104 | $this->debugInfo = \curl_getinfo($this->curl); |
||
105 | |||
106 | /** |
||
107 | * PHP 8 compatibility. |
||
108 | * |
||
109 | * https://www.php.net/manual/en/migration80.incompatible.php |
||
110 | * "The curl_close() function no longer has an effect, |
||
111 | * instead the CurlHandle instance is automatically destroyed if it is no longer referenced. " |
||
112 | * Use `unset` for PHP 8 compatibility (https://php.watch/versions/8.0/resource-CurlHandle) |
||
113 | */ |
||
114 | \curl_close($this->curl); |
||
115 | unset($this->curl); |
||
116 | |||
117 | $this->debugFinish(); |
||
118 | } |
||
119 | } |
||
120 | |||
121 | protected function debugFinish(): bool |
||
122 | { |
||
123 | if ($this->debug) { |
||
124 | \fclose($this->debugStderr); |
||
125 | $this->debugOutput = (string) \ob_get_clean(); |
||
126 | |||
127 | $this->logger->debug('CURL INFO:', $this->debugInfo); |
||
128 | $this->logger->debug('CURL VERBOSE:', ['debugOutput' => $this->debugOutput]); |
||
129 | if ($this->requestData) { |
||
130 | $this->logger->debug('REQUEST DATA:', ['requestData' => $this->requestData]); |
||
131 | } |
||
132 | if ($this->response) { |
||
133 | $this->logger->debug('CURL RESPONSE:', ['response' => $this->response]); |
||
134 | } |
||
135 | |||
136 | return true; |
||
137 | } |
||
138 | return false; |
||
139 | } |
||
140 | |||
141 | protected function debugInit(): bool |
||
142 | { |
||
143 | if ($this->debug) { |
||
144 | \ob_start(); |
||
145 | $debugStderr = \fopen('php://output', 'w'); |
||
146 | if ($debugStderr) { |
||
0 ignored issues
–
show
|
|||
147 | $this->debugStderr = $debugStderr; |
||
148 | return true; |
||
149 | } |
||
150 | } |
||
151 | return false; |
||
152 | } |
||
153 | |||
154 | protected function debugSetOptions(): bool |
||
155 | { |
||
156 | if ($this->debug) { |
||
157 | //curl_setopt($this->curl, CURLINFO_HEADER_OUT, 1); /* verbose not working if this is enabled */ |
||
158 | \curl_setopt($this->curl, \CURLOPT_VERBOSE, 1); |
||
159 | \curl_setopt($this->curl, \CURLOPT_STDERR, $this->debugStderr); |
||
160 | return true; |
||
161 | } |
||
162 | return false; |
||
163 | } |
||
164 | |||
165 | protected function getHttpCode(): int |
||
166 | { |
||
167 | $httpCode = \curl_getinfo($this->curl, \CURLINFO_RESPONSE_CODE); |
||
168 | if (!$httpCode) { |
||
169 | throw new HttpClientException(\sprintf("Empty HTTP status code. cURL error: %s.", $this->curlError)); |
||
170 | } |
||
171 | return $httpCode; |
||
172 | } |
||
173 | |||
174 | protected function handleRequestMethod(): bool |
||
175 | { |
||
176 | if (false === $this->curl) { |
||
0 ignored issues
–
show
|
|||
177 | throw new HttpClientException('Not a valid resource.'); |
||
178 | } |
||
179 | |||
180 | switch ($this->method) { |
||
181 | case Method::DELETE: |
||
182 | case Method::POST: |
||
183 | /* |
||
184 | * This sets the header application/x-www-form-urlencoded |
||
185 | * Use custom request instead and handle headers manually |
||
186 | * curl_setopt($this->curl, CURLOPT_POST, true); |
||
187 | */ |
||
188 | \curl_setopt($this->curl, \CURLOPT_CUSTOMREQUEST, $this->method); |
||
189 | if ($this->requestData) { |
||
190 | \curl_setopt($this->curl, \CURLOPT_POSTFIELDS, $this->requestData); |
||
191 | if (\is_array($this->requestData)) { |
||
192 | $this->setRequestHeader('Content-Type', 'multipart/form-data'); |
||
193 | } else { |
||
194 | if ($this->requestContentType) { |
||
195 | $this->setRequestHeader('Content-Type', $this->requestContentType); |
||
196 | } |
||
197 | // use strlen and not mb_strlen: "The length of the request body in octets (8-bit bytes)." |
||
198 | $this->setRequestHeader('Content-Length', (string) \strlen((string) $this->requestData)); |
||
199 | } |
||
200 | } |
||
201 | break; |
||
202 | case Method::HEAD: |
||
203 | \curl_setopt($this->curl, \CURLOPT_NOBODY, true); |
||
204 | break; |
||
205 | } |
||
206 | |||
207 | return true; |
||
208 | } |
||
209 | |||
210 | /** |
||
211 | * @param resource $curlResource |
||
212 | */ |
||
213 | // phpcs:ignore SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter |
||
214 | protected function headerCallback($curlResource, string $headerData): int |
||
215 | { |
||
216 | $headerDataTrimmed = \trim($headerData); |
||
217 | if (!$headerDataTrimmed) { |
||
218 | $this->responseHeadersArray[] = $this->responseHeaderArray; |
||
219 | $this->responseHeaderArray = []; |
||
220 | } |
||
221 | $this->responseHeaderArray[] = $headerData; |
||
222 | |||
223 | return \strlen($headerData); |
||
224 | } |
||
225 | |||
226 | /** |
||
227 | * @param array<string,mixed> $headers |
||
228 | * @return array<int,string> |
||
229 | */ |
||
230 | protected function parseRequestHeaders(array $headers): array |
||
231 | { |
||
232 | $data = []; |
||
233 | foreach ($headers as $k => $v) { |
||
234 | if (\is_array($v)) { |
||
235 | foreach ($v as $item) { |
||
236 | $data[] = \sprintf('%s: %s', $k, $item); |
||
237 | } |
||
238 | } else { |
||
239 | $data[] = \sprintf('%s: %s', $k, $v); |
||
240 | } |
||
241 | } |
||
242 | return $data; |
||
243 | } |
||
244 | |||
245 | protected function processResponse(): bool |
||
246 | { |
||
247 | $response = \curl_exec($this->curl); |
||
248 | $this->curlError = \curl_error($this->curl); |
||
249 | if (false === $response) { |
||
250 | throw new HttpClientException(\sprintf("cURL error: %s.", $this->curlError)); |
||
251 | } |
||
252 | $this->response = (string) $response; |
||
253 | return true; |
||
254 | } |
||
255 | |||
256 | protected function processResponseHeaders(): bool |
||
257 | { |
||
258 | $this->responseHeaders = []; |
||
259 | foreach ($this->responseHeadersArray as $item) { |
||
260 | $this->responseHeaders[] = \WebServCo\Framework\Helpers\Http\HeadersHelper::parseArray($item); |
||
261 | } |
||
262 | return true; |
||
263 | } |
||
264 | |||
265 | protected function setCurlOptions(string $url): bool |
||
266 | { |
||
267 | if (false === $this->curl) { |
||
0 ignored issues
–
show
|
|||
268 | throw new HttpClientException('Not a valid resource.'); |
||
269 | } |
||
270 | |||
271 | // set options |
||
272 | \curl_setopt_array( |
||
273 | $this->curl, |
||
274 | [ |
||
275 | \CURLOPT_CONNECTTIMEOUT => 60, // The number of seconds to wait while trying to connect. |
||
276 | \CURLOPT_FOLLOWLOCATION => true, /* follow redirects */ |
||
277 | \CURLOPT_HEADER => false, /* do not include the header in the output */ |
||
278 | \CURLOPT_RETURNTRANSFER => true, /* return instead of outputting */ |
||
279 | \CURLOPT_TIMEOUT => $this->timeout, // The maximum number of seconds to allow cURL functions to execute. |
||
280 | \CURLOPT_URL => $url, |
||
281 | ], |
||
282 | ); |
||
283 | // check if we should ignore ssl errors |
||
284 | if ($this->skipSslVerification) { |
||
285 | // stop cURL from verifying the peer's certificate |
||
286 | \curl_setopt($this->curl, \CURLOPT_SSL_VERIFYPEER, false); |
||
287 | // don't check the existence of a common name in the SSL peer certificate |
||
288 | \curl_setopt($this->curl, \CURLOPT_SSL_VERIFYHOST, 0); |
||
289 | } |
||
290 | |||
291 | return true; |
||
292 | } |
||
293 | |||
294 | protected function setRequestHeaders(): bool |
||
295 | { |
||
296 | if (false === $this->curl) { |
||
0 ignored issues
–
show
|
|||
297 | throw new HttpClientException('Not a valid resource.'); |
||
298 | } |
||
299 | |||
300 | // set headers |
||
301 | if ($this->requestHeaders) { |
||
0 ignored issues
–
show
The expression
$this->requestHeaders of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.
This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent. Consider making the comparison explicit by using
Loading history...
|
|||
302 | \curl_setopt( |
||
303 | $this->curl, |
||
304 | \CURLOPT_HTTPHEADER, |
||
305 | $this->parseRequestHeaders($this->requestHeaders), |
||
306 | ); |
||
307 | } |
||
308 | |||
309 | // Callback to process response headers |
||
310 | \curl_setopt($this->curl, \CURLOPT_HEADERFUNCTION, [$this, 'headerCallback']); |
||
311 | |||
312 | return true; |
||
313 | } |
||
314 | } |
||
315 |
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.