Client::__destruct()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
eloc 1
c 0
b 0
f 0
dl 0
loc 3
ccs 2
cts 2
cp 1
rs 10
cc 1
nc 1
nop 0
crap 1
1
<?php
2
3
/**
4
 * It's free open-source software released under the MIT License.
5
 *
6
 * @author Anatoly Nekhay <[email protected]>
7
 * @copyright Copyright (c) 2018, Anatoly Nekhay
8
 * @license https://github.com/sunrise-php/http-client-curl/blob/master/LICENSE
9
 * @link https://github.com/sunrise-php/http-client-curl
10
 */
11
12
declare(strict_types=1);
13
14
namespace Sunrise\Http\Client\Curl;
15
16
use CurlHandle;
17
use CurlMultiHandle;
18
use Psr\Http\Client\ClientInterface;
19
use Psr\Http\Message\RequestInterface;
20
use Psr\Http\Message\ResponseFactoryInterface;
21
use Psr\Http\Message\ResponseInterface;
22
use Sunrise\Http\Client\Curl\Exception\ClientException;
23
use Sunrise\Http\Client\Curl\Exception\NetworkException;
24
25
use function curl_close;
26
use function curl_errno;
27
use function curl_error;
28
use function curl_exec;
29
use function curl_getinfo;
30
use function curl_init;
31
use function curl_multi_add_handle;
32
use function curl_multi_close;
33
use function curl_multi_exec;
34
use function curl_multi_getcontent;
35
use function curl_multi_init;
36
use function curl_multi_remove_handle;
37
use function curl_multi_select;
38
use function curl_setopt_array;
39
use function explode;
40
use function in_array;
41
use function ltrim;
42
use function sprintf;
43
use function strpos;
44
use function substr;
45
use function usleep;
46
47
use const CURLINFO_HEADER_SIZE;
48
use const CURLINFO_RESPONSE_CODE;
49
use const CURLINFO_TOTAL_TIME;
50
use const CURLM_CALL_MULTI_PERFORM;
51
use const CURLM_OK;
52
use const CURLOPT_CUSTOMREQUEST;
53
use const CURLOPT_HEADER;
54
use const CURLOPT_HTTPHEADER;
55
use const CURLOPT_POSTFIELDS;
56
use const CURLOPT_RETURNTRANSFER;
57
use const CURLOPT_URL;
58
59
final class Client implements ClientInterface
60
{
61
    private const BODYLESS_HTTP_METHODS = ['HEAD', 'GET'];
62
    private const DEFAULT_CURL_MULTI_SELECT_TIMEOUT = 1.0;
63
    private const DEFAULT_CURL_MULTI_SELECT_SLEEP_DURATION = 1000;
64
    private const REQUEST_TIME_HEADER_FIELD_NAME = 'X-Request-Time';
65
    private const HEADER_FIELD_SEPARATOR = "\r\n";
66
67
    private ?CurlMultiHandle $curlMultiHandle = null;
68
69
    /**
70
     * @var array<array-key, CurlHandle>
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<array-key, CurlHandle> at position 2 could not be parsed: Unknown type name 'array-key' at position 2 in array<array-key, CurlHandle>.
Loading history...
71
     */
72
    private array $curlHandles = [];
73
74 4
    public function __construct(
75
        private readonly ResponseFactoryInterface $responseFactory,
76
        /** @var array<int, mixed> */
77
        private readonly array $curlOptions = [],
78
        private readonly ?float $curlMultiSelectTimeout = null,
79
        private readonly ?int $curlMultiSelectSleepDuration = null,
80
    ) {
81 4
    }
82
83 4
    public function __destruct()
84
    {
85 4
        $this->clear();
86
    }
87
88
    /**
89
     * @inheritDoc
90
     *
91
     * @return ($request is MultiRequest ? MultiResponse : ResponseInterface)
0 ignored issues
show
Documentation Bug introduced by
The doc comment ($request at position 1 could not be parsed: Unknown type name '$request' at position 1 in ($request.
Loading history...
92
     */
93 4
    public function sendRequest(RequestInterface $request): ResponseInterface
94
    {
95
        try {
96 4
            return $this->executeRequest($request);
97
        } finally {
98 4
            $this->clear();
99
        }
100
    }
101
102 4
    private function executeRequest(RequestInterface $request): ResponseInterface
103
    {
104 4
        return $request instanceof MultiRequest
105 2
            ? $this->executeMultiRequest($request)
106 3
            : $this->executeSingleRequest($request);
107
    }
108
109 2
    private function executeSingleRequest(RequestInterface $request): ResponseInterface
110
    {
111 2
        $curlHandle = $this->createCurlHandleFromRequest($request);
112
113 2
        $curlExecuteResult = curl_exec($curlHandle);
114 2
        if ($curlExecuteResult === false) {
115 1
            throw new NetworkException(
116 1
                $request,
117 1
                curl_error($curlHandle),
118 1
                curl_errno($curlHandle),
119 1
            );
120
        }
121
122 1
        return $this->createResponseFromCurlHandle($curlHandle);
123
    }
124
125 2
    private function executeMultiRequest(MultiRequest $multiRequest): MultiResponse
126
    {
127 2
        $this->curlMultiHandle = curl_multi_init();
128
129 2
        foreach ($multiRequest->getRequests() as $key => $request) {
130 2
            $curlHandle = $this->createCurlHandleFromRequest($request, $key);
131 2
            $curlMultiStatusCode = curl_multi_add_handle($this->curlMultiHandle, $curlHandle);
132 2
            ClientException::assertCurlMultiStatusCodeSame(CURLM_OK, $curlMultiStatusCode);
133
        }
134
135 2
        $curlMultiSelectTimeout = $this->curlMultiSelectTimeout ?? self::DEFAULT_CURL_MULTI_SELECT_TIMEOUT;
136
        // phpcs:ignore Generic.Files.LineLength.TooLong
137 2
        $curlMultiSelectSleepDuration = $this->curlMultiSelectSleepDuration ?? self::DEFAULT_CURL_MULTI_SELECT_SLEEP_DURATION;
138
139
        do {
140 2
            $curlMultiStatusCode = curl_multi_exec($this->curlMultiHandle, $isCurlMultiExecuteStillRunning);
141
            // https://stackoverflow.com/questions/19490837/curlm-call-multi-perform-deprecated
142 2
            if ($curlMultiStatusCode === CURLM_CALL_MULTI_PERFORM) {
143
                continue;
144
            }
145
146 2
            ClientException::assertCurlMultiStatusCodeSame(CURLM_OK, $curlMultiStatusCode);
147
148 2
            if ($isCurlMultiExecuteStillRunning) {
149 1
                $curlMultiSelectResult = curl_multi_select($this->curlMultiHandle, $curlMultiSelectTimeout);
150 1
                if ($curlMultiSelectResult === -1) {
151
                    // Take pauses to reduce CPU load...
152
                    usleep($curlMultiSelectSleepDuration);
153
                }
154
            }
155 2
        } while ($isCurlMultiExecuteStillRunning);
156
157 2
        $responses = [];
158 2
        foreach ($this->curlHandles as $key => $curlHandle) {
159 2
            $responses[$key] = $this->createResponseFromCurlHandle($curlHandle);
160
        }
161
162 1
        return new MultiResponse(...$responses);
163
    }
164
165 4
    private function createCurlHandleFromRequest(RequestInterface $request, int|string $key = 0): CurlHandle
166
    {
167 4
        $curlOptions = $this->curlOptions;
168
169 4
        $curlOptions[CURLOPT_CUSTOMREQUEST] = $request->getMethod();
170 4
        $curlOptions[CURLOPT_URL] = (string) $request->getUri();
171
172 4
        $curlOptions[CURLOPT_HTTPHEADER] = [];
173 4
        foreach ($request->getHeaders() as $name => $values) {
174 2
            foreach ($values as $value) {
175 2
                $curlOptions[CURLOPT_HTTPHEADER][] = sprintf('%s: %s', $name, $value);
176
            }
177
        }
178
179 4
        $curlOptions[CURLOPT_POSTFIELDS] = null;
180 4
        if (!in_array($request->getMethod(), self::BODYLESS_HTTP_METHODS, true)) {
181
            $curlOptions[CURLOPT_POSTFIELDS] = (string) $request->getBody();
182
        }
183
184 4
        $curlOptions[CURLOPT_RETURNTRANSFER] = true;
185 4
        $curlOptions[CURLOPT_HEADER] = true;
186
187 4
        $curlHandle = curl_init();
188 4
        if ($curlHandle === false) {
189
            throw new ClientException('Unable to create CurlHandle.');
190
        }
191
192 4
        $this->curlHandles[$key] = $curlHandle;
193
194 4
        $curlSetOptionsResult = curl_setopt_array($curlHandle, $curlOptions);
195 4
        if ($curlSetOptionsResult === false) {
196
            throw new ClientException('Unable to configure CurlHandle.');
197
        }
198
199 4
        return $curlHandle;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $curlHandle could return the type resource which is incompatible with the type-hinted return CurlHandle. Consider adding an additional type-check to rule them out.
Loading history...
200
    }
201
202 3
    private function createResponseFromCurlHandle(CurlHandle $curlHandle): ResponseInterface
203
    {
204
        /** @var int $responseStatusCode */
205 3
        $responseStatusCode = curl_getinfo($curlHandle, CURLINFO_RESPONSE_CODE);
206 3
        if ($responseStatusCode === 0) {
207 1
            throw new ClientException(
208 1
                'Failed to retrieve response code. Please check the request and verify network accessibility.'
209 1
            );
210
        }
211
212 2
        $response = $this->responseFactory->createResponse($responseStatusCode);
213
214
        /** @var float $requestTime */
215 2
        $requestTime = curl_getinfo($curlHandle, CURLINFO_TOTAL_TIME);
216 2
        $formattedRequestTime = sprintf('%.3f ms', $requestTime * 1000.);
217 2
        $response = $response->withAddedHeader(self::REQUEST_TIME_HEADER_FIELD_NAME, $formattedRequestTime);
218
219
        /** @var string $responseMessage */
220 2
        $responseMessage = curl_multi_getcontent($curlHandle);
221
222
        /** @var int $responseHeaderSize */
223 2
        $responseHeaderSize = curl_getinfo($curlHandle, CURLINFO_HEADER_SIZE);
224 2
        $responseHeader = substr($responseMessage, 0, $responseHeaderSize);
225 2
        $response = $this->populateResponseWithHeaderFields($response, $responseHeader);
226
227 2
        $responseContent = substr($responseMessage, $responseHeaderSize);
228 2
        $response->getBody()->write($responseContent);
229 2
        $response->getBody()->rewind();
230
231 2
        return $response;
232
    }
233
234 2
    private function populateResponseWithHeaderFields(ResponseInterface $response, string $header): ResponseInterface
235
    {
236 2
        $fields = explode(self::HEADER_FIELD_SEPARATOR, $header);
237
238 2
        foreach ($fields as $i => $field) {
239
            // https://datatracker.ietf.org/doc/html/rfc7230#section-3.1.2
240 2
            if ($i === 0) {
241 2
                continue;
242
            }
243
244
            // https://datatracker.ietf.org/doc/html/rfc7230#section-3
245
            // https://datatracker.ietf.org/doc/html/rfc5322
246 2
            if ($field === '') {
247 2
                break;
248
            }
249
250 2
            if (strpos($field, ':') === false) {
251
                continue;
252
            }
253
254
            /** @psalm-suppress PossiblyUndefinedArrayOffset */
255 2
            [$fieldName, $fieldValue] = explode(':', $field, 2);
256
257 2
            $response = $response->withAddedHeader($fieldName, ltrim($fieldValue));
258
        }
259
260 2
        return $response;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $response could return the type Psr\Http\Message\MessageInterface which includes types incompatible with the type-hinted return Psr\Http\Message\ResponseInterface. Consider adding an additional type-check to rule them out.
Loading history...
261
    }
262
263 4
    private function clear(): void
264
    {
265 4
        foreach ($this->curlHandles as $curlHandle) {
266 4
            if ($this->curlMultiHandle instanceof CurlMultiHandle) {
267 2
                curl_multi_remove_handle($this->curlMultiHandle, $curlHandle);
268
            }
269
270 4
            curl_close($curlHandle);
271
        }
272
273 4
        if ($this->curlMultiHandle instanceof CurlMultiHandle) {
274 2
            curl_multi_close($this->curlMultiHandle);
275
        }
276
277 4
        $this->curlMultiHandle = null;
278 4
        $this->curlHandles = [];
279
    }
280
}
281