├── .php-cs-fixer.dist.php ├── .php_cs.dist ├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json └── src ├── BatchClient.php ├── BatchClientInterface.php ├── BatchResult.php ├── Deferred.php ├── EmulatedHttpAsyncClient.php ├── EmulatedHttpClient.php ├── Exception ├── BatchException.php ├── CircularRedirectionException.php ├── ClientErrorException.php ├── HttpClientNoMatchException.php ├── HttpClientNotFoundException.php ├── LoopException.php ├── MultipleRedirectionException.php └── ServerErrorException.php ├── FlexibleHttpClient.php ├── HttpAsyncClientDecorator.php ├── HttpAsyncClientEmulator.php ├── HttpClientDecorator.php ├── HttpClientEmulator.php ├── HttpClientPool.php ├── HttpClientPool ├── HttpClientPool.php ├── HttpClientPoolItem.php ├── LeastUsedClientPool.php ├── RandomClientPool.php └── RoundRobinClientPool.php ├── HttpClientRouter.php ├── HttpClientRouterInterface.php ├── HttpMethodsClient.php ├── HttpMethodsClientInterface.php ├── Plugin.php ├── Plugin ├── AddHostPlugin.php ├── AddPathPlugin.php ├── AuthenticationPlugin.php ├── BaseUriPlugin.php ├── ContentLengthPlugin.php ├── ContentTypePlugin.php ├── CookiePlugin.php ├── DecoderPlugin.php ├── ErrorPlugin.php ├── HeaderAppendPlugin.php ├── HeaderDefaultsPlugin.php ├── HeaderRemovePlugin.php ├── HeaderSetPlugin.php ├── HistoryPlugin.php ├── Journal.php ├── QueryDefaultsPlugin.php ├── RedirectPlugin.php ├── RequestMatcherPlugin.php ├── RequestSeekableBodyPlugin.php ├── ResponseSeekableBodyPlugin.php ├── RetryPlugin.php ├── SeekableBodyPlugin.php └── VersionBridgePlugin.php ├── PluginChain.php ├── PluginClient.php ├── PluginClientBuilder.php ├── PluginClientFactory.php └── VersionBridgeClient.php /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | in(__DIR__.'/src') 5 | ->in(__DIR__.'/tests') 6 | ->name('*.php') 7 | ; 8 | 9 | $config = new PhpCsFixer\Config(); 10 | 11 | return $config 12 | ->setRiskyAllowed(true) 13 | ->setRules([ 14 | '@Symfony' => true, 15 | 'single_line_throw' => false, 16 | ]) 17 | ->setFinder($finder) 18 | ; 19 | -------------------------------------------------------------------------------- /.php_cs.dist: -------------------------------------------------------------------------------- 1 | in('src') 11 | ->in('spec') 12 | ; 13 | return PhpCsFixer\Config::create() 14 | ->setRules([ 15 | '@PSR2' => true, 16 | '@Symfony' => true, 17 | 'array_syntax' => [ 18 | 'syntax' => 'short', 19 | ], 20 | 'no_empty_phpdoc' => true, 21 | 'phpdoc_to_comment' => false, 22 | 'single_line_throw' => false, 23 | ]) 24 | ->setFinder($finder); 25 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## 2.7.2 - 2024-09-24 4 | 5 | - Updated code to not raise warnings for nullable parameters in PHP 8.4. 6 | - Cleaned up PHPDoc comments. 7 | 8 | ## 2.7.1 - 2023-11-30 9 | 10 | - Allow installation with Symfony 7. 11 | 12 | ## 2.7.0 - 2023-05-17 13 | 14 | - Dropped `php-http/message-factory` from composer requirements as it is abandoned and this package does not actually use it. 15 | 16 | ## 2.6.1 - 2023-04-14 17 | 18 | - Allow installation with http-message (PSR-7) version 2 in addition to version 1. 19 | - Support for PHP 8.2 20 | 21 | ## 2.6.0 - 2022-09-29 22 | 23 | - [RedirectPlugin] Redirection of non GET/HEAD requests with a body now removes the body on follow-up requests, if the 24 | HTTP method changes. To do this, the plugin needs to find a PSR-7 stream implementation. If none is found, you can 25 | explicitly pass a PSR-17 StreamFactoryInterface in the `stream_factory` option. 26 | To keep sending the body in all cases, set the `stream_factory` option to null explicitly. 27 | 28 | ## 2.5.1 - 2022-09-29 29 | 30 | ### Fixed 31 | 32 | - [RedirectPlugin] Fixed handling of redirection to different domain with default port 33 | - [RedirectPlugin] Fixed false positive circular detection in RedirectPlugin in cases when target location does not contain path 34 | 35 | ## 2.5.0 - 2021-11-26 36 | 37 | ### Added 38 | 39 | - Support for Symfony 6 40 | - Support for PHP 8.1 41 | 42 | ### Changed 43 | 44 | - Dropped support for Symfony 2 and 3 - please keep using version 2.4.0 of this library if you can't update Symfony. 45 | 46 | ## 2.4.0 - 2021-07-05 47 | 48 | ### Added 49 | 50 | - `strict` option to `RedirectPlugin` to allow preserving the request method on redirections with status 300, 301 and 302. 51 | 52 | ## 2.3.0 - 2020-07-21 53 | 54 | ### Fixed 55 | 56 | - HttpMethodsClient with PSR RequestFactory 57 | - Bug in the cookie plugin with empty cookies 58 | - Bug when parsing null-valued date headers 59 | 60 | ### Changed 61 | 62 | - Deprecation when constructing a HttpMethodsClient with PSR RequestFactory but without a StreamFactory 63 | 64 | ## 2.2.1 - 2020-07-13 65 | 66 | ### Fixed 67 | 68 | - Support for PHP 8 69 | - Plugin callable phpdoc 70 | 71 | ## 2.2.0 - 2020-07-02 72 | 73 | ### Added 74 | 75 | - Plugin client builder for making a `PluginClient` 76 | - Support for the PSR-17 request factory in `HttpMethodsClient` 77 | 78 | ### Changed 79 | 80 | - Restored support for `symfony/options-resolver: ^2.6` 81 | - Consistent implementation of union type checking 82 | 83 | ### Fixed 84 | 85 | - Memory leak when using the `PluginClient` with plugins 86 | 87 | ## 2.1.0 - 2019-11-18 88 | 89 | ### Added 90 | 91 | - Support Symfony 5 92 | 93 | ## 2.0.0 - 2019-02-03 94 | 95 | ### Changed 96 | 97 | - HttpClientRouter now throws a HttpClientNoMatchException instead of a RequestException if it can not find a client for the request. 98 | - RetryPlugin will only retry exceptions when there is no response, or a response in the 5xx HTTP code range. 99 | - RetryPlugin also retries when no exception is thrown if the responses has HTTP code in the 5xx range. 100 | The callbacks for exception handling have been renamed and callbacks for response handling have been added. 101 | - Abstract method `HttpClientPool::chooseHttpClient()` has now an explicit return type (`Http\Client\Common\HttpClientPoolItem`) 102 | - Interface method `Plugin::handleRequest(...)` has now an explicit return type (`Http\Promise\Promise`) 103 | - Made classes final that are not intended to be extended. 104 | - Added interfaces for BatchClient, HttpClientRouter and HttpMethodsClient. 105 | (These interfaces use the `Interface` suffix to avoid name collisions.) 106 | - Added an interface for HttpClientPool and moved the abstract class to the HttpClientPool sub namespace. 107 | - AddPathPlugin: Do not add the prefix if the URL already has the same prefix. 108 | - All exceptions in `Http\Client\Common\Exception` are final. 109 | 110 | ### Removed 111 | 112 | - Deprecated option `debug_plugins` has been removed from `PluginClient` 113 | - Deprecated options `decider` and `delay` have been removed from `RetryPlugin`, use `exception_decider` and `exception_delay` instead. 114 | 115 | ## 1.11.0 - 2021-07-11 116 | 117 | ### Changed 118 | 119 | - Backported from version 2: AddPathPlugin: Do not add the prefix if the URL already has the same prefix. 120 | 121 | ## 1.10.0 - 2019-11-18 122 | 123 | ### Added 124 | 125 | - Support for Symfony 5 126 | 127 | ## 1.9.1 - 2019-02-02 128 | 129 | ### Added 130 | 131 | - Updated type hints in doc blocks. 132 | 133 | ## 1.9.0 - 2019-01-03 134 | 135 | ### Added 136 | 137 | - Support for PSR-18 clients 138 | - Added traits `VersionBridgePlugin` and `VersionBridgeClient` to help plugins and clients to support both 139 | 1.x and 2.x version of `php-http/client-common` and `php-http/httplug`. 140 | 141 | ### Changed 142 | 143 | - RetryPlugin: Renamed the configuration options for the exception retry callback from `decider` to `exception_decider` 144 | and `delay` to `exception_delay`. The old names still work but are deprecated. 145 | 146 | ## 1.8.2 - 2018-12-14 147 | 148 | ### Changed 149 | 150 | - When multiple cookies exist, a single header with all cookies is sent as per RFC 6265 Section 5.4 151 | - AddPathPlugin will now trim of ending slashes in paths 152 | 153 | ## 1.8.1 - 2018-10-09 154 | 155 | ### Fixed 156 | 157 | - Reverted change to RetryPlugin so it again waits when retrying to avoid "can only throw objects" error. 158 | 159 | ## 1.8.0 - 2018-09-21 160 | 161 | ### Added 162 | 163 | - Add an option on ErrorPlugin to only throw exception on response with 5XX status code. 164 | 165 | ### Changed 166 | 167 | - AddPathPlugin no longer add prefix multiple times if a request is restarted - it now only adds the prefix if that request chain has not yet passed through the AddPathPlugin 168 | - RetryPlugin no longer wait for retried requests and use a deferred promise instead 169 | 170 | ### Fixed 171 | 172 | - Decoder plugin will now remove header when there is no more encoding, instead of setting to an empty array 173 | 174 | ## 1.7.0 - 2017-11-30 175 | 176 | ### Added 177 | 178 | - Symfony 4 support 179 | 180 | ### Changed 181 | 182 | - Strict comparison in DecoderPlugin 183 | 184 | ## 1.6.0 - 2017-10-16 185 | 186 | ### Added 187 | 188 | - Add HttpClientPool client to leverage load balancing and fallback mechanism [see the documentation](http://docs.php-http.org/en/latest/components/client-common.html) for more details. 189 | - `PluginClientFactory` to create `PluginClient` instances. 190 | - Added new option 'delay' for `RetryPlugin`. 191 | - Added new option 'decider' for `RetryPlugin`. 192 | - Supports more cookie date formats in the Cookie Plugin 193 | 194 | ### Changed 195 | 196 | - The `RetryPlugin` does now wait between retries. To disable/change this feature you must write something like: 197 | 198 | ```php 199 | $plugin = new RetryPlugin(['delay' => function(RequestInterface $request, Exception $e, $retries) { 200 | return 0; 201 | }); 202 | ``` 203 | 204 | ### Deprecated 205 | 206 | - The `debug_plugins` option for `PluginClient` is deprecated and will be removed in 2.0. Use the decorator design pattern instead like in [ProfilePlugin](https://github.com/php-http/HttplugBundle/blob/de33f9c14252f22093a5ec7d84f17535ab31a384/Collector/ProfilePlugin.php). 207 | 208 | ## 1.5.0 - 2017-03-30 209 | 210 | ### Added 211 | 212 | - `QueryDefaultsPlugin` to add default query parameters. 213 | 214 | ## 1.4.2 - 2017-03-18 215 | 216 | ### Deprecated 217 | 218 | - `DecoderPlugin` does not longer claim to support `compress` content encoding 219 | 220 | ### Fixed 221 | 222 | - `CookiePlugin` allows main domain cookies to be sent/stored for subdomains 223 | - `DecoderPlugin` uses the right `FilteredStream` to handle `deflate` content encoding 224 | 225 | 226 | ## 1.4.1 - 2017-02-20 227 | 228 | ### Fixed 229 | 230 | - Cast return value of `StreamInterface::getSize` to string in `ContentLengthPlugin` 231 | 232 | 233 | ## 1.4.0 - 2016-11-04 234 | 235 | ### Added 236 | 237 | - Add Path plugin 238 | - Base URI plugin that combines Add Host and Add Path plugins 239 | 240 | 241 | ## 1.3.0 - 2016-10-16 242 | 243 | ### Changed 244 | 245 | - Fix Emulated Trait to use Http based promise which respect the HttpAsyncClient interface 246 | - Require Httplug 1.1 where we use HTTP specific promises. 247 | - RedirectPlugin: use the full URL instead of the URI to properly keep track of redirects 248 | - Add AddPathPlugin for API URLs with base path 249 | - Add BaseUriPlugin that combines AddHostPlugin and AddPathPlugin 250 | 251 | 252 | ## 1.2.1 - 2016-07-26 253 | 254 | ### Changed 255 | 256 | - AddHostPlugin also sets the port if specified 257 | 258 | 259 | ## 1.2.0 - 2016-07-14 260 | 261 | ### Added 262 | 263 | - Suggest separate plugins in composer.json 264 | - Introduced `debug_plugins` option for `PluginClient` 265 | 266 | 267 | ## 1.1.0 - 2016-05-04 268 | 269 | ### Added 270 | 271 | - Add a flexible http client providing both contract, and only emulating what's necessary 272 | - HTTP Client Router: route requests to underlying clients 273 | - Plugin client and core plugins moved here from `php-http/plugins` 274 | 275 | ### Deprecated 276 | 277 | - Extending client classes, they will be made final in version 2.0 278 | 279 | 280 | ## 1.0.0 - 2016-01-27 281 | 282 | ### Changed 283 | 284 | - Remove useless interface in BatchException 285 | 286 | 287 | ## 0.2.0 - 2016-01-12 288 | 289 | ### Changed 290 | 291 | - Updated package files 292 | - Updated HTTPlug to RC1 293 | 294 | 295 | ## 0.1.1 - 2015-12-26 296 | 297 | ### Added 298 | 299 | - Emulated clients 300 | 301 | 302 | ## 0.1.0 - 2015-12-25 303 | 304 | ### Added 305 | 306 | - Batch client from utils 307 | - Methods client from utils 308 | - Emulators and decorators from client-tools 309 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-2016 PHP HTTP Team 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HTTP Client Common 2 | 3 | [![Latest Version](https://img.shields.io/github/release/php-http/client-common.svg?style=flat-square)](https://github.com/php-http/client-common/releases) 4 | [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE) 5 | [![Build Status](https://github.com/php-http/client-common/actions/workflows/tests.yml/badge.svg)](https://github.com/php-http/client-common/actions/workflows/tests.yml) 6 | [![Code Coverage](https://img.shields.io/scrutinizer/coverage/g/php-http/client-common.svg?style=flat-square)](https://scrutinizer-ci.com/g/php-http/client-common) 7 | [![Quality Score](https://img.shields.io/scrutinizer/g/php-http/client-common.svg?style=flat-square)](https://scrutinizer-ci.com/g/php-http/client-common) 8 | [![Total Downloads](https://img.shields.io/packagist/dt/php-http/client-common.svg?style=flat-square)](https://packagist.org/packages/php-http/client-common) 9 | 10 | **Common HTTP Client implementations and tools for HTTPlug.** 11 | 12 | 13 | ## Install 14 | 15 | Via Composer 16 | 17 | ``` bash 18 | $ composer require php-http/client-common 19 | ``` 20 | 21 | 22 | ## Usage 23 | 24 | This package provides common tools for HTTP Clients: 25 | 26 | - BatchClient to handle sending requests in parallel 27 | - A convenience client with HTTP method names as class methods 28 | - Emulator, decorator layers for sync/async clients 29 | 30 | 31 | ## Documentation 32 | 33 | Please see the [official documentation](http://docs.php-http.org/en/latest/components/client-common.html). 34 | 35 | 36 | ## Testing 37 | 38 | ``` bash 39 | $ composer test 40 | ``` 41 | 42 | 43 | ## Contributing 44 | 45 | Please see our [contributing guide](http://docs.php-http.org/en/latest/development/contributing.html). 46 | 47 | 48 | ## Security 49 | 50 | If you discover any security related issues, please contact us at [security@php-http.org](mailto:security@php-http.org). 51 | 52 | 53 | ## License 54 | 55 | The MIT License (MIT). Please see [License File](LICENSE) for more information. 56 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "php-http/client-common", 3 | "description": "Common HTTP Client implementations and tools for HTTPlug", 4 | "license": "MIT", 5 | "keywords": ["http", "client", "httplug", "common"], 6 | "homepage": "http://httplug.io", 7 | "authors": [ 8 | { 9 | "name": "Márk Sági-Kazár", 10 | "email": "mark.sagikazar@gmail.com" 11 | } 12 | ], 13 | "require": { 14 | "php": "^7.1 || ^8.0", 15 | "php-http/httplug": "^2.0", 16 | "php-http/message": "^1.6", 17 | "psr/http-client": "^1.0", 18 | "psr/http-factory": "^1.0", 19 | "psr/http-message": "^1.0 || ^2.0", 20 | "symfony/options-resolver": "~4.0.15 || ~4.1.9 || ^4.2.1 || ^5.0 || ^6.0 || ^7.0", 21 | "symfony/polyfill-php80": "^1.17" 22 | }, 23 | "require-dev": { 24 | "doctrine/instantiator": "^1.1", 25 | "guzzlehttp/psr7": "^1.4", 26 | "nyholm/psr7": "^1.2", 27 | "phpspec/phpspec": "^5.1 || ^6.3 || ^7.1", 28 | "phpspec/prophecy": "^1.10.2", 29 | "phpunit/phpunit": "^7.5.20 || ^8.5.33 || ^9.6.7" 30 | }, 31 | "suggest": { 32 | "ext-json": "To detect JSON responses with the ContentTypePlugin", 33 | "ext-libxml": "To detect XML responses with the ContentTypePlugin", 34 | "php-http/logger-plugin": "PSR-3 Logger plugin", 35 | "php-http/cache-plugin": "PSR-6 Cache plugin", 36 | "php-http/stopwatch-plugin": "Symfony Stopwatch plugin" 37 | }, 38 | "autoload": { 39 | "psr-4": { 40 | "Http\\Client\\Common\\": "src/" 41 | } 42 | }, 43 | "autoload-dev": { 44 | "psr-4": { 45 | "spec\\Http\\Client\\Common\\": "spec/", 46 | "Tests\\Http\\Client\\Common\\": "tests/" 47 | } 48 | }, 49 | "scripts": { 50 | "test": [ 51 | "vendor/bin/phpspec run", 52 | "vendor/bin/phpunit" 53 | ], 54 | "test-ci": [ 55 | "vendor/bin/phpspec run -c phpspec.ci.yml", 56 | "vendor/bin/phpunit" 57 | ] 58 | }, 59 | "config": { 60 | "sort-packages": true 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/BatchClient.php: -------------------------------------------------------------------------------- 1 | client = $client; 21 | } 22 | 23 | public function sendRequests(array $requests): BatchResult 24 | { 25 | $batchResult = new BatchResult(); 26 | 27 | foreach ($requests as $request) { 28 | try { 29 | $response = $this->client->sendRequest($request); 30 | $batchResult = $batchResult->addResponse($request, $response); 31 | } catch (ClientExceptionInterface $e) { 32 | $batchResult = $batchResult->addException($request, $e); 33 | } 34 | } 35 | 36 | if ($batchResult->hasExceptions()) { 37 | throw new BatchException($batchResult); 38 | } 39 | 40 | return $batchResult; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/BatchClientInterface.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | interface BatchClientInterface 18 | { 19 | /** 20 | * Send several requests. 21 | * 22 | * You may not assume that the requests are executed in a particular order. If the order matters 23 | * for your application, use sendRequest sequentially. 24 | * 25 | * @param RequestInterface[] $requests The requests to send 26 | * 27 | * @return BatchResult Containing one result per request 28 | * 29 | * @throws BatchException If one or more requests fails. The exception gives access to the 30 | * BatchResult with a map of request to result for success, request to 31 | * exception for failures 32 | */ 33 | public function sendRequests(array $requests): BatchResult; 34 | } 35 | -------------------------------------------------------------------------------- /src/BatchResult.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | final class BatchResult 17 | { 18 | /** 19 | * @var \SplObjectStorage 20 | */ 21 | private $responses; 22 | 23 | /** 24 | * @var \SplObjectStorage 25 | */ 26 | private $exceptions; 27 | 28 | public function __construct() 29 | { 30 | $this->responses = new \SplObjectStorage(); 31 | $this->exceptions = new \SplObjectStorage(); 32 | } 33 | 34 | /** 35 | * Checks if there are any successful responses at all. 36 | */ 37 | public function hasResponses(): bool 38 | { 39 | return $this->responses->count() > 0; 40 | } 41 | 42 | /** 43 | * Returns all successful responses. 44 | * 45 | * @return ResponseInterface[] 46 | */ 47 | public function getResponses(): array 48 | { 49 | $responses = []; 50 | 51 | foreach ($this->responses as $request) { 52 | $responses[] = $this->responses[$request]; 53 | } 54 | 55 | return $responses; 56 | } 57 | 58 | /** 59 | * Checks if there is a successful response for a request. 60 | */ 61 | public function isSuccessful(RequestInterface $request): bool 62 | { 63 | return $this->responses->contains($request); 64 | } 65 | 66 | /** 67 | * Returns the response for a successful request. 68 | * 69 | * @throws \UnexpectedValueException If request was not part of the batch or failed 70 | */ 71 | public function getResponseFor(RequestInterface $request): ResponseInterface 72 | { 73 | try { 74 | return $this->responses[$request]; 75 | } catch (\UnexpectedValueException $e) { 76 | throw new \UnexpectedValueException('Request not found', $e->getCode(), $e); 77 | } 78 | } 79 | 80 | /** 81 | * Adds a response in an immutable way. 82 | * 83 | * @return BatchResult the new BatchResult with this request-response pair added to it 84 | */ 85 | public function addResponse(RequestInterface $request, ResponseInterface $response): self 86 | { 87 | $new = clone $this; 88 | $new->responses->attach($request, $response); 89 | 90 | return $new; 91 | } 92 | 93 | /** 94 | * Checks if there are any unsuccessful requests at all. 95 | */ 96 | public function hasExceptions(): bool 97 | { 98 | return $this->exceptions->count() > 0; 99 | } 100 | 101 | /** 102 | * Returns all exceptions for the unsuccessful requests. 103 | * 104 | * @return ClientExceptionInterface[] 105 | */ 106 | public function getExceptions(): array 107 | { 108 | $exceptions = []; 109 | 110 | foreach ($this->exceptions as $request) { 111 | $exceptions[] = $this->exceptions[$request]; 112 | } 113 | 114 | return $exceptions; 115 | } 116 | 117 | /** 118 | * Checks if there is an exception for a request, meaning the request failed. 119 | */ 120 | public function isFailed(RequestInterface $request): bool 121 | { 122 | return $this->exceptions->contains($request); 123 | } 124 | 125 | /** 126 | * Returns the exception for a failed request. 127 | * 128 | * @throws \UnexpectedValueException If request was not part of the batch or was successful 129 | */ 130 | public function getExceptionFor(RequestInterface $request): ClientExceptionInterface 131 | { 132 | try { 133 | return $this->exceptions[$request]; 134 | } catch (\UnexpectedValueException $e) { 135 | throw new \UnexpectedValueException('Request not found', $e->getCode(), $e); 136 | } 137 | } 138 | 139 | /** 140 | * Adds an exception in an immutable way. 141 | * 142 | * @return BatchResult the new BatchResult with this request-exception pair added to it 143 | */ 144 | public function addException(RequestInterface $request, ClientExceptionInterface $exception): self 145 | { 146 | $new = clone $this; 147 | $new->exceptions->attach($request, $exception); 148 | 149 | return $new; 150 | } 151 | 152 | public function __clone() 153 | { 154 | $this->responses = clone $this->responses; 155 | $this->exceptions = clone $this->exceptions; 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/Deferred.php: -------------------------------------------------------------------------------- 1 | waitCallback = $waitCallback; 49 | $this->state = Promise::PENDING; 50 | $this->onFulfilledCallbacks = []; 51 | $this->onRejectedCallbacks = []; 52 | } 53 | 54 | public function then(?callable $onFulfilled = null, ?callable $onRejected = null): Promise 55 | { 56 | $deferred = new self($this->waitCallback); 57 | 58 | $this->onFulfilledCallbacks[] = function (ResponseInterface $response) use ($onFulfilled, $deferred) { 59 | try { 60 | if (null !== $onFulfilled) { 61 | $response = $onFulfilled($response); 62 | } 63 | $deferred->resolve($response); 64 | } catch (ClientExceptionInterface $exception) { 65 | $deferred->reject($exception); 66 | } 67 | }; 68 | 69 | $this->onRejectedCallbacks[] = function (ClientExceptionInterface $exception) use ($onRejected, $deferred) { 70 | try { 71 | if (null !== $onRejected) { 72 | $response = $onRejected($exception); 73 | $deferred->resolve($response); 74 | 75 | return; 76 | } 77 | $deferred->reject($exception); 78 | } catch (ClientExceptionInterface $newException) { 79 | $deferred->reject($newException); 80 | } 81 | }; 82 | 83 | return $deferred; 84 | } 85 | 86 | public function getState(): string 87 | { 88 | return $this->state; 89 | } 90 | 91 | /** 92 | * Resolve this deferred with a Response. 93 | */ 94 | public function resolve(ResponseInterface $response): void 95 | { 96 | if (Promise::PENDING !== $this->state) { 97 | return; 98 | } 99 | 100 | $this->value = $response; 101 | $this->state = Promise::FULFILLED; 102 | 103 | foreach ($this->onFulfilledCallbacks as $onFulfilledCallback) { 104 | $onFulfilledCallback($response); 105 | } 106 | } 107 | 108 | /** 109 | * Reject this deferred with an Exception. 110 | */ 111 | public function reject(ClientExceptionInterface $exception): void 112 | { 113 | if (Promise::PENDING !== $this->state) { 114 | return; 115 | } 116 | 117 | $this->failure = $exception; 118 | $this->state = Promise::REJECTED; 119 | 120 | foreach ($this->onRejectedCallbacks as $onRejectedCallback) { 121 | $onRejectedCallback($exception); 122 | } 123 | } 124 | 125 | public function wait($unwrap = true) 126 | { 127 | if (Promise::PENDING === $this->state) { 128 | $callback = $this->waitCallback; 129 | $callback(); 130 | } 131 | 132 | if (!$unwrap) { 133 | return null; 134 | } 135 | 136 | if (Promise::FULFILLED === $this->state) { 137 | return $this->value; 138 | } 139 | 140 | if (null === $this->failure) { 141 | throw new \RuntimeException('Internal Error: Promise is not fulfilled but has no exception stored'); 142 | } 143 | 144 | throw $this->failure; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/EmulatedHttpAsyncClient.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | final class EmulatedHttpAsyncClient implements HttpClient, HttpAsyncClient 17 | { 18 | use HttpAsyncClientEmulator; 19 | use HttpClientDecorator; 20 | 21 | public function __construct(ClientInterface $httpClient) 22 | { 23 | $this->httpClient = $httpClient; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/EmulatedHttpClient.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | final class EmulatedHttpClient implements HttpClient, HttpAsyncClient 16 | { 17 | use HttpAsyncClientDecorator; 18 | use HttpClientEmulator; 19 | 20 | public function __construct(HttpAsyncClient $httpAsyncClient) 21 | { 22 | $this->httpAsyncClient = $httpAsyncClient; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Exception/BatchException.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | final class BatchException extends TransferException 18 | { 19 | /** 20 | * @var BatchResult 21 | */ 22 | private $result; 23 | 24 | public function __construct(BatchResult $result) 25 | { 26 | $this->result = $result; 27 | parent::__construct(); 28 | } 29 | 30 | /** 31 | * Returns the BatchResult that contains all responses and exceptions. 32 | */ 33 | public function getResult(): BatchResult 34 | { 35 | return $this->result; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Exception/CircularRedirectionException.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | final class CircularRedirectionException extends HttpException 15 | { 16 | } 17 | -------------------------------------------------------------------------------- /src/Exception/ClientErrorException.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | final class ClientErrorException extends HttpException 15 | { 16 | } 17 | -------------------------------------------------------------------------------- /src/Exception/HttpClientNoMatchException.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | final class HttpClientNoMatchException extends TransferException 16 | { 17 | /** 18 | * @var RequestInterface 19 | */ 20 | private $request; 21 | 22 | public function __construct(string $message, RequestInterface $request, ?\Exception $previous = null) 23 | { 24 | $this->request = $request; 25 | 26 | parent::__construct($message, 0, $previous); 27 | } 28 | 29 | public function getRequest(): RequestInterface 30 | { 31 | return $this->request; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Exception/HttpClientNotFoundException.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | final class HttpClientNotFoundException extends TransferException 15 | { 16 | } 17 | -------------------------------------------------------------------------------- /src/Exception/LoopException.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | final class LoopException extends RequestException 15 | { 16 | } 17 | -------------------------------------------------------------------------------- /src/Exception/MultipleRedirectionException.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | final class MultipleRedirectionException extends HttpException 15 | { 16 | } 17 | -------------------------------------------------------------------------------- /src/Exception/ServerErrorException.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | final class ServerErrorException extends HttpException 15 | { 16 | } 17 | -------------------------------------------------------------------------------- /src/FlexibleHttpClient.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | final class FlexibleHttpClient implements HttpClient, HttpAsyncClient 18 | { 19 | use HttpClientDecorator; 20 | use HttpAsyncClientDecorator; 21 | 22 | /** 23 | * @param ClientInterface|HttpAsyncClient $client 24 | */ 25 | public function __construct($client) 26 | { 27 | if (!$client instanceof ClientInterface && !$client instanceof HttpAsyncClient) { 28 | throw new \TypeError( 29 | sprintf('%s::__construct(): Argument #1 ($client) must be of type %s|%s, %s given', self::class, ClientInterface::class, HttpAsyncClient::class, get_debug_type($client)) 30 | ); 31 | } 32 | 33 | $this->httpClient = $client instanceof ClientInterface ? $client : new EmulatedHttpClient($client); 34 | $this->httpAsyncClient = $client instanceof HttpAsyncClient ? $client : new EmulatedHttpAsyncClient($client); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/HttpAsyncClientDecorator.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | trait HttpAsyncClientDecorator 16 | { 17 | /** 18 | * @var HttpAsyncClient 19 | */ 20 | protected $httpAsyncClient; 21 | 22 | /** 23 | * @see HttpAsyncClient::sendAsyncRequest 24 | */ 25 | public function sendAsyncRequest(RequestInterface $request) 26 | { 27 | return $this->httpAsyncClient->sendAsyncRequest($request); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/HttpAsyncClientEmulator.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | trait HttpAsyncClientEmulator 18 | { 19 | /** 20 | * @see HttpClient::sendRequest 21 | */ 22 | abstract public function sendRequest(RequestInterface $request): ResponseInterface; 23 | 24 | /** 25 | * @see HttpAsyncClient::sendAsyncRequest 26 | */ 27 | public function sendAsyncRequest(RequestInterface $request) 28 | { 29 | try { 30 | return new Promise\HttpFulfilledPromise($this->sendRequest($request)); 31 | } catch (Exception $e) { 32 | return new Promise\HttpRejectedPromise($e); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/HttpClientDecorator.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | trait HttpClientDecorator 17 | { 18 | /** 19 | * @var ClientInterface 20 | */ 21 | protected $httpClient; 22 | 23 | /** 24 | * @see ClientInterface::sendRequest 25 | */ 26 | public function sendRequest(RequestInterface $request): ResponseInterface 27 | { 28 | return $this->httpClient->sendRequest($request); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/HttpClientEmulator.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | trait HttpClientEmulator 16 | { 17 | /** 18 | * @see HttpClient::sendRequest 19 | */ 20 | public function sendRequest(RequestInterface $request): ResponseInterface 21 | { 22 | $promise = $this->sendAsyncRequest($request); 23 | 24 | return $promise->wait(); 25 | } 26 | 27 | /** 28 | * @see HttpAsyncClient::sendAsyncRequest 29 | */ 30 | abstract public function sendAsyncRequest(RequestInterface $request); 31 | } 32 | -------------------------------------------------------------------------------- /src/HttpClientPool.php: -------------------------------------------------------------------------------- 1 | clientPool[] = $client; 44 | } 45 | 46 | /** 47 | * Return an http client given a specific strategy. 48 | * 49 | * @return HttpClientPoolItem Return a http client that can do both sync or async 50 | * 51 | * @throws HttpClientNotFoundException When no http client has been found into the pool 52 | */ 53 | abstract protected function chooseHttpClient(): HttpClientPoolItem; 54 | 55 | public function sendAsyncRequest(RequestInterface $request) 56 | { 57 | return $this->chooseHttpClient()->sendAsyncRequest($request); 58 | } 59 | 60 | public function sendRequest(RequestInterface $request): ResponseInterface 61 | { 62 | return $this->chooseHttpClient()->sendRequest($request); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/HttpClientPool/HttpClientPoolItem.php: -------------------------------------------------------------------------------- 1 | 29 | */ 30 | class HttpClientPoolItem implements HttpClient, HttpAsyncClient 31 | { 32 | /** 33 | * @var int Number of request this client is currently sending 34 | */ 35 | private $sendingRequestCount = 0; 36 | 37 | /** 38 | * @var \DateTime|null Time when this client has been disabled or null if enable 39 | */ 40 | private $disabledAt; 41 | 42 | /** 43 | * Number of seconds until this client is enabled again after an error. 44 | * 45 | * null: never reenable this client. 46 | * 47 | * @var int|null 48 | */ 49 | private $reenableAfter; 50 | 51 | /** 52 | * @var FlexibleHttpClient A http client responding to async and sync request 53 | */ 54 | private $client; 55 | 56 | /** 57 | * @param ClientInterface|HttpAsyncClient $client 58 | * @param int|null $reenableAfter Number of seconds until this client is enabled again after an error 59 | */ 60 | public function __construct($client, ?int $reenableAfter = null) 61 | { 62 | if (!$client instanceof ClientInterface && !$client instanceof HttpAsyncClient) { 63 | throw new \TypeError( 64 | sprintf('%s::__construct(): Argument #1 ($client) must be of type %s|%s, %s given', self::class, ClientInterface::class, HttpAsyncClient::class, get_debug_type($client)) 65 | ); 66 | } 67 | 68 | $this->client = new FlexibleHttpClient($client); 69 | $this->reenableAfter = $reenableAfter; 70 | } 71 | 72 | public function sendRequest(RequestInterface $request): ResponseInterface 73 | { 74 | if ($this->isDisabled()) { 75 | throw new Exception\RequestException('Cannot send the request as this client has been disabled', $request); 76 | } 77 | 78 | try { 79 | $this->incrementRequestCount(); 80 | $response = $this->client->sendRequest($request); 81 | $this->decrementRequestCount(); 82 | } catch (Exception $e) { 83 | $this->disable(); 84 | $this->decrementRequestCount(); 85 | 86 | throw $e; 87 | } 88 | 89 | return $response; 90 | } 91 | 92 | public function sendAsyncRequest(RequestInterface $request) 93 | { 94 | if ($this->isDisabled()) { 95 | throw new Exception\RequestException('Cannot send the request as this client has been disabled', $request); 96 | } 97 | 98 | $this->incrementRequestCount(); 99 | 100 | return $this->client->sendAsyncRequest($request)->then(function ($response) { 101 | $this->decrementRequestCount(); 102 | 103 | return $response; 104 | }, function ($exception) { 105 | $this->disable(); 106 | $this->decrementRequestCount(); 107 | 108 | throw $exception; 109 | }); 110 | } 111 | 112 | /** 113 | * Whether this client is disabled or not. 114 | * 115 | * If the client was disabled, calling this method checks if the client can 116 | * be reenabled and if so enables it. 117 | */ 118 | public function isDisabled(): bool 119 | { 120 | if (null !== $this->reenableAfter && null !== $this->disabledAt) { 121 | // Reenable after a certain time 122 | $now = new \DateTime(); 123 | 124 | if (($now->getTimestamp() - $this->disabledAt->getTimestamp()) >= $this->reenableAfter) { 125 | $this->enable(); 126 | 127 | return false; 128 | } 129 | 130 | return true; 131 | } 132 | 133 | return null !== $this->disabledAt; 134 | } 135 | 136 | /** 137 | * Get current number of request that are currently being sent by the underlying HTTP client. 138 | */ 139 | public function getSendingRequestCount(): int 140 | { 141 | return $this->sendingRequestCount; 142 | } 143 | 144 | /** 145 | * Increment the request count. 146 | */ 147 | private function incrementRequestCount(): void 148 | { 149 | ++$this->sendingRequestCount; 150 | } 151 | 152 | /** 153 | * Decrement the request count. 154 | */ 155 | private function decrementRequestCount(): void 156 | { 157 | --$this->sendingRequestCount; 158 | } 159 | 160 | /** 161 | * Enable the current client. 162 | */ 163 | private function enable(): void 164 | { 165 | $this->disabledAt = null; 166 | } 167 | 168 | /** 169 | * Disable the current client. 170 | */ 171 | private function disable(): void 172 | { 173 | $this->disabledAt = new \DateTime('now'); 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/HttpClientPool/LeastUsedClientPool.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | final class LeastUsedClientPool extends HttpClientPool 17 | { 18 | protected function chooseHttpClient(): HttpClientPoolItem 19 | { 20 | $clientPool = array_filter($this->clientPool, function (HttpClientPoolItem $clientPoolItem) { 21 | return !$clientPoolItem->isDisabled(); 22 | }); 23 | 24 | if (0 === count($clientPool)) { 25 | throw new HttpClientNotFoundException('Cannot choose a http client as there is no one present in the pool'); 26 | } 27 | 28 | usort($clientPool, function (HttpClientPoolItem $clientA, HttpClientPoolItem $clientB) { 29 | if ($clientA->getSendingRequestCount() === $clientB->getSendingRequestCount()) { 30 | return 0; 31 | } 32 | 33 | if ($clientA->getSendingRequestCount() < $clientB->getSendingRequestCount()) { 34 | return -1; 35 | } 36 | 37 | return 1; 38 | }); 39 | 40 | return reset($clientPool); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/HttpClientPool/RandomClientPool.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | final class RandomClientPool extends HttpClientPool 15 | { 16 | protected function chooseHttpClient(): HttpClientPoolItem 17 | { 18 | $clientPool = array_filter($this->clientPool, function (HttpClientPoolItem $clientPoolItem) { 19 | return !$clientPoolItem->isDisabled(); 20 | }); 21 | 22 | if (0 === count($clientPool)) { 23 | throw new HttpClientNotFoundException('Cannot choose a http client as there is no one present in the pool'); 24 | } 25 | 26 | return $clientPool[array_rand($clientPool)]; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/HttpClientPool/RoundRobinClientPool.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | final class RoundRobinClientPool extends HttpClientPool 15 | { 16 | protected function chooseHttpClient(): HttpClientPoolItem 17 | { 18 | $last = current($this->clientPool); 19 | 20 | do { 21 | $client = next($this->clientPool); 22 | 23 | if (false === $client) { 24 | $client = reset($this->clientPool); 25 | 26 | if (false === $client) { 27 | throw new HttpClientNotFoundException('Cannot choose a http client as there is no one present in the pool'); 28 | } 29 | } 30 | 31 | // Case when there is only one and the last one has been disabled 32 | if ($last === $client && $client->isDisabled()) { 33 | throw new HttpClientNotFoundException('Cannot choose a http client as there is no one enabled in the pool'); 34 | } 35 | } while ($client->isDisabled()); 36 | 37 | return $client; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/HttpClientRouter.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | final class HttpClientRouter implements HttpClientRouterInterface 18 | { 19 | /** 20 | * @var (array{matcher: RequestMatcher, client: FlexibleHttpClient})[] 21 | */ 22 | private $clients = []; 23 | 24 | public function sendRequest(RequestInterface $request): ResponseInterface 25 | { 26 | return $this->chooseHttpClient($request)->sendRequest($request); 27 | } 28 | 29 | public function sendAsyncRequest(RequestInterface $request) 30 | { 31 | return $this->chooseHttpClient($request)->sendAsyncRequest($request); 32 | } 33 | 34 | /** 35 | * Add a client to the router. 36 | * 37 | * @param ClientInterface|HttpAsyncClient $client 38 | */ 39 | public function addClient($client, RequestMatcher $requestMatcher): void 40 | { 41 | if (!$client instanceof ClientInterface && !$client instanceof HttpAsyncClient) { 42 | throw new \TypeError( 43 | sprintf('%s::addClient(): Argument #1 ($client) must be of type %s|%s, %s given', self::class, ClientInterface::class, HttpAsyncClient::class, get_debug_type($client)) 44 | ); 45 | } 46 | 47 | $this->clients[] = [ 48 | 'matcher' => $requestMatcher, 49 | 'client' => new FlexibleHttpClient($client), 50 | ]; 51 | } 52 | 53 | /** 54 | * Choose an HTTP client given a specific request. 55 | */ 56 | private function chooseHttpClient(RequestInterface $request): FlexibleHttpClient 57 | { 58 | foreach ($this->clients as $client) { 59 | if ($client['matcher']->matches($request)) { 60 | return $client['client']; 61 | } 62 | } 63 | 64 | throw new HttpClientNoMatchException('No client found for the specified request', $request); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/HttpClientRouterInterface.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | interface HttpClientRouterInterface extends HttpClient, HttpAsyncClient 20 | { 21 | /** 22 | * Add a client to the router. 23 | * 24 | * @param ClientInterface|HttpAsyncClient $client 25 | */ 26 | public function addClient($client, RequestMatcher $requestMatcher): void; 27 | } 28 | -------------------------------------------------------------------------------- /src/HttpMethodsClient.php: -------------------------------------------------------------------------------- 1 | httpClient = $httpClient; 49 | $this->requestFactory = $requestFactory; 50 | $this->streamFactory = $streamFactory; 51 | } 52 | 53 | public function get($uri, array $headers = []): ResponseInterface 54 | { 55 | return $this->send('GET', $uri, $headers, null); 56 | } 57 | 58 | public function head($uri, array $headers = []): ResponseInterface 59 | { 60 | return $this->send('HEAD', $uri, $headers, null); 61 | } 62 | 63 | public function trace($uri, array $headers = []): ResponseInterface 64 | { 65 | return $this->send('TRACE', $uri, $headers, null); 66 | } 67 | 68 | public function post($uri, array $headers = [], $body = null): ResponseInterface 69 | { 70 | return $this->send('POST', $uri, $headers, $body); 71 | } 72 | 73 | public function put($uri, array $headers = [], $body = null): ResponseInterface 74 | { 75 | return $this->send('PUT', $uri, $headers, $body); 76 | } 77 | 78 | public function patch($uri, array $headers = [], $body = null): ResponseInterface 79 | { 80 | return $this->send('PATCH', $uri, $headers, $body); 81 | } 82 | 83 | public function delete($uri, array $headers = [], $body = null): ResponseInterface 84 | { 85 | return $this->send('DELETE', $uri, $headers, $body); 86 | } 87 | 88 | public function options($uri, array $headers = [], $body = null): ResponseInterface 89 | { 90 | return $this->send('OPTIONS', $uri, $headers, $body); 91 | } 92 | 93 | public function send(string $method, $uri, array $headers = [], $body = null): ResponseInterface 94 | { 95 | if (!is_string($uri) && !$uri instanceof UriInterface) { 96 | throw new \TypeError( 97 | sprintf('%s::send(): Argument #2 ($uri) must be of type string|%s, %s given', self::class, UriInterface::class, get_debug_type($uri)) 98 | ); 99 | } 100 | 101 | if (!is_string($body) && !$body instanceof StreamInterface && null !== $body) { 102 | throw new \TypeError( 103 | sprintf('%s::send(): Argument #4 ($body) must be of type string|%s|null, %s given', self::class, StreamInterface::class, get_debug_type($body)) 104 | ); 105 | } 106 | 107 | return $this->sendRequest( 108 | self::createRequest($method, $uri, $headers, $body) 109 | ); 110 | } 111 | 112 | /** 113 | * @param string|UriInterface $uri 114 | * @param string|StreamInterface|null $body 115 | */ 116 | private function createRequest(string $method, $uri, array $headers = [], $body = null): RequestInterface 117 | { 118 | if ($this->requestFactory instanceof RequestFactory) { 119 | return $this->requestFactory->createRequest( 120 | $method, 121 | $uri, 122 | $headers, 123 | $body 124 | ); 125 | } 126 | 127 | $request = $this->requestFactory->createRequest($method, $uri); 128 | 129 | foreach ($headers as $key => $value) { 130 | $request = $request->withHeader($key, $value); 131 | } 132 | 133 | if (null !== $body && '' !== $body) { 134 | if (null === $this->streamFactory) { 135 | throw new \RuntimeException('Cannot create request: A stream factory is required to create a request with a non-empty string body.'); 136 | } 137 | 138 | $request = $request->withBody( 139 | is_string($body) ? $this->streamFactory->createStream($body) : $body 140 | ); 141 | } 142 | 143 | return $request; 144 | } 145 | 146 | public function sendRequest(RequestInterface $request): ResponseInterface 147 | { 148 | return $this->httpClient->sendRequest($request); 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/HttpMethodsClientInterface.php: -------------------------------------------------------------------------------- 1 | get('/foo') 19 | * ->post('/bar') 20 | * ; 21 | * 22 | * The client also exposes the sendRequest methods of the wrapped HttpClient. 23 | * 24 | * @author Márk Sági-Kazár 25 | * @author David Buchmann 26 | */ 27 | interface HttpMethodsClientInterface extends HttpClient 28 | { 29 | /** 30 | * Sends a GET request. 31 | * 32 | * @param string|UriInterface $uri 33 | * 34 | * @throws Exception 35 | */ 36 | public function get($uri, array $headers = []): ResponseInterface; 37 | 38 | /** 39 | * Sends an HEAD request. 40 | * 41 | * @param string|UriInterface $uri 42 | * 43 | * @throws Exception 44 | */ 45 | public function head($uri, array $headers = []): ResponseInterface; 46 | 47 | /** 48 | * Sends a TRACE request. 49 | * 50 | * @param string|UriInterface $uri 51 | * 52 | * @throws Exception 53 | */ 54 | public function trace($uri, array $headers = []): ResponseInterface; 55 | 56 | /** 57 | * Sends a POST request. 58 | * 59 | * @param string|UriInterface $uri 60 | * @param string|StreamInterface|null $body 61 | * 62 | * @throws Exception 63 | */ 64 | public function post($uri, array $headers = [], $body = null): ResponseInterface; 65 | 66 | /** 67 | * Sends a PUT request. 68 | * 69 | * @param string|UriInterface $uri 70 | * @param string|StreamInterface|null $body 71 | * 72 | * @throws Exception 73 | */ 74 | public function put($uri, array $headers = [], $body = null): ResponseInterface; 75 | 76 | /** 77 | * Sends a PATCH request. 78 | * 79 | * @param string|UriInterface $uri 80 | * @param string|StreamInterface|null $body 81 | * 82 | * @throws Exception 83 | */ 84 | public function patch($uri, array $headers = [], $body = null): ResponseInterface; 85 | 86 | /** 87 | * Sends a DELETE request. 88 | * 89 | * @param string|UriInterface $uri 90 | * @param string|StreamInterface|null $body 91 | * 92 | * @throws Exception 93 | */ 94 | public function delete($uri, array $headers = [], $body = null): ResponseInterface; 95 | 96 | /** 97 | * Sends an OPTIONS request. 98 | * 99 | * @param string|UriInterface $uri 100 | * @param string|StreamInterface|null $body 101 | * 102 | * @throws Exception 103 | */ 104 | public function options($uri, array $headers = [], $body = null): ResponseInterface; 105 | 106 | /** 107 | * Sends a request with any HTTP method. 108 | * 109 | * @param string $method HTTP method to use 110 | * @param string|UriInterface $uri 111 | * @param string|StreamInterface|null $body 112 | * 113 | * @throws Exception 114 | */ 115 | public function send(string $method, $uri, array $headers = [], $body = null): ResponseInterface; 116 | } 117 | -------------------------------------------------------------------------------- /src/Plugin.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | interface Plugin 21 | { 22 | /** 23 | * Handle the request and return the response coming from the next callable. 24 | * 25 | * @see http://docs.php-http.org/en/latest/plugins/build-your-own.html 26 | * 27 | * @param callable(RequestInterface): Promise $next Next middleware in the chain, the request is passed as the first argument 28 | * @param callable(RequestInterface): Promise $first First middleware in the chain, used to to restart a request 29 | * 30 | * @return Promise Resolves a PSR-7 Response or fails with an Http\Client\Exception (The same as HttpAsyncClient) 31 | */ 32 | public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise; 33 | } 34 | -------------------------------------------------------------------------------- /src/Plugin/AddHostPlugin.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | final class AddHostPlugin implements Plugin 19 | { 20 | /** 21 | * @var UriInterface 22 | */ 23 | private $host; 24 | 25 | /** 26 | * @var bool 27 | */ 28 | private $replace; 29 | 30 | /** 31 | * @param array{'replace'?: bool} $config 32 | * 33 | * Configuration options: 34 | * - replace: True will replace all hosts, false will only add host when none is specified 35 | */ 36 | public function __construct(UriInterface $host, array $config = []) 37 | { 38 | if ('' === $host->getHost()) { 39 | throw new \LogicException('Host can not be empty'); 40 | } 41 | 42 | $this->host = $host; 43 | 44 | $resolver = new OptionsResolver(); 45 | $this->configureOptions($resolver); 46 | $options = $resolver->resolve($config); 47 | 48 | $this->replace = $options['replace']; 49 | } 50 | 51 | public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise 52 | { 53 | if ($this->replace || '' === $request->getUri()->getHost()) { 54 | $uri = $request->getUri() 55 | ->withHost($this->host->getHost()) 56 | ->withScheme($this->host->getScheme()) 57 | ->withPort($this->host->getPort()) 58 | ; 59 | 60 | $request = $request->withUri($uri); 61 | } 62 | 63 | return $next($request); 64 | } 65 | 66 | private function configureOptions(OptionsResolver $resolver): void 67 | { 68 | $resolver->setDefaults([ 69 | 'replace' => false, 70 | ]); 71 | $resolver->setAllowedTypes('replace', 'bool'); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Plugin/AddPathPlugin.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | final class AddPathPlugin implements Plugin 18 | { 19 | /** 20 | * @var UriInterface 21 | */ 22 | private $uri; 23 | 24 | public function __construct(UriInterface $uri) 25 | { 26 | if ('' === $uri->getPath()) { 27 | throw new \LogicException('URI path cannot be empty'); 28 | } 29 | 30 | if ('/' === substr($uri->getPath(), -1)) { 31 | $uri = $uri->withPath(rtrim($uri->getPath(), '/')); 32 | } 33 | 34 | $this->uri = $uri; 35 | } 36 | 37 | /** 38 | * Adds a prefix in the beginning of the URL's path. 39 | * 40 | * The prefix is not added if that prefix is already on the URL's path. This will fail on the edge 41 | * case of the prefix being repeated, for example if `https://example.com/api/api/foo` is a valid 42 | * URL on the server and the configured prefix is `/api`. 43 | * 44 | * We looked at other solutions, but they are all much more complicated, while still having edge 45 | * cases: 46 | * - Doing an spl_object_hash on `$first` will lead to collisions over time because over time the 47 | * hash can collide. 48 | * - Have the PluginClient provide a magic header to identify the request chain and only apply 49 | * this plugin once. 50 | * 51 | * There are 2 reasons for the AddPathPlugin to be executed twice on the same request: 52 | * - A plugin can restart the chain by calling `$first`, e.g. redirect 53 | * - A plugin can call `$next` more than once, e.g. retry 54 | * 55 | * Depending on the scenario, the path should or should not be added. E.g. `$first` could 56 | * be called after a redirect response from the server. The server likely already has the 57 | * correct path. 58 | * 59 | * No solution fits all use cases. This implementation will work fine for the common use cases. 60 | * If you have a specific situation where this is not the right thing, you can build a custom plugin 61 | * that does exactly what you need. 62 | * 63 | * {@inheritdoc} 64 | */ 65 | public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise 66 | { 67 | $prepend = $this->uri->getPath(); 68 | $path = $request->getUri()->getPath(); 69 | 70 | if (substr($path, 0, strlen($prepend)) !== $prepend) { 71 | $request = $request->withUri($request->getUri() 72 | ->withPath($prepend.$path) 73 | ); 74 | } 75 | 76 | return $next($request); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Plugin/AuthenticationPlugin.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | final class AuthenticationPlugin implements Plugin 18 | { 19 | /** 20 | * @var Authentication An authentication system 21 | */ 22 | private $authentication; 23 | 24 | public function __construct(Authentication $authentication) 25 | { 26 | $this->authentication = $authentication; 27 | } 28 | 29 | public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise 30 | { 31 | $request = $this->authentication->authenticate($request); 32 | 33 | return $next($request); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Plugin/BaseUriPlugin.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | final class BaseUriPlugin implements Plugin 18 | { 19 | /** 20 | * @var AddHostPlugin 21 | */ 22 | private $addHostPlugin; 23 | 24 | /** 25 | * @var AddPathPlugin|null 26 | */ 27 | private $addPathPlugin; 28 | 29 | /** 30 | * @param UriInterface $uri Has to contain a host name and can have a path 31 | * @param array $hostConfig Config for AddHostPlugin. @see AddHostPlugin::configureOptions 32 | */ 33 | public function __construct(UriInterface $uri, array $hostConfig = []) 34 | { 35 | $this->addHostPlugin = new AddHostPlugin($uri, $hostConfig); 36 | 37 | if (rtrim($uri->getPath(), '/')) { 38 | $this->addPathPlugin = new AddPathPlugin($uri); 39 | } 40 | } 41 | 42 | public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise 43 | { 44 | $addHostNext = function (RequestInterface $request) use ($next, $first) { 45 | return $this->addHostPlugin->handleRequest($request, $next, $first); 46 | }; 47 | 48 | if ($this->addPathPlugin) { 49 | return $this->addPathPlugin->handleRequest($request, $addHostNext, $first); 50 | } 51 | 52 | return $addHostNext($request); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Plugin/ContentLengthPlugin.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | final class ContentLengthPlugin implements Plugin 18 | { 19 | public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise 20 | { 21 | if (!$request->hasHeader('Content-Length')) { 22 | $stream = $request->getBody(); 23 | 24 | // Cannot determine the size so we use a chunk stream 25 | if (null === $stream->getSize()) { 26 | $stream = new ChunkStream($stream); 27 | $request = $request->withBody($stream); 28 | $request = $request->withAddedHeader('Transfer-Encoding', 'chunked'); 29 | } else { 30 | $request = $request->withHeader('Content-Length', (string) $stream->getSize()); 31 | } 32 | } 33 | 34 | return $next($request); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Plugin/ContentTypePlugin.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | final class ContentTypePlugin implements Plugin 19 | { 20 | /** 21 | * Allow to disable the content type detection when stream is too large (as it can consume a lot of resource). 22 | * 23 | * @var bool 24 | * 25 | * true skip the content type detection 26 | * false detect the content type (default value) 27 | */ 28 | private $skipDetection; 29 | 30 | /** 31 | * Determine the size stream limit for which the detection as to be skipped (default to 16Mb). 32 | * 33 | * @var int 34 | */ 35 | private $sizeLimit; 36 | 37 | /** 38 | * @param array{'skip_detection'?: bool, 'size_limit'?: int} $config 39 | * 40 | * Configuration options: 41 | * - skip_detection: true skip detection if stream size is bigger than $size_limit 42 | * - size_limit: size stream limit for which the detection as to be skipped 43 | */ 44 | public function __construct(array $config = []) 45 | { 46 | $resolver = new OptionsResolver(); 47 | $resolver->setDefaults([ 48 | 'skip_detection' => false, 49 | 'size_limit' => 16000000, 50 | ]); 51 | $resolver->setAllowedTypes('skip_detection', 'bool'); 52 | $resolver->setAllowedTypes('size_limit', 'int'); 53 | 54 | $options = $resolver->resolve($config); 55 | 56 | $this->skipDetection = $options['skip_detection']; 57 | $this->sizeLimit = $options['size_limit']; 58 | } 59 | 60 | public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise 61 | { 62 | if (!$request->hasHeader('Content-Type')) { 63 | $stream = $request->getBody(); 64 | $streamSize = $stream->getSize(); 65 | 66 | if (!$stream->isSeekable()) { 67 | return $next($request); 68 | } 69 | 70 | if (0 === $streamSize) { 71 | return $next($request); 72 | } 73 | 74 | if ($this->skipDetection && (null === $streamSize || $streamSize >= $this->sizeLimit)) { 75 | return $next($request); 76 | } 77 | 78 | if ($this->isJson($stream)) { 79 | $request = $request->withHeader('Content-Type', 'application/json'); 80 | 81 | return $next($request); 82 | } 83 | 84 | if ($this->isXml($stream)) { 85 | $request = $request->withHeader('Content-Type', 'application/xml'); 86 | 87 | return $next($request); 88 | } 89 | } 90 | 91 | return $next($request); 92 | } 93 | 94 | private function isJson(StreamInterface $stream): bool 95 | { 96 | if (!function_exists('json_decode')) { 97 | return false; 98 | } 99 | $stream->rewind(); 100 | 101 | json_decode($stream->getContents()); 102 | 103 | return JSON_ERROR_NONE === json_last_error(); 104 | } 105 | 106 | private function isXml(StreamInterface $stream): bool 107 | { 108 | if (!function_exists('simplexml_load_string')) { 109 | return false; 110 | } 111 | $stream->rewind(); 112 | 113 | $previousValue = libxml_use_internal_errors(true); 114 | $isXml = simplexml_load_string($stream->getContents()); 115 | libxml_use_internal_errors($previousValue); 116 | 117 | return false !== $isXml; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/Plugin/CookiePlugin.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | final class CookiePlugin implements Plugin 23 | { 24 | /** 25 | * Cookie storage. 26 | * 27 | * @var CookieJar 28 | */ 29 | private $cookieJar; 30 | 31 | public function __construct(CookieJar $cookieJar) 32 | { 33 | $this->cookieJar = $cookieJar; 34 | } 35 | 36 | public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise 37 | { 38 | $cookies = []; 39 | foreach ($this->cookieJar->getCookies() as $cookie) { 40 | if ($cookie->isExpired()) { 41 | continue; 42 | } 43 | 44 | if (!$cookie->matchDomain($request->getUri()->getHost())) { 45 | continue; 46 | } 47 | 48 | if (!$cookie->matchPath($request->getUri()->getPath())) { 49 | continue; 50 | } 51 | 52 | if ($cookie->isSecure() && ('https' !== $request->getUri()->getScheme())) { 53 | continue; 54 | } 55 | 56 | $cookies[] = sprintf('%s=%s', $cookie->getName(), $cookie->getValue()); 57 | } 58 | 59 | if (!empty($cookies)) { 60 | $request = $request->withAddedHeader('Cookie', implode('; ', array_unique($cookies))); 61 | } 62 | 63 | return $next($request)->then(function (ResponseInterface $response) use ($request) { 64 | if ($response->hasHeader('Set-Cookie')) { 65 | $setCookies = $response->getHeader('Set-Cookie'); 66 | 67 | foreach ($setCookies as $setCookie) { 68 | $cookie = $this->createCookie($request, $setCookie); 69 | 70 | // Cookie invalid do not use it 71 | if (null === $cookie) { 72 | continue; 73 | } 74 | 75 | // Restrict setting cookie from another domain 76 | if (!preg_match("/\.{$cookie->getDomain()}$/", '.'.$request->getUri()->getHost())) { 77 | continue; 78 | } 79 | 80 | $this->cookieJar->addCookie($cookie); 81 | } 82 | } 83 | 84 | return $response; 85 | }); 86 | } 87 | 88 | /** 89 | * Creates a cookie from a string. 90 | * 91 | * @throws TransferException 92 | */ 93 | private function createCookie(RequestInterface $request, string $setCookieHeader): ?Cookie 94 | { 95 | $parts = array_map('trim', explode(';', $setCookieHeader)); 96 | 97 | if ('' === $parts[0] || false === strpos($parts[0], '=')) { 98 | return null; 99 | } 100 | 101 | list($name, $cookieValue) = $this->createValueKey(array_shift($parts)); 102 | 103 | $maxAge = null; 104 | $expires = null; 105 | $domain = $request->getUri()->getHost(); 106 | $path = $request->getUri()->getPath(); 107 | $secure = false; 108 | $httpOnly = false; 109 | 110 | // Add the cookie pieces into the parsed data array 111 | foreach ($parts as $part) { 112 | list($key, $value) = $this->createValueKey($part); 113 | 114 | switch (strtolower($key)) { 115 | case 'expires': 116 | try { 117 | $expires = CookieUtil::parseDate((string) $value); 118 | } catch (UnexpectedValueException $e) { 119 | throw new TransferException( 120 | sprintf( 121 | 'Cookie header `%s` expires value `%s` could not be converted to date', 122 | $name, 123 | $value 124 | ), 125 | 0, 126 | $e 127 | ); 128 | } 129 | 130 | break; 131 | 132 | case 'max-age': 133 | $maxAge = (int) $value; 134 | 135 | break; 136 | 137 | case 'domain': 138 | $domain = $value; 139 | 140 | break; 141 | 142 | case 'path': 143 | $path = $value; 144 | 145 | break; 146 | 147 | case 'secure': 148 | $secure = true; 149 | 150 | break; 151 | 152 | case 'httponly': 153 | $httpOnly = true; 154 | 155 | break; 156 | } 157 | } 158 | 159 | return new Cookie($name, $cookieValue, $maxAge, $domain, $path, $secure, $httpOnly, $expires); 160 | } 161 | 162 | /** 163 | * Separates key/value pair from cookie. 164 | * 165 | * @param string $part A single cookie value in format key=value 166 | * 167 | * @return array{0:string, 1:string|null} 168 | */ 169 | private function createValueKey(string $part): array 170 | { 171 | $parts = explode('=', $part, 2); 172 | $key = trim($parts[0]); 173 | $value = isset($parts[1]) ? trim($parts[1]) : null; 174 | 175 | return [$key, $value]; 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/Plugin/DecoderPlugin.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | final class DecoderPlugin implements Plugin 25 | { 26 | /** 27 | * @var bool Whether this plugin decode stream with value in the Content-Encoding header (default to true). 28 | * 29 | * If set to false only the Transfer-Encoding header will be used 30 | */ 31 | private $useContentEncoding; 32 | 33 | /** 34 | * @param array{'use_content_encoding'?: bool} $config 35 | * 36 | * Configuration options: 37 | * - use_content_encoding: Whether this plugin should look at the Content-Encoding header first or only at the Transfer-Encoding (defaults to true) 38 | */ 39 | public function __construct(array $config = []) 40 | { 41 | $resolver = new OptionsResolver(); 42 | $resolver->setDefaults([ 43 | 'use_content_encoding' => true, 44 | ]); 45 | $resolver->setAllowedTypes('use_content_encoding', 'bool'); 46 | $options = $resolver->resolve($config); 47 | 48 | $this->useContentEncoding = $options['use_content_encoding']; 49 | } 50 | 51 | public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise 52 | { 53 | $encodings = extension_loaded('zlib') ? ['gzip', 'deflate'] : ['identity']; 54 | 55 | if ($this->useContentEncoding) { 56 | $request = $request->withHeader('Accept-Encoding', $encodings); 57 | } 58 | $encodings[] = 'chunked'; 59 | $request = $request->withHeader('TE', $encodings); 60 | 61 | return $next($request)->then(function (ResponseInterface $response) { 62 | return $this->decodeResponse($response); 63 | }); 64 | } 65 | 66 | /** 67 | * Decode a response body given its Transfer-Encoding or Content-Encoding value. 68 | */ 69 | private function decodeResponse(ResponseInterface $response): ResponseInterface 70 | { 71 | $response = $this->decodeOnEncodingHeader('Transfer-Encoding', $response); 72 | 73 | if ($this->useContentEncoding) { 74 | $response = $this->decodeOnEncodingHeader('Content-Encoding', $response); 75 | } 76 | 77 | return $response; 78 | } 79 | 80 | /** 81 | * Decode a response on a specific header (content encoding or transfer encoding mainly). 82 | */ 83 | private function decodeOnEncodingHeader(string $headerName, ResponseInterface $response): ResponseInterface 84 | { 85 | if ($response->hasHeader($headerName)) { 86 | $encodings = $response->getHeader($headerName); 87 | $newEncodings = []; 88 | 89 | while ($encoding = array_pop($encodings)) { 90 | $stream = $this->decorateStream($encoding, $response->getBody()); 91 | 92 | if (false === $stream) { 93 | array_unshift($newEncodings, $encoding); 94 | 95 | continue; 96 | } 97 | 98 | $response = $response->withBody($stream); 99 | } 100 | 101 | if (\count($newEncodings) > 0) { 102 | $response = $response->withHeader($headerName, $newEncodings); 103 | } else { 104 | $response = $response->withoutHeader($headerName); 105 | } 106 | } 107 | 108 | return $response; 109 | } 110 | 111 | /** 112 | * Decorate a stream given an encoding. 113 | * 114 | * @return StreamInterface|false A new stream interface or false if encoding is not supported 115 | */ 116 | private function decorateStream(string $encoding, StreamInterface $stream) 117 | { 118 | if ('chunked' === strtolower($encoding)) { 119 | return new Encoding\DechunkStream($stream); 120 | } 121 | 122 | if ('deflate' === strtolower($encoding)) { 123 | return new Encoding\DecompressStream($stream); 124 | } 125 | 126 | if ('gzip' === strtolower($encoding)) { 127 | return new Encoding\GzipDecodeStream($stream); 128 | } 129 | 130 | return false; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/Plugin/ErrorPlugin.php: -------------------------------------------------------------------------------- 1 | 29 | */ 30 | final class ErrorPlugin implements Plugin 31 | { 32 | /** 33 | * @var bool Whether this plugin should only throw 5XX Exceptions (default to false). 34 | * 35 | * If set to true 4XX Responses code will never throw an exception 36 | */ 37 | private $onlyServerException; 38 | 39 | /** 40 | * @param array{'only_server_exception'?: bool} $config 41 | * 42 | * Configuration options: 43 | * - only_server_exception: Whether this plugin should only throw 5XX Exceptions (default to false) 44 | */ 45 | public function __construct(array $config = []) 46 | { 47 | $resolver = new OptionsResolver(); 48 | $resolver->setDefaults([ 49 | 'only_server_exception' => false, 50 | ]); 51 | $resolver->setAllowedTypes('only_server_exception', 'bool'); 52 | $options = $resolver->resolve($config); 53 | 54 | $this->onlyServerException = $options['only_server_exception']; 55 | } 56 | 57 | public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise 58 | { 59 | $promise = $next($request); 60 | 61 | return $promise->then(function (ResponseInterface $response) use ($request) { 62 | return $this->transformResponseToException($request, $response); 63 | }); 64 | } 65 | 66 | /** 67 | * Transform response to an error if possible. 68 | * 69 | * @param RequestInterface $request Request of the call 70 | * @param ResponseInterface $response Response of the call 71 | * 72 | * @return ResponseInterface If status code is not in 4xx or 5xx return response 73 | * 74 | * @throws ClientErrorException If response status code is a 4xx 75 | * @throws ServerErrorException If response status code is a 5xx 76 | */ 77 | private function transformResponseToException(RequestInterface $request, ResponseInterface $response): ResponseInterface 78 | { 79 | if (!$this->onlyServerException && $response->getStatusCode() >= 400 && $response->getStatusCode() < 500) { 80 | throw new ClientErrorException($response->getReasonPhrase(), $request, $response); 81 | } 82 | 83 | if ($response->getStatusCode() >= 500 && $response->getStatusCode() < 600) { 84 | throw new ServerErrorException($response->getReasonPhrase(), $request, $response); 85 | } 86 | 87 | return $response; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Plugin/HeaderAppendPlugin.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | final class HeaderAppendPlugin implements Plugin 23 | { 24 | /** 25 | * @var array 26 | */ 27 | private $headers; 28 | 29 | /** 30 | * @param array $headers Hashmap of header name to header value 31 | */ 32 | public function __construct(array $headers) 33 | { 34 | $this->headers = $headers; 35 | } 36 | 37 | public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise 38 | { 39 | foreach ($this->headers as $header => $headerValue) { 40 | $request = $request->withAddedHeader($header, $headerValue); 41 | } 42 | 43 | return $next($request); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Plugin/HeaderDefaultsPlugin.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | final class HeaderDefaultsPlugin implements Plugin 19 | { 20 | /** 21 | * @var array 22 | */ 23 | private $headers = []; 24 | 25 | /** 26 | * @param array $headers Hashmap of header name to header value 27 | */ 28 | public function __construct(array $headers) 29 | { 30 | $this->headers = $headers; 31 | } 32 | 33 | public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise 34 | { 35 | foreach ($this->headers as $header => $headerValue) { 36 | if (!$request->hasHeader($header)) { 37 | $request = $request->withHeader($header, $headerValue); 38 | } 39 | } 40 | 41 | return $next($request); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Plugin/HeaderRemovePlugin.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | final class HeaderRemovePlugin implements Plugin 17 | { 18 | /** 19 | * @var array 20 | */ 21 | private $headers = []; 22 | 23 | /** 24 | * @param array $headers List of header names to remove from the request 25 | */ 26 | public function __construct(array $headers) 27 | { 28 | $this->headers = $headers; 29 | } 30 | 31 | public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise 32 | { 33 | foreach ($this->headers as $header) { 34 | if ($request->hasHeader($header)) { 35 | $request = $request->withoutHeader($header); 36 | } 37 | } 38 | 39 | return $next($request); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Plugin/HeaderSetPlugin.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | final class HeaderSetPlugin implements Plugin 19 | { 20 | /** 21 | * @var array 22 | */ 23 | private $headers; 24 | 25 | /** 26 | * @param array $headers Hashmap of header name to header value 27 | */ 28 | public function __construct(array $headers) 29 | { 30 | $this->headers = $headers; 31 | } 32 | 33 | public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise 34 | { 35 | foreach ($this->headers as $header => $headerValue) { 36 | $request = $request->withHeader($header, $headerValue); 37 | } 38 | 39 | return $next($request); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Plugin/HistoryPlugin.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | final class HistoryPlugin implements Plugin 19 | { 20 | /** 21 | * Journal use to store request / responses / exception. 22 | * 23 | * @var Journal 24 | */ 25 | private $journal; 26 | 27 | public function __construct(Journal $journal) 28 | { 29 | $this->journal = $journal; 30 | } 31 | 32 | public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise 33 | { 34 | $journal = $this->journal; 35 | 36 | return $next($request)->then(function (ResponseInterface $response) use ($request, $journal) { 37 | $journal->addSuccess($request, $response); 38 | 39 | return $response; 40 | }, function (ClientExceptionInterface $exception) use ($request, $journal) { 41 | $journal->addFailure($request, $exception); 42 | 43 | throw $exception; 44 | }); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Plugin/Journal.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | interface Journal 17 | { 18 | /** 19 | * Record a successful call. 20 | * 21 | * @param RequestInterface $request Request use to make the call 22 | * @param ResponseInterface $response Response returned by the call 23 | */ 24 | public function addSuccess(RequestInterface $request, ResponseInterface $response); 25 | 26 | /** 27 | * Record a failed call. 28 | * 29 | * @param RequestInterface $request Request use to make the call 30 | * @param ClientExceptionInterface $exception Exception returned by the call 31 | */ 32 | public function addFailure(RequestInterface $request, ClientExceptionInterface $exception); 33 | } 34 | -------------------------------------------------------------------------------- /src/Plugin/QueryDefaultsPlugin.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | final class QueryDefaultsPlugin implements Plugin 19 | { 20 | /** 21 | * @var array 22 | */ 23 | private $queryParams = []; 24 | 25 | /** 26 | * @param array $queryParams Hashmap of query name to query value. Names and values must not be url encoded as 27 | * this plugin will encode them 28 | */ 29 | public function __construct(array $queryParams) 30 | { 31 | $this->queryParams = $queryParams; 32 | } 33 | 34 | public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise 35 | { 36 | $uri = $request->getUri(); 37 | 38 | parse_str($uri->getQuery(), $query); 39 | $query += $this->queryParams; 40 | 41 | $request = $request->withUri( 42 | $uri->withQuery(http_build_query($query)) 43 | ); 44 | 45 | return $next($request); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Plugin/RedirectPlugin.php: -------------------------------------------------------------------------------- 1 | 27 | */ 28 | final class RedirectPlugin implements Plugin 29 | { 30 | /** 31 | * Rule on how to redirect, change method for the new request. 32 | * 33 | * @var array 34 | */ 35 | private $redirectCodes = [ 36 | 300 => [ 37 | 'switch' => [ 38 | 'unless' => ['GET', 'HEAD'], 39 | 'to' => 'GET', 40 | ], 41 | 'multiple' => true, 42 | 'permanent' => false, 43 | ], 44 | 301 => [ 45 | 'switch' => [ 46 | 'unless' => ['GET', 'HEAD'], 47 | 'to' => 'GET', 48 | ], 49 | 'multiple' => false, 50 | 'permanent' => true, 51 | ], 52 | 302 => [ 53 | 'switch' => [ 54 | 'unless' => ['GET', 'HEAD'], 55 | 'to' => 'GET', 56 | ], 57 | 'multiple' => false, 58 | 'permanent' => false, 59 | ], 60 | 303 => [ 61 | 'switch' => [ 62 | 'unless' => ['GET', 'HEAD'], 63 | 'to' => 'GET', 64 | ], 65 | 'multiple' => false, 66 | 'permanent' => false, 67 | ], 68 | 307 => [ 69 | 'switch' => false, 70 | 'multiple' => false, 71 | 'permanent' => false, 72 | ], 73 | 308 => [ 74 | 'switch' => false, 75 | 'multiple' => false, 76 | 'permanent' => true, 77 | ], 78 | ]; 79 | 80 | /** 81 | * Determine how header should be preserved from old request. 82 | * 83 | * @var bool|array 84 | * 85 | * true will keep all previous headers (default value) 86 | * false will ditch all previous headers 87 | * string[] will keep only headers with the specified names 88 | */ 89 | private $preserveHeader; 90 | 91 | /** 92 | * Store all previous redirect from 301 / 308 status code. 93 | * 94 | * @var array 95 | */ 96 | private $redirectStorage = []; 97 | 98 | /** 99 | * Whether the location header must be directly used for a multiple redirection status code (300). 100 | * 101 | * @var bool 102 | */ 103 | private $useDefaultForMultiple; 104 | 105 | /** 106 | * @var string[][] Chain identifier => list of URLs for this chain 107 | */ 108 | private $circularDetection = []; 109 | 110 | /** 111 | * @var StreamFactoryInterface|null 112 | */ 113 | private $streamFactory; 114 | 115 | /** 116 | * @param array{'preserve_header'?: bool|string[], 'use_default_for_multiple'?: bool, 'strict'?: bool, 'stream_factory'?:StreamFactoryInterface} $config 117 | * 118 | * Configuration options: 119 | * - preserve_header: True keeps all headers, false remove all of them, an array is interpreted as a list of header names to keep 120 | * - use_default_for_multiple: Whether the location header must be directly used for a multiple redirection status code (300) 121 | * - strict: When true, redirect codes 300, 301, 302 will not modify request method and body 122 | * - stream_factory: If set, must be a PSR-17 StreamFactoryInterface - if not set, we try to discover one 123 | */ 124 | public function __construct(array $config = []) 125 | { 126 | $resolver = new OptionsResolver(); 127 | $resolver->setDefaults([ 128 | 'preserve_header' => true, 129 | 'use_default_for_multiple' => true, 130 | 'strict' => false, 131 | 'stream_factory' => null, 132 | ]); 133 | $resolver->setAllowedTypes('preserve_header', ['bool', 'array']); 134 | $resolver->setAllowedTypes('use_default_for_multiple', 'bool'); 135 | $resolver->setAllowedTypes('strict', 'bool'); 136 | $resolver->setAllowedTypes('stream_factory', [StreamFactoryInterface::class, 'null']); 137 | $resolver->setNormalizer('preserve_header', function (OptionsResolver $resolver, $value) { 138 | if (is_bool($value) && false === $value) { 139 | return []; 140 | } 141 | 142 | return $value; 143 | }); 144 | $resolver->setDefault('stream_factory', function (Options $options): ?StreamFactoryInterface { 145 | return $this->guessStreamFactory(); 146 | }); 147 | $options = $resolver->resolve($config); 148 | 149 | $this->preserveHeader = $options['preserve_header']; 150 | $this->useDefaultForMultiple = $options['use_default_for_multiple']; 151 | 152 | if ($options['strict']) { 153 | $this->redirectCodes[300]['switch'] = false; 154 | $this->redirectCodes[301]['switch'] = false; 155 | $this->redirectCodes[302]['switch'] = false; 156 | } 157 | 158 | $this->streamFactory = $options['stream_factory']; 159 | } 160 | 161 | public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise 162 | { 163 | // Check in storage 164 | if (array_key_exists((string) $request->getUri(), $this->redirectStorage)) { 165 | $uri = $this->redirectStorage[(string) $request->getUri()]['uri']; 166 | $statusCode = $this->redirectStorage[(string) $request->getUri()]['status']; 167 | $redirectRequest = $this->buildRedirectRequest($request, $uri, $statusCode); 168 | 169 | return $first($redirectRequest); 170 | } 171 | 172 | return $next($request)->then(function (ResponseInterface $response) use ($request, $first): ResponseInterface { 173 | $statusCode = $response->getStatusCode(); 174 | 175 | if (!array_key_exists($statusCode, $this->redirectCodes)) { 176 | return $response; 177 | } 178 | 179 | $uri = $this->createUri($response, $request); 180 | $redirectRequest = $this->buildRedirectRequest($request, $uri, $statusCode); 181 | $chainIdentifier = spl_object_hash((object) $first); 182 | 183 | if (!array_key_exists($chainIdentifier, $this->circularDetection)) { 184 | $this->circularDetection[$chainIdentifier] = []; 185 | } 186 | 187 | $this->circularDetection[$chainIdentifier][] = (string) $request->getUri(); 188 | 189 | if (in_array((string) $redirectRequest->getUri(), $this->circularDetection[$chainIdentifier], true)) { 190 | throw new CircularRedirectionException('Circular redirection detected', $request, $response); 191 | } 192 | 193 | if ($this->redirectCodes[$statusCode]['permanent']) { 194 | $this->redirectStorage[(string) $request->getUri()] = [ 195 | 'uri' => $uri, 196 | 'status' => $statusCode, 197 | ]; 198 | } 199 | 200 | // Call redirect request synchronously 201 | return $first($redirectRequest)->wait(); 202 | }); 203 | } 204 | 205 | /** 206 | * The default only needs to be determined if no value is provided. 207 | */ 208 | public function guessStreamFactory(): ?StreamFactoryInterface 209 | { 210 | if (class_exists(Psr17FactoryDiscovery::class)) { 211 | try { 212 | return Psr17FactoryDiscovery::findStreamFactory(); 213 | } catch (\Throwable $t) { 214 | // ignore and try other options 215 | } 216 | } 217 | if (class_exists(Psr17Factory::class)) { 218 | return new Psr17Factory(); 219 | } 220 | if (class_exists(Utils::class)) { 221 | return new class implements StreamFactoryInterface { 222 | public function createStream(string $content = ''): StreamInterface 223 | { 224 | return Utils::streamFor($content); 225 | } 226 | 227 | public function createStreamFromFile(string $filename, string $mode = 'r'): StreamInterface 228 | { 229 | throw new \RuntimeException('Internal error: this method should not be needed'); 230 | } 231 | 232 | public function createStreamFromResource($resource): StreamInterface 233 | { 234 | throw new \RuntimeException('Internal error: this method should not be needed'); 235 | } 236 | }; 237 | } 238 | 239 | return null; 240 | } 241 | 242 | private function buildRedirectRequest(RequestInterface $originalRequest, UriInterface $targetUri, int $statusCode): RequestInterface 243 | { 244 | $originalRequest = $originalRequest->withUri($targetUri); 245 | 246 | if (false !== $this->redirectCodes[$statusCode]['switch'] && !in_array($originalRequest->getMethod(), $this->redirectCodes[$statusCode]['switch']['unless'], true)) { 247 | $originalRequest = $originalRequest->withMethod($this->redirectCodes[$statusCode]['switch']['to']); 248 | if ('GET' === $this->redirectCodes[$statusCode]['switch']['to'] && $this->streamFactory) { 249 | // if we found a stream factory, remove the request body. otherwise leave the body there. 250 | $originalRequest = $originalRequest->withoutHeader('content-type'); 251 | $originalRequest = $originalRequest->withoutHeader('content-length'); 252 | $originalRequest = $originalRequest->withBody($this->streamFactory->createStream()); 253 | } 254 | } 255 | 256 | if (is_array($this->preserveHeader)) { 257 | $headers = array_keys($originalRequest->getHeaders()); 258 | 259 | foreach ($headers as $name) { 260 | if (!in_array($name, $this->preserveHeader, true)) { 261 | $originalRequest = $originalRequest->withoutHeader($name); 262 | } 263 | } 264 | } 265 | 266 | return $originalRequest; 267 | } 268 | 269 | /** 270 | * Creates a new Uri from the old request and the location header. 271 | * 272 | * @throws HttpException If location header is not usable (missing or incorrect) 273 | * @throws MultipleRedirectionException If a 300 status code is received and default location cannot be resolved (doesn't use the location header or not present) 274 | */ 275 | private function createUri(ResponseInterface $redirectResponse, RequestInterface $originalRequest): UriInterface 276 | { 277 | if ($this->redirectCodes[$redirectResponse->getStatusCode()]['multiple'] && (!$this->useDefaultForMultiple || !$redirectResponse->hasHeader('Location'))) { 278 | throw new MultipleRedirectionException('Cannot choose a redirection', $originalRequest, $redirectResponse); 279 | } 280 | 281 | if (!$redirectResponse->hasHeader('Location')) { 282 | throw new HttpException('Redirect status code, but no location header present in the response', $originalRequest, $redirectResponse); 283 | } 284 | 285 | $location = $redirectResponse->getHeaderLine('Location'); 286 | $parsedLocation = parse_url($location); 287 | 288 | if (false === $parsedLocation || '' === $location) { 289 | throw new HttpException(sprintf('Location "%s" could not be parsed', $location), $originalRequest, $redirectResponse); 290 | } 291 | 292 | $uri = $originalRequest->getUri(); 293 | 294 | // Redirections can either use an absolute uri or a relative reference https://www.rfc-editor.org/rfc/rfc3986#section-4.2 295 | // If relative, we need to check if we have an absolute path or not 296 | 297 | $path = array_key_exists('path', $parsedLocation) ? $parsedLocation['path'] : ''; 298 | if (!array_key_exists('host', $parsedLocation) && '/' !== $location[0]) { 299 | // the target is a relative-path reference, we need to merge it with the base path 300 | $originalPath = $uri->getPath(); 301 | if ('' === $path) { 302 | $path = $originalPath; 303 | } elseif (($pos = strrpos($originalPath, '/')) !== false) { 304 | $path = substr($originalPath, 0, $pos + 1).$path; 305 | } else { 306 | $path = '/'.$path; 307 | } 308 | /* replace '/./' or '/foo/../' with '/' */ 309 | $re = ['#(/\./)#', '#/(?!\.\.)[^/]+/\.\./#']; 310 | for ($n = 1; $n > 0; $path = preg_replace($re, '/', $path, -1, $n)) { 311 | if (null === $path) { 312 | throw new HttpException(sprintf('Failed to resolve Location %s', $location), $originalRequest, $redirectResponse); 313 | } 314 | } 315 | } 316 | if (null === $path) { 317 | throw new HttpException(sprintf('Failed to resolve Location %s', $location), $originalRequest, $redirectResponse); 318 | } 319 | $uri = $uri 320 | ->withPath($path) 321 | ->withQuery(array_key_exists('query', $parsedLocation) ? $parsedLocation['query'] : '') 322 | ->withFragment(array_key_exists('fragment', $parsedLocation) ? $parsedLocation['fragment'] : '') 323 | ; 324 | 325 | if (array_key_exists('scheme', $parsedLocation)) { 326 | $uri = $uri->withScheme($parsedLocation['scheme']); 327 | } 328 | 329 | if (array_key_exists('host', $parsedLocation)) { 330 | $uri = $uri->withHost($parsedLocation['host']); 331 | } 332 | 333 | if (array_key_exists('port', $parsedLocation)) { 334 | $uri = $uri->withPort($parsedLocation['port']); 335 | } elseif (array_key_exists('host', $parsedLocation)) { 336 | $uri = $uri->withPort(null); 337 | } 338 | 339 | return $uri; 340 | } 341 | } 342 | -------------------------------------------------------------------------------- /src/Plugin/RequestMatcherPlugin.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | final class RequestMatcherPlugin implements Plugin 18 | { 19 | /** 20 | * @var RequestMatcher 21 | */ 22 | private $requestMatcher; 23 | 24 | /** 25 | * @var Plugin|null 26 | */ 27 | private $successPlugin; 28 | 29 | /** 30 | * @var Plugin|null 31 | */ 32 | private $failurePlugin; 33 | 34 | public function __construct(RequestMatcher $requestMatcher, ?Plugin $delegateOnMatch, ?Plugin $delegateOnNoMatch = null) 35 | { 36 | $this->requestMatcher = $requestMatcher; 37 | $this->successPlugin = $delegateOnMatch; 38 | $this->failurePlugin = $delegateOnNoMatch; 39 | } 40 | 41 | public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise 42 | { 43 | if ($this->requestMatcher->matches($request)) { 44 | if (null !== $this->successPlugin) { 45 | return $this->successPlugin->handleRequest($request, $next, $first); 46 | } 47 | } elseif (null !== $this->failurePlugin) { 48 | return $this->failurePlugin->handleRequest($request, $next, $first); 49 | } 50 | 51 | return $next($request); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Plugin/RequestSeekableBodyPlugin.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | final class RequestSeekableBodyPlugin extends SeekableBodyPlugin 17 | { 18 | public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise 19 | { 20 | if (!$request->getBody()->isSeekable()) { 21 | $request = $request->withBody(new BufferedStream($request->getBody(), $this->useFileBuffer, $this->memoryBufferSize)); 22 | } 23 | 24 | return $next($request); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Plugin/ResponseSeekableBodyPlugin.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | final class ResponseSeekableBodyPlugin extends SeekableBodyPlugin 18 | { 19 | public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise 20 | { 21 | return $next($request)->then(function (ResponseInterface $response) { 22 | if ($response->getBody()->isSeekable()) { 23 | return $response; 24 | } 25 | 26 | return $response->withBody(new BufferedStream($response->getBody(), $this->useFileBuffer, $this->memoryBufferSize)); 27 | }); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Plugin/RetryPlugin.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | final class RetryPlugin implements Plugin 23 | { 24 | /** 25 | * Number of retry before sending an exception. 26 | * 27 | * @var int 28 | */ 29 | private $retry; 30 | 31 | /** 32 | * @var callable 33 | */ 34 | private $errorResponseDelay; 35 | 36 | /** 37 | * @var callable 38 | */ 39 | private $errorResponseDecider; 40 | 41 | /** 42 | * @var callable 43 | */ 44 | private $exceptionDecider; 45 | 46 | /** 47 | * @var callable 48 | */ 49 | private $exceptionDelay; 50 | 51 | /** 52 | * Store the retry counter for each request. 53 | * 54 | * @var array 55 | */ 56 | private $retryStorage = []; 57 | 58 | /** 59 | * @param array{'retries'?: int, 'error_response_decider'?: callable, 'exception_decider'?: callable, 'error_response_delay'?: callable, 'exception_delay'?: callable} $config 60 | * 61 | * Configuration options: 62 | * - retries: Number of retries to attempt if an exception occurs before letting the exception bubble up 63 | * - error_response_decider: A callback that gets a request and response to decide whether the request should be retried 64 | * - exception_decider: A callback that gets a request and an exception to decide after a failure whether the request should be retried 65 | * - error_response_delay: A callback that gets a request and response and the current number of retries and returns how many microseconds we should wait before trying again 66 | * - exception_delay: A callback that gets a request, an exception and the current number of retries and returns how many microseconds we should wait before trying again 67 | */ 68 | public function __construct(array $config = []) 69 | { 70 | $resolver = new OptionsResolver(); 71 | $resolver->setDefaults([ 72 | 'retries' => 1, 73 | 'error_response_decider' => function (RequestInterface $request, ResponseInterface $response) { 74 | // do not retry client errors 75 | return $response->getStatusCode() >= 500 && $response->getStatusCode() < 600; 76 | }, 77 | 'exception_decider' => function (RequestInterface $request, ClientExceptionInterface $e) { 78 | // do not retry client errors 79 | return !$e instanceof HttpException || $e->getCode() >= 500 && $e->getCode() < 600; 80 | }, 81 | 'error_response_delay' => __CLASS__.'::defaultErrorResponseDelay', 82 | 'exception_delay' => __CLASS__.'::defaultExceptionDelay', 83 | ]); 84 | 85 | $resolver->setAllowedTypes('retries', 'int'); 86 | $resolver->setAllowedTypes('error_response_decider', 'callable'); 87 | $resolver->setAllowedTypes('exception_decider', 'callable'); 88 | $resolver->setAllowedTypes('error_response_delay', 'callable'); 89 | $resolver->setAllowedTypes('exception_delay', 'callable'); 90 | $options = $resolver->resolve($config); 91 | 92 | $this->retry = $options['retries']; 93 | $this->errorResponseDecider = $options['error_response_decider']; 94 | $this->errorResponseDelay = $options['error_response_delay']; 95 | $this->exceptionDecider = $options['exception_decider']; 96 | $this->exceptionDelay = $options['exception_delay']; 97 | } 98 | 99 | public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise 100 | { 101 | $chainIdentifier = spl_object_hash((object) $first); 102 | 103 | return $next($request)->then(function (ResponseInterface $response) use ($request, $next, $first, $chainIdentifier) { 104 | if (!array_key_exists($chainIdentifier, $this->retryStorage)) { 105 | $this->retryStorage[$chainIdentifier] = 0; 106 | } 107 | 108 | if ($this->retryStorage[$chainIdentifier] >= $this->retry) { 109 | unset($this->retryStorage[$chainIdentifier]); 110 | 111 | return $response; 112 | } 113 | 114 | if (call_user_func($this->errorResponseDecider, $request, $response)) { 115 | /** @var int $time */ 116 | $time = call_user_func($this->errorResponseDelay, $request, $response, $this->retryStorage[$chainIdentifier]); 117 | $response = $this->retry($request, $next, $first, $chainIdentifier, $time); 118 | } 119 | 120 | if (array_key_exists($chainIdentifier, $this->retryStorage)) { 121 | unset($this->retryStorage[$chainIdentifier]); 122 | } 123 | 124 | return $response; 125 | }, function (ClientExceptionInterface $exception) use ($request, $next, $first, $chainIdentifier) { 126 | if (!array_key_exists($chainIdentifier, $this->retryStorage)) { 127 | $this->retryStorage[$chainIdentifier] = 0; 128 | } 129 | 130 | if ($this->retryStorage[$chainIdentifier] >= $this->retry) { 131 | unset($this->retryStorage[$chainIdentifier]); 132 | 133 | throw $exception; 134 | } 135 | 136 | if (!call_user_func($this->exceptionDecider, $request, $exception)) { 137 | throw $exception; 138 | } 139 | 140 | /** @var int $time */ 141 | $time = call_user_func($this->exceptionDelay, $request, $exception, $this->retryStorage[$chainIdentifier]); 142 | 143 | return $this->retry($request, $next, $first, $chainIdentifier, $time); 144 | }); 145 | } 146 | 147 | /** 148 | * @param int $retries The number of retries we made before. First time this get called it will be 0. 149 | */ 150 | public static function defaultErrorResponseDelay(RequestInterface $request, ResponseInterface $response, int $retries): int 151 | { 152 | return pow(2, $retries) * 500000; 153 | } 154 | 155 | /** 156 | * @param int $retries The number of retries we made before. First time this get called it will be 0. 157 | */ 158 | public static function defaultExceptionDelay(RequestInterface $request, ClientExceptionInterface $e, int $retries): int 159 | { 160 | return pow(2, $retries) * 500000; 161 | } 162 | 163 | /** 164 | * @throws \Exception if retrying returns a failed promise 165 | */ 166 | private function retry(RequestInterface $request, callable $next, callable $first, string $chainIdentifier, int $delay): ResponseInterface 167 | { 168 | usleep($delay); 169 | 170 | // Retry synchronously 171 | ++$this->retryStorage[$chainIdentifier]; 172 | $promise = $this->handleRequest($request, $next, $first); 173 | 174 | return $promise->wait(); 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/Plugin/SeekableBodyPlugin.php: -------------------------------------------------------------------------------- 1 | setDefaults([ 36 | 'use_file_buffer' => true, 37 | 'memory_buffer_size' => 2097152, 38 | ]); 39 | $resolver->setAllowedTypes('use_file_buffer', 'bool'); 40 | $resolver->setAllowedTypes('memory_buffer_size', 'int'); 41 | 42 | $options = $resolver->resolve($config); 43 | 44 | $this->useFileBuffer = $options['use_file_buffer']; 45 | $this->memoryBufferSize = $options['memory_buffer_size']; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Plugin/VersionBridgePlugin.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | trait VersionBridgePlugin 17 | { 18 | abstract protected function doHandleRequest(RequestInterface $request, callable $next, callable $first); 19 | 20 | public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise 21 | { 22 | return $this->doHandleRequest($request, $next, $first); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/PluginChain.php: -------------------------------------------------------------------------------- 1 | plugins = $plugins; 33 | $this->clientCallable = $clientCallable; 34 | $this->maxRestarts = (int) ($options['max_restarts'] ?? 0); 35 | } 36 | 37 | private function createChain(): callable 38 | { 39 | $lastCallable = $this->clientCallable; 40 | $reversedPlugins = \array_reverse($this->plugins); 41 | 42 | foreach ($reversedPlugins as $plugin) { 43 | $lastCallable = function (RequestInterface $request) use ($plugin, $lastCallable) { 44 | return $plugin->handleRequest($request, $lastCallable, $this); 45 | }; 46 | } 47 | 48 | return $lastCallable; 49 | } 50 | 51 | public function __invoke(RequestInterface $request): Promise 52 | { 53 | if ($this->restarts > $this->maxRestarts) { 54 | throw new LoopException('Too many restarts in plugin client', $request); 55 | } 56 | 57 | ++$this->restarts; 58 | 59 | return $this->createChain()($request); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/PluginClient.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | final class PluginClient implements HttpClient, HttpAsyncClient 24 | { 25 | /** 26 | * An HTTP async client. 27 | * 28 | * @var HttpAsyncClient 29 | */ 30 | private $client; 31 | 32 | /** 33 | * The plugin chain. 34 | * 35 | * @var Plugin[] 36 | */ 37 | private $plugins; 38 | 39 | /** 40 | * A list of options. 41 | * 42 | * @var array 43 | */ 44 | private $options; 45 | 46 | /** 47 | * @param ClientInterface|HttpAsyncClient $client An HTTP async client 48 | * @param Plugin[] $plugins A plugin chain 49 | * @param array{'max_restarts'?: int} $options 50 | */ 51 | public function __construct($client, array $plugins = [], array $options = []) 52 | { 53 | if ($client instanceof HttpAsyncClient) { 54 | $this->client = $client; 55 | } elseif ($client instanceof ClientInterface) { 56 | $this->client = new EmulatedHttpAsyncClient($client); 57 | } else { 58 | throw new \TypeError( 59 | sprintf('%s::__construct(): Argument #1 ($client) must be of type %s|%s, %s given', self::class, ClientInterface::class, HttpAsyncClient::class, get_debug_type($client)) 60 | ); 61 | } 62 | 63 | $this->plugins = $plugins; 64 | $this->options = $this->configure($options); 65 | } 66 | 67 | public function sendRequest(RequestInterface $request): ResponseInterface 68 | { 69 | // If the client doesn't support sync calls, call async 70 | if (!$this->client instanceof ClientInterface) { 71 | return $this->sendAsyncRequest($request)->wait(); 72 | } 73 | 74 | // Else we want to use the synchronous call of the underlying client, 75 | // and not the async one in the case we have both an async and sync call 76 | $pluginChain = $this->createPluginChain($this->plugins, function (RequestInterface $request) { 77 | try { 78 | return new HttpFulfilledPromise($this->client->sendRequest($request)); 79 | } catch (HttplugException $exception) { 80 | return new HttpRejectedPromise($exception); 81 | } 82 | }); 83 | 84 | return $pluginChain($request)->wait(); 85 | } 86 | 87 | public function sendAsyncRequest(RequestInterface $request) 88 | { 89 | $pluginChain = $this->createPluginChain($this->plugins, function (RequestInterface $request) { 90 | return $this->client->sendAsyncRequest($request); 91 | }); 92 | 93 | return $pluginChain($request); 94 | } 95 | 96 | /** 97 | * Configure the plugin client. 98 | */ 99 | private function configure(array $options = []): array 100 | { 101 | $resolver = new OptionsResolver(); 102 | $resolver->setDefaults([ 103 | 'max_restarts' => 10, 104 | ]); 105 | 106 | $resolver->setAllowedTypes('max_restarts', 'int'); 107 | 108 | return $resolver->resolve($options); 109 | } 110 | 111 | /** 112 | * Create the plugin chain. 113 | * 114 | * @param Plugin[] $plugins A plugin chain 115 | * @param callable $clientCallable Callable making the HTTP call 116 | * 117 | * @return callable(RequestInterface): Promise 118 | */ 119 | private function createPluginChain(array $plugins, callable $clientCallable): callable 120 | { 121 | return new PluginChain($plugins, $clientCallable, $this->options); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/PluginClientBuilder.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | final class PluginClientBuilder 16 | { 17 | /** @var Plugin[][] List of plugins ordered by priority [priority => Plugin[]]). */ 18 | private $plugins = []; 19 | 20 | /** @var array Array of options to give to the plugin client */ 21 | private $options = []; 22 | 23 | /** 24 | * @param int $priority Priority of the plugin. The higher comes first. 25 | */ 26 | public function addPlugin(Plugin $plugin, int $priority = 0): self 27 | { 28 | $this->plugins[$priority][] = $plugin; 29 | 30 | return $this; 31 | } 32 | 33 | /** 34 | * @param string|int|float|bool|string[] $value 35 | */ 36 | public function setOption(string $name, $value): self 37 | { 38 | $this->options[$name] = $value; 39 | 40 | return $this; 41 | } 42 | 43 | public function removeOption(string $name): self 44 | { 45 | unset($this->options[$name]); 46 | 47 | return $this; 48 | } 49 | 50 | /** 51 | * @param ClientInterface|HttpAsyncClient $client 52 | */ 53 | public function createClient($client): PluginClient 54 | { 55 | if (!$client instanceof ClientInterface && !$client instanceof HttpAsyncClient) { 56 | throw new \TypeError( 57 | sprintf('%s::createClient(): Argument #1 ($client) must be of type %s|%s, %s given', self::class, ClientInterface::class, HttpAsyncClient::class, get_debug_type($client)) 58 | ); 59 | } 60 | 61 | $plugins = $this->plugins; 62 | 63 | if (0 === count($plugins)) { 64 | $plugins[] = []; 65 | } 66 | 67 | krsort($plugins); 68 | $plugins = array_merge(...$plugins); 69 | 70 | return new PluginClient( 71 | $client, 72 | array_values($plugins), 73 | $this->options 74 | ); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/PluginClientFactory.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | final class PluginClientFactory 17 | { 18 | /** 19 | * @var (callable(ClientInterface|HttpAsyncClient, Plugin[], array): PluginClient)|null 20 | */ 21 | private static $factory; 22 | 23 | /** 24 | * Set the factory to use. 25 | * The callable to provide must have the same arguments and return type as PluginClientFactory::createClient. 26 | * This is used by the HTTPlugBundle to provide a better Symfony integration. 27 | * Unlike the createClient method, this one is static to allow zero configuration profiling by hooking into early 28 | * application execution. 29 | * 30 | * @internal 31 | * 32 | * @param callable(ClientInterface|HttpAsyncClient, Plugin[], array): PluginClient $factory 33 | */ 34 | public static function setFactory(callable $factory): void 35 | { 36 | static::$factory = $factory; 37 | } 38 | 39 | /** 40 | * @param ClientInterface|HttpAsyncClient $client 41 | * @param Plugin[] $plugins 42 | * @param array{'client_name'?: string} $options 43 | * 44 | * Configuration options: 45 | * - client_name: to give client a name which may be used when displaying client information 46 | * like in the HTTPlugBundle profiler 47 | * 48 | * @see PluginClient constructor for PluginClient specific $options. 49 | */ 50 | public function createClient($client, array $plugins = [], array $options = []): PluginClient 51 | { 52 | if (!$client instanceof ClientInterface && !$client instanceof HttpAsyncClient) { 53 | throw new \TypeError( 54 | sprintf('%s::createClient(): Argument #1 ($client) must be of type %s|%s, %s given', self::class, ClientInterface::class, HttpAsyncClient::class, get_debug_type($client)) 55 | ); 56 | } 57 | 58 | if (static::$factory) { 59 | $factory = static::$factory; 60 | 61 | return $factory($client, $plugins, $options); 62 | } 63 | 64 | unset($options['client_name']); 65 | 66 | return new PluginClient($client, $plugins, $options); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/VersionBridgeClient.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | trait VersionBridgeClient 17 | { 18 | abstract protected function doSendRequest(RequestInterface $request); 19 | 20 | public function sendRequest(RequestInterface $request): ResponseInterface 21 | { 22 | return $this->doSendRequest($request); 23 | } 24 | } 25 | --------------------------------------------------------------------------------