Passed
Pull Request — master (#44)
by Anatoly
34:56
created

Client::executeMultiRequest()   B

Complexity

Conditions 7
Paths 16

Size

Total Lines 38
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 19
CRAP Score 7.0422

Importance

Changes 0
Metric Value
eloc 21
c 0
b 0
f 0
dl 0
loc 38
ccs 19
cts 21
cp 0.9048
rs 8.6506
cc 7
nc 16
nop 1
crap 7.0422
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 preg_split;
43
use function sprintf;
44
use function strpos;
45
use function substr;
46
use function usleep;
47
48
use const CURLINFO_HEADER_SIZE;
49
use const CURLINFO_RESPONSE_CODE;
50
use const CURLINFO_TOTAL_TIME;
51
use const CURLM_CALL_MULTI_PERFORM;
52
use const CURLM_OK;
53
use const CURLOPT_CUSTOMREQUEST;
54
use const CURLOPT_HEADER;
55
use const CURLOPT_HTTPHEADER;
56
use const CURLOPT_POSTFIELDS;
57
use const CURLOPT_RETURNTRANSFER;
58
use const CURLOPT_URL;
59
60
final class Client implements ClientInterface
61
{
62
    private const BODYLESS_HTTP_METHODS = ['HEAD', 'GET'];
63
    private const DEFAULT_CURL_MULTI_SELECT_TIMEOUT = 1.0;
64
    private const DEFAULT_CURL_MULTI_SELECT_SLEEP_DURATION = 1000;
65
    private const REQUEST_TIME_HEADER_FIELD_NAME = 'X-Request-Time';
66
    private const HEADER_FIELD_SEPARATOR = '/\r\n|\n/';
67
68
    private ?CurlMultiHandle $curlMultiHandle = null;
69
70
    /**
71
     * @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...
72
     */
73
    private array $curlHandles = [];
74
75 4
    public function __construct(
76
        private readonly ResponseFactoryInterface $responseFactory,
77
        /** @var array<int, mixed> */
78
        private readonly array $curlOptions = [],
79
        private readonly ?float $curlMultiSelectTimeout = null,
80
        private readonly ?int $curlMultiSelectSleepDuration = null,
81
    ) {
82 4
    }
83
84 4
    public function __destruct()
85
    {
86 4
        $this->clear();
87
    }
88
89
    /**
90
     * @inheritDoc
91
     */
92 3
    public function sendRequest(RequestInterface $request): ResponseInterface
93
    {
94
        try {
95 3
            return $this->executeRequest($request);
96
        } finally {
97 3
            $this->clear();
98
        }
99
    }
100
101 3
    private function executeRequest(RequestInterface $request): ResponseInterface
102
    {
103 3
        return $request instanceof MultiRequest
104 1
            ? $this->executeMultiRequest($request)
105 3
            : $this->executeSingleRequest($request);
106
    }
107
108 2
    private function executeSingleRequest(RequestInterface $request): ResponseInterface
109
    {
110 2
        $curlHandle = $this->createCurlHandleFromRequest($request);
111
112 2
        $curlExecuteResult = curl_exec($curlHandle);
113 2
        if ($curlExecuteResult === false) {
114 1
            throw new NetworkException(
115 1
                $request,
116 1
                curl_error($curlHandle),
117 1
                curl_errno($curlHandle),
118 1
            );
119
        }
120
121 1
        return $this->createResponseFromCurlHandle($curlHandle);
122
    }
123
124 1
    private function executeMultiRequest(MultiRequest $multiRequest): MultiResponse
125
    {
126 1
        $this->curlMultiHandle = curl_multi_init();
127
128 1
        foreach ($multiRequest->getRequests() as $key => $request) {
129 1
            $curlHandle = $this->createCurlHandleFromRequest($request, $key);
130 1
            $curlMultiStatusCode = curl_multi_add_handle($this->curlMultiHandle, $curlHandle);
131 1
            ClientException::assertCurlMultiStatusCodeSame(CURLM_OK, $curlMultiStatusCode);
132
        }
133
134 1
        $curlMultiSelectTimeout = $this->curlMultiSelectTimeout ?? self::DEFAULT_CURL_MULTI_SELECT_TIMEOUT;
135
        // phpcs:ignore Generic.Files.LineLength.TooLong
136 1
        $curlMultiSelectSleepDuration = $this->curlMultiSelectSleepDuration ?? self::DEFAULT_CURL_MULTI_SELECT_SLEEP_DURATION;
137
138
        do {
139 1
            $curlMultiStatusCode = curl_multi_exec($this->curlMultiHandle, $isCurlMultiExecuteStillRunning);
140
            // https://stackoverflow.com/questions/19490837/curlm-call-multi-perform-deprecated
141 1
            if ($curlMultiStatusCode === CURLM_CALL_MULTI_PERFORM) {
142
                continue;
143
            }
144
145 1
            ClientException::assertCurlMultiStatusCodeSame(CURLM_OK, $curlMultiStatusCode);
146
147 1
            if ($isCurlMultiExecuteStillRunning) {
148 1
                $curlMultiSelectResult = curl_multi_select($this->curlMultiHandle, $curlMultiSelectTimeout);
149 1
                if ($curlMultiSelectResult === -1) {
150
                    // Take pauses to reduce CPU load...
151
                    usleep($curlMultiSelectSleepDuration);
152
                }
153
            }
154 1
        } while ($isCurlMultiExecuteStillRunning);
155
156 1
        $responses = [];
157 1
        foreach ($this->curlHandles as $key => $curlHandle) {
158 1
            $responses[$key] = $this->createResponseFromCurlHandle($curlHandle);
159
        }
160
161 1
        return new MultiResponse(...$responses);
162
    }
163
164 3
    private function createCurlHandleFromRequest(RequestInterface $request, int|string $key = 0): CurlHandle
165
    {
166 3
        $curlOptions = $this->curlOptions;
167
168 3
        $curlOptions[CURLOPT_CUSTOMREQUEST] = $request->getMethod();
169 3
        $curlOptions[CURLOPT_URL] = (string) $request->getUri();
170
171 3
        $curlOptions[CURLOPT_HTTPHEADER] = [];
172 3
        foreach ($request->getHeaders() as $name => $values) {
173 2
            foreach ($values as $value) {
174 2
                $curlOptions[CURLOPT_HTTPHEADER][] = sprintf('%s: %s', $name, $value);
175
            }
176
        }
177
178 3
        $curlOptions[CURLOPT_POSTFIELDS] = null;
179 3
        if (!in_array($request->getMethod(), self::BODYLESS_HTTP_METHODS, true)) {
180
            $curlOptions[CURLOPT_POSTFIELDS] = (string) $request->getBody();
181
        }
182
183 3
        $curlOptions[CURLOPT_RETURNTRANSFER] = true;
184 3
        $curlOptions[CURLOPT_HEADER] = true;
185
186 3
        $curlHandle = curl_init();
187 3
        if ($curlHandle === false) {
188
            throw new ClientException('Unable to create CurlHandle.');
189
        }
190
191 3
        $this->curlHandles[$key] = $curlHandle;
192
193 3
        $curlSetOptionsResult = curl_setopt_array($curlHandle, $curlOptions);
194 3
        if ($curlSetOptionsResult === false) {
195
            throw new ClientException('Unable to configure CurlHandle.');
196
        }
197
198 3
        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...
199
    }
200
201 2
    private function createResponseFromCurlHandle(CurlHandle $curlHandle): ResponseInterface
202
    {
203
        /** @var int $responseStatusCode */
204 2
        $responseStatusCode = curl_getinfo($curlHandle, CURLINFO_RESPONSE_CODE);
205 2
        $response = $this->responseFactory->createResponse($responseStatusCode);
206
207
        /** @var float $requestTime */
208 2
        $requestTime = curl_getinfo($curlHandle, CURLINFO_TOTAL_TIME);
209 2
        $formattedRequestTime = sprintf('%.3f ms', $requestTime * 1000);
210 2
        $response = $response->withAddedHeader(self::REQUEST_TIME_HEADER_FIELD_NAME, $formattedRequestTime);
211
212
        /** @var string $responseMessage */
213 2
        $responseMessage = curl_multi_getcontent($curlHandle);
214
215
        /** @var int $responseHeaderSize */
216 2
        $responseHeaderSize = curl_getinfo($curlHandle, CURLINFO_HEADER_SIZE);
217 2
        $responseHeader = substr($responseMessage, 0, $responseHeaderSize);
218 2
        $response = $this->populateResponseWithHeaderFields($response, $responseHeader);
219
220 2
        $responseContent = substr($responseMessage, $responseHeaderSize);
221 2
        $response->getBody()->write($responseContent);
222
223 2
        return $response;
224
    }
225
226 2
    private function populateResponseWithHeaderFields(ResponseInterface $response, string $header): ResponseInterface
227
    {
228
        /** @var array<int, string> $fields */
229
        // @phpstan-ignore varTag.nativeType
230 2
        $fields = preg_split(self::HEADER_FIELD_SEPARATOR, $header);
231
232 2
        foreach ($fields as $i => $field) {
233
            // https://datatracker.ietf.org/doc/html/rfc7230#section-3.1.2
234 2
            if ($i === 0) {
235 2
                continue;
236
            }
237
238
            // https://datatracker.ietf.org/doc/html/rfc7230#section-3
239
            // https://datatracker.ietf.org/doc/html/rfc5322
240 2
            if ($field === '') {
241 2
                break;
242
            }
243
244 2
            if (strpos($field, ':') === false) {
245
                continue;
246
            }
247
248
            /** @psalm-suppress PossiblyUndefinedArrayOffset */
249 2
            [$fieldName, $fieldValue] = explode(':', $field, 2);
250
251 2
            $response = $response->withAddedHeader($fieldName, ltrim($fieldValue));
252
        }
253
254 2
        return $response;
255
    }
256
257 4
    private function clear(): void
258
    {
259 4
        if ($this->curlMultiHandle instanceof CurlMultiHandle) {
260 1
            foreach ($this->curlHandles as $curlSingleHandle) {
261 1
                curl_multi_remove_handle($this->curlMultiHandle, $curlSingleHandle);
262
            }
263
        }
264
265 4
        foreach ($this->curlHandles as $curlSingleHandle) {
266 3
            curl_close($curlSingleHandle);
267
        }
268
269 4
        if ($this->curlMultiHandle instanceof CurlMultiHandle) {
270 1
            curl_multi_close($this->curlMultiHandle);
271
        }
272
273 4
        $this->curlMultiHandle = null;
274 4
        $this->curlHandles = [];
275
    }
276
}
277