├── .editorconfig
├── .gitignore
├── .scrutinizer.yml
├── LICENSE
├── README.md
├── composer.json
├── phpcs.xml.dist
├── phpstan.neon.php
├── phpunit.xml.dist
├── psalm.xml.dist
├── renovate.json
├── resources
└── definitions
│ ├── client.php
│ └── decorator
│ └── retryable_client.php
├── src
├── Client.php
├── Decorator
│ └── RetryableClient.php
├── Exception
│ ├── ClientException.php
│ ├── NetworkException.php
│ └── RequestException.php
├── MultiRequest.php
└── MultiResponse.php
└── tests
├── ClientTest.php
├── Decorator
└── RetryableClientTest.php
├── Exception
├── ClientExceptionTest.php
├── NetworkExceptionTest.php
└── RequestExceptionTest.php
├── MultiRequestTest.php
└── MultiResponseTest.php
/.editorconfig:
--------------------------------------------------------------------------------
1 | # More info at:
2 | # https://editorconfig.org/
3 |
4 | root = true
5 |
6 | [*]
7 | charset = utf-8
8 | end_of_line = lf
9 | indent_style = space
10 | indent_size = 4
11 | trim_trailing_whitespace = true
12 | insert_final_newline = true
13 |
14 | [*{.json,.yml}]
15 | indent_size = 2
16 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.php_cs.cache
2 | /.phpunit.result.cache
3 | /composer.lock
4 | /coverage.xml
5 | /phpbench.json
6 | /phpcs.xml
7 | /phpunit.xml
8 | /psalm.xml
9 | /vendor/
10 |
--------------------------------------------------------------------------------
/.scrutinizer.yml:
--------------------------------------------------------------------------------
1 | build:
2 | image: default-bionic
3 | nodes:
4 | analysis:
5 | environment:
6 | php: 8.3.16
7 | tests:
8 | override:
9 | - php-scrutinizer-run
10 | coverage:
11 | environment:
12 | php: 8.3.16
13 | tests:
14 | override:
15 | - command: XDEBUG_MODE=coverage php vendor/bin/phpunit --coverage-clover coverage.xml
16 | coverage:
17 | file: coverage.xml
18 | format: clover
19 | php83:
20 | environment:
21 | php: 8.3.16
22 | tests:
23 | override:
24 | - command: composer test
25 | php82:
26 | environment:
27 | php: 8.2.27
28 | tests:
29 | override:
30 | - command: composer test
31 | php81:
32 | environment:
33 | php: 8.1.31
34 | tests:
35 | override:
36 | - command: composer test
37 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Anatoly Nekhay
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # A simple cURL client implementing PSR-18
2 |
3 | ## Resources
4 |
5 | - [Documentations](https://dev.sunrise-studio.io/docs/packages/sunrise/http-client-curl/)
6 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "sunrise/http-client-curl",
3 | "homepage": "https://github.com/sunrise-php/http-client-curl",
4 | "description": "A simple cURL client implementing PSR-18.",
5 | "license": "MIT",
6 | "keywords": [
7 | "fenric",
8 | "sunrise",
9 | "http",
10 | "client",
11 | "curl",
12 | "psr-18"
13 | ],
14 | "authors": [
15 | {
16 | "name": "Anatoly Nekhay",
17 | "email": "afenric@gmail.com",
18 | "homepage": "https://github.com/fenric"
19 | },
20 | {
21 | "name": "李昀陞 (Peter)",
22 | "email": "peter279k@gmail.com",
23 | "homepage": "https://github.com/peter279k"
24 | }
25 | ],
26 | "require": {
27 | "php": ">=8.1",
28 | "ext-curl": "*",
29 | "psr/http-client": "^1.0",
30 | "psr/http-factory": "^1.0",
31 | "psr/http-message": "^1.0 || ^2.0"
32 | },
33 | "require-dev": {
34 | "php-di/php-di": "^7.0",
35 | "phpstan/phpstan": "^2.1",
36 | "phpunit/phpunit": "^10.5",
37 | "squizlabs/php_codesniffer": "^3.11",
38 | "sunrise/http-message": "^3.4",
39 | "vimeo/psalm": "^6.5"
40 | },
41 | "provide": {
42 | "psr/http-client-implementation": "1.0",
43 | "php-http/client-implementation": "1.0"
44 | },
45 | "autoload": {
46 | "psr-4": {
47 | "Sunrise\\Http\\Client\\Curl\\": "src/"
48 | }
49 | },
50 | "autoload-dev": {
51 | "psr-4": {
52 | "Sunrise\\Http\\Client\\Curl\\Tests\\": "tests/"
53 | }
54 | },
55 | "scripts": {
56 | "phpcs": "@php phpcs --colors",
57 | "psalm": "@php psalm --no-cache",
58 | "phpstan": "@php phpstan analyse src --configuration=phpstan.neon.php --level=9 --memory-limit=-1",
59 | "phpunit": "@php phpunit --colors=always",
60 | "test": [
61 | "@phpcs",
62 | "@psalm",
63 | "@phpstan",
64 | "@phpunit"
65 | ]
66 | },
67 | "config": {
68 | "sort-packages": true
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/phpcs.xml.dist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | resources/*
8 | tests/*
9 |
10 |
11 | src
12 | tests
13 |
14 |
--------------------------------------------------------------------------------
/phpstan.neon.php:
--------------------------------------------------------------------------------
1 | [
7 | ],
8 | 'parameters' => [
9 | 'phpVersion' => PHP_VERSION_ID,
10 | ],
11 | ];
12 |
13 | return $config;
14 |
--------------------------------------------------------------------------------
/phpunit.xml.dist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | ./tests/
6 |
7 |
8 |
9 |
10 | ./src
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/psalm.xml.dist:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "config:base"
4 | ]
5 | }
6 |
--------------------------------------------------------------------------------
/resources/definitions/client.php:
--------------------------------------------------------------------------------
1 | [],
14 | 'curl.multi_select_timeout' => null,
15 | 'curl.multi_select_sleep_duration' => null,
16 |
17 | ClientInterface::class => create(Client::class)
18 | ->constructor(
19 | responseFactory: get(ResponseFactoryInterface::class),
20 | curlOptions: get('curl.options'),
21 | curlMultiSelectTimeout: get('curl.multi_select_timeout'),
22 | curlMultiSelectSleepDuration: get('curl.multi_select_sleep_duration'),
23 | ),
24 | ];
25 |
--------------------------------------------------------------------------------
/resources/definitions/decorator/retryable_client.php:
--------------------------------------------------------------------------------
1 | 3,
13 | 'retryable_http_client.base_delay' => 250_000,
14 |
15 | ClientInterface::class => decorate(
16 | static function (ClientInterface $previous, ContainerInterface $container): ClientInterface {
17 | return new RetryableClient(
18 | baseClient: $previous,
19 | maxAttempts: $container->get('retryable_http_client.max_attempts'),
20 | baseDelay: $container->get('retryable_http_client.base_delay'),
21 | );
22 | }
23 | ),
24 | ];
25 |
--------------------------------------------------------------------------------
/src/Client.php:
--------------------------------------------------------------------------------
1 |
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
71 | */
72 | private array $curlHandles = [];
73 |
74 | public function __construct(
75 | private readonly ResponseFactoryInterface $responseFactory,
76 | /** @var array */
77 | private readonly array $curlOptions = [],
78 | private readonly ?float $curlMultiSelectTimeout = null,
79 | private readonly ?int $curlMultiSelectSleepDuration = null,
80 | ) {
81 | }
82 |
83 | public function __destruct()
84 | {
85 | $this->clear();
86 | }
87 |
88 | /**
89 | * @inheritDoc
90 | *
91 | * @return ($request is MultiRequest ? MultiResponse : ResponseInterface)
92 | */
93 | public function sendRequest(RequestInterface $request): ResponseInterface
94 | {
95 | try {
96 | return $this->executeRequest($request);
97 | } finally {
98 | $this->clear();
99 | }
100 | }
101 |
102 | private function executeRequest(RequestInterface $request): ResponseInterface
103 | {
104 | return $request instanceof MultiRequest
105 | ? $this->executeMultiRequest($request)
106 | : $this->executeSingleRequest($request);
107 | }
108 |
109 | private function executeSingleRequest(RequestInterface $request): ResponseInterface
110 | {
111 | $curlHandle = $this->createCurlHandleFromRequest($request);
112 |
113 | $curlExecuteResult = curl_exec($curlHandle);
114 | if ($curlExecuteResult === false) {
115 | throw new NetworkException(
116 | $request,
117 | curl_error($curlHandle),
118 | curl_errno($curlHandle),
119 | );
120 | }
121 |
122 | return $this->createResponseFromCurlHandle($curlHandle);
123 | }
124 |
125 | private function executeMultiRequest(MultiRequest $multiRequest): MultiResponse
126 | {
127 | $this->curlMultiHandle = curl_multi_init();
128 |
129 | foreach ($multiRequest->getRequests() as $key => $request) {
130 | $curlHandle = $this->createCurlHandleFromRequest($request, $key);
131 | $curlMultiStatusCode = curl_multi_add_handle($this->curlMultiHandle, $curlHandle);
132 | ClientException::assertCurlMultiStatusCodeSame(CURLM_OK, $curlMultiStatusCode);
133 | }
134 |
135 | $curlMultiSelectTimeout = $this->curlMultiSelectTimeout ?? self::DEFAULT_CURL_MULTI_SELECT_TIMEOUT;
136 | // phpcs:ignore Generic.Files.LineLength.TooLong
137 | $curlMultiSelectSleepDuration = $this->curlMultiSelectSleepDuration ?? self::DEFAULT_CURL_MULTI_SELECT_SLEEP_DURATION;
138 |
139 | do {
140 | $curlMultiStatusCode = curl_multi_exec($this->curlMultiHandle, $isCurlMultiExecuteStillRunning);
141 | // https://stackoverflow.com/questions/19490837/curlm-call-multi-perform-deprecated
142 | if ($curlMultiStatusCode === CURLM_CALL_MULTI_PERFORM) {
143 | continue;
144 | }
145 |
146 | ClientException::assertCurlMultiStatusCodeSame(CURLM_OK, $curlMultiStatusCode);
147 |
148 | if ($isCurlMultiExecuteStillRunning) {
149 | $curlMultiSelectResult = curl_multi_select($this->curlMultiHandle, $curlMultiSelectTimeout);
150 | if ($curlMultiSelectResult === -1) {
151 | // Take pauses to reduce CPU load...
152 | usleep($curlMultiSelectSleepDuration);
153 | }
154 | }
155 | } while ($isCurlMultiExecuteStillRunning);
156 |
157 | $responses = [];
158 | foreach ($this->curlHandles as $key => $curlHandle) {
159 | $responses[$key] = $this->createResponseFromCurlHandle($curlHandle);
160 | }
161 |
162 | return new MultiResponse(...$responses);
163 | }
164 |
165 | private function createCurlHandleFromRequest(RequestInterface $request, int|string $key = 0): CurlHandle
166 | {
167 | $curlOptions = $this->curlOptions;
168 |
169 | $curlOptions[CURLOPT_CUSTOMREQUEST] = $request->getMethod();
170 | $curlOptions[CURLOPT_URL] = (string) $request->getUri();
171 |
172 | $curlOptions[CURLOPT_HTTPHEADER] = [];
173 | foreach ($request->getHeaders() as $name => $values) {
174 | foreach ($values as $value) {
175 | $curlOptions[CURLOPT_HTTPHEADER][] = sprintf('%s: %s', $name, $value);
176 | }
177 | }
178 |
179 | $curlOptions[CURLOPT_POSTFIELDS] = null;
180 | if (!in_array($request->getMethod(), self::BODYLESS_HTTP_METHODS, true)) {
181 | $curlOptions[CURLOPT_POSTFIELDS] = (string) $request->getBody();
182 | }
183 |
184 | $curlOptions[CURLOPT_RETURNTRANSFER] = true;
185 | $curlOptions[CURLOPT_HEADER] = true;
186 |
187 | $curlHandle = curl_init();
188 | if ($curlHandle === false) {
189 | throw new ClientException('Unable to create CurlHandle.');
190 | }
191 |
192 | $this->curlHandles[$key] = $curlHandle;
193 |
194 | $curlSetOptionsResult = curl_setopt_array($curlHandle, $curlOptions);
195 | if ($curlSetOptionsResult === false) {
196 | throw new ClientException('Unable to configure CurlHandle.');
197 | }
198 |
199 | return $curlHandle;
200 | }
201 |
202 | private function createResponseFromCurlHandle(CurlHandle $curlHandle): ResponseInterface
203 | {
204 | /** @var int $responseStatusCode */
205 | $responseStatusCode = curl_getinfo($curlHandle, CURLINFO_RESPONSE_CODE);
206 | if ($responseStatusCode === 0) {
207 | throw new ClientException(
208 | 'Failed to retrieve response code. Please check the request and verify network accessibility.'
209 | );
210 | }
211 |
212 | $response = $this->responseFactory->createResponse($responseStatusCode);
213 |
214 | /** @var float $requestTime */
215 | $requestTime = curl_getinfo($curlHandle, CURLINFO_TOTAL_TIME);
216 | $formattedRequestTime = sprintf('%.3f ms', $requestTime * 1000.);
217 | $response = $response->withAddedHeader(self::REQUEST_TIME_HEADER_FIELD_NAME, $formattedRequestTime);
218 |
219 | /** @var string $responseMessage */
220 | $responseMessage = curl_multi_getcontent($curlHandle);
221 |
222 | /** @var int $responseHeaderSize */
223 | $responseHeaderSize = curl_getinfo($curlHandle, CURLINFO_HEADER_SIZE);
224 | $responseHeader = substr($responseMessage, 0, $responseHeaderSize);
225 | $response = $this->populateResponseWithHeaderFields($response, $responseHeader);
226 |
227 | $responseContent = substr($responseMessage, $responseHeaderSize);
228 | $response->getBody()->write($responseContent);
229 | $response->getBody()->rewind();
230 |
231 | return $response;
232 | }
233 |
234 | private function populateResponseWithHeaderFields(ResponseInterface $response, string $header): ResponseInterface
235 | {
236 | $fields = explode(self::HEADER_FIELD_SEPARATOR, $header);
237 |
238 | foreach ($fields as $i => $field) {
239 | // https://datatracker.ietf.org/doc/html/rfc7230#section-3.1.2
240 | if ($i === 0) {
241 | continue;
242 | }
243 |
244 | // https://datatracker.ietf.org/doc/html/rfc7230#section-3
245 | // https://datatracker.ietf.org/doc/html/rfc5322
246 | if ($field === '') {
247 | break;
248 | }
249 |
250 | if (strpos($field, ':') === false) {
251 | continue;
252 | }
253 |
254 | /** @psalm-suppress PossiblyUndefinedArrayOffset */
255 | [$fieldName, $fieldValue] = explode(':', $field, 2);
256 |
257 | $response = $response->withAddedHeader($fieldName, ltrim($fieldValue));
258 | }
259 |
260 | return $response;
261 | }
262 |
263 | private function clear(): void
264 | {
265 | foreach ($this->curlHandles as $curlHandle) {
266 | if ($this->curlMultiHandle instanceof CurlMultiHandle) {
267 | curl_multi_remove_handle($this->curlMultiHandle, $curlHandle);
268 | }
269 |
270 | curl_close($curlHandle);
271 | }
272 |
273 | if ($this->curlMultiHandle instanceof CurlMultiHandle) {
274 | curl_multi_close($this->curlMultiHandle);
275 | }
276 |
277 | $this->curlMultiHandle = null;
278 | $this->curlHandles = [];
279 | }
280 | }
281 |
--------------------------------------------------------------------------------
/src/Decorator/RetryableClient.php:
--------------------------------------------------------------------------------
1 |
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\Decorator;
15 |
16 | use InvalidArgumentException;
17 | use Psr\Http\Client\ClientInterface;
18 | use Psr\Http\Client\NetworkExceptionInterface;
19 | use Psr\Http\Message\RequestInterface;
20 | use Psr\Http\Message\ResponseInterface;
21 |
22 | use function random_int;
23 | use function usleep;
24 |
25 | /**
26 | * @since 2.1.0
27 | */
28 | final class RetryableClient implements ClientInterface
29 | {
30 | public function __construct(
31 | private readonly ClientInterface $baseClient,
32 | private readonly int $maxAttempts,
33 | private readonly int $baseDelay,
34 | ) {
35 | if ($maxAttempts < 1) {
36 | throw new InvalidArgumentException('maxAttempts must be >= 1');
37 | }
38 | if ($baseDelay < 0) {
39 | throw new InvalidArgumentException('baseDelay must be >= 0');
40 | }
41 | }
42 |
43 | /**
44 | * @inheritDoc
45 | */
46 | public function sendRequest(RequestInterface $request): ResponseInterface
47 | {
48 | $attempt = 0;
49 | while (true) {
50 | $attempt++;
51 |
52 | try {
53 | return $this->baseClient->sendRequest($request);
54 | } catch (NetworkExceptionInterface $e) {
55 | $attempt < $this->maxAttempts ? $this->applyDelay($attempt) : throw $e;
56 | }
57 | }
58 | }
59 |
60 | private function applyDelay(int $attempt): void
61 | {
62 | usleep($this->calculateDelay($attempt));
63 | }
64 |
65 | /**
66 | * @link https://aws.amazon.com/ru/blogs/architecture/exponential-backoff-and-jitter/
67 | */
68 | private function calculateDelay(int $attempt): int
69 | {
70 | // full jitter
71 | return random_int(0, $this->baseDelay * (2 ** ($attempt - 1)));
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/src/Exception/ClientException.php:
--------------------------------------------------------------------------------
1 |
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\Exception;
15 |
16 | use Psr\Http\Client\ClientExceptionInterface;
17 | use RuntimeException;
18 |
19 | use function curl_multi_strerror;
20 |
21 | class ClientException extends RuntimeException implements ClientExceptionInterface
22 | {
23 | /**
24 | * @throws self
25 | */
26 | final public static function assertCurlMultiStatusCodeSame(int $expectedStatusCode, int $actualStatusCode): void
27 | {
28 | if ($expectedStatusCode === $actualStatusCode) {
29 | return;
30 | }
31 |
32 | /** @var string $message */
33 | $message = curl_multi_strerror($actualStatusCode);
34 |
35 | throw new self($message, $actualStatusCode);
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/Exception/NetworkException.php:
--------------------------------------------------------------------------------
1 |
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\Exception;
15 |
16 | use Psr\Http\Client\NetworkExceptionInterface;
17 | use Psr\Http\Message\RequestInterface;
18 | use Throwable;
19 |
20 | final class NetworkException extends ClientException implements NetworkExceptionInterface
21 | {
22 | public function __construct(
23 | private readonly RequestInterface $request,
24 | string $message = '',
25 | int $code = 0,
26 | ?Throwable $previous = null
27 | ) {
28 | parent::__construct($message, $code, $previous);
29 | }
30 |
31 | public function getRequest(): RequestInterface
32 | {
33 | return $this->request;
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/Exception/RequestException.php:
--------------------------------------------------------------------------------
1 |
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\Exception;
15 |
16 | use Psr\Http\Client\RequestExceptionInterface;
17 | use Psr\Http\Message\RequestInterface;
18 | use Throwable;
19 |
20 | final class RequestException extends ClientException implements RequestExceptionInterface
21 | {
22 | public function __construct(
23 | private readonly RequestInterface $request,
24 | string $message = '',
25 | int $code = 0,
26 | ?Throwable $previous = null
27 | ) {
28 | parent::__construct($message, $code, $previous);
29 | }
30 |
31 | public function getRequest(): RequestInterface
32 | {
33 | return $this->request;
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/MultiRequest.php:
--------------------------------------------------------------------------------
1 |
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 InvalidArgumentException;
17 | use LogicException;
18 | use Psr\Http\Message\MessageInterface;
19 | use Psr\Http\Message\RequestInterface;
20 | use Psr\Http\Message\StreamInterface;
21 | use Psr\Http\Message\UriInterface;
22 |
23 | /**
24 | * @since 2.0.0
25 | */
26 | final class MultiRequest implements RequestInterface
27 | {
28 | /**
29 | * @var non-empty-array
30 | */
31 | private array $requests;
32 |
33 | /**
34 | * @throws InvalidArgumentException
35 | */
36 | public function __construct(RequestInterface ...$requests)
37 | {
38 | if ($requests === []) {
39 | throw new InvalidArgumentException('At least one request is expected.');
40 | }
41 |
42 | $this->requests = $requests;
43 | }
44 |
45 | /**
46 | * @return non-empty-array
47 | */
48 | public function getRequests(): array
49 | {
50 | return $this->requests;
51 | }
52 |
53 | public function getProtocolVersion(): string
54 | {
55 | throw new LogicException('Not implemented.');
56 | }
57 |
58 | public function withProtocolVersion($version): static
59 | {
60 | throw new LogicException('Not implemented.');
61 | }
62 |
63 | public function getHeaders(): array
64 | {
65 | throw new LogicException('Not implemented.');
66 | }
67 |
68 | public function hasHeader($name): bool
69 | {
70 | throw new LogicException('Not implemented.');
71 | }
72 |
73 | public function getHeader($name): array
74 | {
75 | throw new LogicException('Not implemented.');
76 | }
77 |
78 | public function getHeaderLine($name): string
79 | {
80 | throw new LogicException('Not implemented.');
81 | }
82 |
83 | public function withHeader($name, $value): MessageInterface
84 | {
85 | throw new LogicException('Not implemented.');
86 | }
87 |
88 | public function withAddedHeader($name, $value): MessageInterface
89 | {
90 | throw new LogicException('Not implemented.');
91 | }
92 |
93 | public function withoutHeader($name): MessageInterface
94 | {
95 | throw new LogicException('Not implemented.');
96 | }
97 |
98 | public function getBody(): StreamInterface
99 | {
100 | throw new LogicException('Not implemented.');
101 | }
102 |
103 | public function withBody(StreamInterface $body): MessageInterface
104 | {
105 | throw new LogicException('Not implemented.');
106 | }
107 |
108 | public function getRequestTarget(): string
109 | {
110 | throw new LogicException('Not implemented.');
111 | }
112 |
113 | public function withRequestTarget($requestTarget): RequestInterface
114 | {
115 | throw new LogicException('Not implemented.');
116 | }
117 |
118 | public function getMethod(): string
119 | {
120 | throw new LogicException('Not implemented.');
121 | }
122 |
123 | public function withMethod($method): RequestInterface
124 | {
125 | throw new LogicException('Not implemented.');
126 | }
127 |
128 | public function getUri(): UriInterface
129 | {
130 | throw new LogicException('Not implemented.');
131 | }
132 |
133 | public function withUri(UriInterface $uri, $preserveHost = false): RequestInterface
134 | {
135 | throw new LogicException('Not implemented.');
136 | }
137 | }
138 |
--------------------------------------------------------------------------------
/src/MultiResponse.php:
--------------------------------------------------------------------------------
1 |
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 InvalidArgumentException;
17 | use LogicException;
18 | use Psr\Http\Message\MessageInterface;
19 | use Psr\Http\Message\ResponseInterface;
20 | use Psr\Http\Message\StreamInterface;
21 |
22 | /**
23 | * @since 2.0.0
24 | */
25 | final class MultiResponse implements ResponseInterface
26 | {
27 | /**
28 | * @var non-empty-array
29 | */
30 | private array $responses;
31 |
32 | /**
33 | * @throws InvalidArgumentException
34 | */
35 | public function __construct(ResponseInterface ...$responses)
36 | {
37 | if ($responses === []) {
38 | throw new InvalidArgumentException('At least one response is expected.');
39 | }
40 |
41 | $this->responses = $responses;
42 | }
43 |
44 | /**
45 | * @return non-empty-array
46 | */
47 | public function getResponses(): array
48 | {
49 | return $this->responses;
50 | }
51 |
52 | public function getProtocolVersion(): string
53 | {
54 | throw new LogicException('Not implemented.');
55 | }
56 |
57 | public function withProtocolVersion($version): MessageInterface
58 | {
59 | throw new LogicException('Not implemented.');
60 | }
61 |
62 | public function getHeaders(): array
63 | {
64 | throw new LogicException('Not implemented.');
65 | }
66 |
67 | public function hasHeader($name): bool
68 | {
69 | throw new LogicException('Not implemented.');
70 | }
71 |
72 | public function getHeader($name): array
73 | {
74 | throw new LogicException('Not implemented.');
75 | }
76 |
77 | public function getHeaderLine($name): string
78 | {
79 | throw new LogicException('Not implemented.');
80 | }
81 |
82 | public function withHeader($name, $value): MessageInterface
83 | {
84 | throw new LogicException('Not implemented.');
85 | }
86 |
87 | public function withAddedHeader($name, $value): MessageInterface
88 | {
89 | throw new LogicException('Not implemented.');
90 | }
91 |
92 | public function withoutHeader($name): MessageInterface
93 | {
94 | throw new LogicException('Not implemented.');
95 | }
96 |
97 | public function getBody(): StreamInterface
98 | {
99 | throw new LogicException('Not implemented.');
100 | }
101 |
102 | public function withBody(StreamInterface $body): MessageInterface
103 | {
104 | throw new LogicException('Not implemented.');
105 | }
106 |
107 | public function getStatusCode(): int
108 | {
109 | throw new LogicException('Not implemented.');
110 | }
111 |
112 | public function withStatus($code, $reasonPhrase = ''): ResponseInterface
113 | {
114 | throw new LogicException('Not implemented.');
115 | }
116 |
117 | public function getReasonPhrase(): string
118 | {
119 | throw new LogicException('Not implemented.');
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/tests/ClientTest.php:
--------------------------------------------------------------------------------
1 | createRequest('GET', self::TEST_URI);
22 | $response = (new Client(new ResponseFactory()))->sendRequest($request);
23 |
24 | self::assertSame(200, $response->getStatusCode());
25 | self::assertTrue($response->hasHeader('X-Request-Time'));
26 | }
27 |
28 | public function testSendMultiRequest(): void
29 | {
30 | $requestFactory = new RequestFactory();
31 |
32 | $request = new MultiRequest(
33 | foo: $requestFactory->createRequest('GET', self::TEST_URI),
34 | bar: $requestFactory->createRequest('GET', self::TEST_URI),
35 | );
36 |
37 | $responses = (new Client(new ResponseFactory()))->sendRequest($request)->getResponses();
38 |
39 | self::assertArrayHasKey('foo', $responses);
40 | self::assertSame(200, $responses['foo']->getStatusCode());
41 | self::assertTrue($responses['foo']->hasHeader('X-Request-Time'));
42 |
43 | self::assertArrayHasKey('bar', $responses);
44 | self::assertSame(200, $responses['bar']->getStatusCode());
45 | self::assertTrue($responses['bar']->hasHeader('X-Request-Time'));
46 | }
47 |
48 | public function testSendSingleRequestWithEmptyUri(): void
49 | {
50 | $client = new Client(new ResponseFactory());
51 | $request = (new RequestFactory())->createRequest('GET', '');
52 |
53 | $this->expectException(NetworkExceptionInterface::class);
54 | // $this->expectExceptionMessage(' malformed');
55 | $client->sendRequest($request);
56 | }
57 |
58 | public function testSendMultiRequestWithEmptyUri(): void
59 | {
60 | $client = new Client(new ResponseFactory());
61 | $request = new MultiRequest((new RequestFactory())->createRequest('GET', ''));
62 |
63 | $this->expectException(ClientException::class);
64 | $this->expectExceptionMessage('Failed to retrieve response code. Please check the request and verify network accessibility.');
65 | $client->sendRequest($request);
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/tests/Decorator/RetryableClientTest.php:
--------------------------------------------------------------------------------
1 | baseClient = $this->createMock(ClientInterface::class);
27 | $this->testRequest = $this->createMock(RequestInterface::class);
28 | $this->testResponse = $this->createMock(ResponseInterface::class);
29 | $this->networkException = $this->createMock(NetworkExceptionInterface::class);
30 | $this->sendAttempt = 0;
31 | }
32 |
33 | public function testInvalidMaxAttempts(): void
34 | {
35 | $this->expectException(InvalidArgumentException::class);
36 | $this->expectExceptionMessage('maxAttempts must be >= 1');
37 | new RetryableClient($this->baseClient, maxAttempts: 0, baseDelay: 0);
38 | }
39 |
40 | public function testInvalidBaseDelay(): void
41 | {
42 | $this->expectException(InvalidArgumentException::class);
43 | $this->expectExceptionMessage('baseDelay must be >= 0');
44 | new RetryableClient($this->baseClient, maxAttempts: 1, baseDelay: -1);
45 | }
46 |
47 | public function testSendRequestSucceedsOnFirstAttempt(): void
48 | {
49 | $client = new RetryableClient($this->baseClient, maxAttempts: 1, baseDelay: 0);
50 | $this->baseClient->expects(self::exactly(1))->method('sendRequest')->with($this->testRequest)->willReturn($this->testResponse);
51 | self::assertSame($this->testResponse, $client->sendRequest($this->testRequest));
52 | }
53 |
54 | public function testSendRequestSucceedsOnSecondAttempt(): void
55 | {
56 | $client = new RetryableClient($this->baseClient, maxAttempts: 2, baseDelay: 0);
57 | $this->baseClient->expects(self::exactly(2))->method('sendRequest')->with($this->testRequest)->willReturnCallback(fn() => ++$this->sendAttempt < 2 ? throw $this->networkException : $this->testResponse);
58 | self::assertSame($this->testResponse, $client->sendRequest($this->testRequest));
59 | }
60 |
61 | public function testSendRequestSucceedsOnThirdAttempt(): void
62 | {
63 | $client = new RetryableClient($this->baseClient, maxAttempts: 3, baseDelay: 0);
64 | $this->baseClient->expects(self::exactly(3))->method('sendRequest')->with($this->testRequest)->willReturnCallback(fn() => ++$this->sendAttempt < 3 ? throw $this->networkException : $this->testResponse);
65 | self::assertSame($this->testResponse, $client->sendRequest($this->testRequest));
66 | }
67 |
68 | public function testSendRequestFailsAfterFirstAttempt(): void
69 | {
70 | $client = new RetryableClient($this->baseClient, maxAttempts: 1, baseDelay: 0);
71 | $this->baseClient->expects(self::exactly(1))->method('sendRequest')->with($this->testRequest)->willThrowException($this->networkException);
72 | $this->expectException($this->networkException::class);
73 | $client->sendRequest($this->testRequest);
74 | }
75 |
76 | public function testSendRequestFailsAfterTwoAttempts(): void
77 | {
78 | $client = new RetryableClient($this->baseClient, maxAttempts: 2, baseDelay: 0);
79 | $this->baseClient->expects(self::exactly(2))->method('sendRequest')->with($this->testRequest)->willThrowException($this->networkException);
80 | $this->expectException($this->networkException::class);
81 | $client->sendRequest($this->testRequest);
82 | }
83 |
84 | public function testSendRequestFailsAfterThreeAttempts(): void
85 | {
86 | $client = new RetryableClient($this->baseClient, maxAttempts: 3, baseDelay: 0);
87 | $this->baseClient->expects(self::exactly(3))->method('sendRequest')->with($this->testRequest)->willThrowException($this->networkException);
88 | $this->expectException($this->networkException::class);
89 | $client->sendRequest($this->testRequest);
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/tests/Exception/ClientExceptionTest.php:
--------------------------------------------------------------------------------
1 | expectException(ClientException::class);
19 | ClientException::assertCurlMultiStatusCodeSame(CURLM_OK, CURLM_INTERNAL_ERROR);
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/tests/Exception/NetworkExceptionTest.php:
--------------------------------------------------------------------------------
1 | createMock(RequestInterface::class);
17 | $previous = $this->createMock(Throwable::class);
18 | $exception = new NetworkException($request, 'foo', 255, $previous);
19 | self::assertSame($request, $exception->getRequest());
20 | self::assertSame('foo', $exception->getMessage());
21 | self::assertSame(255, $exception->getCode());
22 | self::assertSame($previous, $exception->getPrevious());
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/tests/Exception/RequestExceptionTest.php:
--------------------------------------------------------------------------------
1 | createMock(RequestInterface::class);
17 | $previous = $this->createMock(Throwable::class);
18 | $exception = new RequestException($request, 'foo', 255, $previous);
19 | self::assertSame($request, $exception->getRequest());
20 | self::assertSame('foo', $exception->getMessage());
21 | self::assertSame(255, $exception->getCode());
22 | self::assertSame($previous, $exception->getPrevious());
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/tests/MultiRequestTest.php:
--------------------------------------------------------------------------------
1 | expectException(InvalidArgumentException::class);
20 | new MultiRequest();
21 | }
22 |
23 | public function testGetRequests(): void
24 | {
25 | $requests = [
26 | $this->createMock(RequestInterface::class),
27 | $this->createMock(RequestInterface::class),
28 | ];
29 |
30 | self::assertSame($requests, (new MultiRequest(...$requests))->getRequests());
31 | }
32 |
33 | public function testGetProtocolVersion(): void
34 | {
35 | $request = $this->createMock(RequestInterface::class);
36 | $multiRequest = new MultiRequest($request);
37 | $this->expectException(LogicException::class);
38 | $this->expectExceptionMessage('Not implemented.');
39 | $multiRequest->getProtocolVersion();
40 | }
41 |
42 | public function testWithProtocolVersion(): void
43 | {
44 | $request = $this->createMock(RequestInterface::class);
45 | $multiRequest = new MultiRequest($request);
46 | $this->expectException(LogicException::class);
47 | $this->expectExceptionMessage('Not implemented.');
48 | $multiRequest->withProtocolVersion('1.1');
49 | }
50 |
51 | public function testGetHeaders(): void
52 | {
53 | $request = $this->createMock(RequestInterface::class);
54 | $multiRequest = new MultiRequest($request);
55 | $this->expectException(LogicException::class);
56 | $this->expectExceptionMessage('Not implemented.');
57 | $multiRequest->getHeaders();
58 | }
59 |
60 | public function testHasHeader(): void
61 | {
62 | $request = $this->createMock(RequestInterface::class);
63 | $multiRequest = new MultiRequest($request);
64 | $this->expectException(LogicException::class);
65 | $this->expectExceptionMessage('Not implemented.');
66 | $multiRequest->hasHeader('X-Test');
67 | }
68 |
69 | public function testGetHeader(): void
70 | {
71 | $request = $this->createMock(RequestInterface::class);
72 | $multiRequest = new MultiRequest($request);
73 | $this->expectException(LogicException::class);
74 | $this->expectExceptionMessage('Not implemented.');
75 | $multiRequest->getHeader('X-Test');
76 | }
77 |
78 | public function testGetHeaderLine(): void
79 | {
80 | $request = $this->createMock(RequestInterface::class);
81 | $multiRequest = new MultiRequest($request);
82 | $this->expectException(LogicException::class);
83 | $this->expectExceptionMessage('Not implemented.');
84 | $multiRequest->getHeaderLine('X-Test');
85 | }
86 |
87 | public function testWithHeader(): void
88 | {
89 | $request = $this->createMock(RequestInterface::class);
90 | $multiRequest = new MultiRequest($request);
91 | $this->expectException(LogicException::class);
92 | $this->expectExceptionMessage('Not implemented.');
93 | $multiRequest->withHeader('X-Test', 'test');
94 | }
95 |
96 | public function testWithAddedHeader(): void
97 | {
98 | $request = $this->createMock(RequestInterface::class);
99 | $multiRequest = new MultiRequest($request);
100 | $this->expectException(LogicException::class);
101 | $this->expectExceptionMessage('Not implemented.');
102 | $multiRequest->withAddedHeader('X-Test', 'test');
103 | }
104 |
105 | public function testWithoutHeader(): void
106 | {
107 | $request = $this->createMock(RequestInterface::class);
108 | $multiRequest = new MultiRequest($request);
109 | $this->expectException(LogicException::class);
110 | $this->expectExceptionMessage('Not implemented.');
111 | $multiRequest->withoutHeader('X-Test');
112 | }
113 |
114 | public function testGetBody(): void
115 | {
116 | $request = $this->createMock(RequestInterface::class);
117 | $multiRequest = new MultiRequest($request);
118 | $this->expectException(LogicException::class);
119 | $this->expectExceptionMessage('Not implemented.');
120 | $multiRequest->getBody();
121 | }
122 |
123 | public function testWithBody(): void
124 | {
125 | $body = $this->createMock(StreamInterface::class);
126 | $request = $this->createMock(RequestInterface::class);
127 | $multiRequest = new MultiRequest($request);
128 | $this->expectException(LogicException::class);
129 | $this->expectExceptionMessage('Not implemented.');
130 | $multiRequest->withBody($body);
131 | }
132 |
133 | public function testGetRequestTarget(): void
134 | {
135 | $request = $this->createMock(RequestInterface::class);
136 | $multiRequest = new MultiRequest($request);
137 | $this->expectException(LogicException::class);
138 | $this->expectExceptionMessage('Not implemented.');
139 | $multiRequest->getRequestTarget();
140 | }
141 |
142 | public function testWithRequestTarget(): void
143 | {
144 | $request = $this->createMock(RequestInterface::class);
145 | $multiRequest = new MultiRequest($request);
146 | $this->expectException(LogicException::class);
147 | $this->expectExceptionMessage('Not implemented.');
148 | $multiRequest->withRequestTarget('/');
149 | }
150 |
151 | public function testGetMethod(): void
152 | {
153 | $request = $this->createMock(RequestInterface::class);
154 | $multiRequest = new MultiRequest($request);
155 | $this->expectException(LogicException::class);
156 | $this->expectExceptionMessage('Not implemented.');
157 | $multiRequest->getMethod();
158 | }
159 |
160 | public function testWithMethod(): void
161 | {
162 | $request = $this->createMock(RequestInterface::class);
163 | $multiRequest = new MultiRequest($request);
164 | $this->expectException(LogicException::class);
165 | $this->expectExceptionMessage('Not implemented.');
166 | $multiRequest->withMethod('GET');
167 | }
168 |
169 | public function testGetUri(): void
170 | {
171 | $request = $this->createMock(RequestInterface::class);
172 | $multiRequest = new MultiRequest($request);
173 | $this->expectException(LogicException::class);
174 | $this->expectExceptionMessage('Not implemented.');
175 | $multiRequest->getUri();
176 | }
177 |
178 | public function testWithUri(): void
179 | {
180 | $uri = $this->createMock(UriInterface::class);
181 | $request = $this->createMock(RequestInterface::class);
182 | $multiRequest = new MultiRequest($request);
183 | $this->expectException(LogicException::class);
184 | $this->expectExceptionMessage('Not implemented.');
185 | $multiRequest->withUri($uri);
186 | }
187 | }
188 |
--------------------------------------------------------------------------------
/tests/MultiResponseTest.php:
--------------------------------------------------------------------------------
1 | expectException(InvalidArgumentException::class);
19 | new MultiResponse();
20 | }
21 |
22 | public function testGetResponses(): void
23 | {
24 | $responses = [
25 | $this->createMock(ResponseInterface::class),
26 | $this->createMock(ResponseInterface::class),
27 | ];
28 |
29 | self::assertSame($responses, (new MultiResponse(...$responses))->getResponses());
30 | }
31 |
32 | public function testGetProtocolVersion(): void
33 | {
34 | $response = $this->createMock(ResponseInterface::class);
35 | $multiResponse = new MultiResponse($response);
36 | $this->expectException(LogicException::class);
37 | $this->expectExceptionMessage('Not implemented.');
38 | $multiResponse->getProtocolVersion();
39 | }
40 |
41 | public function testWithProtocolVersion(): void
42 | {
43 | $response = $this->createMock(ResponseInterface::class);
44 | $multiResponse = new MultiResponse($response);
45 | $this->expectException(LogicException::class);
46 | $this->expectExceptionMessage('Not implemented.');
47 | $multiResponse->withProtocolVersion('1.1');
48 | }
49 |
50 | public function testGetHeaders(): void
51 | {
52 | $response = $this->createMock(ResponseInterface::class);
53 | $multiResponse = new MultiResponse($response);
54 | $this->expectException(LogicException::class);
55 | $this->expectExceptionMessage('Not implemented.');
56 | $multiResponse->getHeaders();
57 | }
58 |
59 | public function testHasHeader(): void
60 | {
61 | $response = $this->createMock(ResponseInterface::class);
62 | $multiResponse = new MultiResponse($response);
63 | $this->expectException(LogicException::class);
64 | $this->expectExceptionMessage('Not implemented.');
65 | $multiResponse->hasHeader('X-Test');
66 | }
67 |
68 | public function testGetHeader(): void
69 | {
70 | $response = $this->createMock(ResponseInterface::class);
71 | $multiResponse = new MultiResponse($response);
72 | $this->expectException(LogicException::class);
73 | $this->expectExceptionMessage('Not implemented.');
74 | $multiResponse->getHeader('X-Test');
75 | }
76 |
77 | public function testGetHeaderLine(): void
78 | {
79 | $response = $this->createMock(ResponseInterface::class);
80 | $multiResponse = new MultiResponse($response);
81 | $this->expectException(LogicException::class);
82 | $this->expectExceptionMessage('Not implemented.');
83 | $multiResponse->getHeaderLine('X-Test');
84 | }
85 |
86 | public function testWithHeader(): void
87 | {
88 | $response = $this->createMock(ResponseInterface::class);
89 | $multiResponse = new MultiResponse($response);
90 | $this->expectException(LogicException::class);
91 | $this->expectExceptionMessage('Not implemented.');
92 | $multiResponse->withHeader('X-Test', 'test');
93 | }
94 |
95 | public function testWithAddedHeader(): void
96 | {
97 | $response = $this->createMock(ResponseInterface::class);
98 | $multiResponse = new MultiResponse($response);
99 | $this->expectException(LogicException::class);
100 | $this->expectExceptionMessage('Not implemented.');
101 | $multiResponse->withAddedHeader('X-Test', 'test');
102 | }
103 |
104 | public function testWithoutHeader(): void
105 | {
106 | $response = $this->createMock(ResponseInterface::class);
107 | $multiResponse = new MultiResponse($response);
108 | $this->expectException(LogicException::class);
109 | $this->expectExceptionMessage('Not implemented.');
110 | $multiResponse->withoutHeader('X-Test');
111 | }
112 |
113 | public function testGetBody(): void
114 | {
115 | $response = $this->createMock(ResponseInterface::class);
116 | $multiResponse = new MultiResponse($response);
117 | $this->expectException(LogicException::class);
118 | $this->expectExceptionMessage('Not implemented.');
119 | $multiResponse->getBody();
120 | }
121 |
122 | public function testWithBody(): void
123 | {
124 | $body = $this->createMock(StreamInterface::class);
125 | $response = $this->createMock(ResponseInterface::class);
126 | $multiResponse = new MultiResponse($response);
127 | $this->expectException(LogicException::class);
128 | $this->expectExceptionMessage('Not implemented.');
129 | $multiResponse->withBody($body);
130 | }
131 |
132 | public function testGetStatusCode(): void
133 | {
134 | $response = $this->createMock(ResponseInterface::class);
135 | $multiResponse = new MultiResponse($response);
136 | $this->expectException(LogicException::class);
137 | $this->expectExceptionMessage('Not implemented.');
138 | $multiResponse->getStatusCode();
139 | }
140 |
141 | public function testWithStatus(): void
142 | {
143 | $response = $this->createMock(ResponseInterface::class);
144 | $multiResponse = new MultiResponse($response);
145 | $this->expectException(LogicException::class);
146 | $this->expectExceptionMessage('Not implemented.');
147 | $multiResponse->withStatus(200);
148 | }
149 |
150 | public function testGetReasonPhrase(): void
151 | {
152 | $response = $this->createMock(ResponseInterface::class);
153 | $multiResponse = new MultiResponse($response);
154 | $this->expectException(LogicException::class);
155 | $this->expectExceptionMessage('Not implemented.');
156 | $multiResponse->getReasonPhrase();
157 | }
158 | }
159 |
--------------------------------------------------------------------------------