version 1.5
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user