Subida del módulo y tema de PrestaShop

This commit is contained in:
Kaloyan
2026-04-09 18:31:51 +02:00
parent 12c253296f
commit 16b3ff9424
39262 changed files with 7418797 additions and 0 deletions

View File

@@ -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)
;

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.

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;
}
}

View File

@@ -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;
}

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;
}
}

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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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
{
}

View File

@@ -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
{
}

View File

@@ -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;
}
}

View File

@@ -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
{
}

View File

@@ -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
{
}

View File

@@ -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
{
}

View File

@@ -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
{
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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');
}
}

View File

@@ -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);
}
}

View File

@@ -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)];
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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;
}

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;
}

View File

@@ -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');
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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];
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
});
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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));
});
}
}

View File

@@ -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();
}
}

View File

@@ -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'];
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Http\Client\Common;
use function array_reverse;
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);
}
}

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);
}
}

View File

@@ -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
);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

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;

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.

View File

@@ -0,0 +1,252 @@
<?php
namespace Http\Discovery;
use Http\Discovery\Exception\ClassInstantiationFailedException;
use Http\Discovery\Exception\DiscoveryFailedException;
use Http\Discovery\Exception\NoCandidateFoundException;
use Http\Discovery\Exception\StrategyUnavailableException;
/**
* 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 array
*/
private static $strategies = [
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;
}
$exceptions = [];
foreach (self::$strategies as $strategy) {
try {
$candidates = call_user_func($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 array $strategies string array of fully qualified class name to a 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 to 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();
}
/**
* Clear the cache.
*/
public static function clearCache()
{
self::$cache = [];
}
/**
* Evaluates conditions to boolean.
*
* @param mixed $condition
*
* @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 want to do 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 will catch all potential exceptions and make sure it returns a boolean.
*
* @param string $class
* @param bool $autoload
*
* @return bool
*/
public static function safeClassExists($class)
{
try {
return class_exists($class) || interface_exists($class);
} catch (\Exception $e) {
return false;
}
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace Http\Discovery;
use Throwable;
/**
* An interface implemented by all discovery related exceptions.
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
interface Exception extends Throwable
{
}

View File

@@ -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
{
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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
{
}

View File

@@ -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
{
}

View File

@@ -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
{
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,32 @@
<?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>
*/
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);
}
}

View File

@@ -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 message factories found. To use Guzzle, Diactoros or Slim Framework factories install php-http/message and the chosen message implementation.', 0, $e);
}
return static::instantiateClass($messageFactory);
}
}

View File

@@ -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
{
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,189 @@
<?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\Adapter\Zend\Client as Zend;
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\MessageFactoryDiscovery;
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\RequestFactory;
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;
use Zend\Diactoros\Request as ZendDiactorosRequest;
/**
* @internal
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
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' => [ZendDiactorosRequest::class, DiactorosMessageFactory::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' => [ZendDiactorosRequest::class, DiactorosStreamFactory::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' => [ZendDiactorosRequest::class, DiactorosUriFactory::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, RequestFactory::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, RequestFactory::class, [self::class, 'isPsr17FactoryInstalled']]],
['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' => Zend::class, 'condition' => Zend::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],
],
],
];
/**
* {@inheritdoc}
*/
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(MessageFactoryDiscovery::find());
}
public static function symfonyPsr18Instantiate()
{
return new SymfonyPsr18(null, Psr17FactoryDiscovery::findResponseFactory(), Psr17FactoryDiscovery::findStreamFactory());
}
public static function isGuzzleImplementingPsr18()
{
return defined('GuzzleHttp\ClientInterface::MAJOR_VERSION');
}
/**
* 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;
}
}

View File

@@ -0,0 +1,105 @@
<?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>
*/
final class CommonPsr17ClassesStrategy implements DiscoveryStrategy
{
/**
* @var array
*/
private static $classes = [
RequestFactoryInterface::class => [
'Phalcon\Http\Message\RequestFactory',
'Nyholm\Psr7\Factory\Psr17Factory',
'Zend\Diactoros\RequestFactory',
'GuzzleHttp\Psr7\HttpFactory',
'Http\Factory\Diactoros\RequestFactory',
'Http\Factory\Guzzle\RequestFactory',
'Http\Factory\Slim\RequestFactory',
'Laminas\Diactoros\RequestFactory',
'Slim\Psr7\Factory\RequestFactory',
],
ResponseFactoryInterface::class => [
'Phalcon\Http\Message\ResponseFactory',
'Nyholm\Psr7\Factory\Psr17Factory',
'Zend\Diactoros\ResponseFactory',
'GuzzleHttp\Psr7\HttpFactory',
'Http\Factory\Diactoros\ResponseFactory',
'Http\Factory\Guzzle\ResponseFactory',
'Http\Factory\Slim\ResponseFactory',
'Laminas\Diactoros\ResponseFactory',
'Slim\Psr7\Factory\ResponseFactory',
],
ServerRequestFactoryInterface::class => [
'Phalcon\Http\Message\ServerRequestFactory',
'Nyholm\Psr7\Factory\Psr17Factory',
'Zend\Diactoros\ServerRequestFactory',
'GuzzleHttp\Psr7\HttpFactory',
'Http\Factory\Diactoros\ServerRequestFactory',
'Http\Factory\Guzzle\ServerRequestFactory',
'Http\Factory\Slim\ServerRequestFactory',
'Laminas\Diactoros\ServerRequestFactory',
'Slim\Psr7\Factory\ServerRequestFactory',
],
StreamFactoryInterface::class => [
'Phalcon\Http\Message\StreamFactory',
'Nyholm\Psr7\Factory\Psr17Factory',
'Zend\Diactoros\StreamFactory',
'GuzzleHttp\Psr7\HttpFactory',
'Http\Factory\Diactoros\StreamFactory',
'Http\Factory\Guzzle\StreamFactory',
'Http\Factory\Slim\StreamFactory',
'Laminas\Diactoros\StreamFactory',
'Slim\Psr7\Factory\StreamFactory',
],
UploadedFileFactoryInterface::class => [
'Phalcon\Http\Message\UploadedFileFactory',
'Nyholm\Psr7\Factory\Psr17Factory',
'Zend\Diactoros\UploadedFileFactory',
'GuzzleHttp\Psr7\HttpFactory',
'Http\Factory\Diactoros\UploadedFileFactory',
'Http\Factory\Guzzle\UploadedFileFactory',
'Http\Factory\Slim\UploadedFileFactory',
'Laminas\Diactoros\UploadedFileFactory',
'Slim\Psr7\Factory\UploadedFileFactory',
],
UriFactoryInterface::class => [
'Phalcon\Http\Message\UriFactory',
'Nyholm\Psr7\Factory\Psr17Factory',
'Zend\Diactoros\UriFactory',
'GuzzleHttp\Psr7\HttpFactory',
'Http\Factory\Diactoros\UriFactory',
'Http\Factory\Guzzle\UriFactory',
'Http\Factory\Slim\UriFactory',
'Laminas\Diactoros\UriFactory',
'Slim\Psr7\Factory\UriFactory',
],
];
/**
* {@inheritdoc}
*/
public static function getCandidates($type)
{
$candidates = [];
if (isset(self::$classes[$type])) {
foreach (self::$classes[$type] as $class) {
$candidates[] = ['class' => $class, 'condition' => [$class]];
}
}
return $candidates;
}
}

View File

@@ -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);
}

View File

@@ -0,0 +1,27 @@
<?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
{
/**
* {@inheritdoc}
*/
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 [];
}
}

View File

@@ -0,0 +1,92 @@
<?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;
}
/**
* {@inheritdoc}
*/
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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

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;

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.

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"
}
}
}

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
{
}

View File

@@ -0,0 +1,65 @@
<?php
namespace Http\Client\Exception;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* Thrown when a response was received but the request itself failed.
*
* In addition to the request, this exception always provides access to the response object.
*
* @author Márk Sági-Kazár <mark.sagikazar@gmail.com>
*/
class HttpException extends RequestException
{
/**
* @var ResponseInterface
*/
protected $response;
/**
* @param string $message
*/
public function __construct(
$message,
RequestInterface $request,
ResponseInterface $response,
\Exception $previous = null
) {
parent::__construct($message, $request, $previous);
$this->response = $response;
$this->code = $response->getStatusCode();
}
/**
* Returns the response.
*
* @return ResponseInterface
*/
public function getResponse()
{
return $this->response;
}
/**
* Factory method to create a new exception with a normalized error message.
*/
public static function create(
RequestInterface $request,
ResponseInterface $response,
\Exception $previous = null
) {
$message = sprintf(
'[url] %s [http method] %s [status code] %s [reason phrase] %s',
$request->getRequestTarget(),
$request->getMethod(),
$response->getStatusCode(),
$response->getReasonPhrase()
);
return new static($message, $request, $response, $previous);
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace Http\Client\Exception;
use Psr\Http\Client\NetworkExceptionInterface as PsrNetworkException;
use Psr\Http\Message\RequestInterface;
/**
* Thrown when the request cannot be completed because of network issues.
*
* There is no response object as this exception is thrown when no response has been received.
*
* @author Márk Sági-Kazár <mark.sagikazar@gmail.com>
*/
class NetworkException extends TransferException implements PsrNetworkException
{
use RequestAwareTrait;
/**
* @param string $message
*/
public function __construct($message, RequestInterface $request, \Exception $previous = null)
{
$this->setRequest($request);
parent::__construct($message, 0, $previous);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Http\Client\Exception;
use Psr\Http\Message\RequestInterface;
trait RequestAwareTrait
{
/**
* @var RequestInterface
*/
private $request;
private function setRequest(RequestInterface $request)
{
$this->request = $request;
}
/**
* {@inheritdoc}
*/
public function getRequest(): RequestInterface
{
return $this->request;
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace Http\Client\Exception;
use Psr\Http\Client\RequestExceptionInterface as PsrRequestException;
use Psr\Http\Message\RequestInterface;
/**
* Exception for when a request failed, providing access to the failed request.
*
* This could be due to an invalid request, or one of the extending exceptions
* for network errors or HTTP error responses.
*
* @author Márk Sági-Kazár <mark.sagikazar@gmail.com>
*/
class RequestException extends TransferException implements PsrRequestException
{
use RequestAwareTrait;
/**
* @param string $message
*/
public function __construct($message, RequestInterface $request, \Exception $previous = null)
{
$this->setRequest($request);
parent::__construct($message, 0, $previous);
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace Http\Client\Exception;
use Http\Client\Exception;
/**
* Base exception for transfer related exceptions.
*
* @author Márk Sági-Kazár <mark.sagikazar@gmail.com>
*/
class TransferException extends \RuntimeException implements Exception
{
}

View File

@@ -0,0 +1,25 @@
<?php
namespace Http\Client;
use Http\Promise\Promise;
use Psr\Http\Message\RequestInterface;
/**
* Sends a PSR-7 Request in an asynchronous way by returning a Promise.
*
* @author Joel Wurtz <joel.wurtz@gmail.com>
*/
interface HttpAsyncClient
{
/**
* Sends a PSR-7 request in an asynchronous way.
*
* Exceptions related to processing the request are available from the returned Promise.
*
* @return Promise resolves a PSR-7 Response or fails with an Http\Client\Exception
*
* @throws \Exception If processing the request is impossible (eg. bad configuration).
*/
public function sendAsyncRequest(RequestInterface $request);
}

View File

@@ -0,0 +1,15 @@
<?php
namespace Http\Client;
use Psr\Http\Client\ClientInterface;
/**
* {@inheritdoc}
*
* Provide the Httplug HttpClient interface for BC.
* You should typehint Psr\Http\Client\ClientInterface in new code
*/
interface HttpClient extends ClientInterface
{
}

View File

@@ -0,0 +1,54 @@
<?php
namespace Http\Client\Promise;
use Http\Client\Exception;
use Http\Promise\Promise;
use Psr\Http\Message\ResponseInterface;
final class HttpFulfilledPromise implements Promise
{
/**
* @var ResponseInterface
*/
private $response;
public function __construct(ResponseInterface $response)
{
$this->response = $response;
}
/**
* {@inheritdoc}
*/
public function then(callable $onFulfilled = null, callable $onRejected = null)
{
if (null === $onFulfilled) {
return $this;
}
try {
return new self($onFulfilled($this->response));
} catch (Exception $e) {
return new HttpRejectedPromise($e);
}
}
/**
* {@inheritdoc}
*/
public function getState()
{
return Promise::FULFILLED;
}
/**
* {@inheritdoc}
*/
public function wait($unwrap = true)
{
if ($unwrap) {
return $this->response;
}
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace Http\Client\Promise;
use Http\Client\Exception;
use Http\Promise\Promise;
final class HttpRejectedPromise implements Promise
{
/**
* @var Exception
*/
private $exception;
public function __construct(Exception $exception)
{
$this->exception = $exception;
}
/**
* {@inheritdoc}
*/
public function then(callable $onFulfilled = null, callable $onRejected = null)
{
if (null === $onRejected) {
return $this;
}
try {
$result = $onRejected($this->exception);
if ($result instanceof Promise) {
return $result;
}
return new HttpFulfilledPromise($result);
} catch (Exception $e) {
return new self($e);
}
}
/**
* {@inheritdoc}
*/
public function getState()
{
return Promise::REJECTED;
}
/**
* {@inheritdoc}
*/
public function wait($unwrap = true)
{
if ($unwrap) {
throw $this->exception;
}
}
}

View File

@@ -0,0 +1,19 @@
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.

View File

@@ -0,0 +1,43 @@
{
"version": "1.0",
"binding-types": {
"Http\\Message\\MessageFactory": {
"description": "PSR-7 Message Factory",
"parameters": {
"depends": {
"description": "Optional class dependency which can be checked by consumers"
}
}
},
"Http\\Message\\RequestFactory": {
"parameters": {
"depends": {
"description": "Optional class dependency which can be checked by consumers"
}
}
},
"Http\\Message\\ResponseFactory": {
"parameters": {
"depends": {
"description": "Optional class dependency which can be checked by consumers"
}
}
},
"Http\\Message\\StreamFactory": {
"description": "PSR-7 Stream Factory",
"parameters": {
"depends": {
"description": "Optional class dependency which can be checked by consumers"
}
}
},
"Http\\Message\\UriFactory": {
"description": "PSR-7 URI Factory",
"parameters": {
"depends": {
"description": "Optional class dependency which can be checked by consumers"
}
}
}
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace Http\Message;
/**
* Factory for PSR-7 Request and Response.
*
* @author Márk Sági-Kazár <mark.sagikazar@gmail.com>
*/
interface MessageFactory extends RequestFactory, ResponseFactory
{
}

View File

@@ -0,0 +1,34 @@
<?php
namespace Http\Message;
use Psr\Http\Message\UriInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\StreamInterface;
/**
* Factory for PSR-7 Request.
*
* @author Márk Sági-Kazár <mark.sagikazar@gmail.com>
*/
interface RequestFactory
{
/**
* Creates a new PSR-7 request.
*
* @param string $method
* @param string|UriInterface $uri
* @param array $headers
* @param resource|string|StreamInterface|null $body
* @param string $protocolVersion
*
* @return RequestInterface
*/
public function createRequest(
$method,
$uri,
array $headers = [],
$body = null,
$protocolVersion = '1.1'
);
}

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