Completed
Push — master ( 528705...013ef7 )
by Radu
02:04
created

CurlClient::handleRequestMethod()   A

Complexity

Conditions 6
Paths 6

Size

Total Lines 31
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 17
c 1
b 0
f 0
dl 0
loc 31
rs 9.0777
cc 6
nc 6
nop 0
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();
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...
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) {
0 ignored issues
show
introduced by
$debugStderr is of type resource, thus it always evaluated to false.
Loading history...
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::POST:
158
                /*
159
                * This sets the header application/x-www-form-urlencoded
160
                * Use custom request instead and handle headers manually
161
                * curl_setopt($this->curl, CURLOPT_POST, true);
162
                */
163
                \curl_setopt($this->curl, \CURLOPT_CUSTOMREQUEST, $this->method);
164
                if ($this->requestData) {
165
                    \curl_setopt($this->curl, \CURLOPT_POSTFIELDS, $this->requestData);
166
                    if (\is_array($this->requestData)) {
167
                        $this->setRequestHeader('Content-Type', 'multipart/form-data');
168
                    } else {
169
                        $this->setRequestHeader('Content-Type', $this->requestContentType);
170
                        // use strlen and not mb_strlen: "The length of the request body in octets (8-bit bytes)."
171
                        $this->setRequestHeader('Content-Length', (string) \strlen($this->requestData));
172
                    }
173
                }
174
                break;
175
            case Method::HEAD:
176
                \curl_setopt($this->curl, \CURLOPT_NOBODY, true);
177
                break;
178
        }
179
180
        return true;
181
    }
182
183
    /**
184
    * @param resource $curlResource
185
    */
186
    // phpcs:ignore SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter
187
    protected function headerCallback($curlResource, string $headerData): int
188
    {
189
        $headerDataTrimmed = \trim($headerData);
190
        if (!$headerDataTrimmed) {
191
            $this->responseHeadersArray[] = $this->responseHeaderArray;
192
            $this->responseHeaderArray = [];
193
        }
194
        $this->responseHeaderArray[] = $headerData;
195
196
        return \strlen($headerData);
197
    }
198
199
    /**
200
    * @param array<string,mixed> $headers
201
    * @return array<int,string>
202
    */
203
    protected function parseRequestHeaders(array $headers): array
204
    {
205
        $data = [];
206
        foreach ($headers as $k => $v) {
207
            if (\is_array($v)) {
208
                foreach ($v as $item) {
209
                    $data[] = \sprintf('%s: %s', $k, $item);
210
                }
211
            } else {
212
                $data[] = \sprintf('%s: %s', $k, $v);
213
            }
214
        }
215
        return $data;
216
    }
217
218
    /**
219
    * @param array<int,string> $responseHeadersArray
220
    * @return array<string,mixed>
221
    */
222
    protected function parseResponseHeadersArray(array $responseHeadersArray = []): array
223
    {
224
        $headers = [];
225
226
        foreach ($responseHeadersArray as $index => $line) {
227
            if (0 === $index) {
228
                continue; /* we'll get the status code elsewhere */
229
            }
230
            $parts = \explode(': ', $line, 2);
231
            if (!isset($parts[1])) {
232
                continue; // invalid header (missing colon)
233
            }
234
            [$key, $value] = $parts;
235
            if (isset($headers[$key])) {
236
                if (!\is_array($headers[$key])) {
237
                    $headers[$key] = [$headers[$key]];
238
                }
239
                // check cookies
240
                if ('Set-Cookie' === $key) {
241
                    $parts = \explode('=', $value, 2);
242
                    $cookieName = $parts[0];
243
                    if (\is_array($headers[$key])) {
244
                        foreach ($headers[$key] as $cookieIndex => $existingCookie) {
245
                            // check if we already have a cookie with the same name
246
                            if (0 !== \mb_stripos($existingCookie, $cookieName)) {
247
                                continue;
248
                            }
249
250
                            // remove previous cookie with the same name
251
                            unset($headers[$key][$cookieIndex]);
252
                        }
253
                    }
254
                }
255
                $headers[$key][] = \trim($value);
256
                $headers[$key] = \array_values((array) $headers[$key]); // re-index array
257
            } else {
258
                $headers[$key][] = \trim($value);
259
            }
260
        }
261
        return $headers;
262
    }
263
264
    protected function processResponse(): bool
265
    {
266
        $response = \curl_exec($this->curl);
267
        $this->curlError = \curl_error($this->curl);
268
        if (false === $response) {
269
            throw new HttpClientException(\sprintf("cURL error: %s.", $this->curlError));
270
        }
271
        $this->response = (string) $response;
272
        return true;
273
    }
274
275
    protected function processResponseHeaders(): bool
276
    {
277
        $this->responseHeaders = [];
278
        foreach ($this->responseHeadersArray as $item) {
279
            $this->responseHeaders[] = $this->parseResponseHeadersArray($item);
280
        }
281
        return true;
282
    }
283
284
    protected function setCurlOptions(string $url): bool
285
    {
286
        if (!\is_resource($this->curl)) {
287
            throw new HttpClientException('Not a valid resource.');
288
        }
289
290
        // set options
291
        \curl_setopt_array(
292
            $this->curl,
293
            [
294
                \CURLOPT_RETURNTRANSFER => true, /* return instead of outputting */
295
                \CURLOPT_URL => $url,
296
                \CURLOPT_HEADER => false, /* do not include the header in the output */
297
                \CURLOPT_FOLLOWLOCATION => true, /* follow redirects */
298
                \CURLOPT_CONNECTTIMEOUT => 60, // The number of seconds to wait while trying to connect.
299
                \CURLOPT_TIMEOUT => 60, // The maximum number of seconds to allow cURL functions to execute.
300
            ],
301
        );
302
        // check if we should ignore ssl errors
303
        if ($this->skipSslVerification) {
304
            // stop cURL from verifying the peer's certificate
305
            \curl_setopt($this->curl, \CURLOPT_SSL_VERIFYPEER, false);
306
            // don't check the existence of a common name in the SSL peer certificate
307
            \curl_setopt($this->curl, \CURLOPT_SSL_VERIFYHOST, 0);
308
        }
309
310
        return true;
311
    }
312
313
    protected function setRequestHeaders(): bool
314
    {
315
        if (!\is_resource($this->curl)) {
316
            throw new HttpClientException('Not a valid resource.');
317
        }
318
319
        // set headers
320
        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...
321
            \curl_setopt(
322
                $this->curl,
323
                \CURLOPT_HTTPHEADER,
324
                $this->parseRequestHeaders($this->requestHeaders),
325
            );
326
        }
327
328
        // Callback to process response headers
329
        \curl_setopt($this->curl, \CURLOPT_HEADERFUNCTION, [$this, 'headerCallback']);
330
331
        return true;
332
    }
333
}
334