version 1.5

This commit is contained in:
Johannes Holzwarth | SE-Inno GmbH
2024-01-11 14:04:25 +01:00
parent 0ee8291846
commit 4492763b78
756 changed files with 209957 additions and 2 deletions
@@ -0,0 +1,18 @@
<?php
$finder = PhpCsFixer\Finder::create()
->in(__DIR__.'/src')
->in(__DIR__.'/tests')
->name('*.php')
;
$config = new PhpCsFixer\Config();
return $config
->setRiskyAllowed(true)
->setRules([
'@Symfony' => true,
'single_line_throw' => false,
])
->setFinder($finder)
;
+24
View File
@@ -0,0 +1,24 @@
<?php
/*
* Configuration for fabpot/php-cs-fixer
*
* @link https://github.com/FriendsOfPHP/PHP-CS-Fixer
*/
$finder = PhpCsFixer\Finder::create()
->in('src')
->in('spec')
;
return PhpCsFixer\Config::create()
->setRules([
'@PSR2' => true,
'@Symfony' => true,
'array_syntax' => [
'syntax' => 'short',
],
'no_empty_phpdoc' => true,
'phpdoc_to_comment' => false,
'single_line_throw' => false,
])
->setFinder($finder);
+303
View File
@@ -0,0 +1,303 @@
# Change Log
## 2.7.1 - 2023-11-30
- Allow installation with Symfony 7.
## 2.7.0 - 2023-05-17
- Dropped `php-http/message-factory` from composer requirements as it is abandoned and this package does not actually use it.
## 2.6.1 - 2023-04-14
- Allow installation with http-message (PSR-7) version 2 in addition to version 1.
- Support for PHP 8.2
## 2.6.0 - 2022-09-29
- [RedirectPlugin] Redirection of non GET/HEAD requests with a body now removes the body on follow-up requests, if the
HTTP method changes. To do this, the plugin needs to find a PSR-7 stream implementation. If none is found, you can
explicitly pass a PSR-17 StreamFactoryInterface in the `stream_factory` option.
To keep sending the body in all cases, set the `stream_factory` option to null explicitly.
## 2.5.1 - 2022-09-29
### Fixed
- [RedirectPlugin] Fixed handling of redirection to different domain with default port
- [RedirectPlugin] Fixed false positive circular detection in RedirectPlugin in cases when target location does not contain path
## 2.5.0 - 2021-11-26
### Added
- Support for Symfony 6
- Support for PHP 8.1
### Changed
- Dropped support for Symfony 2 and 3 - please keep using version 2.4.0 of this library if you can't update Symfony.
## 2.4.0 - 2021-07-05
### Added
- `strict` option to `RedirectPlugin` to allow preserving the request method on redirections with status 300, 301 and 302.
## 2.3.0 - 2020-07-21
### Fixed
- HttpMethodsClient with PSR RequestFactory
- Bug in the cookie plugin with empty cookies
- Bug when parsing null-valued date headers
### Changed
- Deprecation when constructing a HttpMethodsClient with PSR RequestFactory but without a StreamFactory
## 2.2.1 - 2020-07-13
### Fixed
- Support for PHP 8
- Plugin callable phpdoc
## 2.2.0 - 2020-07-02
### Added
- Plugin client builder for making a `PluginClient`
- Support for the PSR-17 request factory in `HttpMethodsClient`
### Changed
- Restored support for `symfony/options-resolver: ^2.6`
- Consistent implementation of union type checking
### Fixed
- Memory leak when using the `PluginClient` with plugins
## 2.1.0 - 2019-11-18
### Added
- Support Symfony 5
## 2.0.0 - 2019-02-03
### Changed
- HttpClientRouter now throws a HttpClientNoMatchException instead of a RequestException if it can not find a client for the request.
- RetryPlugin will only retry exceptions when there is no response, or a response in the 5xx HTTP code range.
- RetryPlugin also retries when no exception is thrown if the responses has HTTP code in the 5xx range.
The callbacks for exception handling have been renamed and callbacks for response handling have been added.
- Abstract method `HttpClientPool::chooseHttpClient()` has now an explicit return type (`Http\Client\Common\HttpClientPoolItem`)
- Interface method `Plugin::handleRequest(...)` has now an explicit return type (`Http\Promise\Promise`)
- Made classes final that are not intended to be extended.
- Added interfaces for BatchClient, HttpClientRouter and HttpMethodsClient.
(These interfaces use the `Interface` suffix to avoid name collisions.)
- Added an interface for HttpClientPool and moved the abstract class to the HttpClientPool sub namespace.
- AddPathPlugin: Do not add the prefix if the URL already has the same prefix.
- All exceptions in `Http\Client\Common\Exception` are final.
### Removed
- Deprecated option `debug_plugins` has been removed from `PluginClient`
- Deprecated options `decider` and `delay` have been removed from `RetryPlugin`, use `exception_decider` and `exception_delay` instead.
## 1.11.0 - 2021-07-11
### Changed
- Backported from version 2: AddPathPlugin: Do not add the prefix if the URL already has the same prefix.
## 1.10.0 - 2019-11-18
### Added
- Support for Symfony 5
## 1.9.1 - 2019-02-02
### Added
- Updated type hints in doc blocks.
## 1.9.0 - 2019-01-03
### Added
- Support for PSR-18 clients
- Added traits `VersionBridgePlugin` and `VersionBridgeClient` to help plugins and clients to support both
1.x and 2.x version of `php-http/client-common` and `php-http/httplug`.
### Changed
- RetryPlugin: Renamed the configuration options for the exception retry callback from `decider` to `exception_decider`
and `delay` to `exception_delay`. The old names still work but are deprecated.
## 1.8.2 - 2018-12-14
### Changed
- When multiple cookies exist, a single header with all cookies is sent as per RFC 6265 Section 5.4
- AddPathPlugin will now trim of ending slashes in paths
## 1.8.1 - 2018-10-09
### Fixed
- Reverted change to RetryPlugin so it again waits when retrying to avoid "can only throw objects" error.
## 1.8.0 - 2018-09-21
### Added
- Add an option on ErrorPlugin to only throw exception on response with 5XX status code.
### Changed
- 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
- RetryPlugin no longer wait for retried requests and use a deferred promise instead
### Fixed
- Decoder plugin will now remove header when there is no more encoding, instead of setting to an empty array
## 1.7.0 - 2017-11-30
### Added
- Symfony 4 support
### Changed
- Strict comparison in DecoderPlugin
## 1.6.0 - 2017-10-16
### Added
- 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.
- `PluginClientFactory` to create `PluginClient` instances.
- Added new option 'delay' for `RetryPlugin`.
- Added new option 'decider' for `RetryPlugin`.
- Supports more cookie date formats in the Cookie Plugin
### Changed
- The `RetryPlugin` does now wait between retries. To disable/change this feature you must write something like:
```php
$plugin = new RetryPlugin(['delay' => function(RequestInterface $request, Exception $e, $retries) {
return 0;
});
```
### Deprecated
- 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).
## 1.5.0 - 2017-03-30
### Added
- `QueryDefaultsPlugin` to add default query parameters.
## 1.4.2 - 2017-03-18
### Deprecated
- `DecoderPlugin` does not longer claim to support `compress` content encoding
### Fixed
- `CookiePlugin` allows main domain cookies to be sent/stored for subdomains
- `DecoderPlugin` uses the right `FilteredStream` to handle `deflate` content encoding
## 1.4.1 - 2017-02-20
### Fixed
- Cast return value of `StreamInterface::getSize` to string in `ContentLengthPlugin`
## 1.4.0 - 2016-11-04
### Added
- Add Path plugin
- Base URI plugin that combines Add Host and Add Path plugins
## 1.3.0 - 2016-10-16
### Changed
- Fix Emulated Trait to use Http based promise which respect the HttpAsyncClient interface
- Require Httplug 1.1 where we use HTTP specific promises.
- RedirectPlugin: use the full URL instead of the URI to properly keep track of redirects
- Add AddPathPlugin for API URLs with base path
- Add BaseUriPlugin that combines AddHostPlugin and AddPathPlugin
## 1.2.1 - 2016-07-26
### Changed
- AddHostPlugin also sets the port if specified
## 1.2.0 - 2016-07-14
### Added
- Suggest separate plugins in composer.json
- Introduced `debug_plugins` option for `PluginClient`
## 1.1.0 - 2016-05-04
### Added
- Add a flexible http client providing both contract, and only emulating what's necessary
- HTTP Client Router: route requests to underlying clients
- Plugin client and core plugins moved here from `php-http/plugins`
### Deprecated
- Extending client classes, they will be made final in version 2.0
## 1.0.0 - 2016-01-27
### Changed
- Remove useless interface in BatchException
## 0.2.0 - 2016-01-12
### Changed
- Updated package files
- Updated HTTPlug to RC1
## 0.1.1 - 2015-12-26
### Added
- Emulated clients
## 0.1.0 - 2015-12-25
### Added
- Batch client from utils
- Methods client from utils
- Emulators and decorators from client-tools
+19
View File
@@ -0,0 +1,19 @@
Copyright (c) 2015-2016 PHP HTTP Team <team@php-http.org>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+55
View File
@@ -0,0 +1,55 @@
# HTTP Client Common
[![Latest Version](https://img.shields.io/github/release/php-http/client-common.svg?style=flat-square)](https://github.com/php-http/client-common/releases)
[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE)
[![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)
[![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)
[![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)
[![Total Downloads](https://img.shields.io/packagist/dt/php-http/client-common.svg?style=flat-square)](https://packagist.org/packages/php-http/client-common)
**Common HTTP Client implementations and tools for HTTPlug.**
## Install
Via Composer
``` bash
$ composer require php-http/client-common
```
## Usage
This package provides common tools for HTTP Clients:
- BatchClient to handle sending requests in parallel
- A convenience client with HTTP method names as class methods
- Emulator, decorator layers for sync/async clients
## Documentation
Please see the [official documentation](http://docs.php-http.org/en/latest/components/client-common.html).
## Testing
``` bash
$ composer test
```
## Contributing
Please see our [contributing guide](http://docs.php-http.org/en/latest/development/contributing.html).
## Security
If you discover any security related issues, please contact us at [security@php-http.org](mailto:security@php-http.org).
## License
The MIT License (MIT). Please see [License File](LICENSE) for more information.
+62
View File
@@ -0,0 +1,62 @@
{
"name": "php-http/client-common",
"description": "Common HTTP Client implementations and tools for HTTPlug",
"license": "MIT",
"keywords": ["http", "client", "httplug", "common"],
"homepage": "http://httplug.io",
"authors": [
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com"
}
],
"require": {
"php": "^7.1 || ^8.0",
"php-http/httplug": "^2.0",
"php-http/message": "^1.6",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0",
"psr/http-message": "^1.0 || ^2.0",
"symfony/options-resolver": "~4.0.15 || ~4.1.9 || ^4.2.1 || ^5.0 || ^6.0 || ^7.0",
"symfony/polyfill-php80": "^1.17"
},
"require-dev": {
"doctrine/instantiator": "^1.1",
"guzzlehttp/psr7": "^1.4",
"nyholm/psr7": "^1.2",
"phpspec/phpspec": "^5.1 || ^6.3 || ^7.1",
"phpspec/prophecy": "^1.10.2",
"phpunit/phpunit": "^7.5.20 || ^8.5.33 || ^9.6.7"
},
"suggest": {
"ext-json": "To detect JSON responses with the ContentTypePlugin",
"ext-libxml": "To detect XML responses with the ContentTypePlugin",
"php-http/logger-plugin": "PSR-3 Logger plugin",
"php-http/cache-plugin": "PSR-6 Cache plugin",
"php-http/stopwatch-plugin": "Symfony Stopwatch plugin"
},
"autoload": {
"psr-4": {
"Http\\Client\\Common\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"spec\\Http\\Client\\Common\\": "spec/",
"Tests\\Http\\Client\\Common\\": "tests/"
}
},
"scripts": {
"test": [
"vendor/bin/phpspec run",
"vendor/bin/phpunit"
],
"test-ci": [
"vendor/bin/phpspec run -c phpspec.ci.yml",
"vendor/bin/phpunit"
]
},
"config": {
"sort-packages": true
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common;
use Http\Client\Common\Exception\BatchException;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Client\ClientInterface;
final class BatchClient implements BatchClientInterface
{
/**
* @var ClientInterface
*/
private $client;
public function __construct(ClientInterface $client)
{
$this->client = $client;
}
public function sendRequests(array $requests): BatchResult
{
$batchResult = new BatchResult();
foreach ($requests as $request) {
try {
$response = $this->client->sendRequest($request);
$batchResult = $batchResult->addResponse($request, $response);
} catch (ClientExceptionInterface $e) {
$batchResult = $batchResult->addException($request, $e);
}
}
if ($batchResult->hasExceptions()) {
throw new BatchException($batchResult);
}
return $batchResult;
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common;
use Http\Client\Common\Exception\BatchException;
use Psr\Http\Message\RequestInterface;
/**
* BatchClient allow to sends multiple request and retrieve a Batch Result.
*
* This implementation simply loops over the requests and uses sendRequest with each of them.
*
* @author Joel Wurtz <jwurtz@jolicode.com>
*/
interface BatchClientInterface
{
/**
* Send several requests.
*
* You may not assume that the requests are executed in a particular order. If the order matters
* for your application, use sendRequest sequentially.
*
* @param RequestInterface[] $requests The requests to send
*
* @return BatchResult Containing one result per request
*
* @throws BatchException If one or more requests fails. The exception gives access to the
* BatchResult with a map of request to result for success, request to
* exception for failures
*/
public function sendRequests(array $requests): BatchResult;
}
+157
View File
@@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* Responses and exceptions returned from parallel request execution.
*
* @author Márk Sági-Kazár <mark.sagikazar@gmail.com>
*/
final class BatchResult
{
/**
* @var \SplObjectStorage<RequestInterface, ResponseInterface>
*/
private $responses;
/**
* @var \SplObjectStorage<RequestInterface, ClientExceptionInterface>
*/
private $exceptions;
public function __construct()
{
$this->responses = new \SplObjectStorage();
$this->exceptions = new \SplObjectStorage();
}
/**
* Checks if there are any successful responses at all.
*/
public function hasResponses(): bool
{
return $this->responses->count() > 0;
}
/**
* Returns all successful responses.
*
* @return ResponseInterface[]
*/
public function getResponses(): array
{
$responses = [];
foreach ($this->responses as $request) {
$responses[] = $this->responses[$request];
}
return $responses;
}
/**
* Checks if there is a successful response for a request.
*/
public function isSuccessful(RequestInterface $request): bool
{
return $this->responses->contains($request);
}
/**
* Returns the response for a successful request.
*
* @throws \UnexpectedValueException If request was not part of the batch or failed
*/
public function getResponseFor(RequestInterface $request): ResponseInterface
{
try {
return $this->responses[$request];
} catch (\UnexpectedValueException $e) {
throw new \UnexpectedValueException('Request not found', $e->getCode(), $e);
}
}
/**
* Adds a response in an immutable way.
*
* @return BatchResult the new BatchResult with this request-response pair added to it
*/
public function addResponse(RequestInterface $request, ResponseInterface $response): self
{
$new = clone $this;
$new->responses->attach($request, $response);
return $new;
}
/**
* Checks if there are any unsuccessful requests at all.
*/
public function hasExceptions(): bool
{
return $this->exceptions->count() > 0;
}
/**
* Returns all exceptions for the unsuccessful requests.
*
* @return ClientExceptionInterface[]
*/
public function getExceptions(): array
{
$exceptions = [];
foreach ($this->exceptions as $request) {
$exceptions[] = $this->exceptions[$request];
}
return $exceptions;
}
/**
* Checks if there is an exception for a request, meaning the request failed.
*/
public function isFailed(RequestInterface $request): bool
{
return $this->exceptions->contains($request);
}
/**
* Returns the exception for a failed request.
*
* @throws \UnexpectedValueException If request was not part of the batch or was successful
*/
public function getExceptionFor(RequestInterface $request): ClientExceptionInterface
{
try {
return $this->exceptions[$request];
} catch (\UnexpectedValueException $e) {
throw new \UnexpectedValueException('Request not found', $e->getCode(), $e);
}
}
/**
* Adds an exception in an immutable way.
*
* @return BatchResult the new BatchResult with this request-exception pair added to it
*/
public function addException(RequestInterface $request, ClientExceptionInterface $exception): self
{
$new = clone $this;
$new->exceptions->attach($request, $exception);
return $new;
}
public function __clone()
{
$this->responses = clone $this->responses;
$this->exceptions = clone $this->exceptions;
}
}
+155
View File
@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common;
use Http\Promise\Promise;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Message\ResponseInterface;
/**
* A deferred allow to return a promise which has not been resolved yet.
*/
final class Deferred implements Promise
{
/**
* @var ResponseInterface|null
*/
private $value;
/**
* @var ClientExceptionInterface|null
*/
private $failure;
/**
* @var string
*/
private $state;
/**
* @var callable
*/
private $waitCallback;
/**
* @var callable[]
*/
private $onFulfilledCallbacks;
/**
* @var callable[]
*/
private $onRejectedCallbacks;
public function __construct(callable $waitCallback)
{
$this->waitCallback = $waitCallback;
$this->state = Promise::PENDING;
$this->onFulfilledCallbacks = [];
$this->onRejectedCallbacks = [];
}
/**
* {@inheritdoc}
*/
public function then(callable $onFulfilled = null, callable $onRejected = null): Promise
{
$deferred = new self($this->waitCallback);
$this->onFulfilledCallbacks[] = function (ResponseInterface $response) use ($onFulfilled, $deferred) {
try {
if (null !== $onFulfilled) {
$response = $onFulfilled($response);
}
$deferred->resolve($response);
} catch (ClientExceptionInterface $exception) {
$deferred->reject($exception);
}
};
$this->onRejectedCallbacks[] = function (ClientExceptionInterface $exception) use ($onRejected, $deferred) {
try {
if (null !== $onRejected) {
$response = $onRejected($exception);
$deferred->resolve($response);
return;
}
$deferred->reject($exception);
} catch (ClientExceptionInterface $newException) {
$deferred->reject($newException);
}
};
return $deferred;
}
/**
* {@inheritdoc}
*/
public function getState(): string
{
return $this->state;
}
/**
* Resolve this deferred with a Response.
*/
public function resolve(ResponseInterface $response): void
{
if (Promise::PENDING !== $this->state) {
return;
}
$this->value = $response;
$this->state = Promise::FULFILLED;
foreach ($this->onFulfilledCallbacks as $onFulfilledCallback) {
$onFulfilledCallback($response);
}
}
/**
* Reject this deferred with an Exception.
*/
public function reject(ClientExceptionInterface $exception): void
{
if (Promise::PENDING !== $this->state) {
return;
}
$this->failure = $exception;
$this->state = Promise::REJECTED;
foreach ($this->onRejectedCallbacks as $onRejectedCallback) {
$onRejectedCallback($exception);
}
}
/**
* {@inheritdoc}
*/
public function wait($unwrap = true)
{
if (Promise::PENDING === $this->state) {
$callback = $this->waitCallback;
$callback();
}
if (!$unwrap) {
return null;
}
if (Promise::FULFILLED === $this->state) {
return $this->value;
}
if (null === $this->failure) {
throw new \RuntimeException('Internal Error: Promise is not fulfilled but has no exception stored');
}
throw $this->failure;
}
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common;
use Http\Client\HttpAsyncClient;
use Http\Client\HttpClient;
use Psr\Http\Client\ClientInterface;
/**
* Emulates an async HTTP client with the help of a synchronous client.
*
* @author Márk Sági-Kazár <mark.sagikazar@gmail.com>
*/
final class EmulatedHttpAsyncClient implements HttpClient, HttpAsyncClient
{
use HttpAsyncClientEmulator;
use HttpClientDecorator;
public function __construct(ClientInterface $httpClient)
{
$this->httpClient = $httpClient;
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common;
use Http\Client\HttpAsyncClient;
use Http\Client\HttpClient;
/**
* Emulates a synchronous HTTP client with the help of an asynchronous client.
*
* @author Márk Sági-Kazár <mark.sagikazar@gmail.com>
*/
final class EmulatedHttpClient implements HttpClient, HttpAsyncClient
{
use HttpAsyncClientDecorator;
use HttpClientEmulator;
public function __construct(HttpAsyncClient $httpAsyncClient)
{
$this->httpAsyncClient = $httpAsyncClient;
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common\Exception;
use Http\Client\Common\BatchResult;
use Http\Client\Exception\TransferException;
/**
* This exception is thrown when HttpClient::sendRequests led to at least one failure.
*
* It gives access to a BatchResult with the request-exception and request-response pairs.
*
* @author Márk Sági-Kazár <mark.sagikazar@gmail.com>
*/
final class BatchException extends TransferException
{
/**
* @var BatchResult
*/
private $result;
public function __construct(BatchResult $result)
{
$this->result = $result;
parent::__construct();
}
/**
* Returns the BatchResult that contains all responses and exceptions.
*/
public function getResult(): BatchResult
{
return $this->result;
}
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common\Exception;
use Http\Client\Exception\HttpException;
/**
* Thrown when circular redirection is detected.
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
final class CircularRedirectionException extends HttpException
{
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common\Exception;
use Http\Client\Exception\HttpException;
/**
* Thrown when there is a client error (4xx).
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
final class ClientErrorException extends HttpException
{
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common\Exception;
use Http\Client\Exception\TransferException;
use Psr\Http\Message\RequestInterface;
/**
* Thrown when a http client match in the HTTPClientRouter.
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
final class HttpClientNoMatchException extends TransferException
{
/**
* @var RequestInterface
*/
private $request;
public function __construct(string $message, RequestInterface $request, \Exception $previous = null)
{
$this->request = $request;
parent::__construct($message, 0, $previous);
}
public function getRequest(): RequestInterface
{
return $this->request;
}
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common\Exception;
use Http\Client\Exception\TransferException;
/**
* Thrown when a http client cannot be chosen in a pool.
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
final class HttpClientNotFoundException extends TransferException
{
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common\Exception;
use Http\Client\Exception\RequestException;
/**
* Thrown when the Plugin Client detects an endless loop.
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
final class LoopException extends RequestException
{
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common\Exception;
use Http\Client\Exception\HttpException;
/**
* Redirect location cannot be chosen.
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
final class MultipleRedirectionException extends HttpException
{
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common\Exception;
use Http\Client\Exception\HttpException;
/**
* Thrown when there is a server error (5xx).
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
final class ServerErrorException extends HttpException
{
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common;
use Http\Client\HttpAsyncClient;
use Http\Client\HttpClient;
use Psr\Http\Client\ClientInterface;
/**
* A flexible http client, which implements both interface and will emulate
* one contract, the other, or none at all depending on the injected client contract.
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
final class FlexibleHttpClient implements HttpClient, HttpAsyncClient
{
use HttpClientDecorator;
use HttpAsyncClientDecorator;
/**
* @param ClientInterface|HttpAsyncClient $client
*/
public function __construct($client)
{
if (!$client instanceof ClientInterface && !$client instanceof HttpAsyncClient) {
throw new \TypeError(
sprintf('%s::__construct(): Argument #1 ($client) must be of type %s|%s, %s given', self::class, ClientInterface::class, HttpAsyncClient::class, get_debug_type($client))
);
}
$this->httpClient = $client instanceof ClientInterface ? $client : new EmulatedHttpClient($client);
$this->httpAsyncClient = $client instanceof HttpAsyncClient ? $client : new EmulatedHttpAsyncClient($client);
}
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common;
use Http\Client\HttpAsyncClient;
use Psr\Http\Message\RequestInterface;
/**
* Decorates an HTTP Async Client.
*
* @author Márk Sági-Kazár <mark.sagikazar@gmail.com>
*/
trait HttpAsyncClientDecorator
{
/**
* @var HttpAsyncClient
*/
protected $httpAsyncClient;
/**
* {@inheritdoc}
*
* @see HttpAsyncClient::sendAsyncRequest
*/
public function sendAsyncRequest(RequestInterface $request)
{
return $this->httpAsyncClient->sendAsyncRequest($request);
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common;
use Http\Client\Exception;
use Http\Client\Promise;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* Emulates an HTTP Async Client in an HTTP Client.
*
* @author Márk Sági-Kazár <mark.sagikazar@gmail.com>
*/
trait HttpAsyncClientEmulator
{
/**
* {@inheritdoc}
*
* @see HttpClient::sendRequest
*/
abstract public function sendRequest(RequestInterface $request): ResponseInterface;
/**
* {@inheritdoc}
*
* @see HttpAsyncClient::sendAsyncRequest
*/
public function sendAsyncRequest(RequestInterface $request)
{
try {
return new Promise\HttpFulfilledPromise($this->sendRequest($request));
} catch (Exception $e) {
return new Promise\HttpRejectedPromise($e);
}
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* Decorates an HTTP Client.
*
* @author Márk Sági-Kazár <mark.sagikazar@gmail.com>
*/
trait HttpClientDecorator
{
/**
* @var ClientInterface
*/
protected $httpClient;
/**
* {@inheritdoc}
*
* @see ClientInterface::sendRequest
*/
public function sendRequest(RequestInterface $request): ResponseInterface
{
return $this->httpClient->sendRequest($request);
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* Emulates an HTTP Client in an HTTP Async Client.
*
* @author Márk Sági-Kazár <mark.sagikazar@gmail.com>
*/
trait HttpClientEmulator
{
/**
* {@inheritdoc}
*
* @see HttpClient::sendRequest
*/
public function sendRequest(RequestInterface $request): ResponseInterface
{
$promise = $this->sendAsyncRequest($request);
return $promise->wait();
}
/**
* {@inheritdoc}
*
* @see HttpAsyncClient::sendAsyncRequest
*/
abstract public function sendAsyncRequest(RequestInterface $request);
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common;
use Http\Client\Common\HttpClientPool\HttpClientPoolItem;
use Http\Client\HttpAsyncClient;
use Http\Client\HttpClient;
use Psr\Http\Client\ClientInterface;
/**
* A http client pool allows to send requests on a pool of different http client using a specific strategy (least used,
* round robin, ...).
*/
interface HttpClientPool extends HttpAsyncClient, HttpClient
{
/**
* Add a client to the pool.
*
* @param ClientInterface|HttpAsyncClient|HttpClientPoolItem $client
*/
public function addHttpClient($client): void;
}
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common\HttpClientPool;
use Http\Client\Common\Exception\HttpClientNotFoundException;
use Http\Client\Common\HttpClientPool as HttpClientPoolInterface;
use Http\Client\HttpAsyncClient;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* A http client pool allows to send requests on a pool of different http client using a specific strategy (least used,
* round robin, ...).
*/
abstract class HttpClientPool implements HttpClientPoolInterface
{
/**
* @var HttpClientPoolItem[]
*/
protected $clientPool = [];
/**
* Add a client to the pool.
*
* @param ClientInterface|HttpAsyncClient $client
*/
public function addHttpClient($client): void
{
// no need to check for HttpClientPoolItem here, since it extends the other interfaces
if (!$client instanceof ClientInterface && !$client instanceof HttpAsyncClient) {
throw new \TypeError(
sprintf('%s::addHttpClient(): Argument #1 ($client) must be of type %s|%s, %s given', self::class, ClientInterface::class, HttpAsyncClient::class, get_debug_type($client))
);
}
if (!$client instanceof HttpClientPoolItem) {
$client = new HttpClientPoolItem($client);
}
$this->clientPool[] = $client;
}
/**
* Return an http client given a specific strategy.
*
* @return HttpClientPoolItem Return a http client that can do both sync or async
*
* @throws HttpClientNotFoundException When no http client has been found into the pool
*/
abstract protected function chooseHttpClient(): HttpClientPoolItem;
/**
* {@inheritdoc}
*/
public function sendAsyncRequest(RequestInterface $request)
{
return $this->chooseHttpClient()->sendAsyncRequest($request);
}
/**
* {@inheritdoc}
*/
public function sendRequest(RequestInterface $request): ResponseInterface
{
return $this->chooseHttpClient()->sendRequest($request);
}
}
@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common\HttpClientPool;
use Http\Client\Common\FlexibleHttpClient;
use Http\Client\Exception;
use Http\Client\HttpAsyncClient;
use Http\Client\HttpClient;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* A HttpClientPoolItem represent a HttpClient inside a Pool.
*
* It is disabled when a request failed and can be reenabled after a certain number of seconds.
* It also keep tracks of the current number of open requests the client is currently being sending
* (only usable for async method).
*
* This class is used internally in the client pools and is not supposed to be used anywhere else.
*
* @final
*
* @internal
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
class HttpClientPoolItem implements HttpClient, HttpAsyncClient
{
/**
* @var int Number of request this client is currently sending
*/
private $sendingRequestCount = 0;
/**
* @var \DateTime|null Time when this client has been disabled or null if enable
*/
private $disabledAt;
/**
* Number of seconds until this client is enabled again after an error.
*
* null: never reenable this client.
*
* @var int|null
*/
private $reenableAfter;
/**
* @var FlexibleHttpClient A http client responding to async and sync request
*/
private $client;
/**
* @param ClientInterface|HttpAsyncClient $client
* @param int|null $reenableAfter Number of seconds until this client is enabled again after an error
*/
public function __construct($client, int $reenableAfter = null)
{
if (!$client instanceof ClientInterface && !$client instanceof HttpAsyncClient) {
throw new \TypeError(
sprintf('%s::__construct(): Argument #1 ($client) must be of type %s|%s, %s given', self::class, ClientInterface::class, HttpAsyncClient::class, get_debug_type($client))
);
}
$this->client = new FlexibleHttpClient($client);
$this->reenableAfter = $reenableAfter;
}
/**
* {@inheritdoc}
*/
public function sendRequest(RequestInterface $request): ResponseInterface
{
if ($this->isDisabled()) {
throw new Exception\RequestException('Cannot send the request as this client has been disabled', $request);
}
try {
$this->incrementRequestCount();
$response = $this->client->sendRequest($request);
$this->decrementRequestCount();
} catch (Exception $e) {
$this->disable();
$this->decrementRequestCount();
throw $e;
}
return $response;
}
/**
* {@inheritdoc}
*/
public function sendAsyncRequest(RequestInterface $request)
{
if ($this->isDisabled()) {
throw new Exception\RequestException('Cannot send the request as this client has been disabled', $request);
}
$this->incrementRequestCount();
return $this->client->sendAsyncRequest($request)->then(function ($response) {
$this->decrementRequestCount();
return $response;
}, function ($exception) {
$this->disable();
$this->decrementRequestCount();
throw $exception;
});
}
/**
* Whether this client is disabled or not.
*
* If the client was disabled, calling this method checks if the client can
* be reenabled and if so enables it.
*/
public function isDisabled(): bool
{
if (null !== $this->reenableAfter && null !== $this->disabledAt) {
// Reenable after a certain time
$now = new \DateTime();
if (($now->getTimestamp() - $this->disabledAt->getTimestamp()) >= $this->reenableAfter) {
$this->enable();
return false;
}
return true;
}
return null !== $this->disabledAt;
}
/**
* Get current number of request that are currently being sent by the underlying HTTP client.
*/
public function getSendingRequestCount(): int
{
return $this->sendingRequestCount;
}
/**
* Increment the request count.
*/
private function incrementRequestCount(): void
{
++$this->sendingRequestCount;
}
/**
* Decrement the request count.
*/
private function decrementRequestCount(): void
{
--$this->sendingRequestCount;
}
/**
* Enable the current client.
*/
private function enable(): void
{
$this->disabledAt = null;
}
/**
* Disable the current client.
*/
private function disable(): void
{
$this->disabledAt = new \DateTime('now');
}
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common\HttpClientPool;
use Http\Client\Common\Exception\HttpClientNotFoundException;
/**
* LeastUsedClientPool will choose the client with the less current request in the pool.
*
* This strategy is only useful when doing async request
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
final class LeastUsedClientPool extends HttpClientPool
{
/**
* {@inheritdoc}
*/
protected function chooseHttpClient(): HttpClientPoolItem
{
$clientPool = array_filter($this->clientPool, function (HttpClientPoolItem $clientPoolItem) {
return !$clientPoolItem->isDisabled();
});
if (0 === count($clientPool)) {
throw new HttpClientNotFoundException('Cannot choose a http client as there is no one present in the pool');
}
usort($clientPool, function (HttpClientPoolItem $clientA, HttpClientPoolItem $clientB) {
if ($clientA->getSendingRequestCount() === $clientB->getSendingRequestCount()) {
return 0;
}
if ($clientA->getSendingRequestCount() < $clientB->getSendingRequestCount()) {
return -1;
}
return 1;
});
return reset($clientPool);
}
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common\HttpClientPool;
use Http\Client\Common\Exception\HttpClientNotFoundException;
/**
* RoundRobinClientPool will choose the next client in the pool.
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
final class RandomClientPool extends HttpClientPool
{
/**
* {@inheritdoc}
*/
protected function chooseHttpClient(): HttpClientPoolItem
{
$clientPool = array_filter($this->clientPool, function (HttpClientPoolItem $clientPoolItem) {
return !$clientPoolItem->isDisabled();
});
if (0 === count($clientPool)) {
throw new HttpClientNotFoundException('Cannot choose a http client as there is no one present in the pool');
}
return $clientPool[array_rand($clientPool)];
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common\HttpClientPool;
use Http\Client\Common\Exception\HttpClientNotFoundException;
/**
* RoundRobinClientPool will choose the next client in the pool.
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
final class RoundRobinClientPool extends HttpClientPool
{
/**
* {@inheritdoc}
*/
protected function chooseHttpClient(): HttpClientPoolItem
{
$last = current($this->clientPool);
do {
$client = next($this->clientPool);
if (false === $client) {
$client = reset($this->clientPool);
if (false === $client) {
throw new HttpClientNotFoundException('Cannot choose a http client as there is no one present in the pool');
}
}
// Case when there is only one and the last one has been disabled
if ($last === $client && $client->isDisabled()) {
throw new HttpClientNotFoundException('Cannot choose a http client as there is no one enabled in the pool');
}
} while ($client->isDisabled());
return $client;
}
}
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common;
use Http\Client\Common\Exception\HttpClientNoMatchException;
use Http\Client\HttpAsyncClient;
use Http\Message\RequestMatcher;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* {@inheritdoc}
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
final class HttpClientRouter implements HttpClientRouterInterface
{
/**
* @var (array{matcher: RequestMatcher, client: FlexibleHttpClient})[]
*/
private $clients = [];
/**
* {@inheritdoc}
*/
public function sendRequest(RequestInterface $request): ResponseInterface
{
return $this->chooseHttpClient($request)->sendRequest($request);
}
/**
* {@inheritdoc}
*/
public function sendAsyncRequest(RequestInterface $request)
{
return $this->chooseHttpClient($request)->sendAsyncRequest($request);
}
/**
* Add a client to the router.
*
* @param ClientInterface|HttpAsyncClient $client
*/
public function addClient($client, RequestMatcher $requestMatcher): void
{
if (!$client instanceof ClientInterface && !$client instanceof HttpAsyncClient) {
throw new \TypeError(
sprintf('%s::addClient(): Argument #1 ($client) must be of type %s|%s, %s given', self::class, ClientInterface::class, HttpAsyncClient::class, get_debug_type($client))
);
}
$this->clients[] = [
'matcher' => $requestMatcher,
'client' => new FlexibleHttpClient($client),
];
}
/**
* Choose an HTTP client given a specific request.
*/
private function chooseHttpClient(RequestInterface $request): FlexibleHttpClient
{
foreach ($this->clients as $client) {
if ($client['matcher']->matches($request)) {
return $client['client'];
}
}
throw new HttpClientNoMatchException('No client found for the specified request', $request);
}
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common;
use Http\Client\HttpAsyncClient;
use Http\Client\HttpClient;
use Http\Message\RequestMatcher;
use Psr\Http\Client\ClientInterface;
/**
* Route a request to a specific client in the stack based using a RequestMatcher.
*
* This is not a HttpClientPool client because it uses a matcher to select the client.
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
interface HttpClientRouterInterface extends HttpClient, HttpAsyncClient
{
/**
* Add a client to the router.
*
* @param ClientInterface|HttpAsyncClient $client
*/
public function addClient($client, RequestMatcher $requestMatcher): void;
}
@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common;
use Http\Message\RequestFactory;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UriInterface;
final class HttpMethodsClient implements HttpMethodsClientInterface
{
/**
* @var ClientInterface
*/
private $httpClient;
/**
* @var RequestFactory|RequestFactoryInterface
*/
private $requestFactory;
/**
* @var StreamFactoryInterface|null
*/
private $streamFactory;
/**
* @param RequestFactory|RequestFactoryInterface $requestFactory
*/
public function __construct(ClientInterface $httpClient, $requestFactory, StreamFactoryInterface $streamFactory = null)
{
if (!$requestFactory instanceof RequestFactory && !$requestFactory instanceof RequestFactoryInterface) {
throw new \TypeError(
sprintf('%s::__construct(): Argument #2 ($requestFactory) must be of type %s|%s, %s given', self::class, RequestFactory::class, RequestFactoryInterface::class, get_debug_type($requestFactory))
);
}
if (!$requestFactory instanceof RequestFactory && null === $streamFactory) {
@trigger_error(sprintf('Passing a %s without a %s to %s::__construct() is deprecated as of version 2.3 and will be disallowed in version 3.0. A stream factory is required to create a request with a non-empty string body.', RequestFactoryInterface::class, StreamFactoryInterface::class, self::class));
}
$this->httpClient = $httpClient;
$this->requestFactory = $requestFactory;
$this->streamFactory = $streamFactory;
}
public function get($uri, array $headers = []): ResponseInterface
{
return $this->send('GET', $uri, $headers, null);
}
public function head($uri, array $headers = []): ResponseInterface
{
return $this->send('HEAD', $uri, $headers, null);
}
public function trace($uri, array $headers = []): ResponseInterface
{
return $this->send('TRACE', $uri, $headers, null);
}
public function post($uri, array $headers = [], $body = null): ResponseInterface
{
return $this->send('POST', $uri, $headers, $body);
}
public function put($uri, array $headers = [], $body = null): ResponseInterface
{
return $this->send('PUT', $uri, $headers, $body);
}
public function patch($uri, array $headers = [], $body = null): ResponseInterface
{
return $this->send('PATCH', $uri, $headers, $body);
}
public function delete($uri, array $headers = [], $body = null): ResponseInterface
{
return $this->send('DELETE', $uri, $headers, $body);
}
public function options($uri, array $headers = [], $body = null): ResponseInterface
{
return $this->send('OPTIONS', $uri, $headers, $body);
}
public function send(string $method, $uri, array $headers = [], $body = null): ResponseInterface
{
if (!is_string($uri) && !$uri instanceof UriInterface) {
throw new \TypeError(
sprintf('%s::send(): Argument #2 ($uri) must be of type string|%s, %s given', self::class, UriInterface::class, get_debug_type($uri))
);
}
if (!is_string($body) && !$body instanceof StreamInterface && null !== $body) {
throw new \TypeError(
sprintf('%s::send(): Argument #4 ($body) must be of type string|%s|null, %s given', self::class, StreamInterface::class, get_debug_type($body))
);
}
return $this->sendRequest(
self::createRequest($method, $uri, $headers, $body)
);
}
/**
* @param string|UriInterface $uri
* @param string|StreamInterface|null $body
*/
private function createRequest(string $method, $uri, array $headers = [], $body = null): RequestInterface
{
if ($this->requestFactory instanceof RequestFactory) {
return $this->requestFactory->createRequest(
$method,
$uri,
$headers,
$body
);
}
$request = $this->requestFactory->createRequest($method, $uri);
foreach ($headers as $key => $value) {
$request = $request->withHeader($key, $value);
}
if (null !== $body && '' !== $body) {
if (null === $this->streamFactory) {
throw new \RuntimeException('Cannot create request: A stream factory is required to create a request with a non-empty string body.');
}
$request = $request->withBody(
is_string($body) ? $this->streamFactory->createStream($body) : $body
);
}
return $request;
}
public function sendRequest(RequestInterface $request): ResponseInterface
{
return $this->httpClient->sendRequest($request);
}
}
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common;
use Http\Client\Exception;
use Http\Client\HttpClient;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UriInterface;
/**
* Convenience HTTP client that integrates the MessageFactory in order to send
* requests in the following form:.
*
* $client
* ->get('/foo')
* ->post('/bar')
* ;
*
* The client also exposes the sendRequest methods of the wrapped HttpClient.
*
* @author Márk Sági-Kazár <mark.sagikazar@gmail.com>
* @author David Buchmann <mail@davidbu.ch>
*/
interface HttpMethodsClientInterface extends HttpClient
{
/**
* Sends a GET request.
*
* @param string|UriInterface $uri
*
* @throws Exception
*/
public function get($uri, array $headers = []): ResponseInterface;
/**
* Sends an HEAD request.
*
* @param string|UriInterface $uri
*
* @throws Exception
*/
public function head($uri, array $headers = []): ResponseInterface;
/**
* Sends a TRACE request.
*
* @param string|UriInterface $uri
*
* @throws Exception
*/
public function trace($uri, array $headers = []): ResponseInterface;
/**
* Sends a POST request.
*
* @param string|UriInterface $uri
* @param string|StreamInterface|null $body
*
* @throws Exception
*/
public function post($uri, array $headers = [], $body = null): ResponseInterface;
/**
* Sends a PUT request.
*
* @param string|UriInterface $uri
* @param string|StreamInterface|null $body
*
* @throws Exception
*/
public function put($uri, array $headers = [], $body = null): ResponseInterface;
/**
* Sends a PATCH request.
*
* @param string|UriInterface $uri
* @param string|StreamInterface|null $body
*
* @throws Exception
*/
public function patch($uri, array $headers = [], $body = null): ResponseInterface;
/**
* Sends a DELETE request.
*
* @param string|UriInterface $uri
* @param string|StreamInterface|null $body
*
* @throws Exception
*/
public function delete($uri, array $headers = [], $body = null): ResponseInterface;
/**
* Sends an OPTIONS request.
*
* @param string|UriInterface $uri
* @param string|StreamInterface|null $body
*
* @throws Exception
*/
public function options($uri, array $headers = [], $body = null): ResponseInterface;
/**
* Sends a request with any HTTP method.
*
* @param string $method HTTP method to use
* @param string|UriInterface $uri
* @param string|StreamInterface|null $body
*
* @throws Exception
*/
public function send(string $method, $uri, array $headers = [], $body = null): ResponseInterface;
}
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common;
use Http\Promise\Promise;
use Psr\Http\Message\RequestInterface;
/**
* A plugin is a middleware to transform the request and/or the response.
*
* The plugin can:
* - break the chain and return a response
* - dispatch the request to the next middleware
* - restart the request
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
interface Plugin
{
/**
* Handle the request and return the response coming from the next callable.
*
* @see http://docs.php-http.org/en/latest/plugins/build-your-own.html
*
* @param callable(RequestInterface): Promise $next Next middleware in the chain, the request is passed as the first argument
* @param callable(RequestInterface): Promise $first First middleware in the chain, used to to restart a request
*
* @return Promise Resolves a PSR-7 Response or fails with an Http\Client\Exception (The same as HttpAsyncClient)
*/
public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise;
}
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common\Plugin;
use Http\Client\Common\Plugin;
use Http\Promise\Promise;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\UriInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Add schema, host and port to a request. Can be set to overwrite the schema and host if desired.
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
final class AddHostPlugin implements Plugin
{
/**
* @var UriInterface
*/
private $host;
/**
* @var bool
*/
private $replace;
/**
* @param array{'replace'?: bool} $config
*
* Configuration options:
* - replace: True will replace all hosts, false will only add host when none is specified
*/
public function __construct(UriInterface $host, array $config = [])
{
if ('' === $host->getHost()) {
throw new \LogicException('Host can not be empty');
}
$this->host = $host;
$resolver = new OptionsResolver();
$this->configureOptions($resolver);
$options = $resolver->resolve($config);
$this->replace = $options['replace'];
}
/**
* {@inheritdoc}
*/
public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise
{
if ($this->replace || '' === $request->getUri()->getHost()) {
$uri = $request->getUri()
->withHost($this->host->getHost())
->withScheme($this->host->getScheme())
->withPort($this->host->getPort())
;
$request = $request->withUri($uri);
}
return $next($request);
}
private function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'replace' => false,
]);
$resolver->setAllowedTypes('replace', 'bool');
}
}
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common\Plugin;
use Http\Client\Common\Plugin;
use Http\Promise\Promise;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\UriInterface;
/**
* Prepend a base path to the request URI. Useful for base API URLs like http://domain.com/api.
*
* @author Sullivan Senechal <soullivaneuh@gmail.com>
*/
final class AddPathPlugin implements Plugin
{
/**
* @var UriInterface
*/
private $uri;
public function __construct(UriInterface $uri)
{
if ('' === $uri->getPath()) {
throw new \LogicException('URI path cannot be empty');
}
if ('/' === substr($uri->getPath(), -1)) {
$uri = $uri->withPath(rtrim($uri->getPath(), '/'));
}
$this->uri = $uri;
}
/**
* Adds a prefix in the beginning of the URL's path.
*
* The prefix is not added if that prefix is already on the URL's path. This will fail on the edge
* case of the prefix being repeated, for example if `https://example.com/api/api/foo` is a valid
* URL on the server and the configured prefix is `/api`.
*
* We looked at other solutions, but they are all much more complicated, while still having edge
* cases:
* - Doing an spl_object_hash on `$first` will lead to collisions over time because over time the
* hash can collide.
* - Have the PluginClient provide a magic header to identify the request chain and only apply
* this plugin once.
*
* There are 2 reasons for the AddPathPlugin to be executed twice on the same request:
* - A plugin can restart the chain by calling `$first`, e.g. redirect
* - A plugin can call `$next` more than once, e.g. retry
*
* Depending on the scenario, the path should or should not be added. E.g. `$first` could
* be called after a redirect response from the server. The server likely already has the
* correct path.
*
* No solution fits all use cases. This implementation will work fine for the common use cases.
* If you have a specific situation where this is not the right thing, you can build a custom plugin
* that does exactly what you need.
*
* {@inheritdoc}
*/
public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise
{
$prepend = $this->uri->getPath();
$path = $request->getUri()->getPath();
if (substr($path, 0, strlen($prepend)) !== $prepend) {
$request = $request->withUri($request->getUri()
->withPath($prepend.$path)
);
}
return $next($request);
}
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common\Plugin;
use Http\Client\Common\Plugin;
use Http\Message\Authentication;
use Http\Promise\Promise;
use Psr\Http\Message\RequestInterface;
/**
* Send an authenticated request.
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
final class AuthenticationPlugin implements Plugin
{
/**
* @var Authentication An authentication system
*/
private $authentication;
public function __construct(Authentication $authentication)
{
$this->authentication = $authentication;
}
/**
* {@inheritdoc}
*/
public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise
{
$request = $this->authentication->authenticate($request);
return $next($request);
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common\Plugin;
use Http\Client\Common\Plugin;
use Http\Promise\Promise;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\UriInterface;
/**
* Combines the AddHostPlugin and AddPathPlugin.
*
* @author Sullivan Senechal <soullivaneuh@gmail.com>
*/
final class BaseUriPlugin implements Plugin
{
/**
* @var AddHostPlugin
*/
private $addHostPlugin;
/**
* @var AddPathPlugin|null
*/
private $addPathPlugin = null;
/**
* @param UriInterface $uri Has to contain a host name and can have a path
* @param array $hostConfig Config for AddHostPlugin. @see AddHostPlugin::configureOptions
*/
public function __construct(UriInterface $uri, array $hostConfig = [])
{
$this->addHostPlugin = new AddHostPlugin($uri, $hostConfig);
if (rtrim($uri->getPath(), '/')) {
$this->addPathPlugin = new AddPathPlugin($uri);
}
}
/**
* {@inheritdoc}
*/
public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise
{
$addHostNext = function (RequestInterface $request) use ($next, $first) {
return $this->addHostPlugin->handleRequest($request, $next, $first);
};
if ($this->addPathPlugin) {
return $this->addPathPlugin->handleRequest($request, $addHostNext, $first);
}
return $addHostNext($request);
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common\Plugin;
use Http\Client\Common\Plugin;
use Http\Message\Encoding\ChunkStream;
use Http\Promise\Promise;
use Psr\Http\Message\RequestInterface;
/**
* Allow to set the correct content length header on the request or to transfer it as a chunk if not possible.
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
final class ContentLengthPlugin implements Plugin
{
/**
* {@inheritdoc}
*/
public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise
{
if (!$request->hasHeader('Content-Length')) {
$stream = $request->getBody();
// Cannot determine the size so we use a chunk stream
if (null === $stream->getSize()) {
$stream = new ChunkStream($stream);
$request = $request->withBody($stream);
$request = $request->withAddedHeader('Transfer-Encoding', 'chunked');
} else {
$request = $request->withHeader('Content-Length', (string) $stream->getSize());
}
}
return $next($request);
}
}
@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common\Plugin;
use Http\Client\Common\Plugin;
use Http\Promise\Promise;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\StreamInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Allow to set the correct content type header on the request automatically only if it is not set.
*
* @author Karim Pinchon <karim.pinchon@gmail.com>
*/
final class ContentTypePlugin implements Plugin
{
/**
* Allow to disable the content type detection when stream is too large (as it can consume a lot of resource).
*
* @var bool
*
* true skip the content type detection
* false detect the content type (default value)
*/
private $skipDetection;
/**
* Determine the size stream limit for which the detection as to be skipped (default to 16Mb).
*
* @var int
*/
private $sizeLimit;
/**
* @param array{'skip_detection'?: bool, 'size_limit'?: int} $config
*
* Configuration options:
* - skip_detection: true skip detection if stream size is bigger than $size_limit
* - size_limit: size stream limit for which the detection as to be skipped
*/
public function __construct(array $config = [])
{
$resolver = new OptionsResolver();
$resolver->setDefaults([
'skip_detection' => false,
'size_limit' => 16000000,
]);
$resolver->setAllowedTypes('skip_detection', 'bool');
$resolver->setAllowedTypes('size_limit', 'int');
$options = $resolver->resolve($config);
$this->skipDetection = $options['skip_detection'];
$this->sizeLimit = $options['size_limit'];
}
/**
* {@inheritdoc}
*/
public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise
{
if (!$request->hasHeader('Content-Type')) {
$stream = $request->getBody();
$streamSize = $stream->getSize();
if (!$stream->isSeekable()) {
return $next($request);
}
if (0 === $streamSize) {
return $next($request);
}
if ($this->skipDetection && (null === $streamSize || $streamSize >= $this->sizeLimit)) {
return $next($request);
}
if ($this->isJson($stream)) {
$request = $request->withHeader('Content-Type', 'application/json');
return $next($request);
}
if ($this->isXml($stream)) {
$request = $request->withHeader('Content-Type', 'application/xml');
return $next($request);
}
}
return $next($request);
}
private function isJson(StreamInterface $stream): bool
{
if (!function_exists('json_decode')) {
return false;
}
$stream->rewind();
json_decode($stream->getContents());
return JSON_ERROR_NONE === json_last_error();
}
private function isXml(StreamInterface $stream): bool
{
if (!function_exists('simplexml_load_string')) {
return false;
}
$stream->rewind();
$previousValue = libxml_use_internal_errors(true);
$isXml = simplexml_load_string($stream->getContents());
libxml_use_internal_errors($previousValue);
return false !== $isXml;
}
}
@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common\Plugin;
use Http\Client\Common\Plugin;
use Http\Client\Exception\TransferException;
use Http\Message\Cookie;
use Http\Message\CookieJar;
use Http\Message\CookieUtil;
use Http\Message\Exception\UnexpectedValueException;
use Http\Promise\Promise;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* Handle request cookies.
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
final class CookiePlugin implements Plugin
{
/**
* Cookie storage.
*
* @var CookieJar
*/
private $cookieJar;
public function __construct(CookieJar $cookieJar)
{
$this->cookieJar = $cookieJar;
}
/**
* {@inheritdoc}
*/
public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise
{
$cookies = [];
foreach ($this->cookieJar->getCookies() as $cookie) {
if ($cookie->isExpired()) {
continue;
}
if (!$cookie->matchDomain($request->getUri()->getHost())) {
continue;
}
if (!$cookie->matchPath($request->getUri()->getPath())) {
continue;
}
if ($cookie->isSecure() && ('https' !== $request->getUri()->getScheme())) {
continue;
}
$cookies[] = sprintf('%s=%s', $cookie->getName(), $cookie->getValue());
}
if (!empty($cookies)) {
$request = $request->withAddedHeader('Cookie', implode('; ', array_unique($cookies)));
}
return $next($request)->then(function (ResponseInterface $response) use ($request) {
if ($response->hasHeader('Set-Cookie')) {
$setCookies = $response->getHeader('Set-Cookie');
foreach ($setCookies as $setCookie) {
$cookie = $this->createCookie($request, $setCookie);
// Cookie invalid do not use it
if (null === $cookie) {
continue;
}
// Restrict setting cookie from another domain
if (!preg_match("/\.{$cookie->getDomain()}$/", '.'.$request->getUri()->getHost())) {
continue;
}
$this->cookieJar->addCookie($cookie);
}
}
return $response;
});
}
/**
* Creates a cookie from a string.
*
* @throws TransferException
*/
private function createCookie(RequestInterface $request, string $setCookieHeader): ?Cookie
{
$parts = array_map('trim', explode(';', $setCookieHeader));
if ('' === $parts[0] || false === strpos($parts[0], '=')) {
return null;
}
list($name, $cookieValue) = $this->createValueKey(array_shift($parts));
$maxAge = null;
$expires = null;
$domain = $request->getUri()->getHost();
$path = $request->getUri()->getPath();
$secure = false;
$httpOnly = false;
// Add the cookie pieces into the parsed data array
foreach ($parts as $part) {
list($key, $value) = $this->createValueKey($part);
switch (strtolower($key)) {
case 'expires':
try {
$expires = CookieUtil::parseDate((string) $value);
} catch (UnexpectedValueException $e) {
throw new TransferException(
sprintf(
'Cookie header `%s` expires value `%s` could not be converted to date',
$name,
$value
),
0,
$e
);
}
break;
case 'max-age':
$maxAge = (int) $value;
break;
case 'domain':
$domain = $value;
break;
case 'path':
$path = $value;
break;
case 'secure':
$secure = true;
break;
case 'httponly':
$httpOnly = true;
break;
}
}
return new Cookie($name, $cookieValue, $maxAge, $domain, $path, $secure, $httpOnly, $expires);
}
/**
* Separates key/value pair from cookie.
*
* @param string $part A single cookie value in format key=value
*
* @return array{0:string, 1:?string}
*/
private function createValueKey(string $part): array
{
$parts = explode('=', $part, 2);
$key = trim($parts[0]);
$value = isset($parts[1]) ? trim($parts[1]) : null;
return [$key, $value];
}
}
@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common\Plugin;
use Http\Client\Common\Plugin;
use Http\Message\Encoding;
use Http\Promise\Promise;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Allow to decode response body with a chunk, deflate, compress or gzip encoding.
*
* If zlib is not installed, only chunked encoding can be handled.
*
* If Content-Encoding is not disabled, the plugin will add an Accept-Encoding header for the encoding methods it supports.
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
final class DecoderPlugin implements Plugin
{
/**
* @var bool Whether this plugin decode stream with value in the Content-Encoding header (default to true).
*
* If set to false only the Transfer-Encoding header will be used
*/
private $useContentEncoding;
/**
* @param array{'use_content_encoding'?: bool} $config
*
* Configuration options:
* - use_content_encoding: Whether this plugin should look at the Content-Encoding header first or only at the Transfer-Encoding (defaults to true)
*/
public function __construct(array $config = [])
{
$resolver = new OptionsResolver();
$resolver->setDefaults([
'use_content_encoding' => true,
]);
$resolver->setAllowedTypes('use_content_encoding', 'bool');
$options = $resolver->resolve($config);
$this->useContentEncoding = $options['use_content_encoding'];
}
/**
* {@inheritdoc}
*/
public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise
{
$encodings = extension_loaded('zlib') ? ['gzip', 'deflate'] : ['identity'];
if ($this->useContentEncoding) {
$request = $request->withHeader('Accept-Encoding', $encodings);
}
$encodings[] = 'chunked';
$request = $request->withHeader('TE', $encodings);
return $next($request)->then(function (ResponseInterface $response) {
return $this->decodeResponse($response);
});
}
/**
* Decode a response body given its Transfer-Encoding or Content-Encoding value.
*/
private function decodeResponse(ResponseInterface $response): ResponseInterface
{
$response = $this->decodeOnEncodingHeader('Transfer-Encoding', $response);
if ($this->useContentEncoding) {
$response = $this->decodeOnEncodingHeader('Content-Encoding', $response);
}
return $response;
}
/**
* Decode a response on a specific header (content encoding or transfer encoding mainly).
*/
private function decodeOnEncodingHeader(string $headerName, ResponseInterface $response): ResponseInterface
{
if ($response->hasHeader($headerName)) {
$encodings = $response->getHeader($headerName);
$newEncodings = [];
while ($encoding = array_pop($encodings)) {
$stream = $this->decorateStream($encoding, $response->getBody());
if (false === $stream) {
array_unshift($newEncodings, $encoding);
continue;
}
$response = $response->withBody($stream);
}
if (\count($newEncodings) > 0) {
$response = $response->withHeader($headerName, $newEncodings);
} else {
$response = $response->withoutHeader($headerName);
}
}
return $response;
}
/**
* Decorate a stream given an encoding.
*
* @return StreamInterface|false A new stream interface or false if encoding is not supported
*/
private function decorateStream(string $encoding, StreamInterface $stream)
{
if ('chunked' === strtolower($encoding)) {
return new Encoding\DechunkStream($stream);
}
if ('deflate' === strtolower($encoding)) {
return new Encoding\DecompressStream($stream);
}
if ('gzip' === strtolower($encoding)) {
return new Encoding\GzipDecodeStream($stream);
}
return false;
}
}
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common\Plugin;
use Http\Client\Common\Exception\ClientErrorException;
use Http\Client\Common\Exception\ServerErrorException;
use Http\Client\Common\Plugin;
use Http\Promise\Promise;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Throw exception when the response of a request is not acceptable.
*
* Status codes 400-499 lead to a ClientErrorException, status 500-599 to a ServerErrorException.
*
* Warning
* =======
*
* Throwing an exception on a valid response violates the PSR-18 specification.
* This plugin is provided as a convenience when writing a small application.
* When providing a client to a third party library, this plugin must not be
* included, or the third party library will have problems with error handling.
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
final class ErrorPlugin implements Plugin
{
/**
* @var bool Whether this plugin should only throw 5XX Exceptions (default to false).
*
* If set to true 4XX Responses code will never throw an exception
*/
private $onlyServerException;
/**
* @param array{'only_server_exception'?: bool} $config
*
* Configuration options:
* - only_server_exception: Whether this plugin should only throw 5XX Exceptions (default to false)
*/
public function __construct(array $config = [])
{
$resolver = new OptionsResolver();
$resolver->setDefaults([
'only_server_exception' => false,
]);
$resolver->setAllowedTypes('only_server_exception', 'bool');
$options = $resolver->resolve($config);
$this->onlyServerException = $options['only_server_exception'];
}
/**
* {@inheritdoc}
*/
public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise
{
$promise = $next($request);
return $promise->then(function (ResponseInterface $response) use ($request) {
return $this->transformResponseToException($request, $response);
});
}
/**
* Transform response to an error if possible.
*
* @param RequestInterface $request Request of the call
* @param ResponseInterface $response Response of the call
*
* @return ResponseInterface If status code is not in 4xx or 5xx return response
*
* @throws ClientErrorException If response status code is a 4xx
* @throws ServerErrorException If response status code is a 5xx
*/
private function transformResponseToException(RequestInterface $request, ResponseInterface $response): ResponseInterface
{
if (!$this->onlyServerException && $response->getStatusCode() >= 400 && $response->getStatusCode() < 500) {
throw new ClientErrorException($response->getReasonPhrase(), $request, $response);
}
if ($response->getStatusCode() >= 500 && $response->getStatusCode() < 600) {
throw new ServerErrorException($response->getReasonPhrase(), $request, $response);
}
return $response;
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common\Plugin;
use Http\Client\Common\Plugin;
use Http\Promise\Promise;
use Psr\Http\Message\RequestInterface;
/**
* Append headers to the request.
*
* If the header already exists the value will be appended to the current value.
*
* This only makes sense for headers that can have multiple values like 'Forwarded'
*
* @see https://en.wikipedia.org/wiki/List_of_HTTP_header_fields
*
* @author Soufiane Ghzal <sghzal@gmail.com>
*/
final class HeaderAppendPlugin implements Plugin
{
/**
* @var array
*/
private $headers;
/**
* @param array $headers Hashmap of header name to header value
*/
public function __construct(array $headers)
{
$this->headers = $headers;
}
/**
* {@inheritdoc}
*/
public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise
{
foreach ($this->headers as $header => $headerValue) {
$request = $request->withAddedHeader($header, $headerValue);
}
return $next($request);
}
}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common\Plugin;
use Http\Client\Common\Plugin;
use Http\Promise\Promise;
use Psr\Http\Message\RequestInterface;
/**
* Set header to default value if it does not exist.
*
* If a given header already exists the value wont be replaced and the request wont be changed.
*
* @author Soufiane Ghzal <sghzal@gmail.com>
*/
final class HeaderDefaultsPlugin implements Plugin
{
/**
* @var array
*/
private $headers = [];
/**
* @param array $headers Hashmap of header name to header value
*/
public function __construct(array $headers)
{
$this->headers = $headers;
}
/**
* {@inheritdoc}
*/
public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise
{
foreach ($this->headers as $header => $headerValue) {
if (!$request->hasHeader($header)) {
$request = $request->withHeader($header, $headerValue);
}
}
return $next($request);
}
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common\Plugin;
use Http\Client\Common\Plugin;
use Http\Promise\Promise;
use Psr\Http\Message\RequestInterface;
/**
* Removes headers from the request.
*
* @author Soufiane Ghzal <sghzal@gmail.com>
*/
final class HeaderRemovePlugin implements Plugin
{
/**
* @var array
*/
private $headers = [];
/**
* @param array $headers List of header names to remove from the request
*/
public function __construct(array $headers)
{
$this->headers = $headers;
}
/**
* {@inheritdoc}
*/
public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise
{
foreach ($this->headers as $header) {
if ($request->hasHeader($header)) {
$request = $request->withoutHeader($header);
}
}
return $next($request);
}
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common\Plugin;
use Http\Client\Common\Plugin;
use Http\Promise\Promise;
use Psr\Http\Message\RequestInterface;
/**
* Set headers on the request.
*
* If the header does not exist it wil be set, if the header already exists it will be replaced.
*
* @author Soufiane Ghzal <sghzal@gmail.com>
*/
final class HeaderSetPlugin implements Plugin
{
/**
* @var array
*/
private $headers;
/**
* @param array $headers Hashmap of header name to header value
*/
public function __construct(array $headers)
{
$this->headers = $headers;
}
/**
* {@inheritdoc}
*/
public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise
{
foreach ($this->headers as $header => $headerValue) {
$request = $request->withHeader($header, $headerValue);
}
return $next($request);
}
}
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common\Plugin;
use Http\Client\Common\Plugin;
use Http\Promise\Promise;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* Record HTTP calls.
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
final class HistoryPlugin implements Plugin
{
/**
* Journal use to store request / responses / exception.
*
* @var Journal
*/
private $journal;
public function __construct(Journal $journal)
{
$this->journal = $journal;
}
/**
* {@inheritdoc}
*/
public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise
{
$journal = $this->journal;
return $next($request)->then(function (ResponseInterface $response) use ($request, $journal) {
$journal->addSuccess($request, $response);
return $response;
}, function (ClientExceptionInterface $exception) use ($request, $journal) {
$journal->addFailure($request, $exception);
throw $exception;
});
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common\Plugin;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* Records history of HTTP calls.
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
interface Journal
{
/**
* Record a successful call.
*
* @param RequestInterface $request Request use to make the call
* @param ResponseInterface $response Response returned by the call
*/
public function addSuccess(RequestInterface $request, ResponseInterface $response);
/**
* Record a failed call.
*
* @param RequestInterface $request Request use to make the call
* @param ClientExceptionInterface $exception Exception returned by the call
*/
public function addFailure(RequestInterface $request, ClientExceptionInterface $exception);
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common\Plugin;
use Http\Client\Common\Plugin;
use Http\Promise\Promise;
use Psr\Http\Message\RequestInterface;
/**
* Set query to default value if it does not exist.
*
* If a given query parameter already exists the value wont be replaced and the request wont be changed.
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
final class QueryDefaultsPlugin implements Plugin
{
/**
* @var array
*/
private $queryParams = [];
/**
* @param array $queryParams Hashmap of query name to query value. Names and values must not be url encoded as
* this plugin will encode them
*/
public function __construct(array $queryParams)
{
$this->queryParams = $queryParams;
}
/**
* {@inheritdoc}
*/
public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise
{
$uri = $request->getUri();
parse_str($uri->getQuery(), $query);
$query += $this->queryParams;
$request = $request->withUri(
$uri->withQuery(http_build_query($query))
);
return $next($request);
}
}
@@ -0,0 +1,344 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common\Plugin;
use GuzzleHttp\Psr7\Utils;
use Http\Client\Common\Exception\CircularRedirectionException;
use Http\Client\Common\Exception\MultipleRedirectionException;
use Http\Client\Common\Plugin;
use Http\Client\Exception\HttpException;
use Http\Discovery\Psr17FactoryDiscovery;
use Http\Promise\Promise;
use Nyholm\Psr7\Factory\Psr17Factory;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UriInterface;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Follow redirections.
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
final class RedirectPlugin implements Plugin
{
/**
* Rule on how to redirect, change method for the new request.
*
* @var array
*/
private $redirectCodes = [
300 => [
'switch' => [
'unless' => ['GET', 'HEAD'],
'to' => 'GET',
],
'multiple' => true,
'permanent' => false,
],
301 => [
'switch' => [
'unless' => ['GET', 'HEAD'],
'to' => 'GET',
],
'multiple' => false,
'permanent' => true,
],
302 => [
'switch' => [
'unless' => ['GET', 'HEAD'],
'to' => 'GET',
],
'multiple' => false,
'permanent' => false,
],
303 => [
'switch' => [
'unless' => ['GET', 'HEAD'],
'to' => 'GET',
],
'multiple' => false,
'permanent' => false,
],
307 => [
'switch' => false,
'multiple' => false,
'permanent' => false,
],
308 => [
'switch' => false,
'multiple' => false,
'permanent' => true,
],
];
/**
* Determine how header should be preserved from old request.
*
* @var bool|array
*
* true will keep all previous headers (default value)
* false will ditch all previous headers
* string[] will keep only headers with the specified names
*/
private $preserveHeader;
/**
* Store all previous redirect from 301 / 308 status code.
*
* @var array
*/
private $redirectStorage = [];
/**
* Whether the location header must be directly used for a multiple redirection status code (300).
*
* @var bool
*/
private $useDefaultForMultiple;
/**
* @var string[][] Chain identifier => list of URLs for this chain
*/
private $circularDetection = [];
/**
* @var StreamFactoryInterface|null
*/
private $streamFactory;
/**
* @param array{'preserve_header'?: bool|string[], 'use_default_for_multiple'?: bool, 'strict'?: bool} $config
*
* Configuration options:
* - preserve_header: True keeps all headers, false remove all of them, an array is interpreted as a list of header names to keep
* - use_default_for_multiple: Whether the location header must be directly used for a multiple redirection status code (300)
* - strict: When true, redirect codes 300, 301, 302 will not modify request method and body
* - stream_factory: If set, must be a PSR-17 StreamFactoryInterface - if not set, we try to discover one
*/
public function __construct(array $config = [])
{
$resolver = new OptionsResolver();
$resolver->setDefaults([
'preserve_header' => true,
'use_default_for_multiple' => true,
'strict' => false,
'stream_factory' => null,
]);
$resolver->setAllowedTypes('preserve_header', ['bool', 'array']);
$resolver->setAllowedTypes('use_default_for_multiple', 'bool');
$resolver->setAllowedTypes('strict', 'bool');
$resolver->setAllowedTypes('stream_factory', [StreamFactoryInterface::class, 'null']);
$resolver->setNormalizer('preserve_header', function (OptionsResolver $resolver, $value) {
if (is_bool($value) && false === $value) {
return [];
}
return $value;
});
$resolver->setDefault('stream_factory', function (Options $options): ?StreamFactoryInterface {
return $this->guessStreamFactory();
});
$options = $resolver->resolve($config);
$this->preserveHeader = $options['preserve_header'];
$this->useDefaultForMultiple = $options['use_default_for_multiple'];
if ($options['strict']) {
$this->redirectCodes[300]['switch'] = false;
$this->redirectCodes[301]['switch'] = false;
$this->redirectCodes[302]['switch'] = false;
}
$this->streamFactory = $options['stream_factory'];
}
/**
* {@inheritdoc}
*/
public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise
{
// Check in storage
if (array_key_exists((string) $request->getUri(), $this->redirectStorage)) {
$uri = $this->redirectStorage[(string) $request->getUri()]['uri'];
$statusCode = $this->redirectStorage[(string) $request->getUri()]['status'];
$redirectRequest = $this->buildRedirectRequest($request, $uri, $statusCode);
return $first($redirectRequest);
}
return $next($request)->then(function (ResponseInterface $response) use ($request, $first): ResponseInterface {
$statusCode = $response->getStatusCode();
if (!array_key_exists($statusCode, $this->redirectCodes)) {
return $response;
}
$uri = $this->createUri($response, $request);
$redirectRequest = $this->buildRedirectRequest($request, $uri, $statusCode);
$chainIdentifier = spl_object_hash((object) $first);
if (!array_key_exists($chainIdentifier, $this->circularDetection)) {
$this->circularDetection[$chainIdentifier] = [];
}
$this->circularDetection[$chainIdentifier][] = (string) $request->getUri();
if (in_array((string) $redirectRequest->getUri(), $this->circularDetection[$chainIdentifier], true)) {
throw new CircularRedirectionException('Circular redirection detected', $request, $response);
}
if ($this->redirectCodes[$statusCode]['permanent']) {
$this->redirectStorage[(string) $request->getUri()] = [
'uri' => $uri,
'status' => $statusCode,
];
}
// Call redirect request synchronously
return $first($redirectRequest)->wait();
});
}
/**
* The default only needs to be determined if no value is provided.
*/
public function guessStreamFactory(): ?StreamFactoryInterface
{
if (class_exists(Psr17FactoryDiscovery::class)) {
try {
return Psr17FactoryDiscovery::findStreamFactory();
} catch (\Throwable $t) {
// ignore and try other options
}
}
if (class_exists(Psr17Factory::class)) {
return new Psr17Factory();
}
if (class_exists(Utils::class)) {
return new class() implements StreamFactoryInterface {
public function createStream(string $content = ''): StreamInterface
{
return Utils::streamFor($content);
}
public function createStreamFromFile(string $filename, string $mode = 'r'): StreamInterface
{
throw new \RuntimeException('Internal error: this method should not be needed');
}
public function createStreamFromResource($resource): StreamInterface
{
throw new \RuntimeException('Internal error: this method should not be needed');
}
};
}
return null;
}
private function buildRedirectRequest(RequestInterface $originalRequest, UriInterface $targetUri, int $statusCode): RequestInterface
{
$originalRequest = $originalRequest->withUri($targetUri);
if (false !== $this->redirectCodes[$statusCode]['switch'] && !in_array($originalRequest->getMethod(), $this->redirectCodes[$statusCode]['switch']['unless'], true)) {
$originalRequest = $originalRequest->withMethod($this->redirectCodes[$statusCode]['switch']['to']);
if ('GET' === $this->redirectCodes[$statusCode]['switch']['to'] && $this->streamFactory) {
// if we found a stream factory, remove the request body. otherwise leave the body there.
$originalRequest = $originalRequest->withoutHeader('content-type');
$originalRequest = $originalRequest->withoutHeader('content-length');
$originalRequest = $originalRequest->withBody($this->streamFactory->createStream());
}
}
if (is_array($this->preserveHeader)) {
$headers = array_keys($originalRequest->getHeaders());
foreach ($headers as $name) {
if (!in_array($name, $this->preserveHeader, true)) {
$originalRequest = $originalRequest->withoutHeader($name);
}
}
}
return $originalRequest;
}
/**
* Creates a new Uri from the old request and the location header.
*
* @throws HttpException If location header is not usable (missing or incorrect)
* @throws MultipleRedirectionException If a 300 status code is received and default location cannot be resolved (doesn't use the location header or not present)
*/
private function createUri(ResponseInterface $redirectResponse, RequestInterface $originalRequest): UriInterface
{
if ($this->redirectCodes[$redirectResponse->getStatusCode()]['multiple'] && (!$this->useDefaultForMultiple || !$redirectResponse->hasHeader('Location'))) {
throw new MultipleRedirectionException('Cannot choose a redirection', $originalRequest, $redirectResponse);
}
if (!$redirectResponse->hasHeader('Location')) {
throw new HttpException('Redirect status code, but no location header present in the response', $originalRequest, $redirectResponse);
}
$location = $redirectResponse->getHeaderLine('Location');
$parsedLocation = parse_url($location);
if (false === $parsedLocation || '' === $location) {
throw new HttpException(sprintf('Location "%s" could not be parsed', $location), $originalRequest, $redirectResponse);
}
$uri = $originalRequest->getUri();
// Redirections can either use an absolute uri or a relative reference https://www.rfc-editor.org/rfc/rfc3986#section-4.2
// If relative, we need to check if we have an absolute path or not
$path = array_key_exists('path', $parsedLocation) ? $parsedLocation['path'] : '';
if (!array_key_exists('host', $parsedLocation) && '/' !== $location[0]) {
// the target is a relative-path reference, we need to merge it with the base path
$originalPath = $uri->getPath();
if ('' === $path) {
$path = $originalPath;
} elseif (($pos = strrpos($originalPath, '/')) !== false) {
$path = substr($originalPath, 0, $pos + 1).$path;
} else {
$path = '/'.$path;
}
/* replace '/./' or '/foo/../' with '/' */
$re = ['#(/\./)#', '#/(?!\.\.)[^/]+/\.\./#'];
for ($n = 1; $n > 0; $path = preg_replace($re, '/', $path, -1, $n)) {
if (null === $path) {
throw new HttpException(sprintf('Failed to resolve Location %s', $location), $originalRequest, $redirectResponse);
}
}
}
if (null === $path) {
throw new HttpException(sprintf('Failed to resolve Location %s', $location), $originalRequest, $redirectResponse);
}
$uri = $uri
->withPath($path)
->withQuery(array_key_exists('query', $parsedLocation) ? $parsedLocation['query'] : '')
->withFragment(array_key_exists('fragment', $parsedLocation) ? $parsedLocation['fragment'] : '')
;
if (array_key_exists('scheme', $parsedLocation)) {
$uri = $uri->withScheme($parsedLocation['scheme']);
}
if (array_key_exists('host', $parsedLocation)) {
$uri = $uri->withHost($parsedLocation['host']);
}
if (array_key_exists('port', $parsedLocation)) {
$uri = $uri->withPort($parsedLocation['port']);
} elseif (array_key_exists('host', $parsedLocation)) {
$uri = $uri->withPort(null);
}
return $uri;
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common\Plugin;
use Http\Client\Common\Plugin;
use Http\Message\RequestMatcher;
use Http\Promise\Promise;
use Psr\Http\Message\RequestInterface;
/**
* Apply a delegated plugin based on a request match.
*
* @author Márk Sági-Kazár <mark.sagikazar@gmail.com>
*/
final class RequestMatcherPlugin implements Plugin
{
/**
* @var RequestMatcher
*/
private $requestMatcher;
/**
* @var Plugin|null
*/
private $successPlugin;
/**
* @var Plugin|null
*/
private $failurePlugin;
public function __construct(RequestMatcher $requestMatcher, ?Plugin $delegateOnMatch, Plugin $delegateOnNoMatch = null)
{
$this->requestMatcher = $requestMatcher;
$this->successPlugin = $delegateOnMatch;
$this->failurePlugin = $delegateOnNoMatch;
}
/**
* {@inheritdoc}
*/
public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise
{
if ($this->requestMatcher->matches($request)) {
if (null !== $this->successPlugin) {
return $this->successPlugin->handleRequest($request, $next, $first);
}
} elseif (null !== $this->failurePlugin) {
return $this->failurePlugin->handleRequest($request, $next, $first);
}
return $next($request);
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common\Plugin;
use Http\Message\Stream\BufferedStream;
use Http\Promise\Promise;
use Psr\Http\Message\RequestInterface;
/**
* Allow body used in request to be always seekable.
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
final class RequestSeekableBodyPlugin extends SeekableBodyPlugin
{
/**
* {@inheritdoc}
*/
public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise
{
if (!$request->getBody()->isSeekable()) {
$request = $request->withBody(new BufferedStream($request->getBody(), $this->useFileBuffer, $this->memoryBufferSize));
}
return $next($request);
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common\Plugin;
use Http\Message\Stream\BufferedStream;
use Http\Promise\Promise;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* Allow body used in response to be always seekable.
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
final class ResponseSeekableBodyPlugin extends SeekableBodyPlugin
{
/**
* {@inheritdoc}
*/
public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise
{
return $next($request)->then(function (ResponseInterface $response) {
if ($response->getBody()->isSeekable()) {
return $response;
}
return $response->withBody(new BufferedStream($response->getBody(), $this->useFileBuffer, $this->memoryBufferSize));
});
}
}
@@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common\Plugin;
use Http\Client\Common\Plugin;
use Http\Client\Exception\HttpException;
use Http\Promise\Promise;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Retry the request if an exception is thrown.
*
* By default will retry only one time.
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
final class RetryPlugin implements Plugin
{
/**
* Number of retry before sending an exception.
*
* @var int
*/
private $retry;
/**
* @var callable
*/
private $errorResponseDelay;
/**
* @var callable
*/
private $errorResponseDecider;
/**
* @var callable
*/
private $exceptionDecider;
/**
* @var callable
*/
private $exceptionDelay;
/**
* Store the retry counter for each request.
*
* @var array
*/
private $retryStorage = [];
/**
* @param array{'retries'?: int, 'error_response_decider'?: callable, 'exception_decider'?: callable, 'error_response_delay'?: callable, 'exception_delay'?: callable} $config
*
* Configuration options:
* - retries: Number of retries to attempt if an exception occurs before letting the exception bubble up
* - error_response_decider: A callback that gets a request and response to decide whether the request should be retried
* - exception_decider: A callback that gets a request and an exception to decide after a failure whether the request should be retried
* - 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
* - 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
*/
public function __construct(array $config = [])
{
$resolver = new OptionsResolver();
$resolver->setDefaults([
'retries' => 1,
'error_response_decider' => function (RequestInterface $request, ResponseInterface $response) {
// do not retry client errors
return $response->getStatusCode() >= 500 && $response->getStatusCode() < 600;
},
'exception_decider' => function (RequestInterface $request, ClientExceptionInterface $e) {
// do not retry client errors
return !$e instanceof HttpException || $e->getCode() >= 500 && $e->getCode() < 600;
},
'error_response_delay' => __CLASS__.'::defaultErrorResponseDelay',
'exception_delay' => __CLASS__.'::defaultExceptionDelay',
]);
$resolver->setAllowedTypes('retries', 'int');
$resolver->setAllowedTypes('error_response_decider', 'callable');
$resolver->setAllowedTypes('exception_decider', 'callable');
$resolver->setAllowedTypes('error_response_delay', 'callable');
$resolver->setAllowedTypes('exception_delay', 'callable');
$options = $resolver->resolve($config);
$this->retry = $options['retries'];
$this->errorResponseDecider = $options['error_response_decider'];
$this->errorResponseDelay = $options['error_response_delay'];
$this->exceptionDecider = $options['exception_decider'];
$this->exceptionDelay = $options['exception_delay'];
}
/**
* {@inheritdoc}
*/
public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise
{
$chainIdentifier = spl_object_hash((object) $first);
return $next($request)->then(function (ResponseInterface $response) use ($request, $next, $first, $chainIdentifier) {
if (!array_key_exists($chainIdentifier, $this->retryStorage)) {
$this->retryStorage[$chainIdentifier] = 0;
}
if ($this->retryStorage[$chainIdentifier] >= $this->retry) {
unset($this->retryStorage[$chainIdentifier]);
return $response;
}
if (call_user_func($this->errorResponseDecider, $request, $response)) {
/** @var int $time */
$time = call_user_func($this->errorResponseDelay, $request, $response, $this->retryStorage[$chainIdentifier]);
$response = $this->retry($request, $next, $first, $chainIdentifier, $time);
}
if (array_key_exists($chainIdentifier, $this->retryStorage)) {
unset($this->retryStorage[$chainIdentifier]);
}
return $response;
}, function (ClientExceptionInterface $exception) use ($request, $next, $first, $chainIdentifier) {
if (!array_key_exists($chainIdentifier, $this->retryStorage)) {
$this->retryStorage[$chainIdentifier] = 0;
}
if ($this->retryStorage[$chainIdentifier] >= $this->retry) {
unset($this->retryStorage[$chainIdentifier]);
throw $exception;
}
if (!call_user_func($this->exceptionDecider, $request, $exception)) {
throw $exception;
}
/** @var int $time */
$time = call_user_func($this->exceptionDelay, $request, $exception, $this->retryStorage[$chainIdentifier]);
return $this->retry($request, $next, $first, $chainIdentifier, $time);
});
}
/**
* @param int $retries The number of retries we made before. First time this get called it will be 0.
*/
public static function defaultErrorResponseDelay(RequestInterface $request, ResponseInterface $response, int $retries): int
{
return pow(2, $retries) * 500000;
}
/**
* @param int $retries The number of retries we made before. First time this get called it will be 0.
*/
public static function defaultExceptionDelay(RequestInterface $request, ClientExceptionInterface $e, int $retries): int
{
return pow(2, $retries) * 500000;
}
/**
* @throws \Exception if retrying returns a failed promise
*/
private function retry(RequestInterface $request, callable $next, callable $first, string $chainIdentifier, int $delay): ResponseInterface
{
usleep($delay);
// Retry synchronously
++$this->retryStorage[$chainIdentifier];
$promise = $this->handleRequest($request, $next, $first);
return $promise->wait();
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common\Plugin;
use Http\Client\Common\Plugin;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @internal
*/
abstract class SeekableBodyPlugin implements Plugin
{
/**
* @var bool
*/
protected $useFileBuffer;
/**
* @var int
*/
protected $memoryBufferSize;
/**
* @param array{'use_file_buffer'?: bool, 'memory_boffer_size'?: int} $config
*
* Configuration options:
* - use_file_buffer: Whether this plugin should use a file as a buffer if the stream is too big, defaults to true
* - memory_buffer_size: Max memory size in bytes to use for the buffer before it use a file, defaults to 2097152 (2 mb)
*/
public function __construct(array $config = [])
{
$resolver = new OptionsResolver();
$resolver->setDefaults([
'use_file_buffer' => true,
'memory_buffer_size' => 2097152,
]);
$resolver->setAllowedTypes('use_file_buffer', 'bool');
$resolver->setAllowedTypes('memory_buffer_size', 'int');
$options = $resolver->resolve($config);
$this->useFileBuffer = $options['use_file_buffer'];
$this->memoryBufferSize = $options['memory_buffer_size'];
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common\Plugin;
use Http\Promise\Promise;
use Psr\Http\Message\RequestInterface;
/**
* A plugin that helps you migrate from php-http/client-common 1.x to 2.x. This
* will also help you to support PHP5 at the same time you support 2.x.
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
trait VersionBridgePlugin
{
abstract protected function doHandleRequest(RequestInterface $request, callable $next, callable $first);
public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise
{
return $this->doHandleRequest($request, $next, $first);
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common;
use Http\Client\Common\Exception\LoopException;
use Http\Promise\Promise;
use Psr\Http\Message\RequestInterface;
final class PluginChain
{
/** @var Plugin[] */
private $plugins;
/** @var callable(RequestInterface): Promise */
private $clientCallable;
/** @var int */
private $maxRestarts;
/** @var int */
private $restarts = 0;
/**
* @param Plugin[] $plugins A plugin chain
* @param callable(RequestInterface): Promise $clientCallable Callable making the HTTP call
* @param array{'max_restarts'?: int} $options
*/
public function __construct(array $plugins, callable $clientCallable, array $options = [])
{
$this->plugins = $plugins;
$this->clientCallable = $clientCallable;
$this->maxRestarts = (int) ($options['max_restarts'] ?? 0);
}
private function createChain(): callable
{
$lastCallable = $this->clientCallable;
$reversedPlugins = \array_reverse($this->plugins);
foreach ($reversedPlugins as $plugin) {
$lastCallable = function (RequestInterface $request) use ($plugin, $lastCallable) {
return $plugin->handleRequest($request, $lastCallable, $this);
};
}
return $lastCallable;
}
public function __invoke(RequestInterface $request): Promise
{
if ($this->restarts > $this->maxRestarts) {
throw new LoopException('Too many restarts in plugin client', $request);
}
++$this->restarts;
return $this->createChain()($request);
}
}
+129
View File
@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common;
use Http\Client\Exception as HttplugException;
use Http\Client\HttpAsyncClient;
use Http\Client\HttpClient;
use Http\Client\Promise\HttpFulfilledPromise;
use Http\Client\Promise\HttpRejectedPromise;
use Http\Promise\Promise;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* The client managing plugins and providing a decorator around HTTP Clients.
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
final class PluginClient implements HttpClient, HttpAsyncClient
{
/**
* An HTTP async client.
*
* @var HttpAsyncClient
*/
private $client;
/**
* The plugin chain.
*
* @var Plugin[]
*/
private $plugins;
/**
* A list of options.
*
* @var array
*/
private $options;
/**
* @param ClientInterface|HttpAsyncClient $client An HTTP async client
* @param Plugin[] $plugins A plugin chain
* @param array{'max_restarts'?: int} $options
*/
public function __construct($client, array $plugins = [], array $options = [])
{
if ($client instanceof HttpAsyncClient) {
$this->client = $client;
} elseif ($client instanceof ClientInterface) {
$this->client = new EmulatedHttpAsyncClient($client);
} else {
throw new \TypeError(
sprintf('%s::__construct(): Argument #1 ($client) must be of type %s|%s, %s given', self::class, ClientInterface::class, HttpAsyncClient::class, get_debug_type($client))
);
}
$this->plugins = $plugins;
$this->options = $this->configure($options);
}
/**
* {@inheritdoc}
*/
public function sendRequest(RequestInterface $request): ResponseInterface
{
// If the client doesn't support sync calls, call async
if (!$this->client instanceof ClientInterface) {
return $this->sendAsyncRequest($request)->wait();
}
// Else we want to use the synchronous call of the underlying client,
// and not the async one in the case we have both an async and sync call
$pluginChain = $this->createPluginChain($this->plugins, function (RequestInterface $request) {
try {
return new HttpFulfilledPromise($this->client->sendRequest($request));
} catch (HttplugException $exception) {
return new HttpRejectedPromise($exception);
}
});
return $pluginChain($request)->wait();
}
/**
* {@inheritdoc}
*/
public function sendAsyncRequest(RequestInterface $request)
{
$pluginChain = $this->createPluginChain($this->plugins, function (RequestInterface $request) {
return $this->client->sendAsyncRequest($request);
});
return $pluginChain($request);
}
/**
* Configure the plugin client.
*/
private function configure(array $options = []): array
{
$resolver = new OptionsResolver();
$resolver->setDefaults([
'max_restarts' => 10,
]);
$resolver->setAllowedTypes('max_restarts', 'int');
return $resolver->resolve($options);
}
/**
* Create the plugin chain.
*
* @param Plugin[] $plugins A plugin chain
* @param callable $clientCallable Callable making the HTTP call
*
* @return callable(RequestInterface): Promise
*/
private function createPluginChain(array $plugins, callable $clientCallable): callable
{
return new PluginChain($plugins, $clientCallable, $this->options);
}
}
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common;
use Http\Client\HttpAsyncClient;
use Psr\Http\Client\ClientInterface;
/**
* Build an instance of a PluginClient with a dynamic list of plugins.
*
* @author Baptiste Clavié <clavie.b@gmail.com>
*/
final class PluginClientBuilder
{
/** @var Plugin[][] List of plugins ordered by priority [priority => Plugin[]]). */
private $plugins = [];
/** @var array Array of options to give to the plugin client */
private $options = [];
/**
* @param int $priority Priority of the plugin. The higher comes first.
*/
public function addPlugin(Plugin $plugin, int $priority = 0): self
{
$this->plugins[$priority][] = $plugin;
return $this;
}
/**
* @param mixed $value
*/
public function setOption(string $name, $value): self
{
$this->options[$name] = $value;
return $this;
}
public function removeOption(string $name): self
{
unset($this->options[$name]);
return $this;
}
/**
* @param ClientInterface|HttpAsyncClient $client
*/
public function createClient($client): PluginClient
{
if (!$client instanceof ClientInterface && !$client instanceof HttpAsyncClient) {
throw new \TypeError(
sprintf('%s::createClient(): Argument #1 ($client) must be of type %s|%s, %s given', self::class, ClientInterface::class, HttpAsyncClient::class, get_debug_type($client))
);
}
$plugins = $this->plugins;
if (0 === count($plugins)) {
$plugins[] = [];
}
krsort($plugins);
$plugins = array_merge(...$plugins);
return new PluginClient(
$client,
array_values($plugins),
$this->options
);
}
}
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common;
use Http\Client\HttpAsyncClient;
use Psr\Http\Client\ClientInterface;
/**
* Factory to create PluginClient instances. Using this factory instead of calling PluginClient constructor will enable
* the Symfony profiling without any configuration.
*
* @author Fabien Bourigault <bourigaultfabien@gmail.com>
*/
final class PluginClientFactory
{
/**
* @var (callable(ClientInterface|HttpAsyncClient, Plugin[], array): PluginClient)|null
*/
private static $factory;
/**
* Set the factory to use.
* The callable to provide must have the same arguments and return type as PluginClientFactory::createClient.
* This is used by the HTTPlugBundle to provide a better Symfony integration.
* Unlike the createClient method, this one is static to allow zero configuration profiling by hooking into early
* application execution.
*
* @internal
*
* @param callable(ClientInterface|HttpAsyncClient, Plugin[], array): PluginClient $factory
*/
public static function setFactory(callable $factory): void
{
static::$factory = $factory;
}
/**
* @param ClientInterface|HttpAsyncClient $client
* @param Plugin[] $plugins
* @param array{'client_name'?: string} $options
*
* Configuration options:
* - client_name: to give client a name which may be used when displaying client information
* like in the HTTPlugBundle profiler
*
* @see PluginClient constructor for PluginClient specific $options.
*/
public function createClient($client, array $plugins = [], array $options = []): PluginClient
{
if (!$client instanceof ClientInterface && !$client instanceof HttpAsyncClient) {
throw new \TypeError(
sprintf('%s::createClient(): Argument #1 ($client) must be of type %s|%s, %s given', self::class, ClientInterface::class, HttpAsyncClient::class, get_debug_type($client))
);
}
if (static::$factory) {
$factory = static::$factory;
return $factory($client, $plugins, $options);
}
unset($options['client_name']);
return new PluginClient($client, $plugins, $options);
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* A client that helps you migrate from php-http/httplug 1.x to 2.x. This
* will also help you to support PHP5 at the same time you support 2.x.
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
trait VersionBridgeClient
{
abstract protected function doSendRequest(RequestInterface $request);
public function sendRequest(RequestInterface $request): ResponseInterface
{
return $this->doSendRequest($request);
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
$finder = PhpCsFixer\Finder::create()
->in(__DIR__.'/src')
->name('*.php')
;
$config = (new PhpCsFixer\Config())
->setRiskyAllowed(true)
->setRules([
'@Symfony' => true,
])
->setFinder($finder)
;
return $config;
+378
View File
@@ -0,0 +1,378 @@
# Change Log
## 1.19.2 - 2023-11-30
- [#253](https://github.com/php-http/discovery/pull/253) - Symfony 7 dropped the deprecated PHP-HTTP `HttpClient` interface from their HTTP client, do not discover the version 7 client when lookig for the old interface.
## 1.19.1 - 2023-07-11
- [#250](https://github.com/php-http/discovery/pull/250) - Fix: Buzz client instantiation using deprecated Message Factory Discovery, use PSR-17 factory discovery instead.
## 1.19.0 - 2023-06-19
- [#249](https://github.com/php-http/discovery/pull/249) - Have composer plugin correctly install Symfony http client when nothing explicitly requires psr 18 resp. httplug.
- [#241](https://github.com/php-http/discovery/pull/241) - Support discovering PSR-17 factories of `httpsoft/http-message` package
## 1.18.1 - 2023-05-17
- [#242](https://github.com/php-http/discovery/pull/242) - Better exception message when no legacy php-http message factories can be built. Also needs php-http/message-factory package and they are deprecated in favor of PSR-17 anyways.
## 1.18.0 - 2023-05-03
- [#235](https://github.com/php-http/discovery/pull/235) - Deprecate HttpClientDiscovery, use Psr18ClientDiscovery instead
- [#238](https://github.com/php-http/discovery/pull/238) - Skip requiring php-http/message-factory when installing symfony/http-client 6.3+
- [#239](https://github.com/php-http/discovery/pull/239) - Skip auto-installing when the root package's extra.discovery is enough
## 1.17.0 - 2023-04-26
- [#230](https://github.com/php-http/discovery/pull/230) - Add Psr18Client to make it straightforward to use PSR-18
- [#232](https://github.com/php-http/discovery/pull/232) - Allow pinning the preferred implementations in composer.json
- [#233](https://github.com/php-http/discovery/pull/233) - Fix Psr17Factory::createServerRequestFromGlobals() when uploaded files have been moved
## 1.16.0 - 2023-04-26
- [#225](https://github.com/php-http/discovery/pull/225) - Remove support for the abandoned Zend Diactoros which has been replaced with Laminas Diactoros; marked the zend library as conflict in composer.json to avoid confusion
- [#227](https://github.com/php-http/discovery/pull/227) - Fix handling requests with nested files
## 1.15.3 - 2023-03-31
- [#224](https://github.com/php-http/discovery/pull/224) - Fix regression with Magento classloader
## 1.15.2 - 2023-02-11
- [#219](https://github.com/php-http/discovery/pull/219) - Fix handling of replaced packages
## 1.15.1 - 2023-02-10
- [#214](https://github.com/php-http/discovery/pull/214) - Fix resolving deps for psr/http-message-implementation
- [#216](https://github.com/php-http/discovery/pull/216) - Fix keeping platform requirements when rebooting composer
- [#217](https://github.com/php-http/discovery/pull/217) - Set extra.plugin-optional composer flag
## 1.15.0 - 2023-02-09
- [#209](https://github.com/php-http/discovery/pull/209) - Add generic `Psr17Factory` class
- [#208](https://github.com/php-http/discovery/pull/208) - Add composer plugin to auto-install missing implementations.
When libraries require an http implementation but no packages providing that implementation is installed in the application, the plugin will automatically install one.
This is only done for libraries that directly require php-http/discovery to avoid unexpected dependency installation.
## 1.14.3 - 2022-07-11
- [#207](https://github.com/php-http/discovery/pull/207) - Updates Exception to extend Throwable solving static analysis errors for consumers
## 1.14.2 - 2022-05-25
- [#202](https://github.com/php-http/discovery/pull/202) - Avoid error when the Symfony PSR-18 client exists but its dependencies are not installed
## 1.14.1 - 2021-09-18
- [#199](https://github.com/php-http/discovery/pull/199) - Fixes message factory discovery for `laminas-diactoros ^2.7`
## 1.14.0 - 2021-06-21
- Deprecate puli as it has been unmaintained for a long time and is not compatible with composer 2 https://github.com/php-http/discovery/pull/195
## 1.13.0 - 2020-11-27
- Support discovering PSR-17 factories of `slim/psr7` package https://github.com/php-http/discovery/pull/192
## 1.12.0 - 2020-09-22
- Support discovering HttpClient of `php-http/guzzle7-adapter` https://github.com/php-http/discovery/pull/189
## 1.11.0 - 2020-09-22
- Use correct method name to find Uri Factory in PSR17 https://github.com/php-http/discovery/pull/181
## 1.10.0 - 2020-09-04
- Discover PSR-18 implementation of phalcon
## 1.9.1 - 2020-07-13
### Fixed
- Support PHP 7.4 and 8.0
## 1.9.0 - 2020-07-02
### Added
- Support discovering PSR-18 factories of `guzzlehttp/guzzle` 7+
## 1.8.0 - 2020-06-14
### Added
- Support discovering PSR-17 factories of `guzzlehttp/psr7` package
- Support discovering PSR-17 factories of `laminas/laminas-diactoros` package
- `ClassDiscovery::getStrategies()` to retrieve the list of current strategies.
### Fixed
- Ignore exception during discovery when Symfony HttplugClient checks if HTTPlug is available.
## 1.7.4 - 2020-01-03
### Fixed
- Improve conditions on Symfony's async HTTPlug client.
## 1.7.3 - 2019-12-27
### Fixed
- Enough conditions to only use Symfony HTTP client if all needed components are available.
## 1.7.2 - 2019-12-27
### Fixed
- Allow a condition to specify an interface and not just classes.
## 1.7.1 - 2019-12-26
### Fixed
- Better conditions to see if Symfony's HTTP clients are available.
## 1.7.0 - 2019-06-30
### Added
- Dropped support for PHP < 7.1
- Support for `symfony/http-client`
## 1.6.1 - 2019-02-23
### Fixed
- MockClientStrategy also provides the mock client when requesting an async client
## 1.6.0 - 2019-01-23
### Added
- Support for PSR-17 factories
- Support for PSR-18 clients
## 1.5.2 - 2018-12-31
Corrected mistakes in 1.5.1. The different between 1.5.2 and 1.5.0 is that
we removed some PHP 7 code.
https://github.com/php-http/discovery/compare/1.5.0...1.5.2
## 1.5.1 - 2018-12-31
This version added new features by mistake. These are reverted in 1.5.2.
Do not use 1.5.1.
### Fixed
- Removed PHP 7 code
## 1.5.0 - 2018-12-30
### Added
- Support for `nyholm/psr7` version 1.0.
- `ClassDiscovery::safeClassExists` which will help Magento users.
- Support for HTTPlug 2.0
- Support for Buzz 1.0
- Better error message when nothing found by introducing a new exception: `NoCandidateFoundException`.
### Fixed
- Fixed condition evaluation, it should stop after first invalid condition.
## 1.4.0 - 2018-02-06
### Added
- Discovery support for nyholm/psr7
## 1.3.0 - 2017-08-03
### Added
- Discovery support for CakePHP adapter
- Discovery support for Zend adapter
- Discovery support for Artax adapter
## 1.2.1 - 2017-03-02
### Fixed
- Fixed minor issue with `MockClientStrategy`, also added more tests.
## 1.2.0 - 2017-02-12
### Added
- MockClientStrategy class.
## 1.1.1 - 2016-11-27
### Changed
- Made exception messages clearer. `StrategyUnavailableException` is no longer the previous exception to `DiscoveryFailedException`.
- `CommonClassesStrategy` is using `self` instead of `static`. Using `static` makes no sense when `CommonClassesStrategy` is final.
## 1.1.0 - 2016-10-20
### Added
- Discovery support for Slim Framework factories
## 1.0.0 - 2016-07-18
### Added
- Added back `Http\Discovery\NotFoundException` to preserve BC with 0.8 version. You may upgrade from 0.8.x and 0.9.x to 1.0.0 without any BC breaks.
- Added interface `Http\Discovery\Exception` which is implemented by all our exceptions
### Changed
- Puli strategy renamed to Puli Beta strategy to prevent incompatibility with a future Puli stable
### Deprecated
- For BC reasons, the old `Http\Discovery\NotFoundException` (extending the new exception) will be thrown until version 2.0
## 0.9.1 - 2016-06-28
### Changed
- Dropping PHP 5.4 support because we use the ::class constant.
## 0.9.0 - 2016-06-25
### Added
- Discovery strategies to find classes
### Changed
- [Puli](http://puli.io) made optional
- Improved exceptions
- **[BC] `NotFoundException` moved to `Http\Discovery\Exception\NotFoundException`**
## 0.8.0 - 2016-02-11
### Changed
- Puli composer plugin must be installed separately
## 0.7.0 - 2016-01-15
### Added
- Temporary puli.phar (Beta 10) executable
### Changed
- Updated HTTPlug dependencies
- Updated Puli dependencies
- Local configuration to make tests passing
### Removed
- Puli CLI dependency
## 0.6.4 - 2016-01-07
### Fixed
- Puli [not working](https://twitter.com/PuliPHP/status/685132540588507137) with the latest json-schema
## 0.6.3 - 2016-01-04
### Changed
- Adjust Puli dependencies
## 0.6.2 - 2016-01-04
### Changed
- Make Puli CLI a requirement
## 0.6.1 - 2016-01-03
### Changed
- More flexible Puli requirement
## 0.6.0 - 2015-12-30
### Changed
- Use [Puli](http://puli.io) for discovery
- Improved exception messages
## 0.5.0 - 2015-12-25
### Changed
- Updated message factory dependency (php-http/message)
## 0.4.0 - 2015-12-17
### Added
- Array condition evaluation in the Class Discovery
### Removed
- Message factories (moved to php-http/utils)
## 0.3.0 - 2015-11-18
### Added
- HTTP Async Client Discovery
- Stream factories
### Changed
- Discoveries and Factories are final
- Message and Uri factories have the type in their names
- Diactoros Message factory uses Stream factory internally
### Fixed
- Improved docblocks for API documentation generation
## 0.2.0 - 2015-10-31
### Changed
- Renamed AdapterDiscovery to ClientDiscovery
## 0.1.1 - 2015-06-13
### Fixed
- Bad HTTP Adapter class name for Guzzle 5
## 0.1.0 - 2015-06-12
### Added
- Initial release
+19
View File
@@ -0,0 +1,19 @@
Copyright (c) 2015-2016 PHP HTTP Team <team@php-http.org>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+122
View File
@@ -0,0 +1,122 @@
# HTTPlug Discovery
[![Latest Version](https://img.shields.io/github/release/php-http/discovery.svg?style=flat-square)](https://github.com/php-http/discovery/releases)
[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE)
[![Tests](https://github.com/php-http/discovery/actions/workflows/ci.yml/badge.svg?branch=1.x)](https://github.com/php-http/discovery/actions/workflows/ci.yml?query=branch%3A1.x)
[![Code Coverage](https://img.shields.io/scrutinizer/coverage/g/php-http/discovery.svg?style=flat-square)](https://scrutinizer-ci.com/g/php-http/discovery)
[![Quality Score](https://img.shields.io/scrutinizer/g/php-http/discovery.svg?style=flat-square)](https://scrutinizer-ci.com/g/php-http/discovery)
[![Total Downloads](https://img.shields.io/packagist/dt/php-http/discovery.svg?style=flat-square)](https://packagist.org/packages/php-http/discovery)
**This library provides auto-discovery and auto-installation of well-known PSR-17, PSR-18 and HTTPlug implementations.**
## Install
Via Composer
``` bash
composer require php-http/discovery
```
## Usage as a library author
Please see the [official documentation](http://php-http.readthedocs.org/en/latest/discovery.html).
If your library/SDK needs a PSR-18 client, here is a quick example.
First, you need to install a PSR-18 client and a PSR-17 factory implementations.
This should be done only for dev dependencies as you don't want to force a
specific implementation on your users:
```bash
composer require --dev symfony/http-client
composer require --dev nyholm/psr7
```
Then, you can disable the Composer plugin embeded in `php-http/discovery`
because you just installed the dev dependencies you need for testing:
```bash
composer config allow-plugins.php-http/discovery false
```
Finally, you need to require `php-http/discovery` and the generic implementations
that your library is going to need:
```bash
composer require 'php-http/discovery:^1.17'
composer require 'psr/http-client-implementation:*'
composer require 'psr/http-factory-implementation:*'
```
Now, you're ready to make an HTTP request:
```php
use Http\Discovery\Psr18Client;
$client = new Psr18Client();
$request = $client->createRequest('GET', 'https://example.com');
$response = $client->sendRequest($request);
```
Internally, this code will use whatever PSR-7, PSR-17 and PSR-18 implementations
that your users have installed.
## Usage as a library user
If you use a library/SDK that requires `php-http/discovery`, you can configure
the auto-discovery mechanism to use a specific implementation when many are
available in your project.
For example, if you have both `nyholm/psr7` and `guzzlehttp/guzzle` in your
project, you can tell `php-http/discovery` to use `guzzlehttp/guzzle` instead of
`nyholm/psr7` by running the following command:
```bash
composer config extra.discovery.psr/http-factory-implementation GuzzleHttp\\Psr7\\HttpFactory
```
This will update your `composer.json` file to add the following configuration:
```json
{
"extra": {
"discovery": {
"psr/http-factory-implementation": "GuzzleHttp\\Psr7\\HttpFactory"
}
}
}
```
Don't forget to run `composer install` to apply the changes, and ensure that
the composer plugin is enabled:
```bash
composer config allow-plugins.php-http/discovery true
composer install
```
## Testing
``` bash
composer test
```
## Contributing
Please see our [contributing guide](http://docs.php-http.org/en/latest/development/contributing.html).
## Security
If you discover any security related issues, please contact us at [security@php-http.org](mailto:security@php-http.org).
## License
The MIT License (MIT). Please see [License File](LICENSE) for more information.
+63
View File
@@ -0,0 +1,63 @@
{
"name": "php-http/discovery",
"description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations",
"type": "composer-plugin",
"license": "MIT",
"keywords": ["http", "discovery", "client", "adapter", "message", "factory", "psr7", "psr17"],
"homepage": "http://php-http.org",
"authors": [
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com"
}
],
"provide": {
"php-http/async-client-implementation": "*",
"php-http/client-implementation": "*",
"psr/http-client-implementation": "*",
"psr/http-factory-implementation": "*",
"psr/http-message-implementation": "*"
},
"require": {
"php": "^7.1 || ^8.0",
"composer-plugin-api": "^1.0|^2.0"
},
"require-dev": {
"composer/composer": "^1.0.2|^2.0",
"graham-campbell/phpspec-skip-example-extension": "^5.0",
"php-http/httplug": "^1.0 || ^2.0",
"php-http/message-factory": "^1.0",
"phpspec/phpspec": "^5.1 || ^6.1 || ^7.3",
"symfony/phpunit-bridge": "^6.2"
},
"autoload": {
"psr-4": {
"Http\\Discovery\\": "src/"
},
"exclude-from-classmap": [
"src/Composer/Plugin.php"
]
},
"autoload-dev": {
"psr-4": {
"spec\\Http\\Discovery\\": "spec/"
}
},
"scripts": {
"test": [
"vendor/bin/phpspec run",
"vendor/bin/simple-phpunit --group NothingInstalled"
],
"test-ci": "vendor/bin/phpspec run -c phpspec.ci.yml"
},
"extra": {
"class": "Http\\Discovery\\Composer\\Plugin",
"plugin-optional": true
},
"conflict": {
"nyholm/psr7": "<1.0",
"zendframework/zend-diactoros": "*"
},
"prefer-stable": true,
"minimum-stability": "beta"
}
+255
View File
@@ -0,0 +1,255 @@
<?php
namespace Http\Discovery;
use Http\Discovery\Exception\ClassInstantiationFailedException;
use Http\Discovery\Exception\DiscoveryFailedException;
use Http\Discovery\Exception\NoCandidateFoundException;
use Http\Discovery\Exception\StrategyUnavailableException;
use Http\Discovery\Strategy\DiscoveryStrategy;
/**
* Registry that based find results on class existence.
*
* @author David de Boer <david@ddeboer.nl>
* @author Márk Sági-Kazár <mark.sagikazar@gmail.com>
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
abstract class ClassDiscovery
{
/**
* A list of strategies to find classes.
*
* @var DiscoveryStrategy[]
*/
private static $strategies = [
Strategy\GeneratedDiscoveryStrategy::class,
Strategy\CommonClassesStrategy::class,
Strategy\CommonPsr17ClassesStrategy::class,
Strategy\PuliBetaStrategy::class,
];
private static $deprecatedStrategies = [
Strategy\PuliBetaStrategy::class => true,
];
/**
* Discovery cache to make the second time we use discovery faster.
*
* @var array
*/
private static $cache = [];
/**
* Finds a class.
*
* @param string $type
*
* @return string|\Closure
*
* @throws DiscoveryFailedException
*/
protected static function findOneByType($type)
{
// Look in the cache
if (null !== ($class = self::getFromCache($type))) {
return $class;
}
static $skipStrategy;
$skipStrategy ?? $skipStrategy = self::safeClassExists(Strategy\GeneratedDiscoveryStrategy::class) ? false : Strategy\GeneratedDiscoveryStrategy::class;
$exceptions = [];
foreach (self::$strategies as $strategy) {
if ($skipStrategy === $strategy) {
continue;
}
try {
$candidates = $strategy::getCandidates($type);
} catch (StrategyUnavailableException $e) {
if (!isset(self::$deprecatedStrategies[$strategy])) {
$exceptions[] = $e;
}
continue;
}
foreach ($candidates as $candidate) {
if (isset($candidate['condition'])) {
if (!self::evaluateCondition($candidate['condition'])) {
continue;
}
}
// save the result for later use
self::storeInCache($type, $candidate);
return $candidate['class'];
}
$exceptions[] = new NoCandidateFoundException($strategy, $candidates);
}
throw DiscoveryFailedException::create($exceptions);
}
/**
* Get a value from cache.
*
* @param string $type
*
* @return string|null
*/
private static function getFromCache($type)
{
if (!isset(self::$cache[$type])) {
return;
}
$candidate = self::$cache[$type];
if (isset($candidate['condition'])) {
if (!self::evaluateCondition($candidate['condition'])) {
return;
}
}
return $candidate['class'];
}
/**
* Store a value in cache.
*
* @param string $type
* @param string $class
*/
private static function storeInCache($type, $class)
{
self::$cache[$type] = $class;
}
/**
* Set new strategies and clear the cache.
*
* @param string[] $strategies list of fully qualified class names that implement DiscoveryStrategy
*/
public static function setStrategies(array $strategies)
{
self::$strategies = $strategies;
self::clearCache();
}
/**
* Returns the currently configured discovery strategies as fully qualified class names.
*
* @return string[]
*/
public static function getStrategies(): iterable
{
return self::$strategies;
}
/**
* Append a strategy at the end of the strategy queue.
*
* @param string $strategy Fully qualified class name of a DiscoveryStrategy
*/
public static function appendStrategy($strategy)
{
self::$strategies[] = $strategy;
self::clearCache();
}
/**
* Prepend a strategy at the beginning of the strategy queue.
*
* @param string $strategy Fully qualified class name to a DiscoveryStrategy
*/
public static function prependStrategy($strategy)
{
array_unshift(self::$strategies, $strategy);
self::clearCache();
}
public static function clearCache()
{
self::$cache = [];
}
/**
* Evaluates conditions to boolean.
*
* @return bool
*/
protected static function evaluateCondition($condition)
{
if (is_string($condition)) {
// Should be extended for functions, extensions???
return self::safeClassExists($condition);
}
if (is_callable($condition)) {
return (bool) $condition();
}
if (is_bool($condition)) {
return $condition;
}
if (is_array($condition)) {
foreach ($condition as $c) {
if (false === static::evaluateCondition($c)) {
// Immediately stop execution if the condition is false
return false;
}
}
return true;
}
return false;
}
/**
* Get an instance of the $class.
*
* @param string|\Closure $class a FQCN of a class or a closure that instantiate the class
*
* @return object
*
* @throws ClassInstantiationFailedException
*/
protected static function instantiateClass($class)
{
try {
if (is_string($class)) {
return new $class();
}
if (is_callable($class)) {
return $class();
}
} catch (\Exception $e) {
throw new ClassInstantiationFailedException('Unexpected exception when instantiating class.', 0, $e);
}
throw new ClassInstantiationFailedException('Could not instantiate class because parameter is neither a callable nor a string');
}
/**
* We need a "safe" version of PHP's "class_exists" because Magento has a bug
* (or they call it a "feature"). Magento is throwing an exception if you do class_exists()
* on a class that ends with "Factory" and if that file does not exits.
*
* This function catches all potential exceptions and makes sure to always return a boolean.
*
* @param string $class
*
* @return bool
*/
public static function safeClassExists($class)
{
try {
return class_exists($class) || interface_exists($class);
} catch (\Exception $e) {
return false;
}
}
}
+465
View File
@@ -0,0 +1,465 @@
<?php
namespace Http\Discovery\Composer;
use Composer\Composer;
use Composer\DependencyResolver\Pool;
use Composer\EventDispatcher\EventSubscriberInterface;
use Composer\Factory;
use Composer\Installer;
use Composer\IO\IOInterface;
use Composer\Json\JsonFile;
use Composer\Json\JsonManipulator;
use Composer\Package\Locker;
use Composer\Package\Version\VersionParser;
use Composer\Package\Version\VersionSelector;
use Composer\Plugin\PluginInterface;
use Composer\Repository\InstalledRepositoryInterface;
use Composer\Repository\RepositorySet;
use Composer\Script\Event;
use Composer\Script\ScriptEvents;
use Composer\Util\Filesystem;
use Http\Discovery\ClassDiscovery;
/**
* Auto-installs missing implementations.
*
* When a dependency requires both this package and one of the supported `*-implementation`
* virtual packages, this plugin will auto-install a well-known implementation if none is
* found. The plugin will first look at already installed packages and figure out the
* preferred implementation to install based on the below stickyness rules (or on the first
* listed implementation if no rules match.)
*
* Don't miss updating src/Strategy/Common*Strategy.php when adding a new supported package.
*
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
class Plugin implements PluginInterface, EventSubscriberInterface
{
/**
* Describes, for every supported virtual implementation, which packages
* provide said implementation and which extra dependencies each package
* requires to provide the implementation.
*/
private const PROVIDE_RULES = [
'php-http/async-client-implementation' => [
'symfony/http-client:>=6.3' => ['guzzlehttp/promises', 'psr/http-factory-implementation', 'php-http/httplug'],
'symfony/http-client' => ['guzzlehttp/promises', 'php-http/message-factory', 'psr/http-factory-implementation', 'php-http/httplug'],
'php-http/guzzle7-adapter' => [],
'php-http/guzzle6-adapter' => [],
'php-http/curl-client' => [],
'php-http/react-adapter' => [],
],
'php-http/client-implementation' => [
'symfony/http-client:>=6.3' => ['psr/http-factory-implementation', 'php-http/httplug'],
'symfony/http-client' => ['php-http/message-factory', 'psr/http-factory-implementation', 'php-http/httplug'],
'php-http/guzzle7-adapter' => [],
'php-http/guzzle6-adapter' => [],
'php-http/cakephp-adapter' => [],
'php-http/curl-client' => [],
'php-http/react-adapter' => [],
'php-http/buzz-adapter' => [],
'php-http/artax-adapter' => [],
'kriswallsmith/buzz:^1' => [],
],
'psr/http-client-implementation' => [
'symfony/http-client' => ['psr/http-factory-implementation', 'psr/http-client'],
'guzzlehttp/guzzle' => [],
'kriswallsmith/buzz:^1' => [],
],
'psr/http-message-implementation' => [
'php-http/discovery' => ['psr/http-factory-implementation'],
],
'psr/http-factory-implementation' => [
'nyholm/psr7' => [],
'guzzlehttp/psr7:>=2' => [],
'slim/psr7' => [],
'laminas/laminas-diactoros' => [],
'phalcon/cphalcon:^4' => [],
'http-interop/http-factory-guzzle' => [],
'http-interop/http-factory-diactoros' => [],
'http-interop/http-factory-slim' => [],
'httpsoft/http-message' => [],
],
];
/**
* Describes which package should be preferred on the left side
* depending on which one is already installed on the right side.
*/
private const STICKYNESS_RULES = [
'symfony/http-client' => 'symfony/framework-bundle',
'php-http/guzzle7-adapter' => 'guzzlehttp/guzzle:^7',
'php-http/guzzle6-adapter' => 'guzzlehttp/guzzle:^6',
'php-http/guzzle5-adapter' => 'guzzlehttp/guzzle:^5',
'php-http/cakephp-adapter' => 'cakephp/cakephp',
'php-http/react-adapter' => 'react/event-loop',
'php-http/buzz-adapter' => 'kriswallsmith/buzz:^0.15.1',
'php-http/artax-adapter' => 'amphp/artax:^3',
'http-interop/http-factory-guzzle' => 'guzzlehttp/psr7:^1',
'http-interop/http-factory-slim' => 'slim/slim:^3',
];
private const INTERFACE_MAP = [
'php-http/async-client-implementation' => [
'Http\Client\HttpAsyncClient',
],
'php-http/client-implementation' => [
'Http\Client\HttpClient',
],
'psr/http-client-implementation' => [
'Psr\Http\Client\ClientInterface',
],
'psr/http-factory-implementation' => [
'Psr\Http\Message\RequestFactoryInterface',
'Psr\Http\Message\ResponseFactoryInterface',
'Psr\Http\Message\ServerRequestFactoryInterface',
'Psr\Http\Message\StreamFactoryInterface',
'Psr\Http\Message\UploadedFileFactoryInterface',
'Psr\Http\Message\UriFactoryInterface',
],
];
public static function getSubscribedEvents(): array
{
return [
ScriptEvents::PRE_AUTOLOAD_DUMP => 'preAutoloadDump',
ScriptEvents::POST_UPDATE_CMD => 'postUpdate',
];
}
public function activate(Composer $composer, IOInterface $io): void
{
}
public function deactivate(Composer $composer, IOInterface $io)
{
}
public function uninstall(Composer $composer, IOInterface $io)
{
}
public function postUpdate(Event $event)
{
$composer = $event->getComposer();
$repo = $composer->getRepositoryManager()->getLocalRepository();
$requires = [
$composer->getPackage()->getRequires(),
$composer->getPackage()->getDevRequires(),
];
$pinnedAbstractions = [];
$pinned = $composer->getPackage()->getExtra()['discovery'] ?? [];
foreach (self::INTERFACE_MAP as $abstraction => $interfaces) {
foreach (isset($pinned[$abstraction]) ? [] : $interfaces as $interface) {
if (!isset($pinned[$interface])) {
continue 2;
}
}
$pinnedAbstractions[$abstraction] = true;
}
$missingRequires = $this->getMissingRequires($repo, $requires, 'project' === $composer->getPackage()->getType(), $pinnedAbstractions);
$missingRequires = [
'require' => array_fill_keys(array_merge([], ...array_values($missingRequires[0])), '*'),
'require-dev' => array_fill_keys(array_merge([], ...array_values($missingRequires[1])), '*'),
'remove' => array_fill_keys(array_merge([], ...array_values($missingRequires[2])), '*'),
];
if (!$missingRequires = array_filter($missingRequires)) {
return;
}
$composerJsonContents = file_get_contents(Factory::getComposerFile());
$this->updateComposerJson($missingRequires, $composer->getConfig()->get('sort-packages'));
$installer = null;
// Find the composer installer, hack borrowed from symfony/flex
foreach (debug_backtrace(\DEBUG_BACKTRACE_PROVIDE_OBJECT) as $trace) {
if (isset($trace['object']) && $trace['object'] instanceof Installer) {
$installer = $trace['object'];
break;
}
}
if (!$installer) {
return;
}
$event->stopPropagation();
$dispatcher = $composer->getEventDispatcher();
$disableScripts = !method_exists($dispatcher, 'setRunScripts') || !((array) $dispatcher)["\0*\0runScripts"];
$composer = Factory::create($event->getIO(), null, false, $disableScripts);
/** @var Installer $installer */
$installer = clone $installer;
if (method_exists($installer, 'setAudit')) {
$trace['object']->setAudit(false);
}
// we need a clone of the installer to preserve its configuration state but with our own service objects
$installer->__construct(
$event->getIO(),
$composer->getConfig(),
$composer->getPackage(),
$composer->getDownloadManager(),
$composer->getRepositoryManager(),
$composer->getLocker(),
$composer->getInstallationManager(),
$composer->getEventDispatcher(),
$composer->getAutoloadGenerator()
);
if (method_exists($installer, 'setPlatformRequirementFilter')) {
$installer->setPlatformRequirementFilter(((array) $trace['object'])["\0*\0platformRequirementFilter"]);
}
if (0 !== $installer->run()) {
file_put_contents(Factory::getComposerFile(), $composerJsonContents);
return;
}
$versionSelector = new VersionSelector(ClassDiscovery::safeClassExists(RepositorySet::class) ? new RepositorySet() : new Pool());
$updateComposerJson = false;
foreach ($composer->getRepositoryManager()->getLocalRepository()->getPackages() as $package) {
foreach (['require', 'require-dev'] as $key) {
if (!isset($missingRequires[$key][$package->getName()])) {
continue;
}
$updateComposerJson = true;
$missingRequires[$key][$package->getName()] = $versionSelector->findRecommendedRequireVersion($package);
}
}
if ($updateComposerJson) {
$this->updateComposerJson($missingRequires, $composer->getConfig()->get('sort-packages'));
$this->updateComposerLock($composer, $event->getIO());
}
}
public function getMissingRequires(InstalledRepositoryInterface $repo, array $requires, bool $isProject, array $pinnedAbstractions): array
{
$allPackages = [];
$devPackages = method_exists($repo, 'getDevPackageNames') ? array_fill_keys($repo->getDevPackageNames(), true) : [];
// One must require "php-http/discovery"
// to opt-in for auto-installation of virtual package implementations
if (!isset($requires[0]['php-http/discovery'])) {
$requires = [[], []];
}
foreach ($repo->getPackages() as $package) {
$allPackages[$package->getName()] = true;
if (1 < \count($names = $package->getNames(false))) {
$allPackages += array_fill_keys($names, false);
if (isset($devPackages[$package->getName()])) {
$devPackages += $names;
}
}
if (isset($package->getRequires()['php-http/discovery'])) {
$requires[(int) isset($devPackages[$package->getName()])] += $package->getRequires();
}
}
$missingRequires = [[], [], []];
$versionParser = new VersionParser();
if (ClassDiscovery::safeClassExists(\Phalcon\Http\Message\RequestFactory::class, false)) {
$missingRequires[0]['psr/http-factory-implementation'] = [];
$missingRequires[1]['psr/http-factory-implementation'] = [];
}
foreach ($requires as $dev => $rules) {
$abstractions = [];
$rules = array_intersect_key(self::PROVIDE_RULES, $rules);
while ($rules) {
$abstraction = key($rules);
if (isset($pinnedAbstractions[$abstraction])) {
unset($rules[$abstraction]);
continue;
}
$abstractions[] = $abstraction;
foreach (array_shift($rules) as $candidate => $deps) {
[$candidate, $version] = explode(':', $candidate, 2) + [1 => null];
if (!isset($allPackages[$candidate])) {
continue;
}
if (null !== $version && !$repo->findPackage($candidate, $versionParser->parseConstraints($version))) {
continue;
}
if ($isProject && !$dev && isset($devPackages[$candidate])) {
$missingRequires[0][$abstraction] = [$candidate];
$missingRequires[2][$abstraction] = [$candidate];
} else {
$missingRequires[$dev][$abstraction] = [];
}
foreach ($deps as $dep) {
if (isset(self::PROVIDE_RULES[$dep])) {
$rules[$dep] = self::PROVIDE_RULES[$dep];
} elseif (!isset($allPackages[$dep])) {
$missingRequires[$dev][$abstraction][] = $dep;
} elseif ($isProject && !$dev && isset($devPackages[$dep])) {
$missingRequires[0][$abstraction][] = $dep;
$missingRequires[2][$abstraction][] = $dep;
}
}
break;
}
}
while ($abstractions) {
$abstraction = array_shift($abstractions);
if (isset($missingRequires[$dev][$abstraction])) {
continue;
}
$candidates = self::PROVIDE_RULES[$abstraction];
foreach ($candidates as $candidate => $deps) {
[$candidate, $version] = explode(':', $candidate, 2) + [1 => null];
if (null !== $version && !$repo->findPackage($candidate, $versionParser->parseConstraints($version))) {
continue;
}
if (isset($allPackages[$candidate]) && (!$isProject || $dev || !isset($devPackages[$candidate]))) {
continue 2;
}
}
foreach (array_intersect_key(self::STICKYNESS_RULES, $candidates) as $candidate => $stickyRule) {
[$stickyName, $stickyVersion] = explode(':', $stickyRule, 2) + [1 => null];
if (!isset($allPackages[$stickyName]) || ($isProject && !$dev && isset($devPackages[$stickyName]))) {
continue;
}
if (null !== $stickyVersion && !$repo->findPackage($stickyName, $versionParser->parseConstraints($stickyVersion))) {
continue;
}
$candidates = [$candidate => $candidates[$candidate]];
break;
}
$dep = key($candidates);
[$dep] = explode(':', $dep, 2);
$missingRequires[$dev][$abstraction] = [$dep];
if ($isProject && !$dev && isset($devPackages[$dep])) {
$missingRequires[2][$abstraction][] = $dep;
}
}
}
$missingRequires[1] = array_diff_key($missingRequires[1], $missingRequires[0]);
return $missingRequires;
}
public function preAutoloadDump(Event $event)
{
$filesystem = new Filesystem();
// Double realpath() on purpose, see https://bugs.php.net/72738
$vendorDir = $filesystem->normalizePath(realpath(realpath($event->getComposer()->getConfig()->get('vendor-dir'))));
$filesystem->ensureDirectoryExists($vendorDir.'/composer');
$pinned = $event->getComposer()->getPackage()->getExtra()['discovery'] ?? [];
$candidates = [];
$allInterfaces = array_merge(...array_values(self::INTERFACE_MAP));
foreach ($pinned as $abstraction => $class) {
if (isset(self::INTERFACE_MAP[$abstraction])) {
$interfaces = self::INTERFACE_MAP[$abstraction];
} elseif (false !== $k = array_search($abstraction, $allInterfaces, true)) {
$interfaces = [$allInterfaces[$k]];
} else {
throw new \UnexpectedValueException(sprintf('Invalid "extra.discovery" pinned in composer.json: "%s" is not one of ["%s"].', $abstraction, implode('", "', array_keys(self::INTERFACE_MAP))));
}
foreach ($interfaces as $interface) {
$candidates[] = sprintf("case %s: return [['class' => %s]];\n", var_export($interface, true), var_export($class, true));
}
}
$file = $vendorDir.'/composer/GeneratedDiscoveryStrategy.php';
if (!$candidates) {
if (file_exists($file)) {
unlink($file);
}
return;
}
$candidates = implode(' ', $candidates);
$code = <<<EOPHP
<?php
namespace Http\Discovery\Strategy;
class GeneratedDiscoveryStrategy implements DiscoveryStrategy
{
public static function getCandidates(\$type)
{
switch (\$type) {
$candidates
default: return [];
}
}
}
EOPHP
;
if (!file_exists($file) || $code !== file_get_contents($file)) {
file_put_contents($file, $code);
}
$rootPackage = $event->getComposer()->getPackage();
$autoload = $rootPackage->getAutoload();
$autoload['classmap'][] = $vendorDir.'/composer/GeneratedDiscoveryStrategy.php';
$rootPackage->setAutoload($autoload);
}
private function updateComposerJson(array $missingRequires, bool $sortPackages)
{
$file = Factory::getComposerFile();
$contents = file_get_contents($file);
$manipulator = new JsonManipulator($contents);
foreach ($missingRequires as $key => $packages) {
foreach ($packages as $package => $constraint) {
if ('remove' === $key) {
$manipulator->removeSubNode('require-dev', $package);
} else {
$manipulator->addLink($key, $package, $constraint, $sortPackages);
}
}
}
file_put_contents($file, $manipulator->getContents());
}
private function updateComposerLock(Composer $composer, IOInterface $io)
{
$lock = substr(Factory::getComposerFile(), 0, -4).'lock';
$composerJson = file_get_contents(Factory::getComposerFile());
$lockFile = new JsonFile($lock, null, $io);
$locker = ClassDiscovery::safeClassExists(RepositorySet::class)
? new Locker($io, $lockFile, $composer->getInstallationManager(), $composerJson)
: new Locker($io, $lockFile, $composer->getRepositoryManager(), $composer->getInstallationManager(), $composerJson);
$lockData = $locker->getLockData();
$lockData['content-hash'] = Locker::getContentHash($composerJson);
$lockFile->write($lockData);
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace Http\Discovery;
/**
* An interface implemented by all discovery related exceptions.
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
interface Exception extends \Throwable
{
}
@@ -0,0 +1,14 @@
<?php
namespace Http\Discovery\Exception;
use Http\Discovery\Exception;
/**
* Thrown when a class fails to instantiate.
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
final class ClassInstantiationFailedException extends \RuntimeException implements Exception
{
}
@@ -0,0 +1,51 @@
<?php
namespace Http\Discovery\Exception;
use Http\Discovery\Exception;
/**
* Thrown when all discovery strategies fails to find a resource.
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
final class DiscoveryFailedException extends \Exception implements Exception
{
/**
* @var \Exception[]
*/
private $exceptions;
/**
* @param string $message
* @param \Exception[] $exceptions
*/
public function __construct($message, array $exceptions = [])
{
$this->exceptions = $exceptions;
parent::__construct($message);
}
/**
* @param \Exception[] $exceptions
*/
public static function create($exceptions)
{
$message = 'Could not find resource using any discovery strategy. Find more information at http://docs.php-http.org/en/latest/discovery.html#common-errors';
foreach ($exceptions as $e) {
$message .= "\n - ".$e->getMessage();
}
$message .= "\n\n";
return new self($message, $exceptions);
}
/**
* @return \Exception[]
*/
public function getExceptions()
{
return $this->exceptions;
}
}
@@ -0,0 +1,47 @@
<?php
namespace Http\Discovery\Exception;
use Http\Discovery\Exception;
/**
* When we have used a strategy but no candidates provided by that strategy could be used.
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
final class NoCandidateFoundException extends \Exception implements Exception
{
/**
* @param string $strategy
*/
public function __construct($strategy, array $candidates)
{
$classes = array_map(
function ($a) {
return $a['class'];
},
$candidates
);
$message = sprintf(
'No valid candidate found using strategy "%s". We tested the following candidates: %s.',
$strategy,
implode(', ', array_map([$this, 'stringify'], $classes))
);
parent::__construct($message);
}
private function stringify($mixed)
{
if (is_string($mixed)) {
return $mixed;
}
if (is_array($mixed) && 2 === count($mixed)) {
return sprintf('%s::%s', $this->stringify($mixed[0]), $mixed[1]);
}
return is_object($mixed) ? get_class($mixed) : gettype($mixed);
}
}
@@ -0,0 +1,16 @@
<?php
namespace Http\Discovery\Exception;
use Http\Discovery\Exception;
/**
* Thrown when a discovery does not find any matches.
*
* @final do NOT extend this class, not final for BC reasons
*
* @author Márk Sági-Kazár <mark.sagikazar@gmail.com>
*/
/* final */ class NotFoundException extends \RuntimeException implements Exception
{
}
@@ -0,0 +1,12 @@
<?php
namespace Http\Discovery\Exception;
/**
* Thrown when we can't use Puli for discovery.
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
final class PuliUnavailableException extends StrategyUnavailableException
{
}
@@ -0,0 +1,15 @@
<?php
namespace Http\Discovery\Exception;
use Http\Discovery\Exception;
/**
* This exception is thrown when we cannot use a discovery strategy. This is *not* thrown when
* the discovery fails to find a class.
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
class StrategyUnavailableException extends \RuntimeException implements Exception
{
}
@@ -0,0 +1,32 @@
<?php
namespace Http\Discovery;
use Http\Client\HttpAsyncClient;
use Http\Discovery\Exception\DiscoveryFailedException;
/**
* Finds an HTTP Asynchronous Client.
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
final class HttpAsyncClientDiscovery extends ClassDiscovery
{
/**
* Finds an HTTP Async Client.
*
* @return HttpAsyncClient
*
* @throws Exception\NotFoundException
*/
public static function find()
{
try {
$asyncClient = static::findOneByType(HttpAsyncClient::class);
} catch (DiscoveryFailedException $e) {
throw new NotFoundException('No HTTPlug async clients found. Make sure to install a package providing "php-http/async-client-implementation". Example: "php-http/guzzle6-adapter".', 0, $e);
}
return static::instantiateClass($asyncClient);
}
}
@@ -0,0 +1,34 @@
<?php
namespace Http\Discovery;
use Http\Client\HttpClient;
use Http\Discovery\Exception\DiscoveryFailedException;
/**
* Finds an HTTP Client.
*
* @author Márk Sági-Kazár <mark.sagikazar@gmail.com>
*
* @deprecated This will be removed in 2.0. Consider using Psr18ClientDiscovery.
*/
final class HttpClientDiscovery extends ClassDiscovery
{
/**
* Finds an HTTP Client.
*
* @return HttpClient
*
* @throws Exception\NotFoundException
*/
public static function find()
{
try {
$client = static::findOneByType(HttpClient::class);
} catch (DiscoveryFailedException $e) {
throw new NotFoundException('No HTTPlug clients found. Make sure to install a package providing "php-http/client-implementation". Example: "php-http/guzzle6-adapter".', 0, $e);
}
return static::instantiateClass($client);
}
}
@@ -0,0 +1,34 @@
<?php
namespace Http\Discovery;
use Http\Discovery\Exception\DiscoveryFailedException;
use Http\Message\MessageFactory;
/**
* Finds a Message Factory.
*
* @author Márk Sági-Kazár <mark.sagikazar@gmail.com>
*
* @deprecated This will be removed in 2.0. Consider using Psr17FactoryDiscovery.
*/
final class MessageFactoryDiscovery extends ClassDiscovery
{
/**
* Finds a Message Factory.
*
* @return MessageFactory
*
* @throws Exception\NotFoundException
*/
public static function find()
{
try {
$messageFactory = static::findOneByType(MessageFactory::class);
} catch (DiscoveryFailedException $e) {
throw new NotFoundException('No php-http message factories found. Note that the php-http message factories are deprecated in favor of the PSR-17 message factories. To use the legacy Guzzle, Diactoros or Slim Framework factories of php-http, install php-http/message and php-http/message-factory and the chosen message implementation.', 0, $e);
}
return static::instantiateClass($messageFactory);
}
}
@@ -0,0 +1,14 @@
<?php
namespace Http\Discovery;
/**
* Thrown when a discovery does not find any matches.
*
* @author Márk Sági-Kazár <mark.sagikazar@gmail.com>
*
* @deprecated since since version 1.0, and will be removed in 2.0. Use {@link \Http\Discovery\Exception\NotFoundException} instead.
*/
final class NotFoundException extends \Http\Discovery\Exception\NotFoundException
{
}
+303
View File
@@ -0,0 +1,303 @@
<?php
namespace Http\Discovery;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestFactoryInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UploadedFileFactoryInterface;
use Psr\Http\Message\UploadedFileInterface;
use Psr\Http\Message\UriFactoryInterface;
use Psr\Http\Message\UriInterface;
/**
* A generic PSR-17 implementation.
*
* You can create this class with concrete factory instances or let
* it use discovery to find suitable implementations as needed.
*
* This class also provides two additional methods that are not in PSR17,
* to help with creating PSR-7 objects from PHP superglobals:
* - createServerRequestFromGlobals()
* - createUriFromGlobals()
*
* The code in this class is inspired by the "nyholm/psr7", "guzzlehttp/psr7"
* and "symfony/http-foundation" packages, all licenced under MIT.
*
* Copyright (c) 2004-2023 Fabien Potencier <fabien@symfony.com>
* Copyright (c) 2015 Michael Dowling <mtdowling@gmail.com>
* Copyright (c) 2015 Márk Sági-Kazár <mark.sagikazar@gmail.com>
* Copyright (c) 2015 Graham Campbell <hello@gjcampbell.co.uk>
* Copyright (c) 2016 Tobias Schultze <webmaster@tubo-world.de>
* Copyright (c) 2016 George Mponos <gmponos@gmail.com>
* Copyright (c) 2016-2018 Tobias Nyholm <tobias.nyholm@gmail.com>
*
* @author Nicolas Grekas <p@tchwork.com>
*/
class Psr17Factory implements RequestFactoryInterface, ResponseFactoryInterface, ServerRequestFactoryInterface, StreamFactoryInterface, UploadedFileFactoryInterface, UriFactoryInterface
{
private $requestFactory;
private $responseFactory;
private $serverRequestFactory;
private $streamFactory;
private $uploadedFileFactory;
private $uriFactory;
public function __construct(
RequestFactoryInterface $requestFactory = null,
ResponseFactoryInterface $responseFactory = null,
ServerRequestFactoryInterface $serverRequestFactory = null,
StreamFactoryInterface $streamFactory = null,
UploadedFileFactoryInterface $uploadedFileFactory = null,
UriFactoryInterface $uriFactory = null
) {
$this->requestFactory = $requestFactory;
$this->responseFactory = $responseFactory;
$this->serverRequestFactory = $serverRequestFactory;
$this->streamFactory = $streamFactory;
$this->uploadedFileFactory = $uploadedFileFactory;
$this->uriFactory = $uriFactory;
$this->setFactory($requestFactory);
$this->setFactory($responseFactory);
$this->setFactory($serverRequestFactory);
$this->setFactory($streamFactory);
$this->setFactory($uploadedFileFactory);
$this->setFactory($uriFactory);
}
/**
* @param UriInterface|string $uri
*/
public function createRequest(string $method, $uri): RequestInterface
{
$factory = $this->requestFactory ?? $this->setFactory(Psr17FactoryDiscovery::findRequestFactory());
return $factory->createRequest(...\func_get_args());
}
public function createResponse(int $code = 200, string $reasonPhrase = ''): ResponseInterface
{
$factory = $this->responseFactory ?? $this->setFactory(Psr17FactoryDiscovery::findResponseFactory());
return $factory->createResponse(...\func_get_args());
}
/**
* @param UriInterface|string $uri
*/
public function createServerRequest(string $method, $uri, array $serverParams = []): ServerRequestInterface
{
$factory = $this->serverRequestFactory ?? $this->setFactory(Psr17FactoryDiscovery::findServerRequestFactory());
return $factory->createServerRequest(...\func_get_args());
}
public function createServerRequestFromGlobals(array $server = null, array $get = null, array $post = null, array $cookie = null, array $files = null, StreamInterface $body = null): ServerRequestInterface
{
$server = $server ?? $_SERVER;
$request = $this->createServerRequest($server['REQUEST_METHOD'] ?? 'GET', $this->createUriFromGlobals($server), $server);
return $this->buildServerRequestFromGlobals($request, $server, $files ?? $_FILES)
->withQueryParams($get ?? $_GET)
->withParsedBody($post ?? $_POST)
->withCookieParams($cookie ?? $_COOKIE)
->withBody($body ?? $this->createStreamFromFile('php://input', 'r+'));
}
public function createStream(string $content = ''): StreamInterface
{
$factory = $this->streamFactory ?? $this->setFactory(Psr17FactoryDiscovery::findStreamFactory());
return $factory->createStream($content);
}
public function createStreamFromFile(string $filename, string $mode = 'r'): StreamInterface
{
$factory = $this->streamFactory ?? $this->setFactory(Psr17FactoryDiscovery::findStreamFactory());
return $factory->createStreamFromFile($filename, $mode);
}
/**
* @param resource $resource
*/
public function createStreamFromResource($resource): StreamInterface
{
$factory = $this->streamFactory ?? $this->setFactory(Psr17FactoryDiscovery::findStreamFactory());
return $factory->createStreamFromResource($resource);
}
public function createUploadedFile(StreamInterface $stream, int $size = null, int $error = \UPLOAD_ERR_OK, string $clientFilename = null, string $clientMediaType = null): UploadedFileInterface
{
$factory = $this->uploadedFileFactory ?? $this->setFactory(Psr17FactoryDiscovery::findUploadedFileFactory());
return $factory->createUploadedFile(...\func_get_args());
}
public function createUri(string $uri = ''): UriInterface
{
$factory = $this->uriFactory ?? $this->setFactory(Psr17FactoryDiscovery::findUriFactory());
return $factory->createUri(...\func_get_args());
}
public function createUriFromGlobals(array $server = null): UriInterface
{
return $this->buildUriFromGlobals($this->createUri(''), $server ?? $_SERVER);
}
private function setFactory($factory)
{
if (!$this->requestFactory && $factory instanceof RequestFactoryInterface) {
$this->requestFactory = $factory;
}
if (!$this->responseFactory && $factory instanceof ResponseFactoryInterface) {
$this->responseFactory = $factory;
}
if (!$this->serverRequestFactory && $factory instanceof ServerRequestFactoryInterface) {
$this->serverRequestFactory = $factory;
}
if (!$this->streamFactory && $factory instanceof StreamFactoryInterface) {
$this->streamFactory = $factory;
}
if (!$this->uploadedFileFactory && $factory instanceof UploadedFileFactoryInterface) {
$this->uploadedFileFactory = $factory;
}
if (!$this->uriFactory && $factory instanceof UriFactoryInterface) {
$this->uriFactory = $factory;
}
return $factory;
}
private function buildServerRequestFromGlobals(ServerRequestInterface $request, array $server, array $files): ServerRequestInterface
{
$request = $request
->withProtocolVersion(isset($server['SERVER_PROTOCOL']) ? str_replace('HTTP/', '', $server['SERVER_PROTOCOL']) : '1.1')
->withUploadedFiles($this->normalizeFiles($files));
$headers = [];
foreach ($server as $k => $v) {
if (0 === strpos($k, 'HTTP_')) {
$k = substr($k, 5);
} elseif (!\in_array($k, ['CONTENT_TYPE', 'CONTENT_LENGTH', 'CONTENT_MD5'], true)) {
continue;
}
$k = str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', $k))));
$headers[$k] = $v;
}
if (!isset($headers['Authorization'])) {
if (isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) {
$headers['Authorization'] = $_SERVER['REDIRECT_HTTP_AUTHORIZATION'];
} elseif (isset($_SERVER['PHP_AUTH_USER'])) {
$headers['Authorization'] = 'Basic '.base64_encode($_SERVER['PHP_AUTH_USER'].':'.($_SERVER['PHP_AUTH_PW'] ?? ''));
} elseif (isset($_SERVER['PHP_AUTH_DIGEST'])) {
$headers['Authorization'] = $_SERVER['PHP_AUTH_DIGEST'];
}
}
foreach ($headers as $k => $v) {
try {
$request = $request->withHeader($k, $v);
} catch (\InvalidArgumentException $e) {
// ignore invalid headers
}
}
return $request;
}
private function buildUriFromGlobals(UriInterface $uri, array $server): UriInterface
{
$uri = $uri->withScheme(!empty($server['HTTPS']) && 'off' !== strtolower($server['HTTPS']) ? 'https' : 'http');
$hasPort = false;
if (isset($server['HTTP_HOST'])) {
$parts = parse_url('http://'.$server['HTTP_HOST']);
$uri = $uri->withHost($parts['host'] ?? 'localhost');
if ($parts['port'] ?? false) {
$hasPort = true;
$uri = $uri->withPort($parts['port']);
}
} else {
$uri = $uri->withHost($server['SERVER_NAME'] ?? $server['SERVER_ADDR'] ?? 'localhost');
}
if (!$hasPort && isset($server['SERVER_PORT'])) {
$uri = $uri->withPort($server['SERVER_PORT']);
}
$hasQuery = false;
if (isset($server['REQUEST_URI'])) {
$requestUriParts = explode('?', $server['REQUEST_URI'], 2);
$uri = $uri->withPath($requestUriParts[0]);
if (isset($requestUriParts[1])) {
$hasQuery = true;
$uri = $uri->withQuery($requestUriParts[1]);
}
}
if (!$hasQuery && isset($server['QUERY_STRING'])) {
$uri = $uri->withQuery($server['QUERY_STRING']);
}
return $uri;
}
private function normalizeFiles(array $files): array
{
foreach ($files as $k => $v) {
if ($v instanceof UploadedFileInterface) {
continue;
}
if (!\is_array($v)) {
unset($files[$k]);
} elseif (!isset($v['tmp_name'])) {
$files[$k] = $this->normalizeFiles($v);
} else {
$files[$k] = $this->createUploadedFileFromSpec($v);
}
}
return $files;
}
/**
* Create and return an UploadedFile instance from a $_FILES specification.
*
* @param array $value $_FILES struct
*
* @return UploadedFileInterface|UploadedFileInterface[]
*/
private function createUploadedFileFromSpec(array $value)
{
if (!is_array($tmpName = $value['tmp_name'])) {
$file = is_file($tmpName) ? $this->createStreamFromFile($tmpName, 'r') : $this->createStream();
return $this->createUploadedFile($file, $value['size'], $value['error'], $value['name'], $value['type']);
}
foreach ($tmpName as $k => $v) {
$tmpName[$k] = $this->createUploadedFileFromSpec([
'tmp_name' => $v,
'size' => $value['size'][$k] ?? null,
'error' => $value['error'][$k] ?? null,
'name' => $value['name'][$k] ?? null,
'type' => $value['type'][$k] ?? null,
]);
}
return $tmpName;
}
}
@@ -0,0 +1,136 @@
<?php
namespace Http\Discovery;
use Http\Discovery\Exception\DiscoveryFailedException;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ServerRequestFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\UploadedFileFactoryInterface;
use Psr\Http\Message\UriFactoryInterface;
/**
* Finds PSR-17 factories.
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
final class Psr17FactoryDiscovery extends ClassDiscovery
{
private static function createException($type, Exception $e)
{
return new \Http\Discovery\Exception\NotFoundException(
'No PSR-17 '.$type.' found. Install a package from this list: https://packagist.org/providers/psr/http-factory-implementation',
0,
$e
);
}
/**
* @return RequestFactoryInterface
*
* @throws Exception\NotFoundException
*/
public static function findRequestFactory()
{
try {
$messageFactory = static::findOneByType(RequestFactoryInterface::class);
} catch (DiscoveryFailedException $e) {
throw self::createException('request factory', $e);
}
return static::instantiateClass($messageFactory);
}
/**
* @return ResponseFactoryInterface
*
* @throws Exception\NotFoundException
*/
public static function findResponseFactory()
{
try {
$messageFactory = static::findOneByType(ResponseFactoryInterface::class);
} catch (DiscoveryFailedException $e) {
throw self::createException('response factory', $e);
}
return static::instantiateClass($messageFactory);
}
/**
* @return ServerRequestFactoryInterface
*
* @throws Exception\NotFoundException
*/
public static function findServerRequestFactory()
{
try {
$messageFactory = static::findOneByType(ServerRequestFactoryInterface::class);
} catch (DiscoveryFailedException $e) {
throw self::createException('server request factory', $e);
}
return static::instantiateClass($messageFactory);
}
/**
* @return StreamFactoryInterface
*
* @throws Exception\NotFoundException
*/
public static function findStreamFactory()
{
try {
$messageFactory = static::findOneByType(StreamFactoryInterface::class);
} catch (DiscoveryFailedException $e) {
throw self::createException('stream factory', $e);
}
return static::instantiateClass($messageFactory);
}
/**
* @return UploadedFileFactoryInterface
*
* @throws Exception\NotFoundException
*/
public static function findUploadedFileFactory()
{
try {
$messageFactory = static::findOneByType(UploadedFileFactoryInterface::class);
} catch (DiscoveryFailedException $e) {
throw self::createException('uploaded file factory', $e);
}
return static::instantiateClass($messageFactory);
}
/**
* @return UriFactoryInterface
*
* @throws Exception\NotFoundException
*/
public static function findUriFactory()
{
try {
$messageFactory = static::findOneByType(UriFactoryInterface::class);
} catch (DiscoveryFailedException $e) {
throw self::createException('url factory', $e);
}
return static::instantiateClass($messageFactory);
}
/**
* @return UriFactoryInterface
*
* @throws Exception\NotFoundException
*
* @deprecated This will be removed in 2.0. Consider using the findUriFactory() method.
*/
public static function findUrlFactory()
{
return static::findUriFactory();
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace Http\Discovery;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\UploadedFileFactoryInterface;
use Psr\Http\Message\UriFactoryInterface;
/**
* A generic PSR-18 and PSR-17 implementation.
*
* You can create this class with concrete client and factory instances
* or let it use discovery to find suitable implementations as needed.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
class Psr18Client extends Psr17Factory implements ClientInterface
{
private $client;
public function __construct(
ClientInterface $client = null,
RequestFactoryInterface $requestFactory = null,
ResponseFactoryInterface $responseFactory = null,
ServerRequestFactoryInterface $serverRequestFactory = null,
StreamFactoryInterface $streamFactory = null,
UploadedFileFactoryInterface $uploadedFileFactory = null,
UriFactoryInterface $uriFactory = null
) {
parent::__construct($requestFactory, $responseFactory, $serverRequestFactory, $streamFactory, $uploadedFileFactory, $uriFactory);
$this->client = $client ?? Psr18ClientDiscovery::find();
}
public function sendRequest(RequestInterface $request): ResponseInterface
{
return $this->client->sendRequest($request);
}
}
@@ -0,0 +1,32 @@
<?php
namespace Http\Discovery;
use Http\Discovery\Exception\DiscoveryFailedException;
use Psr\Http\Client\ClientInterface;
/**
* Finds a PSR-18 HTTP Client.
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
final class Psr18ClientDiscovery extends ClassDiscovery
{
/**
* Finds a PSR-18 HTTP Client.
*
* @return ClientInterface
*
* @throws Exception\NotFoundException
*/
public static function find()
{
try {
$client = static::findOneByType(ClientInterface::class);
} catch (DiscoveryFailedException $e) {
throw new \Http\Discovery\Exception\NotFoundException('No PSR-18 clients found. Make sure to install a package providing "psr/http-client-implementation". Example: "php-http/guzzle7-adapter".', 0, $e);
}
return static::instantiateClass($client);
}
}
@@ -0,0 +1,185 @@
<?php
namespace Http\Discovery\Strategy;
use GuzzleHttp\Client as GuzzleHttp;
use GuzzleHttp\Promise\Promise;
use GuzzleHttp\Psr7\Request as GuzzleRequest;
use Http\Adapter\Artax\Client as Artax;
use Http\Adapter\Buzz\Client as Buzz;
use Http\Adapter\Cake\Client as Cake;
use Http\Adapter\Guzzle5\Client as Guzzle5;
use Http\Adapter\Guzzle6\Client as Guzzle6;
use Http\Adapter\Guzzle7\Client as Guzzle7;
use Http\Adapter\React\Client as React;
use Http\Client\Curl\Client as Curl;
use Http\Client\HttpAsyncClient;
use Http\Client\HttpClient;
use Http\Client\Socket\Client as Socket;
use Http\Discovery\ClassDiscovery;
use Http\Discovery\Exception\NotFoundException;
use Http\Discovery\Psr17FactoryDiscovery;
use Http\Message\MessageFactory;
use Http\Message\MessageFactory\DiactorosMessageFactory;
use Http\Message\MessageFactory\GuzzleMessageFactory;
use Http\Message\MessageFactory\SlimMessageFactory;
use Http\Message\StreamFactory;
use Http\Message\StreamFactory\DiactorosStreamFactory;
use Http\Message\StreamFactory\GuzzleStreamFactory;
use Http\Message\StreamFactory\SlimStreamFactory;
use Http\Message\UriFactory;
use Http\Message\UriFactory\DiactorosUriFactory;
use Http\Message\UriFactory\GuzzleUriFactory;
use Http\Message\UriFactory\SlimUriFactory;
use Laminas\Diactoros\Request as DiactorosRequest;
use Nyholm\Psr7\Factory\HttplugFactory as NyholmHttplugFactory;
use Psr\Http\Client\ClientInterface as Psr18Client;
use Psr\Http\Message\RequestFactoryInterface as Psr17RequestFactory;
use Slim\Http\Request as SlimRequest;
use Symfony\Component\HttpClient\HttplugClient as SymfonyHttplug;
use Symfony\Component\HttpClient\Psr18Client as SymfonyPsr18;
/**
* @internal
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*
* Don't miss updating src/Composer/Plugin.php when adding a new supported class.
*/
final class CommonClassesStrategy implements DiscoveryStrategy
{
/**
* @var array
*/
private static $classes = [
MessageFactory::class => [
['class' => NyholmHttplugFactory::class, 'condition' => [NyholmHttplugFactory::class]],
['class' => GuzzleMessageFactory::class, 'condition' => [GuzzleRequest::class, GuzzleMessageFactory::class]],
['class' => DiactorosMessageFactory::class, 'condition' => [DiactorosRequest::class, DiactorosMessageFactory::class]],
['class' => SlimMessageFactory::class, 'condition' => [SlimRequest::class, SlimMessageFactory::class]],
],
StreamFactory::class => [
['class' => NyholmHttplugFactory::class, 'condition' => [NyholmHttplugFactory::class]],
['class' => GuzzleStreamFactory::class, 'condition' => [GuzzleRequest::class, GuzzleStreamFactory::class]],
['class' => DiactorosStreamFactory::class, 'condition' => [DiactorosRequest::class, DiactorosStreamFactory::class]],
['class' => SlimStreamFactory::class, 'condition' => [SlimRequest::class, SlimStreamFactory::class]],
],
UriFactory::class => [
['class' => NyholmHttplugFactory::class, 'condition' => [NyholmHttplugFactory::class]],
['class' => GuzzleUriFactory::class, 'condition' => [GuzzleRequest::class, GuzzleUriFactory::class]],
['class' => DiactorosUriFactory::class, 'condition' => [DiactorosRequest::class, DiactorosUriFactory::class]],
['class' => SlimUriFactory::class, 'condition' => [SlimRequest::class, SlimUriFactory::class]],
],
HttpAsyncClient::class => [
['class' => SymfonyHttplug::class, 'condition' => [SymfonyHttplug::class, Promise::class, [self::class, 'isPsr17FactoryInstalled']]],
['class' => Guzzle7::class, 'condition' => Guzzle7::class],
['class' => Guzzle6::class, 'condition' => Guzzle6::class],
['class' => Curl::class, 'condition' => Curl::class],
['class' => React::class, 'condition' => React::class],
],
HttpClient::class => [
['class' => SymfonyHttplug::class, 'condition' => [SymfonyHttplug::class, [self::class, 'isPsr17FactoryInstalled'], [self::class, 'isSymfonyImplementingHttpClient']]],
['class' => Guzzle7::class, 'condition' => Guzzle7::class],
['class' => Guzzle6::class, 'condition' => Guzzle6::class],
['class' => Guzzle5::class, 'condition' => Guzzle5::class],
['class' => Curl::class, 'condition' => Curl::class],
['class' => Socket::class, 'condition' => Socket::class],
['class' => Buzz::class, 'condition' => Buzz::class],
['class' => React::class, 'condition' => React::class],
['class' => Cake::class, 'condition' => Cake::class],
['class' => Artax::class, 'condition' => Artax::class],
[
'class' => [self::class, 'buzzInstantiate'],
'condition' => [\Buzz\Client\FileGetContents::class, \Buzz\Message\ResponseBuilder::class],
],
],
Psr18Client::class => [
[
'class' => [self::class, 'symfonyPsr18Instantiate'],
'condition' => [SymfonyPsr18::class, Psr17RequestFactory::class],
],
[
'class' => GuzzleHttp::class,
'condition' => [self::class, 'isGuzzleImplementingPsr18'],
],
[
'class' => [self::class, 'buzzInstantiate'],
'condition' => [\Buzz\Client\FileGetContents::class, \Buzz\Message\ResponseBuilder::class],
],
],
];
public static function getCandidates($type)
{
if (Psr18Client::class === $type) {
return self::getPsr18Candidates();
}
return self::$classes[$type] ?? [];
}
/**
* @return array The return value is always an array with zero or more elements. Each
* element is an array with two keys ['class' => string, 'condition' => mixed].
*/
private static function getPsr18Candidates()
{
$candidates = self::$classes[Psr18Client::class];
// HTTPlug 2.0 clients implements PSR18Client too.
foreach (self::$classes[HttpClient::class] as $c) {
if (!is_string($c['class'])) {
continue;
}
try {
if (ClassDiscovery::safeClassExists($c['class']) && is_subclass_of($c['class'], Psr18Client::class)) {
$candidates[] = $c;
}
} catch (\Throwable $e) {
trigger_error(sprintf('Got exception "%s (%s)" while checking if a PSR-18 Client is available', get_class($e), $e->getMessage()), E_USER_WARNING);
}
}
return $candidates;
}
public static function buzzInstantiate()
{
return new \Buzz\Client\FileGetContents(Psr17FactoryDiscovery::findResponseFactory());
}
public static function symfonyPsr18Instantiate()
{
return new SymfonyPsr18(null, Psr17FactoryDiscovery::findResponseFactory(), Psr17FactoryDiscovery::findStreamFactory());
}
public static function isGuzzleImplementingPsr18()
{
return defined('GuzzleHttp\ClientInterface::MAJOR_VERSION');
}
public static function isSymfonyImplementingHttpClient()
{
return is_subclass_of(SymfonyHttplug::class, HttpClient::class);
}
/**
* Can be used as a condition.
*
* @return bool
*/
public static function isPsr17FactoryInstalled()
{
try {
Psr17FactoryDiscovery::findResponseFactory();
} catch (NotFoundException $e) {
return false;
} catch (\Throwable $e) {
trigger_error(sprintf('Got exception "%s (%s)" while checking if a PSR-17 ResponseFactory is available', get_class($e), $e->getMessage()), E_USER_WARNING);
return false;
}
return true;
}
}
@@ -0,0 +1,104 @@
<?php
namespace Http\Discovery\Strategy;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ServerRequestFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\UploadedFileFactoryInterface;
use Psr\Http\Message\UriFactoryInterface;
/**
* @internal
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*
* Don't miss updating src/Composer/Plugin.php when adding a new supported class.
*/
final class CommonPsr17ClassesStrategy implements DiscoveryStrategy
{
/**
* @var array
*/
private static $classes = [
RequestFactoryInterface::class => [
'Phalcon\Http\Message\RequestFactory',
'Nyholm\Psr7\Factory\Psr17Factory',
'GuzzleHttp\Psr7\HttpFactory',
'Http\Factory\Diactoros\RequestFactory',
'Http\Factory\Guzzle\RequestFactory',
'Http\Factory\Slim\RequestFactory',
'Laminas\Diactoros\RequestFactory',
'Slim\Psr7\Factory\RequestFactory',
'HttpSoft\Message\RequestFactory',
],
ResponseFactoryInterface::class => [
'Phalcon\Http\Message\ResponseFactory',
'Nyholm\Psr7\Factory\Psr17Factory',
'GuzzleHttp\Psr7\HttpFactory',
'Http\Factory\Diactoros\ResponseFactory',
'Http\Factory\Guzzle\ResponseFactory',
'Http\Factory\Slim\ResponseFactory',
'Laminas\Diactoros\ResponseFactory',
'Slim\Psr7\Factory\ResponseFactory',
'HttpSoft\Message\ResponseFactory',
],
ServerRequestFactoryInterface::class => [
'Phalcon\Http\Message\ServerRequestFactory',
'Nyholm\Psr7\Factory\Psr17Factory',
'GuzzleHttp\Psr7\HttpFactory',
'Http\Factory\Diactoros\ServerRequestFactory',
'Http\Factory\Guzzle\ServerRequestFactory',
'Http\Factory\Slim\ServerRequestFactory',
'Laminas\Diactoros\ServerRequestFactory',
'Slim\Psr7\Factory\ServerRequestFactory',
'HttpSoft\Message\ServerRequestFactory',
],
StreamFactoryInterface::class => [
'Phalcon\Http\Message\StreamFactory',
'Nyholm\Psr7\Factory\Psr17Factory',
'GuzzleHttp\Psr7\HttpFactory',
'Http\Factory\Diactoros\StreamFactory',
'Http\Factory\Guzzle\StreamFactory',
'Http\Factory\Slim\StreamFactory',
'Laminas\Diactoros\StreamFactory',
'Slim\Psr7\Factory\StreamFactory',
'HttpSoft\Message\StreamFactory',
],
UploadedFileFactoryInterface::class => [
'Phalcon\Http\Message\UploadedFileFactory',
'Nyholm\Psr7\Factory\Psr17Factory',
'GuzzleHttp\Psr7\HttpFactory',
'Http\Factory\Diactoros\UploadedFileFactory',
'Http\Factory\Guzzle\UploadedFileFactory',
'Http\Factory\Slim\UploadedFileFactory',
'Laminas\Diactoros\UploadedFileFactory',
'Slim\Psr7\Factory\UploadedFileFactory',
'HttpSoft\Message\UploadedFileFactory',
],
UriFactoryInterface::class => [
'Phalcon\Http\Message\UriFactory',
'Nyholm\Psr7\Factory\Psr17Factory',
'GuzzleHttp\Psr7\HttpFactory',
'Http\Factory\Diactoros\UriFactory',
'Http\Factory\Guzzle\UriFactory',
'Http\Factory\Slim\UriFactory',
'Laminas\Diactoros\UriFactory',
'Slim\Psr7\Factory\UriFactory',
'HttpSoft\Message\UriFactory',
],
];
public static function getCandidates($type)
{
$candidates = [];
if (isset(self::$classes[$type])) {
foreach (self::$classes[$type] as $class) {
$candidates[] = ['class' => $class, 'condition' => [$class]];
}
}
return $candidates;
}
}
@@ -0,0 +1,23 @@
<?php
namespace Http\Discovery\Strategy;
use Http\Discovery\Exception\StrategyUnavailableException;
/**
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
interface DiscoveryStrategy
{
/**
* Find a resource of a specific type.
*
* @param string $type
*
* @return array The return value is always an array with zero or more elements. Each
* element is an array with two keys ['class' => string, 'condition' => mixed].
*
* @throws StrategyUnavailableException if we cannot use this strategy
*/
public static function getCandidates($type);
}
@@ -0,0 +1,24 @@
<?php
namespace Http\Discovery\Strategy;
use Http\Client\HttpAsyncClient;
use Http\Client\HttpClient;
use Http\Mock\Client as Mock;
/**
* Find the Mock client.
*
* @author Sam Rapaport <me@samrapdev.com>
*/
final class MockClientStrategy implements DiscoveryStrategy
{
public static function getCandidates($type)
{
if (is_a(HttpClient::class, $type, true) || is_a(HttpAsyncClient::class, $type, true)) {
return [['class' => Mock::class, 'condition' => Mock::class]];
}
return [];
}
}
@@ -0,0 +1,90 @@
<?php
namespace Http\Discovery\Strategy;
use Http\Discovery\ClassDiscovery;
use Http\Discovery\Exception\PuliUnavailableException;
use Puli\Discovery\Api\Discovery;
use Puli\GeneratedPuliFactory;
/**
* Find candidates using Puli.
*
* @internal
*
* @final
*
* @author David de Boer <david@ddeboer.nl>
* @author Márk Sági-Kazár <mark.sagikazar@gmail.com>
*/
class PuliBetaStrategy implements DiscoveryStrategy
{
/**
* @var GeneratedPuliFactory
*/
protected static $puliFactory;
/**
* @var Discovery
*/
protected static $puliDiscovery;
/**
* @return GeneratedPuliFactory
*
* @throws PuliUnavailableException
*/
private static function getPuliFactory()
{
if (null === self::$puliFactory) {
if (!defined('PULI_FACTORY_CLASS')) {
throw new PuliUnavailableException('Puli Factory is not available');
}
$puliFactoryClass = PULI_FACTORY_CLASS;
if (!ClassDiscovery::safeClassExists($puliFactoryClass)) {
throw new PuliUnavailableException('Puli Factory class does not exist');
}
self::$puliFactory = new $puliFactoryClass();
}
return self::$puliFactory;
}
/**
* Returns the Puli discovery layer.
*
* @return Discovery
*
* @throws PuliUnavailableException
*/
private static function getPuliDiscovery()
{
if (!isset(self::$puliDiscovery)) {
$factory = self::getPuliFactory();
$repository = $factory->createRepository();
self::$puliDiscovery = $factory->createDiscovery($repository);
}
return self::$puliDiscovery;
}
public static function getCandidates($type)
{
$returnData = [];
$bindings = self::getPuliDiscovery()->findBindings($type);
foreach ($bindings as $binding) {
$condition = true;
if ($binding->hasParameterValue('depends')) {
$condition = $binding->getParameterValue('depends');
}
$returnData[] = ['class' => $binding->getClassName(), 'condition' => $condition];
}
return $returnData;
}
}
@@ -0,0 +1,34 @@
<?php
namespace Http\Discovery;
use Http\Discovery\Exception\DiscoveryFailedException;
use Http\Message\StreamFactory;
/**
* Finds a Stream Factory.
*
* @author Михаил Красильников <m.krasilnikov@yandex.ru>
*
* @deprecated This will be removed in 2.0. Consider using Psr17FactoryDiscovery.
*/
final class StreamFactoryDiscovery extends ClassDiscovery
{
/**
* Finds a Stream Factory.
*
* @return StreamFactory
*
* @throws Exception\NotFoundException
*/
public static function find()
{
try {
$streamFactory = static::findOneByType(StreamFactory::class);
} catch (DiscoveryFailedException $e) {
throw new NotFoundException('No stream factories found. To use Guzzle, Diactoros or Slim Framework factories install php-http/message and the chosen message implementation.', 0, $e);
}
return static::instantiateClass($streamFactory);
}
}
@@ -0,0 +1,34 @@
<?php
namespace Http\Discovery;
use Http\Discovery\Exception\DiscoveryFailedException;
use Http\Message\UriFactory;
/**
* Finds a URI Factory.
*
* @author David de Boer <david@ddeboer.nl>
*
* @deprecated This will be removed in 2.0. Consider using Psr17FactoryDiscovery.
*/
final class UriFactoryDiscovery extends ClassDiscovery
{
/**
* Finds a URI Factory.
*
* @return UriFactory
*
* @throws Exception\NotFoundException
*/
public static function find()
{
try {
$uriFactory = static::findOneByType(UriFactory::class);
} catch (DiscoveryFailedException $e) {
throw new NotFoundException('No uri factories found. To use Guzzle, Diactoros or Slim Framework factories install php-http/message and the chosen message implementation.', 0, $e);
}
return static::instantiateClass($uriFactory);
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
$finder = PhpCsFixer\Finder::create()
->in(__DIR__.'/src')
->name('*.php')
;
$config = (new PhpCsFixer\Config())
->setRiskyAllowed(true)
->setRules([
'@Symfony' => true,
])
->setFinder($finder)
;
return $config;
+141
View File
@@ -0,0 +1,141 @@
# Change Log
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
## [2.4.0] - 2023-04-14
### Changed
- Allow `psr/http-message` v2 in addition to v1
- Deprecate `Http\Client\HttpClient`, use [PSR-18](https://www.php-fig.org/psr/psr-18/) instead
## [2.3.0] - 2022-02-21
### Changed
- Enabled the `$onRejected` callback of `HttpRejectedPromise` to return a promise for implementing a retry
mechanism [#168](https://github.com/php-http/httplug/pull/168)
## [2.2.0] - 2020-07-13
### Changed
- Support PHP 7.1-8.0
## [2.1.0] - 2019-12-27
### Changed
- `Http\Client\Exception\NetworkException` no longer extends `Http\Client\Exception\RequestException`,
in accordance with [PSR-18](https://www.php-fig.org/psr/psr-18/)
## [2.0.0] - 2018-10-31
This version is no BC break for consumers using HTTPlug. However, HTTP clients that
implement HTTPlug need to adjust because we add return type declarations.
### Added
- Support for PSR-18 (HTTP client).
### Changed
- **BC Break:** `HttpClient::sendRequest(RequestInterface $request)` has a return type annotation. The new
signature is `HttpClient::sendRequest(RequestInterface $request): ResponseInterface`.
- **BC Break:** `RequestException::getRequest()` has a return type annotation. The new
signature is `RequestException::getRequest(): RequestInterface`.
### Removed
- PHP 5 support
## [1.1.0] - 2016-08-31
### Added
- HttpFulfilledPromise and HttpRejectedPromise which respect the HttpAsyncClient interface
## [1.0.0] - 2016-01-26
### Removed
- Stability configuration from composer
## [1.0.0-RC1] - 2016-01-12
### Changed
- Updated package files
- Updated promise dependency to RC1
## [1.0.0-beta] - 2015-12-17
### Added
- Puli configuration and binding types
### Changed
- Exception concept
## [1.0.0-alpha3] - 2015-12-13
### Changed
- Async client does not throw exceptions
### Removed
- Promise interface moved to its own repository: [php-http/promise](https://github.com/php-http/promise)
## [1.0.0-alpha2] - 2015-11-16
### Added
- Async client and Promise interface
## [1.0.0-alpha] - 2015-10-26
### Added
- Better domain exceptions.
### Changed
- Purpose of the library: general HTTP CLient abstraction.
### Removed
- Request options: they should be configured at construction time.
- Multiple request sending: should be done asynchronously using Async Client.
- `getName` method
## 0.1.0 - 2015-06-03
### Added
- Initial release
[Unreleased]: https://github.com/php-http/httplug/compare/v2.0.0...HEAD
[2.0.0]: https://github.com/php-http/httplug/compare/v1.1.0...HEAD
[1.1.0]: https://github.com/php-http/httplug/compare/v1.0.0...v1.1.0
[1.0.0]: https://github.com/php-http/httplug/compare/v1.0.0-RC1...v1.0.0
[1.0.0-RC1]: https://github.com/php-http/httplug/compare/v1.0.0-beta...v1.0.0-RC1
[1.0.0-beta]: https://github.com/php-http/httplug/compare/v1.0.0-alpha3...v1.0.0-beta
[1.0.0-alpha3]: https://github.com/php-http/httplug/compare/v1.0.0-alpha2...v1.0.0-alpha3
[1.0.0-alpha2]: https://github.com/php-http/httplug/compare/v1.0.0-alpha...v1.0.0-alpha2
[1.0.0-alpha]: https://github.com/php-http/httplug/compare/v0.1.0...v1.0.0-alpha
+20
View File
@@ -0,0 +1,20 @@
Copyright (c) 2014 Eric GELOEN <geloen.eric@gmail.com>
Copyright (c) 2015 PHP HTTP Team <team@php-http.org>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+54
View File
@@ -0,0 +1,54 @@
# HTTPlug
[![Latest Version](https://img.shields.io/github/release/php-http/httplug.svg?style=flat-square)](https://github.com/php-http/httplug/releases)
[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE)
[![Build Status](https://github.com/php-http/httplug/actions/workflows/ci.yml/badge.svg)](https://github.com/php-http/httplug/actions/workflows/ci.yml)
[![Code Coverage](https://img.shields.io/scrutinizer/coverage/g/php-http/httplug.svg?style=flat-square)](https://scrutinizer-ci.com/g/php-http/httplug)
[![Quality Score](https://img.shields.io/scrutinizer/g/php-http/httplug.svg?style=flat-square)](https://scrutinizer-ci.com/g/php-http/httplug)
[![Total Downloads](https://img.shields.io/packagist/dt/php-http/httplug.svg?style=flat-square)](https://packagist.org/packages/php-http/httplug)
[![Email](https://img.shields.io/badge/email-team@httplug.io-blue.svg?style=flat-square)](mailto:team@httplug.io)
**HTTPlug, the HTTP client abstraction for PHP.**
## Intro
HTTP client standard built on [PSR-7](http://www.php-fig.org/psr/psr-7/) HTTP
messages. The HttpAsyncClient defines an asynchronous HTTP client for PHP.
This package also provides a synchronous HttpClient interface with the same
method signature as the [PSR-18](http://www.php-fig.org/psr/psr-18/) client.
For synchronous requests, we recommend using PSR-18 directly.
## History
HTTPlug is the official successor of the [ivory http adapter](https://github.com/egeloen/ivory-http-adapter).
HTTPlug is a predecessor of [PSR-18](http://www.php-fig.org/psr/psr-18/)
## Install
Via Composer
``` bash
$ composer require php-http/httplug
```
## Documentation
Please see the [official documentation](http://docs.php-http.org).
## Testing
``` bash
$ composer test
```
## License
The MIT License (MIT). Please see [License File](LICENSE) for more information.
+40
View File
@@ -0,0 +1,40 @@
{
"name": "php-http/httplug",
"description": "HTTPlug, the HTTP client abstraction for PHP",
"keywords": [
"http",
"client"
],
"homepage": "http://httplug.io",
"license": "MIT",
"authors": [
{
"name": "Eric GELOEN",
"email": "geloen.eric@gmail.com"
},
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com",
"homepage": "https://sagikazarmark.hu"
}
],
"require": {
"php": "^7.1 || ^8.0",
"php-http/promise": "^1.1",
"psr/http-client": "^1.0",
"psr/http-message": "^1.0 || ^2.0"
},
"require-dev": {
"friends-of-phpspec/phpspec-code-coverage": "^4.1 || ^5.0 || ^6.0",
"phpspec/phpspec": "^5.1 || ^6.0 || ^7.0"
},
"autoload": {
"psr-4": {
"Http\\Client\\": "src/"
}
},
"scripts": {
"test": "vendor/bin/phpspec run",
"test-ci": "vendor/bin/phpspec run -c phpspec.ci.yml"
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"version": "1.0",
"name": "php-http/httplug",
"binding-types": {
"Http\\Client\\HttpAsyncClient": {
"description": "Async HTTP Client"
},
"Http\\Client\\HttpClient": {
"description": "HTTP Client"
}
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace Http\Client;
use Psr\Http\Client\ClientExceptionInterface as PsrClientException;
/**
* Every HTTP Client related Exception must implement this interface.
*
* @author Márk Sági-Kazár <mark.sagikazar@gmail.com>
*/
interface Exception extends PsrClientException
{
}

Some files were not shown because too many files have changed in this diff Show More