├── .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 | --------------------------------------------------------------------------------