Completed
Push — master ( ab2f67...9155c4 )
by Radu
01:45
created

CurlClient::processResponse()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 6
c 0
b 0
f 0
dl 0
loc 9
rs 10
cc 2
nc 2
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::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) {
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...
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