Issues (61)

src/WebServCo/Framework/Http/CurlClient.php (6 issues)

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
Documentation Bug introduced by
It seems like curl_init() can also be of type CurlHandle. However, the property $curl is declared as type resource. Maybe add an additional type check?

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 the id property of an instance of the Account 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.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
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
$debugStderr is of type resource, thus it always evaluated to false.
Loading history...
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
The condition false === $this->curl is always false.
Loading history...
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
The condition false === $this->curl is always false.
Loading history...
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
The condition false === $this->curl is always false.
Loading history...
297
            throw new HttpClientException('Not a valid resource.');
298
        }
299
300
        // set headers
301
        if ($this->requestHeaders) {
0 ignored issues
show
Bug Best Practice introduced by
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 empty(..) or ! empty(...) instead.

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