Passed
Push — master ( f9e962...0b76d4 )
by Radu
02:01
created

CurlClient::headerCallback()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

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