Completed
Push — master ( 6acfe1...0042b2 )
by Radu
11:51 queued 10s
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
    protected \WebServCo\Framework\Interfaces\LoggerInterface $logger;
38
39
    protected string $response;
40
41
    /**
42
    * Response header array
43
    *
44
    * @var array<int,string>
45
    */
46
    protected array $responseHeaderArray;
47
48
    /**
49
    * Response headers array
50
    *
51
    * @var array<int,array<int,string>>
52
    */
53
    protected array $responseHeadersArray;
54
55
    public function __construct(\WebServCo\Framework\Interfaces\LoggerInterface $logger)
56
    {
57
        $this->logger = $logger;
58
        $this->debug = false;
59
        $this->skipSslVerification = false;
60
        $this->requestHeaders = [];
61
        // Default Content-Type for
62
        $this->requestContentType = 'application/x-www-form-urlencoded';
63
    }
64
65
    public function retrieve(string $url): Response
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
        if (!\is_resource($this->curl)) {
71
            throw new HttpClientException('Not a valid cURL resource.');
72
        }
73
74
        $this->setCurlOptions($url);
75
76
        $this->debugDo();
77
78
        $this->handleRequestMethod();
79
80
        $this->setRequestHeaders();
81
82
        $this->processResponse();
83
84
        $this->debugInfo = \curl_getinfo($this->curl);
85
86
        \curl_close($this->curl);
87
88
        $httpCode = $this->getHttpCode();
89
90
        if (empty($httpCode)) {
91
            throw new HttpClientException(\sprintf("Empty HTTP status code. cURL error: %s.", $this->curlError));
92
        }
93
94
        $body = \trim($this->response);
95
96
        $this->processResponseHeaders();
97
98
        $this->debugFinish();
99
100
        $headers = \end($this->responseHeaders);
101
        return new Response(
102
            $body,
103
            $httpCode,
104
            \is_array($headers) ? $headers : [],
105
        );
106
    }
107
108
    protected function debugDo(): bool
109
    {
110
        if ($this->debug) {
111
            //curl_setopt($this->curl, CURLINFO_HEADER_OUT, 1); /* verbose not working if this is enabled */
112
            \curl_setopt($this->curl, \CURLOPT_VERBOSE, 1);
113
            \curl_setopt($this->curl, \CURLOPT_STDERR, $this->debugStderr);
114
            return true;
115
        }
116
        return false;
117
    }
118
119
    protected function debugFinish(): bool
120
    {
121
        if ($this->debug) {
122
            \fclose($this->debugStderr);
123
            $this->debugOutput = (string) \ob_get_clean();
124
125
            $this->logger->debug('CURL INFO:', $this->debugInfo);
126
            $this->logger->debug('CURL VERBOSE:', ['debugOutput' => $this->debugOutput]);
127
            if (!empty($this->requestData)) {
128
                $this->logger->debug('REQUEST DATA:', ['requestData' => $this->requestData]);
129
            }
130
            $this->logger->debug('CURL RESPONSE:', ['response' => $this->response]);
131
132
            return true;
133
        }
134
        return false;
135
    }
136
137
    protected function debugInit(): bool
138
    {
139
        if ($this->debug) {
140
            \ob_start();
141
            $debugStderr = \fopen('php://output', 'w');
142
            if ($debugStderr) {
0 ignored issues
show
introduced by
$debugStderr is of type resource, thus it always evaluated to false.
Loading history...
143
                $this->debugStderr = $debugStderr;
144
                return true;
145
            }
146
        }
147
        return false;
148
    }
149
150
    protected function getHttpCode(): int
151
    {
152
        return isset($this->debugInfo['http_code'])
153
            ? (int) $this->debugInfo['http_code']
154
            : 0;
155
    }
156
157
    protected function handleRequestMethod(): bool
158
    {
159
        if (!\is_resource($this->curl)) {
160
            throw new HttpClientException('Not a valid resource.');
161
        }
162
163
        switch ($this->method) {
164
            case Method::POST:
165
                /*
166
                * This sets the header application/x-www-form-urlencoded
167
                * Use custom request instead and handle headers manually
168
                * curl_setopt($this->curl, CURLOPT_POST, true);
169
                */
170
                \curl_setopt($this->curl, \CURLOPT_CUSTOMREQUEST, $this->method);
171
                if (!empty($this->requestData)) {
172
                    \curl_setopt($this->curl, \CURLOPT_POSTFIELDS, $this->requestData);
173
                    if (\is_array($this->requestData)) {
174
                        $this->setRequestHeader('Content-Type', 'multipart/form-data');
175
                    } else {
176
                        $this->setRequestHeader('Content-Type', $this->requestContentType);
177
                        // use strlen and not mb_strlen: "The length of the request body in octets (8-bit bytes)."
178
                        $this->setRequestHeader('Content-Length', (string) \strlen($this->requestData));
179
                    }
180
                }
181
                break;
182
            case Method::HEAD:
183
                \curl_setopt($this->curl, \CURLOPT_NOBODY, true);
184
                break;
185
        }
186
187
        return true;
188
    }
189
190
    /**
191
    * @param resource $curlResource
192
    */
193
    // phpcs:ignore SlevomatCodingStandard.Functions.UnusedParameter.UnusedParameter
194
    protected function headerCallback($curlResource, string $headerData): int
195
    {
196
        $headerDataTrimmed = \trim($headerData);
197
        if (empty($headerDataTrimmed)) {
198
            $this->responseHeadersArray[] = $this->responseHeaderArray;
199
            $this->responseHeaderArray = [];
200
        }
201
        $this->responseHeaderArray[] = $headerData;
202
203
        return \strlen($headerData);
204
    }
205
206
    /**
207
    * @param array<string,mixed> $headers
208
    * @return array<int,string>
209
    */
210
    protected function parseRequestHeaders(array $headers): array
211
    {
212
        $data = [];
213
        foreach ($headers as $k => $v) {
214
            if (\is_array($v)) {
215
                foreach ($v as $item) {
216
                    $data[] = \sprintf('%s: %s', $k, $item);
217
                }
218
            } else {
219
                $data[] = \sprintf('%s: %s', $k, $v);
220
            }
221
        }
222
        return $data;
223
    }
224
225
    /**
226
    * @param array<int,string> $responseHeadersArray
227
    * @return array<string,mixed>
228
    */
229
    protected function parseResponseHeadersArray(array $responseHeadersArray = []): array
230
    {
231
        $headers = [];
232
233
        foreach ($responseHeadersArray as $index => $line) {
234
            if (0 === $index) {
235
                continue; /* we'll get the status code elsewhere */
236
            }
237
            $parts = \explode(': ', $line, 2);
238
            if (!isset($parts[1])) {
239
                continue; // invalid header (missing colon)
240
            }
241
            [$key, $value] = $parts;
242
            if (isset($headers[$key])) {
243
                if (!\is_array($headers[$key])) {
244
                    $headers[$key] = [$headers[$key]];
245
                }
246
                // check cookies
247
                if ('Set-Cookie' === $key) {
248
                    $parts = \explode('=', $value, 2);
249
                    $cookieName = $parts[0];
250
                    if (\is_array($headers[$key])) {
251
                        foreach ($headers[$key] as $cookieIndex => $existingCookie) {
252
                            // check if we already have a cookie with the same name
253
                            if (0 !== \mb_stripos($existingCookie, $cookieName)) {
254
                                continue;
255
                            }
256
257
                            // remove previous cookie with the same name
258
                            unset($headers[$key][$cookieIndex]);
259
                        }
260
                    }
261
                }
262
                $headers[$key][] = \trim($value);
263
                $headers[$key] = \array_values((array) $headers[$key]); // re-index array
264
            } else {
265
                $headers[$key][] = \trim($value);
266
            }
267
        }
268
        return $headers;
269
    }
270
271
    protected function processResponse(): bool
272
    {
273
        $response = \curl_exec($this->curl);
274
        $this->curlError = \curl_error($this->curl);
275
        if (false === $response) {
276
            throw new HttpClientException(\sprintf("cURL error: %s.", $this->curlError));
277
        }
278
        $this->response = (string) $response;
279
        return true;
280
    }
281
282
    protected function processResponseHeaders(): bool
283
    {
284
        $this->responseHeaders = [];
285
        foreach ($this->responseHeadersArray as $item) {
286
            $this->responseHeaders[] = $this->parseResponseHeadersArray($item);
287
        }
288
        return true;
289
    }
290
291
    protected function setCurlOptions(string $url): bool
292
    {
293
        if (!\is_resource($this->curl)) {
294
            throw new HttpClientException('Not a valid resource.');
295
        }
296
297
        // set options
298
        \curl_setopt_array(
299
            $this->curl,
300
            [
301
                \CURLOPT_RETURNTRANSFER => true, /* return instead of outputting */
302
                \CURLOPT_URL => $url,
303
                \CURLOPT_HEADER => false, /* do not include the header in the output */
304
                \CURLOPT_FOLLOWLOCATION => true, /* follow redirects */
305
                \CURLOPT_CONNECTTIMEOUT => 60, // The number of seconds to wait while trying to connect.
306
                \CURLOPT_TIMEOUT => 60, // The maximum number of seconds to allow cURL functions to execute.
307
            ],
308
        );
309
        // check if we should ignore ssl errors
310
        if ($this->skipSslVerification) {
311
            // stop cURL from verifying the peer's certificate
312
            \curl_setopt($this->curl, \CURLOPT_SSL_VERIFYPEER, false);
313
            // don't check the existence of a common name in the SSL peer certificate
314
            \curl_setopt($this->curl, \CURLOPT_SSL_VERIFYHOST, 0);
315
        }
316
        return true;
317
    }
318
319
    protected function setRequestHeaders(): bool
320
    {
321
        if (!\is_resource($this->curl)) {
322
            throw new HttpClientException('Not a valid resource.');
323
        }
324
325
        // set headers
326
        if (!empty($this->requestHeaders)) {
327
            \curl_setopt(
328
                $this->curl,
329
                \CURLOPT_HTTPHEADER,
330
                $this->parseRequestHeaders($this->requestHeaders),
331
            );
332
        }
333
334
        // Callback to process response headers
335
        \curl_setopt($this->curl, \CURLOPT_HEADERFUNCTION, [$this, 'headerCallback']);
336
337
        return true;
338
    }
339
}
340