Initial Commit

This commit is contained in:
David Stone
2024-11-30 18:24:12 -07:00
commit e8f7955c1c
5432 changed files with 1397750 additions and 0 deletions

View File

@ -0,0 +1,34 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client;
use WP_Ultimo\Dependencies\Amp\CancellationToken;
use WP_Ultimo\Dependencies\Amp\Http\Client\Interceptor\DecompressResponse;
use WP_Ultimo\Dependencies\Amp\Promise;
/**
* Allows intercepting an application request to an HTTP resource.
*/
interface ApplicationInterceptor
{
/**
* Intercepts an application request to an HTTP resource.
*
* The implementation might modify the request, delegate the request handling to the `$httpClient`, and/or modify
* the response after the promise returned from `$httpClient->request(...)` resolves.
*
* An interceptor might also short-circuit and not delegate to the `$httpClient` at all.
*
* Any retry or cloned follow-up request must be manually cloned from `$request` to ensure a properly working
* interceptor chain, e.g. the {@see DecompressResponse} interceptor only decodes a response if the
* `accept-encoding` header isn't set manually. If the request isn't cloned, the first attempt will set the header
* and the second attempt will see the header and won't decode the response, because it thinks another interceptor
* or the application itself will care about the decoding.
*
* @param Request $request
* @param CancellationToken $cancellation
* @param DelegateHttpClient $httpClient
*
* @return Promise<Response>
*/
public function request(Request $request, CancellationToken $cancellation, DelegateHttpClient $httpClient) : Promise;
}

View File

@ -0,0 +1,69 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Body;
use WP_Ultimo\Dependencies\Amp\ByteStream\InputStream;
use WP_Ultimo\Dependencies\Amp\File\Driver;
use WP_Ultimo\Dependencies\Amp\Http\Client\RequestBody;
use WP_Ultimo\Dependencies\Amp\Promise;
use WP_Ultimo\Dependencies\Amp\Success;
use function WP_Ultimo\Dependencies\Amp\call;
use function WP_Ultimo\Dependencies\Amp\File\getSize;
use function WP_Ultimo\Dependencies\Amp\File\open;
use function WP_Ultimo\Dependencies\Amp\File\openFile;
use function WP_Ultimo\Dependencies\Amp\File\size;
final class FileBody implements RequestBody
{
/** @var string */
private $path;
/**
* @param string $path The filesystem path for the file we wish to send
*/
public function __construct(string $path)
{
if (!\interface_exists(Driver::class)) {
throw new \Error("File request bodies require amphp/file to be installed");
}
$this->path = $path;
}
public function createBodyStream() : InputStream
{
$handlePromise = \function_exists('WP_Ultimo\\Dependencies\\Amp\\File\\openFile') ? openFile($this->path, "r") : open($this->path, "r");
return new class($handlePromise) implements InputStream
{
/** @var Promise<InputStream> */
private $promise;
/** @var InputStream|null */
private $stream;
public function __construct(Promise $promise)
{
$this->promise = $promise;
$this->promise->onResolve(function ($error, $stream) {
if ($error) {
return;
}
$this->stream = $stream;
});
}
public function read() : Promise
{
if (!$this->stream) {
return call(function () {
/** @var InputStream $stream */
$stream = (yield $this->promise);
return $stream->read();
});
}
return $this->stream->read();
}
};
}
public function getHeaders() : Promise
{
return new Success([]);
}
public function getBodyLength() : Promise
{
return \function_exists('WP_Ultimo\\Dependencies\\Amp\\File\\getSize') ? getSize($this->path) : size($this->path);
}
}

View File

@ -0,0 +1,218 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Body;
use WP_Ultimo\Dependencies\Amp\ByteStream\InMemoryStream;
use WP_Ultimo\Dependencies\Amp\ByteStream\InputStream;
use WP_Ultimo\Dependencies\Amp\ByteStream\IteratorStream;
use WP_Ultimo\Dependencies\Amp\Http\Client\RequestBody;
use WP_Ultimo\Dependencies\Amp\Producer;
use WP_Ultimo\Dependencies\Amp\Promise;
use WP_Ultimo\Dependencies\Amp\Success;
use function WP_Ultimo\Dependencies\Amp\call;
final class FormBody implements RequestBody
{
/** @var (array{0: string, 1: string, 2: string, 3: null}|array{0: string, 1: FileBody, 2: string, 3: string})[] */
private $fields = [];
/** @var string */
private $boundary;
/** @var bool */
private $isMultipart = \false;
/** @var string|null */
private $cachedBody;
/** @var Promise<int>|null */
private $cachedLength;
/** @var list<string|FileBody>|null */
private $cachedFields;
/**
* @param string $boundary An optional multipart boundary string
*/
public function __construct(string $boundary = null)
{
/** @noinspection PhpUnhandledExceptionInspection */
$this->boundary = $boundary ?? \bin2hex(\random_bytes(16));
}
/**
* Add a data field to the form entity body.
*
* @param string $name
* @param string $value
* @param string $contentType
*/
public function addField(string $name, string $value, string $contentType = 'text/plain') : void
{
$this->fields[] = [$name, $value, $contentType, null];
$this->resetCache();
}
/**
* Add each element of a associative array as a data field to the form entity body.
*
* @param array $data
* @param string $contentType
*/
public function addFields(array $data, string $contentType = 'text/plain') : void
{
foreach ($data as $key => $value) {
$this->addField($key, $value, $contentType);
}
}
/**
* Add a file field to the form entity body.
*
* @param string $name
* @param string $filePath
* @param string $contentType
*/
public function addFile(string $name, string $filePath, string $contentType = 'application/octet-stream') : void
{
$fileName = \basename($filePath);
$this->fields[] = [$name, new FileBody($filePath), $contentType, $fileName];
$this->isMultipart = \true;
$this->resetCache();
}
/**
* Add each element of a associative array as a file field to the form entity body.
*
* @param array $data
* @param string $contentType
*/
public function addFiles(array $data, string $contentType = 'application/octet-stream') : void
{
foreach ($data as $key => $value) {
$this->addFile($key, $value, $contentType);
}
}
/**
* Add a file field to the form from a string.
*
* @param string $name
* @param string $fileContent
* @param string $fileName
* @param string $contentType
*/
public function addFileFromString(string $name, string $fileContent, string $fileName, string $contentType = 'application/octet-stream') : void
{
$this->fields[] = [$name, $fileContent, $contentType, $fileName];
$this->isMultipart = \true;
$this->resetCache();
}
/**
* Returns an array of fields, each being an array of [name, value, content-type, file-name|null].
* Both fields and files are returned in the array. Files use a FileBody object as the value. The file-name is
* always null for fields.
*
* @return array
*/
public function getFields() : array
{
return $this->fields;
}
private function resetCache() : void
{
$this->cachedBody = null;
$this->cachedLength = null;
$this->cachedFields = null;
}
public function createBodyStream() : InputStream
{
if ($this->isMultipart) {
return $this->generateMultipartStreamFromFields($this->getMultipartFieldArray());
}
return new InMemoryStream($this->getFormEncodedBodyString());
}
private function getMultipartFieldArray() : array
{
if (isset($this->cachedFields)) {
return $this->cachedFields;
}
$fields = [];
foreach ($this->fields as $fieldArr) {
[$name, $field, $contentType, $fileName] = $fieldArr;
$fields[] = "--{$this->boundary}\r\n";
/** @psalm-suppress PossiblyNullArgument */
$fields[] = $fileName !== null ? $this->generateMultipartFileHeader($name, $fileName, $contentType) : $this->generateMultipartFieldHeader($name, $contentType);
$fields[] = $field;
$fields[] = "\r\n";
}
$fields[] = "--{$this->boundary}--\r\n";
return $this->cachedFields = $fields;
}
private function generateMultipartFileHeader(string $name, string $fileName, string $contentType) : string
{
$header = "Content-Disposition: form-data; name=\"{$name}\"; filename=\"{$fileName}\"\r\n";
$header .= "Content-Type: {$contentType}\r\n";
$header .= "Content-Transfer-Encoding: binary\r\n\r\n";
return $header;
}
private function generateMultipartFieldHeader(string $name, string $contentType) : string
{
$header = "Content-Disposition: form-data; name=\"{$name}\"\r\n";
if ($contentType !== "") {
$header .= "Content-Type: {$contentType}\r\n\r\n";
} else {
$header .= "\r\n";
}
return $header;
}
private function generateMultipartStreamFromFields(array $fields) : InputStream
{
foreach ($fields as $key => $field) {
$fields[$key] = $field instanceof FileBody ? $field->createBodyStream() : new InMemoryStream($field);
}
return new IteratorStream(new Producer(static function (callable $emit) use($fields) {
/** @psalm-var callable(string) $emit */
foreach ($fields as $stream) {
/** @var InputStream $stream */
while (null !== ($chunk = (yield $stream->read()))) {
(yield $emit($chunk));
}
}
}));
}
private function getFormEncodedBodyString() : string
{
if ($this->cachedBody) {
return $this->cachedBody;
}
$fields = [];
foreach ($this->fields as $fieldArr) {
[$name, $value] = $fieldArr;
$fields[$name][] = $value;
}
foreach ($fields as $key => $value) {
$fields[$key] = isset($value[1]) ? $value : $value[0];
}
return $this->cachedBody = \http_build_query($fields);
}
public function getHeaders() : Promise
{
return new Success(['Content-Type' => $this->determineContentType()]);
}
private function determineContentType() : string
{
return $this->isMultipart ? "multipart/form-data; boundary={$this->boundary}" : 'application/x-www-form-urlencoded';
}
public function getBodyLength() : Promise
{
if ($this->cachedLength) {
return $this->cachedLength;
}
if (!$this->isMultipart) {
return $this->cachedLength = new Success(\strlen($this->getFormEncodedBodyString()));
}
/** @var Promise<int> $lengthPromise */
$lengthPromise = call(function () : \Generator {
$fields = $this->getMultipartFieldArray();
$length = 0;
foreach ($fields as $field) {
if (\is_string($field)) {
$length += \strlen($field);
} else {
$length += (yield $field->getBodyLength());
}
}
return $length;
});
return $this->cachedLength = $lengthPromise;
}
}

View File

@ -0,0 +1,44 @@
<?php
/** @noinspection PhpComposerExtensionStubsInspection */
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Body;
use WP_Ultimo\Dependencies\Amp\ByteStream\InMemoryStream;
use WP_Ultimo\Dependencies\Amp\ByteStream\InputStream;
use WP_Ultimo\Dependencies\Amp\Http\Client\HttpException;
use WP_Ultimo\Dependencies\Amp\Http\Client\RequestBody;
use WP_Ultimo\Dependencies\Amp\Promise;
use WP_Ultimo\Dependencies\Amp\Success;
final class JsonBody implements RequestBody
{
/** @var string */
private $json;
/**
* JsonBody constructor.
*
* @param mixed $data
* @param int $options
* @param int<1, 2147483647> $depth
*
* @throws HttpException
*/
public function __construct($data, int $options = 0, int $depth = 512)
{
$this->json = \json_encode($data, $options, $depth);
if (\json_last_error() !== \JSON_ERROR_NONE) {
throw new HttpException('Failed to encode data to JSON');
}
}
public function getHeaders() : Promise
{
return new Success(['content-type' => 'application/json; charset=utf-8']);
}
public function createBodyStream() : InputStream
{
return new InMemoryStream($this->json);
}
public function getBodyLength() : Promise
{
return new Success(\strlen($this->json));
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Body;
use WP_Ultimo\Dependencies\Amp\ByteStream\InMemoryStream;
use WP_Ultimo\Dependencies\Amp\ByteStream\InputStream;
use WP_Ultimo\Dependencies\Amp\Http\Client\RequestBody;
use WP_Ultimo\Dependencies\Amp\Promise;
use WP_Ultimo\Dependencies\Amp\Success;
final class StringBody implements RequestBody
{
private $body;
public function __construct(string $body)
{
$this->body = $body;
}
public function createBodyStream() : InputStream
{
return new InMemoryStream($this->body !== '' ? $this->body : null);
}
public function getHeaders() : Promise
{
return new Success([]);
}
public function getBodyLength() : Promise
{
return new Success(\strlen($this->body));
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Connection;
use WP_Ultimo\Dependencies\Amp\Http\Client\Request;
use WP_Ultimo\Dependencies\Amp\Promise;
use WP_Ultimo\Dependencies\Amp\Socket\SocketAddress;
use WP_Ultimo\Dependencies\Amp\Socket\TlsInfo;
interface Connection
{
/**
* @param Request $request
*
* @return Promise<Stream|null> Returns a stream for the given request, or null if no stream is available or if
* the connection is not suited for the given request. The first request for a stream
* on a new connection MUST resolve the promise with a Stream instance.
*/
public function getStream(Request $request) : Promise;
/**
* @return string[] Array of supported protocol versions.
*/
public function getProtocolVersions() : array;
public function close() : Promise;
public function onClose(callable $onClose) : void;
public function getLocalAddress() : SocketAddress;
public function getRemoteAddress() : SocketAddress;
public function getTlsInfo() : ?TlsInfo;
}

View File

@ -0,0 +1,26 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Connection;
use WP_Ultimo\Dependencies\Amp\CancellationToken;
use WP_Ultimo\Dependencies\Amp\Http\Client\Request;
use WP_Ultimo\Dependencies\Amp\Promise;
interface ConnectionFactory
{
/**
* During connection establishment, the factory must call the {@see EventListener::startConnectionCreation()},
* {@see EventListener::startTlsNegotiation()}, {@see EventListener::completeTlsNegotiation()}, and
* {@see EventListener::completeConnectionCreation()} on all event listeners registered on the given request in the
* order defined by {@see Request::getEventListeners()} as appropriate (TLS events are only invoked if TLS is
* used). Before calling the next listener, the promise returned from the previous one must resolve successfully.
*
* Additionally, the factory may invoke {@see EventListener::startDnsResolution()} and
* {@see EventListener::completeDnsResolution()}, but is not required to implement such granular events.
*
* @param Request $request
* @param CancellationToken $cancellationToken
*
* @return Promise
*/
public function create(Request $request, CancellationToken $cancellationToken) : Promise;
}

View File

@ -0,0 +1,282 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Connection;
use WP_Ultimo\Dependencies\Amp\CancellationToken;
use WP_Ultimo\Dependencies\Amp\Coroutine;
use WP_Ultimo\Dependencies\Amp\Deferred;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidSerialization;
use WP_Ultimo\Dependencies\Amp\Http\Client\Request;
use WP_Ultimo\Dependencies\Amp\Http\Client\Response;
use WP_Ultimo\Dependencies\Amp\MultiReasonException;
use WP_Ultimo\Dependencies\Amp\Promise;
use WP_Ultimo\Dependencies\Amp\Success;
use function WP_Ultimo\Dependencies\Amp\call;
use function WP_Ultimo\Dependencies\Amp\coroutine;
final class ConnectionLimitingPool implements ConnectionPool
{
use ForbidSerialization;
/**
* Create a connection pool that limits the number of connections per authority.
*
* @param int $connectionLimit Maximum number of connections allowed to a single authority.
* @param ConnectionFactory|null $connectionFactory
*
* @return self
*/
public static function byAuthority(int $connectionLimit, ?ConnectionFactory $connectionFactory = null) : self
{
return new self($connectionLimit, $connectionFactory);
}
private static function formatUri(Request $request) : string
{
$uri = $request->getUri();
$scheme = $uri->getScheme();
$isHttps = $scheme === 'https';
$defaultPort = $isHttps ? 443 : 80;
$host = $uri->getHost();
$port = $uri->getPort() ?? $defaultPort;
$authority = $host . ':' . $port;
return $scheme . '://' . $authority;
}
/** @var int */
private $connectionLimit;
/** @var ConnectionFactory */
private $connectionFactory;
/** @var array<string, \ArrayObject<int, Promise<Connection>>> */
private $connections = [];
/** @var Connection[] */
private $idleConnections = [];
/** @var int[] */
private $activeRequestCounts = [];
/** @var Deferred[][] */
private $waiting = [];
/** @var bool[] */
private $waitForPriorConnection = [];
/** @var int */
private $totalConnectionAttempts = 0;
/** @var int */
private $totalStreamRequests = 0;
/** @var int */
private $openConnectionCount = 0;
private function __construct(int $connectionLimit, ?ConnectionFactory $connectionFactory = null)
{
if ($connectionLimit < 1) {
throw new \Error('The connection limit must be greater than 0');
}
$this->connectionLimit = $connectionLimit;
$this->connectionFactory = $connectionFactory ?? new DefaultConnectionFactory();
}
public function __clone()
{
$this->connections = [];
$this->totalConnectionAttempts = 0;
$this->totalStreamRequests = 0;
$this->openConnectionCount = 0;
}
public function getTotalConnectionAttempts() : int
{
return $this->totalConnectionAttempts;
}
public function getTotalStreamRequests() : int
{
return $this->totalStreamRequests;
}
public function getOpenConnectionCount() : int
{
return $this->openConnectionCount;
}
public function getStream(Request $request, CancellationToken $cancellation) : Promise
{
return call(function () use($request, $cancellation) {
$this->totalStreamRequests++;
$uri = self::formatUri($request);
// Using new Coroutine avoids a bug on PHP < 7.4, see #265
/**
* @var Stream $stream
* @psalm-suppress all
*/
[$connection, $stream] = (yield new Coroutine($this->getStreamFor($uri, $request, $cancellation)));
$connectionId = \spl_object_id($connection);
$this->activeRequestCounts[$connectionId] = ($this->activeRequestCounts[$connectionId] ?? 0) + 1;
unset($this->idleConnections[$connectionId]);
return HttpStream::fromStream($stream, coroutine(function (Request $request, CancellationToken $cancellationToken) use($connection, $stream, $uri) {
try {
/** @var Response $response */
$response = (yield $stream->request($request, $cancellationToken));
} catch (\Throwable $e) {
$this->onReadyConnection($connection, $uri);
throw $e;
}
// await response being completely received
$response->getTrailers()->onResolve(function () use($connection, $uri) : void {
$this->onReadyConnection($connection, $uri);
});
return $response;
}), function () use($connection, $uri) : void {
$this->onReadyConnection($connection, $uri);
});
});
}
private function getStreamFor(string $uri, Request $request, CancellationToken $cancellation) : \Generator
{
$isHttps = $request->getUri()->getScheme() === 'https';
$connections = $this->connections[$uri] ?? new \ArrayObject();
do {
foreach ($connections as $connectionPromise) {
\assert($connectionPromise instanceof Promise);
try {
if ($isHttps && ($this->waitForPriorConnection[$uri] ?? \true)) {
// Wait for first successful connection if using a secure connection (maybe we can use HTTP/2).
$connection = (yield $connectionPromise);
} else {
$connection = (yield Promise\first([$connectionPromise, new Success()]));
if ($connection === null) {
continue;
}
}
} catch (\Exception $exception) {
continue;
// Ignore cancellations and errors of other requests.
}
\assert($connection instanceof Connection);
$stream = (yield $this->getStreamFromConnection($connection, $request));
if ($stream === null) {
if (!$this->isAdditionalConnectionAllowed($uri) && $this->isConnectionIdle($connection)) {
$connection->close();
break;
}
continue;
// No stream available for the given request.
}
return [$connection, $stream];
}
$deferred = new Deferred();
$deferredId = \spl_object_id($deferred);
$this->waiting[$uri][$deferredId] = $deferred;
$deferredPromise = $deferred->promise();
$deferredPromise->onResolve(function () use($uri, $deferredId) : void {
$this->removeWaiting($uri, $deferredId);
});
if ($this->isAdditionalConnectionAllowed($uri)) {
break;
}
$connection = (yield $deferredPromise);
\assert($connection instanceof Connection);
$stream = (yield $this->getStreamFromConnection($connection, $request));
if ($stream === null) {
continue;
// Wait for a different connection to become available.
}
return [$connection, $stream];
} while (\true);
$this->totalConnectionAttempts++;
$connectionPromise = $this->connectionFactory->create($request, $cancellation);
$promiseId = \spl_object_id($connectionPromise);
$this->connections[$uri] = $this->connections[$uri] ?? new \ArrayObject();
$this->connections[$uri][$promiseId] = $connectionPromise;
$connectionPromise->onResolve(function (?\Throwable $exception, ?Connection $connection) use(&$deferred, $uri, $promiseId, $isHttps) : void {
if ($exception) {
$this->dropConnection($uri, null, $promiseId);
if ($deferred !== null) {
$deferred->fail($exception);
// Fail Deferred so Promise\first() below fails.
}
return;
}
\assert($connection !== null);
$connectionId = \spl_object_id($connection);
$this->openConnectionCount++;
if ($isHttps) {
$this->waitForPriorConnection[$uri] = \in_array('2', $connection->getProtocolVersions(), \true);
}
$connection->onClose(function () use($uri, $connectionId, $promiseId) : void {
$this->openConnectionCount--;
$this->dropConnection($uri, $connectionId, $promiseId);
});
});
try {
$connection = (yield Promise\first([$connectionPromise, $deferredPromise]));
} catch (MultiReasonException $exception) {
[$exception] = $exception->getReasons();
// The first reason is why the connection failed.
throw $exception;
}
$deferred = null;
// Null reference so connection promise handler does not double-resolve the Deferred.
$this->removeWaiting($uri, $deferredId);
// Deferred no longer needed for this request.
\assert($connection instanceof Connection);
$stream = (yield $this->getStreamFromConnection($connection, $request));
if ($stream === null) {
// Reused connection did not have an available stream for the given request.
$connection = (yield $connectionPromise);
// Wait for new connection request instead.
$stream = (yield $this->getStreamFromConnection($connection, $request));
if ($stream === null) {
// Other requests used the new connection first, so we need to go around again.
// Using new Coroutine avoids a bug on PHP < 7.4, see #265
return (yield new Coroutine($this->getStreamFor($uri, $request, $cancellation)));
}
}
return [$connection, $stream];
}
private function getStreamFromConnection(Connection $connection, Request $request) : Promise
{
if (!\array_intersect($request->getProtocolVersions(), $connection->getProtocolVersions())) {
return new Success();
// Connection does not support any of the requested protocol versions.
}
return $connection->getStream($request);
}
private function isAdditionalConnectionAllowed(string $uri) : bool
{
return \count($this->connections[$uri] ?? []) < $this->connectionLimit;
}
private function onReadyConnection(Connection $connection, string $uri) : void
{
$connectionId = \spl_object_id($connection);
if (isset($this->activeRequestCounts[$connectionId])) {
$this->activeRequestCounts[$connectionId]--;
if ($this->activeRequestCounts[$connectionId] === 0) {
while (\count($this->idleConnections) > 64) {
// not customizable for now
$idleConnection = \reset($this->idleConnections);
$key = \key($this->idleConnections);
unset($this->idleConnections[$key]);
$idleConnection->close();
}
$this->idleConnections[$connectionId] = $connection;
}
}
if (empty($this->waiting[$uri])) {
return;
}
$deferred = \reset($this->waiting[$uri]);
// Deferred is removed from waiting list in onResolve callback attached above.
$deferred->resolve($connection);
}
private function isConnectionIdle(Connection $connection) : bool
{
$connectionId = \spl_object_id($connection);
\assert(!isset($this->activeRequestCounts[$connectionId]) || $this->activeRequestCounts[$connectionId] >= 0);
return ($this->activeRequestCounts[$connectionId] ?? 0) === 0;
}
private function removeWaiting(string $uri, int $deferredId) : void
{
unset($this->waiting[$uri][$deferredId]);
if (empty($this->waiting[$uri])) {
unset($this->waiting[$uri]);
}
}
private function dropConnection(string $uri, ?int $connectionId, int $promiseId) : void
{
unset($this->connections[$uri][$promiseId]);
if ($connectionId !== null) {
unset($this->activeRequestCounts[$connectionId], $this->idleConnections[$connectionId]);
}
if ($this->connections[$uri]->count() === 0) {
unset($this->connections[$uri], $this->waitForPriorConnection[$uri]);
}
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Connection;
use WP_Ultimo\Dependencies\Amp\CancellationToken;
use WP_Ultimo\Dependencies\Amp\Http\Client\Request;
use WP_Ultimo\Dependencies\Amp\Promise;
interface ConnectionPool
{
/**
* Reserve a stream for a particular request.
*
* @param Request $request
* @param CancellationToken $cancellation
*
* @return Promise<Stream>
*/
public function getStream(Request $request, CancellationToken $cancellation) : Promise;
}

View File

@ -0,0 +1,149 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Connection;
use WP_Ultimo\Dependencies\Amp\ByteStream\StreamException;
use WP_Ultimo\Dependencies\Amp\CancellationToken;
use WP_Ultimo\Dependencies\Amp\CancelledException;
use WP_Ultimo\Dependencies\Amp\CombinedCancellationToken;
use WP_Ultimo\Dependencies\Amp\Http\Client\InvalidRequestException;
use WP_Ultimo\Dependencies\Amp\Http\Client\Request;
use WP_Ultimo\Dependencies\Amp\Http\Client\SocketException;
use WP_Ultimo\Dependencies\Amp\Http\Client\TimeoutException;
use WP_Ultimo\Dependencies\Amp\Promise;
use WP_Ultimo\Dependencies\Amp\Socket;
use WP_Ultimo\Dependencies\Amp\Socket\ClientTlsContext;
use WP_Ultimo\Dependencies\Amp\Socket\ConnectContext;
use WP_Ultimo\Dependencies\Amp\Socket\Connector;
use WP_Ultimo\Dependencies\Amp\Socket\EncryptableSocket;
use WP_Ultimo\Dependencies\Amp\TimeoutCancellationToken;
use function WP_Ultimo\Dependencies\Amp\call;
use function WP_Ultimo\Dependencies\Amp\Socket\connector;
final class DefaultConnectionFactory implements ConnectionFactory
{
/** @var Connector|null */
private $connector;
/** @var ConnectContext|null */
private $connectContext;
public function __construct(?Connector $connector = null, ?ConnectContext $connectContext = null)
{
$this->connector = $connector;
$this->connectContext = $connectContext;
}
public function create(Request $request, CancellationToken $cancellationToken) : Promise
{
return call(function () use($request, $cancellationToken) {
foreach ($request->getEventListeners() as $eventListener) {
(yield $eventListener->startConnectionCreation($request));
}
$connector = $this->connector ?? connector();
$connectContext = $this->connectContext ?? new ConnectContext();
$uri = $request->getUri();
$scheme = $uri->getScheme();
if (!\in_array($scheme, ['http', 'https'], \true)) {
throw new InvalidRequestException($request, 'Invalid scheme provided in the request URI: ' . $uri);
}
$isHttps = $scheme === 'https';
$defaultPort = $isHttps ? 443 : 80;
$host = $uri->getHost();
$port = $uri->getPort() ?? $defaultPort;
if ($host === '') {
throw new InvalidRequestException($request, 'A host must be provided in the request URI: ' . $uri);
}
$authority = $host . ':' . $port;
$protocolVersions = $request->getProtocolVersions();
$isConnect = $request->getMethod() === 'CONNECT';
if ($isHttps) {
$protocols = [];
if (!$isConnect && \in_array('2', $protocolVersions, \true)) {
$protocols[] = 'h2';
}
if (\in_array('1.1', $protocolVersions, \true) || \in_array('1.0', $protocolVersions, \true)) {
$protocols[] = 'http/1.1';
}
if (!$protocols) {
throw new InvalidRequestException($request, \sprintf("None of the requested protocol versions (%s) are supported by %s (HTTP/2 is only supported on HTTPS)", \implode(', ', $protocolVersions), self::class));
}
$tlsContext = ($connectContext->getTlsContext() ?? new ClientTlsContext(''))->withPeerCapturing();
// If we only have HTTP/1.1 available, don't set application layer protocols.
// There are misbehaving sites like n11.com, see https://github.com/amphp/http-client/issues/255
if ($protocols !== ['http/1.1'] && Socket\hasTlsAlpnSupport()) {
$tlsContext = $tlsContext->withApplicationLayerProtocols($protocols);
}
if ($tlsContext->getPeerName() === '') {
$tlsContext = $tlsContext->withPeerName($host);
}
$connectContext = $connectContext->withTlsContext($tlsContext);
}
try {
/** @var EncryptableSocket $socket */
$socket = (yield $connector->connect('tcp://' . $authority, $connectContext->withConnectTimeout($request->getTcpConnectTimeout()), $cancellationToken));
} catch (Socket\ConnectException $e) {
throw new UnprocessedRequestException(new SocketException(\sprintf("Connection to '%s' failed", $authority), 0, $e));
} catch (CancelledException $e) {
// In case of a user cancellation request, throw the expected exception
$cancellationToken->throwIfRequested();
// Otherwise we ran into a timeout of our TimeoutCancellationToken
throw new UnprocessedRequestException(new TimeoutException(\sprintf("Connection to '%s' timed out, took longer than " . $request->getTcpConnectTimeout() . ' ms', $authority)));
// don't pass $e
}
if ($isHttps) {
try {
$tlsState = $socket->getTlsState();
// Error if anything enabled TLS on a new connection before we can do it
if ($tlsState !== EncryptableSocket::TLS_STATE_DISABLED) {
$socket->close();
throw new UnprocessedRequestException(new SocketException('Failed to setup TLS connection, connection was in an unexpected TLS state (' . $tlsState . ')'));
}
foreach ($request->getEventListeners() as $eventListener) {
(yield $eventListener->startTlsNegotiation($request));
}
$tlsCancellationToken = new CombinedCancellationToken($cancellationToken, new TimeoutCancellationToken($request->getTlsHandshakeTimeout()));
(yield $socket->setupTls($tlsCancellationToken));
foreach ($request->getEventListeners() as $eventListener) {
(yield $eventListener->completeTlsNegotiation($request));
}
} catch (StreamException $exception) {
$socket->close();
throw new UnprocessedRequestException(new SocketException(\sprintf("Connection to '%s' @ '%s' closed during TLS handshake", $authority, $socket->getRemoteAddress()->toString()), 0, $exception));
} catch (CancelledException $e) {
$socket->close();
// In case of a user cancellation request, throw the expected exception
$cancellationToken->throwIfRequested();
// Otherwise we ran into a timeout of our TimeoutCancellationToken
throw new UnprocessedRequestException(new TimeoutException(\sprintf("TLS handshake with '%s' @ '%s' timed out, took longer than " . $request->getTlsHandshakeTimeout() . ' ms', $authority, $socket->getRemoteAddress()->toString())));
// don't pass $e
}
$tlsInfo = $socket->getTlsInfo();
if ($tlsInfo === null) {
throw new UnprocessedRequestException(new SocketException(\sprintf("Socket closed after TLS handshake with '%s' @ '%s'", $authority, $socket->getRemoteAddress()->toString())));
}
if ($tlsInfo->getApplicationLayerProtocol() === 'h2') {
$http2Connection = new Http2Connection($socket);
(yield $http2Connection->initialize());
foreach ($request->getEventListeners() as $eventListener) {
(yield $eventListener->completeConnectionCreation($request));
}
return $http2Connection;
}
}
// Treat the presence of only HTTP/2 as prior knowledge, see https://http2.github.io/http2-spec/#known-http
if ($request->getProtocolVersions() === ['2']) {
$http2Connection = new Http2Connection($socket);
(yield $http2Connection->initialize());
foreach ($request->getEventListeners() as $eventListener) {
(yield $eventListener->completeConnectionCreation($request));
}
return $http2Connection;
}
if (!\array_intersect($request->getProtocolVersions(), ['1.0', '1.1'])) {
$socket->close();
throw new InvalidRequestException($request, \sprintf("None of the requested protocol versions (%s) are supported by '%s' @ '%s'", \implode(', ', $protocolVersions), $authority, $socket->getRemoteAddress()->toString()));
}
foreach ($request->getEventListeners() as $eventListener) {
(yield $eventListener->completeConnectionCreation($request));
}
return new Http1Connection($socket);
});
}
}

View File

@ -0,0 +1,546 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Connection;
use WP_Ultimo\Dependencies\Amp\ByteStream\IteratorStream;
use WP_Ultimo\Dependencies\Amp\ByteStream\StreamException;
use WP_Ultimo\Dependencies\Amp\CancellationToken;
use WP_Ultimo\Dependencies\Amp\CancellationTokenSource;
use WP_Ultimo\Dependencies\Amp\CancelledException;
use WP_Ultimo\Dependencies\Amp\CombinedCancellationToken;
use WP_Ultimo\Dependencies\Amp\Deferred;
use WP_Ultimo\Dependencies\Amp\Emitter;
use WP_Ultimo\Dependencies\Amp\Http;
use WP_Ultimo\Dependencies\Amp\Http\Client\Connection\Internal\Http1Parser;
use WP_Ultimo\Dependencies\Amp\Http\Client\Connection\Internal\RequestNormalizer;
use WP_Ultimo\Dependencies\Amp\Http\Client\HttpException;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidCloning;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidSerialization;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ResponseBodyStream;
use WP_Ultimo\Dependencies\Amp\Http\Client\InvalidRequestException;
use WP_Ultimo\Dependencies\Amp\Http\Client\ParseException;
use WP_Ultimo\Dependencies\Amp\Http\Client\Request;
use WP_Ultimo\Dependencies\Amp\Http\Client\Response;
use WP_Ultimo\Dependencies\Amp\Http\Client\SocketException;
use WP_Ultimo\Dependencies\Amp\Http\Client\TimeoutException;
use WP_Ultimo\Dependencies\Amp\Http\InvalidHeaderException;
use WP_Ultimo\Dependencies\Amp\Http\Rfc7230;
use WP_Ultimo\Dependencies\Amp\Loop;
use WP_Ultimo\Dependencies\Amp\Promise;
use WP_Ultimo\Dependencies\Amp\Socket\EncryptableSocket;
use WP_Ultimo\Dependencies\Amp\Socket\SocketAddress;
use WP_Ultimo\Dependencies\Amp\Socket\TlsInfo;
use WP_Ultimo\Dependencies\Amp\Success;
use WP_Ultimo\Dependencies\Amp\TimeoutCancellationToken;
use WP_Ultimo\Dependencies\Amp\TimeoutException as PromiseTimeoutException;
use function WP_Ultimo\Dependencies\Amp\asyncCall;
use function WP_Ultimo\Dependencies\Amp\call;
use function WP_Ultimo\Dependencies\Amp\getCurrentTime;
use function WP_Ultimo\Dependencies\Amp\Http\Client\Internal\normalizeRequestPathWithQuery;
/**
* Socket client implementation.
*
* @see Client
*/
final class Http1Connection implements Connection
{
use ForbidSerialization;
use ForbidCloning;
private const MAX_KEEP_ALIVE_TIMEOUT = 60;
private const PROTOCOL_VERSIONS = ['1.0', '1.1'];
/** @var EncryptableSocket|null */
private $socket;
/** @var bool */
private $busy = \false;
/** @var int Number of requests made on this connection. */
private $requestCounter = 0;
/** @var string|null Keep alive timeout watcher ID. */
private $timeoutWatcher;
/** @var int Keep-Alive timeout from last response. */
private $priorTimeout = self::MAX_KEEP_ALIVE_TIMEOUT;
/** @var callable[]|null */
private $onClose = [];
/** @var int */
private $timeoutGracePeriod;
/** @var int */
private $lastUsedAt;
/** @var bool */
private $explicitTimeout = \false;
/** @var SocketAddress */
private $localAddress;
/** @var SocketAddress */
private $remoteAddress;
/** @var TlsInfo|null */
private $tlsInfo;
/** @var Promise|null */
private $idleRead;
public function __construct(EncryptableSocket $socket, int $timeoutGracePeriod = 2000)
{
$this->socket = $socket;
$this->localAddress = $socket->getLocalAddress();
$this->remoteAddress = $socket->getRemoteAddress();
$this->tlsInfo = $socket->getTlsInfo();
$this->timeoutGracePeriod = $timeoutGracePeriod;
$this->lastUsedAt = getCurrentTime();
$this->watchIdleConnection();
}
public function __destruct()
{
$this->close();
}
public function onClose(callable $onClose) : void
{
if (!$this->socket || $this->socket->isClosed()) {
Promise\rethrow(call($onClose, $this));
return;
}
$this->onClose[] = $onClose;
}
public function close() : Promise
{
if ($this->socket) {
$this->socket->close();
}
return $this->free();
}
public function getLocalAddress() : SocketAddress
{
return $this->localAddress;
}
public function getRemoteAddress() : SocketAddress
{
return $this->remoteAddress;
}
public function getTlsInfo() : ?TlsInfo
{
return $this->tlsInfo;
}
public function getProtocolVersions() : array
{
return self::PROTOCOL_VERSIONS;
}
public function getStream(Request $request) : Promise
{
if ($this->busy || $this->requestCounter && !$this->hasStreamFor($request)) {
return new Success();
}
$this->busy = \true;
return new Success(HttpStream::fromConnection($this, \Closure::fromCallable([$this, 'request']), \Closure::fromCallable([$this, 'release'])));
}
private function free() : Promise
{
$this->socket = null;
$this->idleRead = null;
$this->lastUsedAt = 0;
if ($this->timeoutWatcher !== null) {
Loop::cancel($this->timeoutWatcher);
}
if ($this->onClose !== null) {
$onClose = $this->onClose;
$this->onClose = null;
foreach ($onClose as $callback) {
asyncCall($callback, $this);
}
}
return new Success();
}
private function hasStreamFor(Request $request) : bool
{
return !$this->busy && $this->socket && !$this->socket->isClosed() && ($this->getRemainingTime() > 0 || $request->isIdempotent());
}
/** @inheritdoc */
private function request(Request $request, CancellationToken $cancellation, Stream $stream) : Promise
{
return call(function () use($request, $cancellation, $stream) {
++$this->requestCounter;
if ($this->socket !== null && !$this->socket->isClosed()) {
$this->socket->reference();
}
if ($this->timeoutWatcher !== null) {
Loop::cancel($this->timeoutWatcher);
$this->timeoutWatcher = null;
}
(yield RequestNormalizer::normalizeRequest($request));
$protocolVersion = $this->determineProtocolVersion($request);
$request->setProtocolVersions([$protocolVersion]);
if ($request->getTransferTimeout() > 0) {
$timeoutToken = new TimeoutCancellationToken($request->getTransferTimeout());
$combinedCancellation = new CombinedCancellationToken($cancellation, $timeoutToken);
} else {
$combinedCancellation = $cancellation;
}
$id = $combinedCancellation->subscribe([$this, 'close']);
try {
foreach ($request->getEventListeners() as $eventListener) {
(yield $eventListener->startSendingRequest($request, $stream));
}
yield from $this->writeRequest($request, $protocolVersion, $combinedCancellation);
foreach ($request->getEventListeners() as $eventListener) {
(yield $eventListener->completeSendingRequest($request, $stream));
}
return yield from $this->readResponse($request, $cancellation, $combinedCancellation, $stream);
} catch (\Throwable $e) {
foreach ($request->getEventListeners() as $eventListener) {
(yield $eventListener->abort($request, $e));
}
if ($this->socket !== null) {
$this->socket->close();
}
throw $e;
} finally {
$combinedCancellation->unsubscribe($id);
$cancellation->throwIfRequested();
}
});
}
private function release() : void
{
$this->busy = \false;
}
/**
* @param Request $request
* @param CancellationToken $originalCancellation
* @param CancellationToken $readingCancellation
*
* @param Stream $stream
*
* @return \Generator
* @throws CancelledException
* @throws HttpException
* @throws ParseException
* @throws SocketException
*/
private function readResponse(Request $request, CancellationToken $originalCancellation, CancellationToken $readingCancellation, Stream $stream) : \Generator
{
$bodyEmitter = new Emitter();
$backpressure = new Success();
$bodyCallback = static function ($data) use($bodyEmitter, &$backpressure) : void {
$backpressure = $bodyEmitter->emit($data);
};
$trailersDeferred = new Deferred();
$trailers = [];
$trailersCallback = static function (array $headers) use(&$trailers) : void {
$trailers = $headers;
};
$parser = new Http1Parser($request, $bodyCallback, $trailersCallback);
$start = getCurrentTime();
$timeout = $request->getInactivityTimeout();
try {
if ($this->socket === null) {
throw new SocketException('Socket closed prior to response completion');
}
while (null !== ($chunk = (yield $timeout > 0 ? Promise\timeout($this->idleRead ?: $this->socket->read(), $timeout) : ($this->idleRead ?: $this->socket->read())))) {
$this->idleRead = null;
parseChunk:
$response = $parser->parse($chunk);
if ($response === null) {
if ($this->socket === null) {
throw new SocketException('Socket closed prior to response completion');
}
continue;
}
$this->lastUsedAt = getCurrentTime();
$status = $response->getStatus();
if ($status === Http\Status::SWITCHING_PROTOCOLS) {
$connection = Http\createFieldValueComponentMap(Http\parseFieldValueComponents($response, 'connection'));
if (!isset($connection['upgrade'])) {
throw new HttpException('Switching protocols response missing "Connection: upgrade" header');
}
if (!$response->hasHeader('upgrade')) {
throw new HttpException('Switching protocols response missing "Upgrade" header');
}
foreach ($request->getEventListeners() as $eventListener) {
(yield $eventListener->completeReceivingResponse($request, $stream));
}
$trailersDeferred->resolve($trailers);
return $this->handleUpgradeResponse($request, $response, $parser->getBuffer());
}
if ($status < 200) {
// 1XX responses (excluding 101, handled above)
$onInformationalResponse = $request->getInformationalResponseHandler();
if ($onInformationalResponse !== null) {
(yield call($onInformationalResponse, $response));
}
$chunk = $parser->getBuffer();
$parser = new Http1Parser($request, $bodyCallback, $trailersCallback);
goto parseChunk;
}
foreach ($request->getEventListeners() as $eventListener) {
(yield $eventListener->startReceivingResponse($request, $stream));
}
if ($status >= 200 && $status < 300 && $request->getMethod() === 'CONNECT') {
foreach ($request->getEventListeners() as $eventListener) {
(yield $eventListener->completeReceivingResponse($request, $stream));
}
$trailersDeferred->resolve($trailers);
return $this->handleUpgradeResponse($request, $response, $parser->getBuffer());
}
$bodyCancellationSource = new CancellationTokenSource();
$bodyCancellationToken = new CombinedCancellationToken($readingCancellation, $bodyCancellationSource->getToken());
$response->setTrailers($trailersDeferred->promise());
$response->setBody(new ResponseBodyStream(new IteratorStream($bodyEmitter->iterate()), $bodyCancellationSource));
// Read body async
asyncCall(function () use($parser, $request, $response, $bodyEmitter, $trailersDeferred, $originalCancellation, $readingCancellation, $bodyCancellationToken, $stream, $timeout, &$backpressure, &$trailers) {
$id = $bodyCancellationToken->subscribe([$this, 'close']);
try {
// Required, otherwise responses without body hang
if (!$parser->isComplete()) {
// Directly parse again in case we already have the full body but aborted parsing
// to resolve promise with headers.
$chunk = null;
try {
/** @psalm-suppress PossiblyNullReference */
do {
/** @noinspection CallableParameterUseCaseInTypeContextInspection */
$parser->parse($chunk);
/**
* @noinspection NotOptimalIfConditionsInspection
* @psalm-suppress TypeDoesNotContainType
*/
if ($parser->isComplete()) {
break;
}
if (!$backpressure instanceof Success) {
(yield $this->withCancellation($backpressure, $bodyCancellationToken));
}
/** @psalm-suppress TypeDoesNotContainNull */
if ($this->socket === null) {
throw new SocketException('Socket closed prior to response completion');
}
} while (null !== ($chunk = (yield $timeout > 0 ? Promise\timeout($this->socket->read(), $timeout) : $this->socket->read())));
} catch (PromiseTimeoutException $e) {
$this->close();
throw new TimeoutException('Inactivity timeout exceeded, more than ' . $timeout . ' ms elapsed from last data received', 0, $e);
}
$originalCancellation->throwIfRequested();
if ($readingCancellation->isRequested()) {
throw new TimeoutException('Allowed transfer timeout exceeded, took longer than ' . $request->getTransferTimeout() . ' ms');
}
$bodyCancellationToken->throwIfRequested();
// Ignore check if neither content-length nor chunked encoding are given.
/** @psalm-suppress RedundantCondition */
if (!$parser->isComplete() && $parser->getState() !== Http1Parser::BODY_IDENTITY_EOF) {
throw new SocketException('Socket disconnected prior to response completion');
}
}
$timeout = $this->determineKeepAliveTimeout($response);
if ($timeout > 0 && $parser->getState() !== Http1Parser::BODY_IDENTITY_EOF) {
$this->timeoutWatcher = Loop::delay($timeout * 1000, [$this, 'close']);
Loop::unreference($this->timeoutWatcher);
$this->watchIdleConnection();
} else {
$this->close();
}
$this->busy = \false;
foreach ($request->getEventListeners() as $eventListener) {
(yield $eventListener->completeReceivingResponse($request, $stream));
}
$bodyEmitter->complete();
$trailersDeferred->resolve($trailers);
} catch (\Throwable $e) {
$this->close();
try {
foreach ($request->getEventListeners() as $eventListener) {
(yield $eventListener->abort($request, $e));
}
} finally {
$bodyEmitter->fail($e);
$trailersDeferred->fail($e);
}
} finally {
$bodyCancellationToken->unsubscribe($id);
}
});
return $response;
}
$originalCancellation->throwIfRequested();
throw new SocketException(\sprintf("Receiving the response headers for '%s' failed, because the socket to '%s' @ '%s' closed early with %d bytes received within %d milliseconds", (string) $request->getUri()->withUserInfo(''), $request->getUri()->withUserInfo('')->getAuthority(), $this->socket === null ? '???' : (string) $this->socket->getRemoteAddress(), \strlen($parser->getBuffer()), getCurrentTime() - $start));
} catch (HttpException $e) {
$this->close();
throw $e;
} catch (PromiseTimeoutException $e) {
$this->close();
throw new TimeoutException('Inactivity timeout exceeded, more than ' . $timeout . ' ms elapsed from last data received', 0, $e);
} catch (\Throwable $e) {
$this->close();
throw new SocketException('Receiving the response headers failed: ' . $e->getMessage(), 0, $e);
}
}
private function handleUpgradeResponse(Request $request, Response $response, string $buffer) : Response
{
if ($this->socket === null) {
throw new SocketException('Socket closed while upgrading');
}
$socket = new UpgradedSocket($this->socket, $buffer);
$this->free();
// Mark this connection as unusable without closing socket.
if (($onUpgrade = $request->getUpgradeHandler()) === null) {
$socket->close();
throw new HttpException('CONNECT or upgrade request made without upgrade handler callback');
}
asyncCall(static function () use($onUpgrade, $socket, $request, $response) : \Generator {
try {
(yield call($onUpgrade, $socket, $request, $response));
} catch (\Throwable $exception) {
$socket->close();
throw new HttpException('Upgrade handler threw an exception', 0, $exception);
}
});
return $response;
}
/**
* @return int Approximate number of milliseconds remaining until the connection is closed.
*/
private function getRemainingTime() : int
{
$timestamp = $this->lastUsedAt + ($this->explicitTimeout ? $this->priorTimeout * 1000 : $this->timeoutGracePeriod);
return \max(0, $timestamp - getCurrentTime());
}
private function withCancellation(Promise $promise, CancellationToken $cancellationToken) : Promise
{
$deferred = new Deferred();
$newPromise = $deferred->promise();
$promise->onResolve(static function ($error, $value) use(&$deferred) : void {
if ($deferred) {
$temp = $deferred;
$deferred = null;
if ($error) {
$temp->fail($error);
} else {
$temp->resolve($value);
}
}
});
$cancellationSubscription = $cancellationToken->subscribe(static function ($e) use(&$deferred) : void {
if ($deferred) {
$temp = $deferred;
$deferred = null;
$temp->fail($e);
}
});
$newPromise->onResolve(static function () use($cancellationToken, $cancellationSubscription) : void {
$cancellationToken->unsubscribe($cancellationSubscription);
});
return $newPromise;
}
private function determineKeepAliveTimeout(Response $response) : int
{
$request = $response->getRequest();
$requestConnHeader = $request->getHeader('connection') ?? '';
$responseConnHeader = $response->getHeader('connection') ?? '';
if (!\strcasecmp($requestConnHeader, 'close')) {
return 0;
}
if ($response->getProtocolVersion() === '1.0') {
return 0;
}
if (!\strcasecmp($responseConnHeader, 'close')) {
return 0;
}
$params = Http\createFieldValueComponentMap(Http\parseFieldValueComponents($response, 'keep-alive'));
$timeout = (int) ($params['timeout'] ?? $this->priorTimeout);
if (isset($params['timeout'])) {
$this->explicitTimeout = \true;
}
return $this->priorTimeout = \min(\max(0, $timeout), self::MAX_KEEP_ALIVE_TIMEOUT);
}
private function determineProtocolVersion(Request $request) : string
{
$protocolVersions = $request->getProtocolVersions();
if (\in_array("1.1", $protocolVersions, \true)) {
return "1.1";
}
if (\in_array("1.0", $protocolVersions, \true)) {
return "1.0";
}
throw new InvalidRequestException($request, "None of the requested protocol versions is supported: " . \implode(", ", $protocolVersions));
}
private function writeRequest(Request $request, string $protocolVersion, CancellationToken $cancellation) : \Generator
{
try {
$rawHeaders = $this->generateRawHeader($request, $protocolVersion);
if ($this->socket === null) {
throw new UnprocessedRequestException(new SocketException('Socket closed before request started'));
}
(yield $this->socket->write($rawHeaders));
if ($request->getMethod() === 'CONNECT') {
return;
}
$body = $request->getBody()->createBodyStream();
$chunking = $request->getHeader("transfer-encoding") === "chunked";
$remainingBytes = $request->getHeader("content-length");
if ($remainingBytes !== null) {
$remainingBytes = (int) $remainingBytes;
}
if ($chunking && $protocolVersion === "1.0") {
throw new InvalidRequestException($request, "Can't send chunked bodies over HTTP/1.0");
}
// We always buffer the last chunk to make sure we don't write $contentLength bytes if the body is too long.
$buffer = "";
while (null !== ($chunk = (yield $body->read()))) {
$cancellation->throwIfRequested();
if ($chunk === "") {
continue;
}
if ($chunking) {
$chunk = \dechex(\strlen($chunk)) . "\r\n" . $chunk . "\r\n";
} elseif ($remainingBytes !== null) {
$remainingBytes -= \strlen($chunk);
if ($remainingBytes < 0) {
throw new InvalidRequestException($request, "Body contained more bytes than specified in Content-Length, aborting request");
}
}
(yield $this->socket->write($buffer));
$buffer = $chunk;
}
$cancellation->throwIfRequested();
// Flush last buffered chunk.
(yield $this->socket->write($buffer));
if ($chunking) {
(yield $this->socket->write("0\r\n\r\n"));
} elseif ($remainingBytes !== null && $remainingBytes > 0) {
throw new InvalidRequestException($request, "Body contained fewer bytes than specified in Content-Length, aborting request");
}
} catch (StreamException $exception) {
throw new SocketException('Socket disconnected prior to response completion');
}
}
/**
* @param Request $request
* @param string $protocolVersion
*
* @return string
*
* @throws HttpException
*/
private function generateRawHeader(Request $request, string $protocolVersion) : string
{
$uri = $request->getUri();
$requestUri = normalizeRequestPathWithQuery($request);
$method = $request->getMethod();
if ($method === 'CONNECT') {
$defaultPort = $uri->getScheme() === 'https' ? 443 : 80;
$requestUri = $uri->getHost() . ':' . ($uri->getPort() ?? $defaultPort);
}
$header = $method . ' ' . $requestUri . ' HTTP/' . $protocolVersion . "\r\n";
try {
$header .= Rfc7230::formatRawHeaders($request->getRawHeaders());
} catch (InvalidHeaderException $e) {
throw new HttpException($e->getMessage());
}
return $header . "\r\n";
}
private function watchIdleConnection() : void
{
if ($this->socket === null || $this->socket->isClosed()) {
return;
}
$this->socket->unreference();
$this->idleRead = $this->socket->read();
$this->idleRead->onResolve(function ($error, $chunk) {
if ($error || $chunk === null) {
$this->close();
}
});
}
}

View File

@ -0,0 +1,78 @@
<?php
/** @noinspection PhpUnhandledExceptionInspection */
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Connection;
use WP_Ultimo\Dependencies\Amp\CancellationToken;
use WP_Ultimo\Dependencies\Amp\Http\Client\Connection\Internal\Http2ConnectionProcessor;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidCloning;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidSerialization;
use WP_Ultimo\Dependencies\Amp\Http\Client\Request;
use WP_Ultimo\Dependencies\Amp\Promise;
use WP_Ultimo\Dependencies\Amp\Socket\EncryptableSocket;
use WP_Ultimo\Dependencies\Amp\Socket\SocketAddress;
use WP_Ultimo\Dependencies\Amp\Socket\TlsInfo;
use function WP_Ultimo\Dependencies\Amp\call;
final class Http2Connection implements Connection
{
use ForbidSerialization;
use ForbidCloning;
private const PROTOCOL_VERSIONS = ['2'];
/** @var EncryptableSocket */
private $socket;
/** @var Http2ConnectionProcessor */
private $processor;
/** @var int */
private $requestCount = 0;
public function __construct(EncryptableSocket $socket)
{
$this->socket = $socket;
$this->processor = new Http2ConnectionProcessor($socket);
}
public function getProtocolVersions() : array
{
return self::PROTOCOL_VERSIONS;
}
public function initialize() : Promise
{
return $this->processor->initialize();
}
public function getStream(Request $request) : Promise
{
if (!$this->processor->isInitialized()) {
throw new \Error('The promise returned from ' . __CLASS__ . '::initialize() must resolve before using the connection');
}
return call(function () {
if ($this->processor->isClosed() || $this->processor->getRemainingStreams() <= 0) {
return null;
}
$this->processor->reserveStream();
return HttpStream::fromConnection($this, \Closure::fromCallable([$this, 'request']), \Closure::fromCallable([$this->processor, 'unreserveStream']));
});
}
public function onClose(callable $onClose) : void
{
$this->processor->onClose($onClose);
}
public function close() : Promise
{
return $this->processor->close();
}
public function getLocalAddress() : SocketAddress
{
return $this->socket->getLocalAddress();
}
public function getRemoteAddress() : SocketAddress
{
return $this->socket->getRemoteAddress();
}
public function getTlsInfo() : ?TlsInfo
{
return $this->socket->getTlsInfo();
}
private function request(Request $request, CancellationToken $token, Stream $applicationStream) : Promise
{
$this->requestCount++;
return $this->processor->request($request, $token, $applicationStream);
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Connection;
use WP_Ultimo\Dependencies\Amp\Http\Client\HttpException;
/**
* @deprecated Exception moved to amphp/http. Catch the base exception class (HttpException) instead.
*/
final class Http2ConnectionException extends HttpException
{
public function __construct(string $message, int $code, ?\Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Connection;
use WP_Ultimo\Dependencies\Amp\Http\Client\HttpException;
/**
* @deprecated Exception moved to amphp/http. Catch the base exception class (HttpException) instead.
*/
final class Http2StreamException extends HttpException
{
/** @var int */
private $streamId;
public function __construct(string $message, int $streamId, int $code, ?\Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
$this->streamId = $streamId;
}
public function getStreamId() : int
{
return $this->streamId;
}
}

View File

@ -0,0 +1,74 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Connection;
use WP_Ultimo\Dependencies\Amp\CancellationToken;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidCloning;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidSerialization;
use WP_Ultimo\Dependencies\Amp\Http\Client\Request;
use WP_Ultimo\Dependencies\Amp\Promise;
use WP_Ultimo\Dependencies\Amp\Socket\SocketAddress;
use WP_Ultimo\Dependencies\Amp\Socket\TlsInfo;
use function WP_Ultimo\Dependencies\Amp\call;
final class HttpStream implements Stream
{
use ForbidSerialization;
use ForbidCloning;
public static function fromConnection(Connection $connection, callable $requestCallback, callable $releaseCallback) : self
{
return new self($connection->getLocalAddress(), $connection->getRemoteAddress(), $connection->getTlsInfo(), $requestCallback, $releaseCallback);
}
public static function fromStream(Stream $stream, callable $requestCallback, callable $releaseCallback) : self
{
return new self($stream->getLocalAddress(), $stream->getRemoteAddress(), $stream->getTlsInfo(), $requestCallback, $releaseCallback);
}
/** @var SocketAddress */
private $localAddress;
/** @var SocketAddress */
private $remoteAddress;
/** @var TlsInfo|null */
private $tlsInfo;
/** @var callable */
private $requestCallback;
/** @var callable|null */
private $releaseCallback;
private function __construct(SocketAddress $localAddress, SocketAddress $remoteAddress, ?TlsInfo $tlsInfo, callable $requestCallback, callable $releaseCallback)
{
$this->localAddress = $localAddress;
$this->remoteAddress = $remoteAddress;
$this->tlsInfo = $tlsInfo;
$this->requestCallback = $requestCallback;
$this->releaseCallback = $releaseCallback;
}
public function __destruct()
{
if ($this->releaseCallback !== null) {
($this->releaseCallback)();
}
}
public function request(Request $request, CancellationToken $cancellation) : Promise
{
if ($this->releaseCallback === null) {
throw new \Error('A stream may only be used for a single request');
}
$this->releaseCallback = null;
return call(function () use($request, $cancellation) {
foreach ($request->getEventListeners() as $eventListener) {
(yield $eventListener->startRequest($request));
}
return call($this->requestCallback, $request, $cancellation, $this);
});
}
public function getLocalAddress() : SocketAddress
{
return $this->localAddress;
}
public function getRemoteAddress() : SocketAddress
{
return $this->remoteAddress;
}
public function getTlsInfo() : ?TlsInfo
{
return $this->tlsInfo;
}
}

View File

@ -0,0 +1,53 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Connection;
use WP_Ultimo\Dependencies\Amp\CancellationToken;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidCloning;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidSerialization;
use WP_Ultimo\Dependencies\Amp\Http\Client\NetworkInterceptor;
use WP_Ultimo\Dependencies\Amp\Http\Client\Request;
use WP_Ultimo\Dependencies\Amp\Promise;
use WP_Ultimo\Dependencies\Amp\Socket\SocketAddress;
use WP_Ultimo\Dependencies\Amp\Socket\TlsInfo;
use function WP_Ultimo\Dependencies\Amp\call;
final class InterceptedStream implements Stream
{
use ForbidCloning;
use ForbidSerialization;
/** @var Stream */
private $stream;
/** @var NetworkInterceptor|null */
private $interceptor;
public function __construct(Stream $stream, NetworkInterceptor $interceptor)
{
$this->stream = $stream;
$this->interceptor = $interceptor;
}
public function request(Request $request, CancellationToken $cancellation) : Promise
{
if (!$this->interceptor) {
throw new \Error(__METHOD__ . ' may only be invoked once per instance. ' . 'If you need to implement retries or otherwise issue multiple requests, register an ApplicationInterceptor to do so.');
}
$interceptor = $this->interceptor;
$this->interceptor = null;
return call(function () use($interceptor, $request, $cancellation) {
foreach ($request->getEventListeners() as $eventListener) {
(yield $eventListener->startRequest($request));
}
return $interceptor->requestViaNetwork($request, $cancellation, $this->stream);
});
}
public function getLocalAddress() : SocketAddress
{
return $this->stream->getLocalAddress();
}
public function getRemoteAddress() : SocketAddress
{
return $this->stream->getRemoteAddress();
}
public function getTlsInfo() : ?TlsInfo
{
return $this->stream->getTlsInfo();
}
}

View File

@ -0,0 +1,344 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Connection\Internal;
use WP_Ultimo\Dependencies\Amp\ByteStream\InMemoryStream;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidCloning;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidSerialization;
use WP_Ultimo\Dependencies\Amp\Http\Client\ParseException;
use WP_Ultimo\Dependencies\Amp\Http\Client\Request;
use WP_Ultimo\Dependencies\Amp\Http\Client\Response;
use WP_Ultimo\Dependencies\Amp\Http\InvalidHeaderException;
use WP_Ultimo\Dependencies\Amp\Http\Rfc7230;
use WP_Ultimo\Dependencies\Amp\Http\Status;
/** @internal */
final class Http1Parser
{
use ForbidSerialization;
use ForbidCloning;
private const STATUS_LINE_PATTERN = "#^\n HTTP/(?P<protocol>\\d+\\.\\d+)[ \t]+\n (?P<status>[1-9]\\d\\d)[ \t]*\n (?P<reason>[^\x01-\x08\x10-\x19]*)\n \$#ix";
public const AWAITING_HEADERS = 0;
public const BODY_IDENTITY = 1;
public const BODY_IDENTITY_EOF = 2;
public const BODY_CHUNKS = 3;
public const TRAILERS_START = 4;
public const TRAILERS = 5;
/** @var int */
private $state = self::AWAITING_HEADERS;
/** @var string */
private $buffer = '';
/** @var string|null */
private $protocol;
/** @var int|null */
private $statusCode;
/** @var string|null */
private $statusReason;
/** @var string[][] */
private $headers = [];
/** @var int|null */
private $remainingBodyBytes;
/** @var int */
private $bodyBytesConsumed = 0;
/** @var bool */
private $chunkedEncoding = \false;
/** @var int|null */
private $chunkLengthRemaining;
/** @var bool */
private $complete = \false;
/** @var Request */
private $request;
/** @var int */
private $maxHeaderBytes;
/** @var int */
private $maxBodyBytes;
/** @var callable */
private $bodyDataCallback;
/** @var callable */
private $trailersCallback;
public function __construct(Request $request, callable $bodyDataCallback, callable $trailersCallback)
{
$this->request = $request;
$this->bodyDataCallback = $bodyDataCallback;
$this->trailersCallback = $trailersCallback;
$this->maxHeaderBytes = $request->getHeaderSizeLimit();
$this->maxBodyBytes = $request->getBodySizeLimit();
}
public function getBuffer() : string
{
return $this->buffer;
}
public function getState() : int
{
return $this->state;
}
public function buffer(string $data) : void
{
$this->buffer .= $data;
}
/**
* @param string|null $data
*
* @return Response|null
*
* @throws ParseException
*/
public function parse(string $data = null) : ?Response
{
if ($data !== null) {
$this->buffer .= $data;
}
if ($this->buffer === '') {
return null;
}
if ($this->complete) {
throw new ParseException('Can\'t continue parsing, response is already complete', Status::BAD_REQUEST);
}
switch ($this->state) {
case self::AWAITING_HEADERS:
goto headers;
case self::BODY_IDENTITY:
goto body_identity;
case self::BODY_IDENTITY_EOF:
goto body_identity_eof;
case self::BODY_CHUNKS:
goto body_chunks;
case self::TRAILERS_START:
goto trailers_start;
case self::TRAILERS:
goto trailers;
}
headers:
$startLineAndHeaders = $this->shiftHeadersFromBuffer();
if ($startLineAndHeaders === null) {
return null;
}
$startLineEndPos = \strpos($startLineAndHeaders, "\r\n");
\assert($startLineEndPos !== \false);
$startLine = \substr($startLineAndHeaders, 0, $startLineEndPos);
$rawHeaders = \substr($startLineAndHeaders, $startLineEndPos + 2);
if (\preg_match(self::STATUS_LINE_PATTERN, $startLine, $match)) {
$this->protocol = $match['protocol'];
$this->statusCode = (int) $match['status'];
$this->statusReason = \trim($match['reason']);
} else {
throw new ParseException('Invalid status line: ' . $startLine, Status::BAD_REQUEST);
}
if ($rawHeaders !== '') {
$this->headers = $this->parseRawHeaders($rawHeaders);
} else {
$this->headers = [];
}
$requestMethod = $this->request->getMethod();
$skipBody = $this->statusCode < Status::OK || $this->statusCode === Status::NOT_MODIFIED || $this->statusCode === Status::NO_CONTENT || $requestMethod === 'HEAD' || $requestMethod === 'CONNECT';
if ($skipBody) {
$this->complete = \true;
} elseif ($this->chunkedEncoding) {
$this->state = self::BODY_CHUNKS;
} elseif ($this->remainingBodyBytes === null) {
$this->state = self::BODY_IDENTITY_EOF;
} elseif ($this->remainingBodyBytes > 0) {
$this->state = self::BODY_IDENTITY;
} else {
$this->complete = \true;
}
$response = new Response($this->protocol, $this->statusCode, $this->statusReason, [], new InMemoryStream(), $this->request);
foreach ($this->headers as [$key, $value]) {
$response->addHeader($key, $value);
}
return $response;
body_identity:
$bufferDataSize = \strlen($this->buffer);
if ($bufferDataSize <= $this->remainingBodyBytes) {
$chunk = $this->buffer;
$this->buffer = '';
$this->remainingBodyBytes -= $bufferDataSize;
$this->addToBody($chunk);
if ($this->remainingBodyBytes === 0) {
$this->complete = \true;
}
return null;
}
$bodyData = \substr($this->buffer, 0, $this->remainingBodyBytes);
$this->addToBody($bodyData);
$this->buffer = \substr($this->buffer, $this->remainingBodyBytes);
$this->remainingBodyBytes = 0;
goto complete;
body_identity_eof:
$this->addToBody($this->buffer);
$this->buffer = '';
return null;
body_chunks:
if ($this->parseChunkedBody()) {
$this->state = self::TRAILERS_START;
goto trailers_start;
}
return null;
trailers_start:
$firstTwoBytes = \substr($this->buffer, 0, 2);
if ($firstTwoBytes === "" || $firstTwoBytes === "\r") {
return null;
}
if ($firstTwoBytes === "\r\n") {
$this->buffer = \substr($this->buffer, 2);
goto complete;
}
$this->state = self::TRAILERS;
goto trailers;
trailers:
$trailers = $this->shiftHeadersFromBuffer();
if ($trailers === null) {
return null;
}
$this->parseTrailers($trailers);
goto complete;
complete:
$this->complete = \true;
return null;
}
public function isComplete() : bool
{
return $this->complete;
}
/**
* @return string|null
*
* @throws ParseException
*/
private function shiftHeadersFromBuffer() : ?string
{
$this->buffer = \ltrim($this->buffer, "\r\n");
if ($headersSize = \strpos($this->buffer, "\r\n\r\n")) {
$headers = \substr($this->buffer, 0, $headersSize + 2);
$this->buffer = \substr($this->buffer, $headersSize + 4);
} else {
$headersSize = \strlen($this->buffer);
$headers = null;
}
if ($this->maxHeaderBytes > 0 && $headersSize > $this->maxHeaderBytes) {
throw new ParseException("Configured header size exceeded: {$headersSize} bytes received, while the configured limit is {$this->maxHeaderBytes} bytes", Status::REQUEST_HEADER_FIELDS_TOO_LARGE);
}
return $headers;
}
/**
* @param string $rawHeaders
*
* @return array
*
* @throws ParseException
*/
private function parseRawHeaders(string $rawHeaders) : array
{
// Legacy support for folded headers
if (\strpos($rawHeaders, "\r\n ") || \strpos($rawHeaders, "\r\n\t")) {
$rawHeaders = \preg_replace("/\r\n[ \t]++/", ' ', $rawHeaders);
}
try {
$headers = Rfc7230::parseRawHeaders($rawHeaders);
$headerMap = [];
foreach ($headers as [$key, $value]) {
$headerMap[\strtolower($key)][] = $value;
}
} catch (InvalidHeaderException $e) {
throw new ParseException('Invalid headers', Status::BAD_REQUEST, $e);
}
if (isset($headerMap['transfer-encoding'])) {
$transferEncodings = \explode(',', \strtolower(\implode(',', $headerMap['transfer-encoding'])));
$transferEncodings = \array_map('trim', $transferEncodings);
$this->chunkedEncoding = \in_array('chunked', $transferEncodings, \true);
} elseif (isset($headerMap['content-length'])) {
if (\count($headerMap['content-length']) > 1) {
throw new ParseException('Can\'t determine body length, because multiple content-length headers present in the response', Status::BAD_REQUEST);
}
$contentLength = $headerMap['content-length'][0];
if (!\preg_match('/^(0|[1-9][0-9]*)$/', $contentLength)) {
throw new ParseException('Can\'t determine body length, because the content-length header value is invalid', Status::BAD_REQUEST);
}
$this->remainingBodyBytes = (int) $contentLength;
}
return $headers;
}
/**
* Decodes a chunked response body.
*
* @return bool Returns {@code true} if the body is complete, otherwise {@code false}.
*
* @throws ParseException
*/
private function parseChunkedBody() : bool
{
if ($this->chunkLengthRemaining !== null) {
goto decode_chunk;
}
determine_chunk_size:
if (\false === ($lineEndPos = \strpos($this->buffer, "\r\n"))) {
return \false;
}
if ($lineEndPos === 0) {
throw new ParseException('Invalid line; hexadecimal chunk size expected', Status::BAD_REQUEST);
}
$line = \substr($this->buffer, 0, $lineEndPos);
$hex = \strtolower(\trim(\ltrim($line, '0'))) ?: '0';
$dec = \hexdec($hex);
if ($hex !== \dechex($dec)) {
throw new ParseException('Invalid hexadecimal chunk size', Status::BAD_REQUEST);
}
$this->chunkLengthRemaining = $dec;
$this->buffer = \substr($this->buffer, $lineEndPos + 2);
if ($this->chunkLengthRemaining === 0) {
return \true;
}
decode_chunk:
$bufferLength = \strlen($this->buffer);
// These first two (extreme) edge cases prevent errors where the packet boundary ends after
// the \r and before the \n at the end of a chunk.
if ($bufferLength === $this->chunkLengthRemaining || $bufferLength === $this->chunkLengthRemaining + 1) {
return \false;
}
if ($bufferLength >= $this->chunkLengthRemaining + 2) {
$chunk = \substr($this->buffer, 0, $this->chunkLengthRemaining);
$this->buffer = \substr($this->buffer, $this->chunkLengthRemaining + 2);
$this->chunkLengthRemaining = null;
$this->addToBody($chunk);
goto determine_chunk_size;
}
/** @noinspection SuspiciousAssignmentsInspection */
$chunk = $this->buffer;
$this->buffer = '';
$this->chunkLengthRemaining -= $bufferLength;
$this->addToBody($chunk);
return \false;
}
/**
* @param string $trailers
*
* @throws ParseException
*/
private function parseTrailers(string $trailers) : void
{
try {
$trailers = Rfc7230::parseHeaders($trailers);
} catch (InvalidHeaderException $e) {
throw new ParseException('Invalid trailers', Status::BAD_REQUEST, $e);
}
($this->trailersCallback)($trailers);
}
/**
* @param string $data
*
* @throws ParseException
*/
private function addToBody(string $data) : void
{
$length = \strlen($data);
if (!$length) {
return;
}
$this->bodyBytesConsumed += $length;
if ($this->maxBodyBytes > 0 && $this->bodyBytesConsumed > $this->maxBodyBytes) {
throw new ParseException("Configured body size exceeded: {$this->bodyBytesConsumed} bytes received, while the configured limit is {$this->maxBodyBytes} bytes", Status::PAYLOAD_TOO_LARGE);
}
if ($this->bodyDataCallback) {
($this->bodyDataCallback)($data);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,107 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Connection\Internal;
use WP_Ultimo\Dependencies\Amp\CancellationToken;
use WP_Ultimo\Dependencies\Amp\Deferred;
use WP_Ultimo\Dependencies\Amp\Emitter;
use WP_Ultimo\Dependencies\Amp\Http\Client\Connection\Stream;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidCloning;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidSerialization;
use WP_Ultimo\Dependencies\Amp\Http\Client\Request;
use WP_Ultimo\Dependencies\Amp\Http\Client\Response;
use WP_Ultimo\Dependencies\Amp\Loop;
use WP_Ultimo\Dependencies\Amp\Promise;
use WP_Ultimo\Dependencies\Amp\Struct;
/**
* Used in Http2Connection.
*
* @internal
*/
final class Http2Stream
{
use Struct;
use ForbidSerialization;
use ForbidCloning;
/** @var int */
public $id;
/** @var Request */
public $request;
/** @var Response|null */
public $response;
/** @var Deferred|null */
public $pendingResponse;
/** @var Promise|null */
public $preResponseResolution;
/** @var bool */
public $responsePending = \true;
/** @var Emitter|null */
public $body;
/** @var Deferred|null */
public $trailers;
/** @var CancellationToken */
public $originalCancellation;
/** @var CancellationToken */
public $cancellationToken;
/** @var int Bytes received on the stream. */
public $received = 0;
/** @var int */
public $serverWindow;
/** @var int */
public $clientWindow;
/** @var int */
public $bufferSize;
/** @var string */
public $requestBodyBuffer = '';
/** @var bool */
public $requestBodyComplete = \false;
/** @var Deferred */
public $requestBodyCompletion;
/** @var int Integer between 1 and 256 */
public $weight = 16;
/** @var int */
public $dependency = 0;
/** @var int|null */
public $expectedLength;
/** @var Stream */
public $stream;
/** @var Deferred|null */
public $windowSizeIncrease;
/** @var string|null */
private $watcher;
public function __construct(int $id, Request $request, Stream $stream, CancellationToken $cancellationToken, CancellationToken $originalCancellation, ?string $watcher, int $serverSize, int $clientSize)
{
$this->id = $id;
$this->request = $request;
$this->stream = $stream;
$this->cancellationToken = $cancellationToken;
$this->originalCancellation = $originalCancellation;
$this->watcher = $watcher;
$this->serverWindow = $serverSize;
$this->clientWindow = $clientSize;
$this->pendingResponse = new Deferred();
$this->requestBodyCompletion = new Deferred();
$this->bufferSize = 0;
}
public function __destruct()
{
if ($this->watcher !== null) {
Loop::cancel($this->watcher);
}
}
public function disableInactivityWatcher() : void
{
if ($this->watcher === null) {
return;
}
Loop::disable($this->watcher);
}
public function enableInactivityWatcher() : void
{
if ($this->watcher === null) {
return;
}
Loop::disable($this->watcher);
Loop::enable($this->watcher);
}
}

View File

@ -0,0 +1,71 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Connection\Internal;
use WP_Ultimo\Dependencies\Amp\Http\Client\Request;
use WP_Ultimo\Dependencies\Amp\Http\Client\RequestBody;
use WP_Ultimo\Dependencies\Amp\Promise;
use function WP_Ultimo\Dependencies\Amp\call;
/** @internal */
final class RequestNormalizer
{
public static function normalizeRequest(Request $request) : Promise
{
return call(static function () use($request) {
/** @var array $headers */
$headers = (yield $request->getBody()->getHeaders());
foreach ($headers as $name => $header) {
if (!$request->hasHeader($name)) {
$request->setHeaders([$name => $header]);
}
}
yield from self::normalizeRequestBodyHeaders($request);
// Always normalize this as last item, because we need to strip sensitive headers
self::normalizeTraceRequest($request);
return $request;
});
}
private static function normalizeRequestBodyHeaders(Request $request) : \Generator
{
if (!$request->hasHeader('host')) {
// Though servers are supposed to be able to handle standard port names on the end of the
// Host header some fail to do this correctly. Thankfully PSR-7 recommends to strip the port
// if it is the standard port for the given scheme.
$request->setHeader('host', $request->getUri()->withUserInfo('')->getAuthority());
}
if ($request->hasHeader("transfer-encoding")) {
$request->removeHeader("content-length");
return;
}
if ($request->hasHeader("content-length")) {
return;
}
/** @var RequestBody $body */
$body = $request->getBody();
$bodyLength = (yield $body->getBodyLength());
if ($bodyLength === 0) {
if (\in_array($request->getMethod(), ['HEAD', 'GET', 'CONNECT'], \true)) {
$request->removeHeader('content-length');
} else {
$request->setHeader('content-length', '0');
}
$request->removeHeader('transfer-encoding');
} elseif ($bodyLength > 0) {
$request->setHeader("content-length", $bodyLength);
$request->removeHeader("transfer-encoding");
} else {
$request->setHeader("transfer-encoding", "chunked");
}
}
private static function normalizeTraceRequest(Request $request) : void
{
$method = $request->getMethod();
if ($method !== 'TRACE') {
return;
}
// https://tools.ietf.org/html/rfc7231#section-4.3.8
$request->setBody(null);
// Remove all body and sensitive headers
$request->setHeaders(["transfer-encoding" => [], "content-length" => [], "authorization" => [], "proxy-authorization" => [], "cookie" => []]);
}
}

View File

@ -0,0 +1,6 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Connection;
// Alias for backward compatibility.
\class_alias(StreamLimitingPool::class, LimitedConnectionPool::class);

View File

@ -0,0 +1,37 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Connection;
use WP_Ultimo\Dependencies\Amp\CancellationToken;
use WP_Ultimo\Dependencies\Amp\Http\Client\DelegateHttpClient;
use WP_Ultimo\Dependencies\Amp\Http\Client\EventListener;
use WP_Ultimo\Dependencies\Amp\Http\Client\Request;
use WP_Ultimo\Dependencies\Amp\Http\Client\Response;
use WP_Ultimo\Dependencies\Amp\Promise;
use WP_Ultimo\Dependencies\Amp\Socket\SocketAddress;
use WP_Ultimo\Dependencies\Amp\Socket\TlsInfo;
interface Stream extends DelegateHttpClient
{
/**
* Executes the request.
*
* This method may only be invoked once per instance.
*
* The stream must call {@see EventListener::startSendingRequest()},
* {@see EventListener::completeSendingRequest()}, {@see EventListener::startReceivingResponse()}, and
* {@see EventListener::completeReceivingResponse()} event listener methods on all event listeners registered on
* the given request in the order defined by {@see Request::getEventListeners()}. Before calling the next listener,
* the promise returned from the previous one must resolve successfully.
*
* @param Request $request
* @param CancellationToken $cancellation
*
* @return Promise<Response>
*
* @throws \Error Thrown if this method is called more than once.
*/
public function request(Request $request, CancellationToken $cancellation) : Promise;
public function getLocalAddress() : SocketAddress;
public function getRemoteAddress() : SocketAddress;
public function getTlsInfo() : ?TlsInfo;
}

View File

@ -0,0 +1,72 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Connection;
use WP_Ultimo\Dependencies\Amp\CancellationToken;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidCloning;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidSerialization;
use WP_Ultimo\Dependencies\Amp\Http\Client\Request;
use WP_Ultimo\Dependencies\Amp\Http\Client\Response;
use WP_Ultimo\Dependencies\Amp\Promise;
use WP_Ultimo\Dependencies\Amp\Sync\KeyedSemaphore;
use WP_Ultimo\Dependencies\Amp\Sync\Lock;
use function WP_Ultimo\Dependencies\Amp\call;
use function WP_Ultimo\Dependencies\Amp\coroutine;
final class StreamLimitingPool implements ConnectionPool
{
use ForbidCloning;
use ForbidSerialization;
public static function byHost(ConnectionPool $delegate, KeyedSemaphore $semaphore) : self
{
return new self($delegate, $semaphore, static function (Request $request) {
return $request->getUri()->getHost();
});
}
public static function byStaticKey(ConnectionPool $delegate, KeyedSemaphore $semaphore, string $key = '') : self
{
return new self($delegate, $semaphore, static function () use($key) {
return $key;
});
}
public static function byCustomKey(ConnectionPool $delegate, KeyedSemaphore $semaphore, callable $requestToKeyMapper) : self
{
return new self($delegate, $semaphore, $requestToKeyMapper);
}
/** @var ConnectionPool */
private $delegate;
/** @var KeyedSemaphore */
private $semaphore;
/** @var callable */
private $requestToKeyMapper;
private function __construct(ConnectionPool $delegate, KeyedSemaphore $semaphore, callable $requestToKeyMapper)
{
$this->delegate = $delegate;
$this->semaphore = $semaphore;
$this->requestToKeyMapper = $requestToKeyMapper;
}
public function getStream(Request $request, CancellationToken $cancellation) : Promise
{
return call(function () use($request, $cancellation) {
/** @var Lock $lock */
$lock = (yield $this->semaphore->acquire(($this->requestToKeyMapper)($request)));
/** @var Stream $stream */
$stream = (yield $this->delegate->getStream($request, $cancellation));
return HttpStream::fromStream($stream, coroutine(static function (Request $request, CancellationToken $cancellationToken) use($stream, $lock) {
try {
/** @var Response $response */
$response = (yield $stream->request($request, $cancellationToken));
// await response being completely received
$response->getTrailers()->onResolve(static function () use($lock) {
$lock->release();
});
} catch (\Throwable $e) {
$lock->release();
throw $e;
}
return $response;
}), static function () use($lock) {
$lock->release();
});
});
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Connection;
use WP_Ultimo\Dependencies\Amp\CancellationToken;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidSerialization;
use WP_Ultimo\Dependencies\Amp\Http\Client\Request;
use WP_Ultimo\Dependencies\Amp\Promise;
final class UnlimitedConnectionPool implements ConnectionPool
{
use ForbidSerialization;
/** @var ConnectionLimitingPool */
private $pool;
public function __construct(?ConnectionFactory $connectionFactory = null)
{
$this->pool = ConnectionLimitingPool::byAuthority(\PHP_INT_MAX, $connectionFactory);
}
public function __clone()
{
$this->pool = clone $this->pool;
}
public function getTotalConnectionAttempts() : int
{
return $this->pool->getTotalConnectionAttempts();
}
public function getTotalStreamRequests() : int
{
return $this->pool->getTotalStreamRequests();
}
public function getOpenConnectionCount() : int
{
return $this->pool->getOpenConnectionCount();
}
public function getStream(Request $request, CancellationToken $cancellation) : Promise
{
return $this->pool->getStream($request, $cancellation);
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Connection;
use WP_Ultimo\Dependencies\Amp\Http\Client\HttpException;
final class UnprocessedRequestException extends HttpException
{
public function __construct(HttpException $previous)
{
parent::__construct("The request was not processed and can be safely retried", 0, $previous);
}
}

View File

@ -0,0 +1,91 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Connection;
use WP_Ultimo\Dependencies\Amp\CancellationToken;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidCloning;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidSerialization;
use WP_Ultimo\Dependencies\Amp\Promise;
use WP_Ultimo\Dependencies\Amp\Socket\EncryptableSocket;
use WP_Ultimo\Dependencies\Amp\Socket\SocketAddress;
use WP_Ultimo\Dependencies\Amp\Socket\TlsInfo;
use WP_Ultimo\Dependencies\Amp\Success;
final class UpgradedSocket implements EncryptableSocket
{
use ForbidCloning;
use ForbidSerialization;
/** @var EncryptableSocket */
private $socket;
/** @var string|null */
private $buffer;
/**
* @param EncryptableSocket $socket
* @param string $buffer Remaining buffer previously read from the socket.
*/
public function __construct(EncryptableSocket $socket, string $buffer)
{
$this->socket = $socket;
$this->buffer = $buffer !== '' ? $buffer : null;
}
public function read() : Promise
{
if ($this->buffer !== null) {
$buffer = $this->buffer;
$this->buffer = null;
return new Success($buffer);
}
return $this->socket->read();
}
public function close() : void
{
$this->socket->close();
}
public function __destruct()
{
$this->close();
}
public function write(string $data) : Promise
{
return $this->socket->write($data);
}
public function end(string $finalData = "") : Promise
{
return $this->socket->end($finalData);
}
public function reference() : void
{
$this->socket->reference();
}
public function unreference() : void
{
$this->socket->unreference();
}
public function isClosed() : bool
{
return $this->socket->isClosed();
}
public function getLocalAddress() : SocketAddress
{
return $this->socket->getLocalAddress();
}
public function getRemoteAddress() : SocketAddress
{
return $this->socket->getRemoteAddress();
}
public function setupTls(?CancellationToken $cancellationToken = null) : Promise
{
return $this->socket->setupTls($cancellationToken);
}
public function shutdownTls(?CancellationToken $cancellationToken = null) : Promise
{
return $this->socket->shutdownTls();
}
public function getTlsState() : int
{
return $this->socket->getTlsState();
}
public function getTlsInfo() : ?TlsInfo
{
return $this->socket->getTlsInfo();
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client;
use WP_Ultimo\Dependencies\Amp\CancellationToken;
use WP_Ultimo\Dependencies\Amp\Promise;
/**
* Base HTTP client interface for use in {@see ApplicationInterceptor}.
*
* Applications and implementations should depend on {@see HttpClient} instead. The intent of this interface is to
* allow static analysis tools to find interceptors that forget to pass the cancellation token down. This situation is
* created because of the cancellation token being optional.
*
* Before executing or delegating the request, any client implementation must call {@see EventListener::startRequest()}
* on all event listeners registered on the given request in the order defined by {@see Request::getEventListeners()}.
* Before calling the next listener, the promise returned from the previous one must resolve successfully.
*
* @see HttpClient
*/
interface DelegateHttpClient
{
/**
* Request a specific resource from an HTTP server.
*
* @param Request $request
* @param CancellationToken $cancellation
*
* @return Promise<Response>
*/
public function request(Request $request, CancellationToken $cancellation) : Promise;
}

View File

@ -0,0 +1,117 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client;
use WP_Ultimo\Dependencies\Amp\Http\Client\Connection\ConnectionPool;
use WP_Ultimo\Dependencies\Amp\Http\Client\Connection\Stream;
use WP_Ultimo\Dependencies\Amp\Promise;
/**
* Allows listening to more fine granular events than interceptors are able to achieve.
*
* All event listener methods might be called multiple times for a single request. The implementing listener is
* responsible to detect another call, e.g. via attributes in the request.
*/
interface EventListener
{
/**
* Called at the very beginning of {@see DelegateHttpClient::request()}.
*
* @param Request $request
*
* @return Promise Should resolve successfully, otherwise aborts the request.
*/
public function startRequest(Request $request) : Promise;
/**
* Optionally called by {@see ConnectionPool::getStream()} before DNS resolution is started.
*
* @param Request $request
*
* @return Promise Should resolve successfully, otherwise aborts the request.
*/
public function startDnsResolution(Request $request) : Promise;
/**
* Optionally called by {@see ConnectionPool::getStream()} after DNS resolution is completed.
*
* @param Request $request
*
* @return Promise Should resolve successfully, otherwise aborts the request.
*/
public function completeDnsResolution(Request $request) : Promise;
/**
* Called by {@see ConnectionPool::getStream()} before a new connection is initiated.
*
* @param Request $request
*
* @return Promise Should resolve successfully, otherwise aborts the request.
*/
public function startConnectionCreation(Request $request) : Promise;
/**
* Called by {@see ConnectionPool::getStream()} after a new connection is established and TLS negotiated.
*
* @param Request $request
*
* @return Promise Should resolve successfully, otherwise aborts the request.
*/
public function completeConnectionCreation(Request $request) : Promise;
/**
* Called by {@see ConnectionPool::getStream()} before TLS negotiation is started (only if HTTPS is used).
*
* @param Request $request
*
* @return Promise Should resolve successfully, otherwise aborts the request.
*/
public function startTlsNegotiation(Request $request) : Promise;
/**
* Called by {@see ConnectionPool::getStream()} after TLS negotiation is successful (only if HTTPS is used).
*
* @param Request $request
*
* @return Promise Should resolve successfully, otherwise aborts the request.
*/
public function completeTlsNegotiation(Request $request) : Promise;
/**
* Called by {@see Stream::request()} before the request is sent.
*
* @param Request $request
* @param Stream $stream
*
* @return Promise Should resolve successfully, otherwise aborts the request.
*/
public function startSendingRequest(Request $request, Stream $stream) : Promise;
/**
* Called by {@see Stream::request()} after the request is sent.
*
* @param Request $request
* @param Stream $stream
*
* @return Promise Should resolve successfully, otherwise aborts the request.
*/
public function completeSendingRequest(Request $request, Stream $stream) : Promise;
/**
* Called by {@see Stream::request()} after the first response byte is received.
*
* @param Request $request
* @param Stream $stream
*
* @return Promise Should resolve successfully, otherwise aborts the request.
*/
public function startReceivingResponse(Request $request, Stream $stream) : Promise;
/**
* Called by {@see Stream::request()} after the request is complete.
*
* @param Request $request
* @param Stream $stream
*
* @return Promise Should resolve successfully, otherwise aborts the request.
*/
public function completeReceivingResponse(Request $request, Stream $stream) : Promise;
/**
* Called if the request is aborted.
*
* @param Request $request
* @param \Throwable $cause
*
* @return Promise Should resolve successfully.
*/
public function abort(Request $request, \Throwable $cause) : Promise;
}

View File

@ -0,0 +1,81 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\EventListener;
use WP_Ultimo\Dependencies\Amp\Http\Client\Connection\Stream;
use WP_Ultimo\Dependencies\Amp\Http\Client\EventListener;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\HarAttributes;
use WP_Ultimo\Dependencies\Amp\Http\Client\Request;
use WP_Ultimo\Dependencies\Amp\Promise;
use WP_Ultimo\Dependencies\Amp\Success;
use function WP_Ultimo\Dependencies\Amp\getCurrentTime;
final class RecordHarAttributes implements EventListener
{
public function startRequest(Request $request) : Promise
{
if (!$request->hasAttribute(HarAttributes::STARTED_DATE_TIME)) {
$request->setAttribute(HarAttributes::STARTED_DATE_TIME, new \DateTimeImmutable());
}
return $this->addTiming(HarAttributes::TIME_START, $request);
}
public function startDnsResolution(Request $request) : Promise
{
return new Success();
// not implemented
}
public function startConnectionCreation(Request $request) : Promise
{
return $this->addTiming(HarAttributes::TIME_CONNECT, $request);
}
public function startTlsNegotiation(Request $request) : Promise
{
return $this->addTiming(HarAttributes::TIME_SSL, $request);
}
public function startSendingRequest(Request $request, Stream $stream) : Promise
{
$host = $stream->getRemoteAddress()->getHost();
if (\strrpos($host, ':')) {
$host = '[' . $host . ']';
}
$request->setAttribute(HarAttributes::SERVER_IP_ADDRESS, $host);
return $this->addTiming(HarAttributes::TIME_SEND, $request);
}
public function completeSendingRequest(Request $request, Stream $stream) : Promise
{
return $this->addTiming(HarAttributes::TIME_WAIT, $request);
}
public function startReceivingResponse(Request $request, Stream $stream) : Promise
{
return $this->addTiming(HarAttributes::TIME_RECEIVE, $request);
}
public function completeReceivingResponse(Request $request, Stream $stream) : Promise
{
return $this->addTiming(HarAttributes::TIME_COMPLETE, $request);
}
public function completeDnsResolution(Request $request) : Promise
{
return new Success();
// not implemented
}
public function completeConnectionCreation(Request $request) : Promise
{
return new Success();
// not implemented
}
public function completeTlsNegotiation(Request $request) : Promise
{
return new Success();
// not implemented
}
private function addTiming(string $key, Request $request) : Promise
{
if (!$request->hasAttribute($key)) {
$request->setAttribute($key, getCurrentTime());
}
return new Success();
}
public function abort(Request $request, \Throwable $cause) : Promise
{
return new Success();
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client;
use WP_Ultimo\Dependencies\Amp\CancellationToken;
use WP_Ultimo\Dependencies\Amp\NullCancellationToken;
use WP_Ultimo\Dependencies\Amp\Promise;
/**
* Convenient HTTP client for use in applications and libraries, providing a default for the cancellation token and
* automatically cloning the passed request, so future application requests can re-use the same object again.
*/
final class HttpClient implements DelegateHttpClient
{
private $httpClient;
public function __construct(DelegateHttpClient $httpClient)
{
$this->httpClient = $httpClient;
}
/**
* Request a specific resource from an HTTP server.
*
* @param Request $request
* @param CancellationToken $cancellation
*
* @return Promise<Response>
*/
public function request(Request $request, ?CancellationToken $cancellation = null) : Promise
{
return $this->httpClient->request(clone $request, $cancellation ?? new NullCancellationToken());
}
}

View File

@ -0,0 +1,204 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client;
use WP_Ultimo\Dependencies\Amp\Http\Client\Connection\ConnectionPool;
use WP_Ultimo\Dependencies\Amp\Http\Client\Connection\UnlimitedConnectionPool;
use WP_Ultimo\Dependencies\Amp\Http\Client\Interceptor\DecompressResponse;
use WP_Ultimo\Dependencies\Amp\Http\Client\Interceptor\FollowRedirects;
use WP_Ultimo\Dependencies\Amp\Http\Client\Interceptor\ForbidUriUserInfo;
use WP_Ultimo\Dependencies\Amp\Http\Client\Interceptor\RetryRequests;
use WP_Ultimo\Dependencies\Amp\Http\Client\Interceptor\SetRequestHeaderIfUnset;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidCloning;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidSerialization;
/**
* Allows building an HttpClient instance.
*
* The builder is the recommended way to build an HttpClient instance.
*/
final class HttpClientBuilder
{
use ForbidCloning;
use ForbidSerialization;
public static function buildDefault() : HttpClient
{
return (new self())->build();
}
/** @var ForbidUriUserInfo|null */
private $forbidUriUserInfo;
/** @var RetryRequests|null */
private $retryInterceptor;
/** @var FollowRedirects|null */
private $followRedirectsInterceptor;
/** @var SetRequestHeaderIfUnset|null */
private $defaultUserAgentInterceptor;
/** @var SetRequestHeaderIfUnset|null */
private $defaultAcceptInterceptor;
/** @var NetworkInterceptor|null */
private $defaultCompressionHandler;
/** @var ApplicationInterceptor[] */
private $applicationInterceptors = [];
/** @var NetworkInterceptor[] */
private $networkInterceptors = [];
/** @var ConnectionPool */
private $pool;
public function __construct()
{
$this->pool = new UnlimitedConnectionPool();
$this->forbidUriUserInfo = new ForbidUriUserInfo();
$this->followRedirectsInterceptor = new FollowRedirects(10);
$this->retryInterceptor = new RetryRequests(2);
$this->defaultAcceptInterceptor = new SetRequestHeaderIfUnset('accept', '*/*');
$this->defaultUserAgentInterceptor = new SetRequestHeaderIfUnset('user-agent', 'amphp/http-client @ v4.x');
$this->defaultCompressionHandler = new DecompressResponse();
}
public function build() : HttpClient
{
/** @var PooledHttpClient $client */
$client = new PooledHttpClient($this->pool);
foreach ($this->networkInterceptors as $interceptor) {
$client = $client->intercept($interceptor);
}
if ($this->defaultAcceptInterceptor) {
$client = $client->intercept($this->defaultAcceptInterceptor);
}
if ($this->defaultUserAgentInterceptor) {
$client = $client->intercept($this->defaultUserAgentInterceptor);
}
if ($this->defaultCompressionHandler) {
$client = $client->intercept($this->defaultCompressionHandler);
}
$applicationInterceptors = $this->applicationInterceptors;
if ($this->followRedirectsInterceptor) {
\array_unshift($applicationInterceptors, $this->followRedirectsInterceptor);
}
if ($this->forbidUriUserInfo) {
\array_unshift($applicationInterceptors, $this->forbidUriUserInfo);
}
if ($this->retryInterceptor) {
$applicationInterceptors[] = $this->retryInterceptor;
}
foreach (\array_reverse($applicationInterceptors) as $applicationInterceptor) {
$client = new InterceptedHttpClient($client, $applicationInterceptor);
}
return new HttpClient($client);
}
/**
* @param ConnectionPool $pool Connection pool to use.
*
* @return self
*/
public function usingPool(ConnectionPool $pool) : self
{
$builder = clone $this;
$builder->pool = $pool;
return $builder;
}
/**
* @param ApplicationInterceptor $interceptor This interceptor gets added to the interceptor queue, so interceptors
* are executed in the order given to this method.
*
* @return self
*/
public function intercept(ApplicationInterceptor $interceptor) : self
{
if ($this->followRedirectsInterceptor !== null && $interceptor instanceof FollowRedirects) {
throw new \Error('Disable automatic redirect following or use HttpClientBuilder::followRedirects() to customize redirects');
}
if ($this->retryInterceptor !== null && $interceptor instanceof RetryRequests) {
throw new \Error('Disable automatic retries or use HttpClientBuilder::retry() to customize retries');
}
$builder = clone $this;
$builder->applicationInterceptors[] = $interceptor;
return $builder;
}
/**
* @param NetworkInterceptor $interceptor This interceptor gets added to the interceptor queue, so interceptors
* are executed in the order given to this method.
*
* @return self
*/
public function interceptNetwork(NetworkInterceptor $interceptor) : self
{
$builder = clone $this;
$builder->networkInterceptors[] = $interceptor;
return $builder;
}
/**
* @param int $retryLimit Maximum number of times a request may be retried. Only certain requests will be retried
* automatically (GET, HEAD, PUT, and DELETE requests are automatically retried, or any
* request that was indicated as unprocessed by the connection).
*
* @return self
*/
public function retry(int $retryLimit) : self
{
$builder = clone $this;
if ($retryLimit <= 0) {
$builder->retryInterceptor = null;
} else {
$builder->retryInterceptor = new RetryRequests($retryLimit);
}
return $builder;
}
/**
* @param int $limit Maximum number of redirects to follow. The client will automatically request the URI supplied
* by a redirect response (3xx status codes) and returns that response instead.
*
* @return self
*/
public function followRedirects(int $limit = 10) : self
{
$builder = clone $this;
if ($limit <= 0) {
$builder->followRedirectsInterceptor = null;
} else {
$builder->followRedirectsInterceptor = new FollowRedirects($limit);
}
return $builder;
}
/**
* Removes the default restriction of user:password in request URIs.
*
* @return self
*/
public function allowDeprecatedUriUserInfo() : self
{
$builder = clone $this;
$builder->forbidUriUserInfo = null;
return $builder;
}
/**
* Doesn't automatically set an 'accept' header.
*
* @return self
*/
public function skipDefaultAcceptHeader() : self
{
$builder = clone $this;
$builder->defaultAcceptInterceptor = null;
return $builder;
}
/**
* Doesn't automatically set a 'user-agent' header.
*
* @return self
*/
public function skipDefaultUserAgent() : self
{
$builder = clone $this;
$builder->defaultUserAgentInterceptor = null;
return $builder;
}
/**
* Doesn't automatically set an 'accept-encoding' header and decompress the response.
*
* @return self
*/
public function skipAutomaticCompression() : self
{
$builder = clone $this;
$builder->defaultCompressionHandler = null;
return $builder;
}
}

View File

@ -0,0 +1,7 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client;
class HttpException extends \Exception
{
}

View File

@ -0,0 +1,32 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client;
use WP_Ultimo\Dependencies\Amp\CancellationToken;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidCloning;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidSerialization;
use WP_Ultimo\Dependencies\Amp\Promise;
use function WP_Ultimo\Dependencies\Amp\call;
final class InterceptedHttpClient implements DelegateHttpClient
{
use ForbidCloning;
use ForbidSerialization;
/** @var DelegateHttpClient */
private $httpClient;
/** @var ApplicationInterceptor */
private $interceptor;
public function __construct(DelegateHttpClient $httpClient, ApplicationInterceptor $interceptor)
{
$this->httpClient = $httpClient;
$this->interceptor = $interceptor;
}
public function request(Request $request, CancellationToken $cancellation) : Promise
{
return call(function () use($request, $cancellation) {
foreach ($request->getEventListeners() as $eventListener) {
(yield $eventListener->startRequest($request));
}
return $this->interceptor->request($request, $cancellation, $this->httpClient);
});
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Interceptor;
use WP_Ultimo\Dependencies\Amp\Http\Client\Request;
final class AddRequestHeader extends ModifyRequest
{
public function __construct(string $headerName, string ...$headerValues)
{
parent::__construct(static function (Request $request) use($headerName, $headerValues) {
$request->addHeader($headerName, $headerValues);
return $request;
});
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Interceptor;
use WP_Ultimo\Dependencies\Amp\Http\Client\Response;
final class AddResponseHeader extends ModifyResponse
{
public function __construct(string $headerName, string ...$headerValues)
{
parent::__construct(static function (Response $response) use($headerName, $headerValues) {
$response->addHeader($headerName, $headerValues);
return $response;
});
}
}

View File

@ -0,0 +1,76 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Interceptor;
use WP_Ultimo\Dependencies\Amp\ByteStream\ZlibInputStream;
use WP_Ultimo\Dependencies\Amp\CancellationToken;
use WP_Ultimo\Dependencies\Amp\Http\Client\Connection\Stream;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidCloning;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidSerialization;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\SizeLimitingInputStream;
use WP_Ultimo\Dependencies\Amp\Http\Client\NetworkInterceptor;
use WP_Ultimo\Dependencies\Amp\Http\Client\Request;
use WP_Ultimo\Dependencies\Amp\Http\Client\Response;
use WP_Ultimo\Dependencies\Amp\Promise;
use function WP_Ultimo\Dependencies\Amp\call;
final class DecompressResponse implements NetworkInterceptor
{
use ForbidCloning;
use ForbidSerialization;
/** @var bool */
private $hasZlib;
public function __construct()
{
$this->hasZlib = \extension_loaded('zlib');
}
public function requestViaNetwork(Request $request, CancellationToken $cancellation, Stream $stream) : Promise
{
// If a header is manually set, we won't interfere
if ($request->hasHeader('accept-encoding')) {
return $stream->request($request, $cancellation);
}
return call(function () use($request, $cancellation, $stream) {
$this->addAcceptEncodingHeader($request);
$request->interceptPush(function (Response $response) {
return $this->decompressResponse($response);
});
return $this->decompressResponse((yield $stream->request($request, $cancellation)));
});
}
private function addAcceptEncodingHeader(Request $request) : void
{
if ($this->hasZlib) {
$request->setHeader('Accept-Encoding', 'gzip, deflate, identity');
}
}
private function decompressResponse(Response $response) : Response
{
if ($encoding = $this->determineCompressionEncoding($response)) {
$sizeLimit = $response->getRequest()->getBodySizeLimit();
/** @noinspection PhpUnhandledExceptionInspection */
$decompressedBody = new ZlibInputStream($response->getBody(), $encoding);
$response->setBody(new SizeLimitingInputStream($decompressedBody, $sizeLimit));
$response->removeHeader('content-encoding');
}
return $response;
}
private function determineCompressionEncoding(Response $response) : int
{
if (!$this->hasZlib) {
return 0;
}
if (!$response->hasHeader("content-encoding")) {
return 0;
}
$contentEncoding = $response->getHeader("content-encoding");
\assert($contentEncoding !== null);
$contentEncodingHeader = \trim($contentEncoding);
if (\strcasecmp($contentEncodingHeader, 'gzip') === 0) {
return \ZLIB_ENCODING_GZIP;
}
if (\strcasecmp($contentEncodingHeader, 'deflate') === 0) {
return \ZLIB_ENCODING_DEFLATE;
}
return 0;
}
}

View File

@ -0,0 +1,245 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Interceptor;
use WP_Ultimo\Dependencies\Amp\ByteStream\StreamException;
use WP_Ultimo\Dependencies\Amp\CancellationToken;
use WP_Ultimo\Dependencies\Amp\Http\Client\ApplicationInterceptor;
use WP_Ultimo\Dependencies\Amp\Http\Client\DelegateHttpClient;
use WP_Ultimo\Dependencies\Amp\Http\Client\HttpException;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidCloning;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidSerialization;
use WP_Ultimo\Dependencies\Amp\Http\Client\Request;
use WP_Ultimo\Dependencies\Amp\Http\Client\Response;
use WP_Ultimo\Dependencies\Amp\Promise;
use WP_Ultimo\Dependencies\League\Uri;
use WP_Ultimo\Dependencies\Psr\Http\Message\UriInterface as PsrUri;
use function WP_Ultimo\Dependencies\Amp\call;
final class FollowRedirects implements ApplicationInterceptor
{
use ForbidCloning;
use ForbidSerialization;
/**
* Resolves the given path in $locationUri using $baseUri as a base URI. For example, a base URI of
* http://example.com/example/path and a location path of 'to/resolve' will return a URI of
* http://example.com/example/to/resolve.
*
* @param PsrUri $baseUri
* @param PsrUri $locationUri
*
* @return PsrUri
*/
public static function resolve(PsrUri $baseUri, PsrUri $locationUri) : PsrUri
{
if ((string) $locationUri === '') {
return $baseUri;
}
if ($locationUri->getScheme() !== '' || $locationUri->getHost() !== '') {
$resultUri = $locationUri->withPath(self::removeDotSegments($locationUri->getPath()));
if ($locationUri->getScheme() === '') {
$resultUri = $resultUri->withScheme($baseUri->getScheme());
}
return $resultUri;
}
$baseUri = $baseUri->withQuery($locationUri->getQuery());
$baseUri = $baseUri->withFragment($locationUri->getFragment());
if ($locationUri->getPath() !== '' && \substr($locationUri->getPath(), 0, 1) === "/") {
$baseUri = $baseUri->withPath(self::removeDotSegments($locationUri->getPath()));
} else {
$baseUri = $baseUri->withPath(self::mergePaths($baseUri->getPath(), $locationUri->getPath()));
}
return $baseUri;
}
/**
* @param string $input
*
* @return string
*
* @link http://www.apps.ietf.org/rfc/rfc3986.html#sec-5.2.4
*/
private static function removeDotSegments(string $input) : string
{
$output = '';
$patternA = ',^(\\.\\.?/),';
$patternB1 = ',^(/\\./),';
$patternB2 = ',^(/\\.)$,';
$patternC = ',^(/\\.\\./|/\\.\\.),';
// $patternD = ',^(\.\.?)$,';
$patternE = ',(/*[^/]*),';
while ($input !== '') {
if (\preg_match($patternA, $input)) {
$input = \preg_replace($patternA, '', $input);
} elseif (\preg_match($patternB1, $input, $match) || \preg_match($patternB2, $input, $match)) {
$input = \preg_replace(",^" . $match[1] . ",", '/', $input);
} elseif (\preg_match($patternC, $input, $match)) {
$input = \preg_replace(',^' . \preg_quote($match[1], ',') . ',', '/', $input);
$output = \preg_replace(',/([^/]+)$,', '', $output);
} elseif ($input === '.' || $input === '..') {
// pattern D
$input = '';
} elseif (\preg_match($patternE, $input, $match)) {
$initialSegment = $match[1];
$input = \preg_replace(',^' . \preg_quote($initialSegment, ',') . ',', '', $input, 1);
$output .= $initialSegment;
}
}
return $output;
}
/**
* @param string $basePath
* @param string $pathToMerge
*
* @return string
*
* @link http://tools.ietf.org/html/rfc3986#section-5.2.3
*/
private static function mergePaths(string $basePath, string $pathToMerge) : string
{
if ($pathToMerge === '') {
return self::removeDotSegments($basePath);
}
if ($basePath === '') {
return self::removeDotSegments('/' . $pathToMerge);
}
$parts = \explode('/', $basePath);
\array_pop($parts);
$parts[] = $pathToMerge;
return self::removeDotSegments(\implode('/', $parts));
}
/** @var int */
private $maxRedirects;
/** @var bool */
private $autoReferrer;
public function __construct(int $limit, bool $autoReferrer = \true)
{
if ($limit < 1) {
/** @noinspection PhpUndefinedClassInspection */
throw new \Error("Invalid redirection limit: " . $limit);
}
$this->maxRedirects = $limit;
$this->autoReferrer = $autoReferrer;
}
public function request(Request $request, CancellationToken $cancellation, DelegateHttpClient $httpClient) : Promise
{
// Don't follow redirects on pushes, just store the redirect in cache (if an interceptor is configured)
return call(function () use($request, $cancellation, $httpClient) {
/** @var Response $response */
$response = (yield $httpClient->request(clone $request, $cancellation));
$response = (yield from $this->followRedirects($request, $response, $httpClient, $cancellation));
return $response;
});
}
private function followRedirects(Request $request, Response $response, DelegateHttpClient $client, CancellationToken $cancellationToken) : \Generator
{
$previousResponse = null;
$maxRedirects = $this->maxRedirects;
$requestNr = 2;
do {
$request = (yield from $this->createRedirectRequest($request, $response));
if ($request === null) {
return $response;
}
/** @var Response $redirectResponse */
$redirectResponse = (yield $client->request(clone $request, $cancellationToken));
$redirectResponse->setPreviousResponse($response);
$response = $redirectResponse;
} while (++$requestNr <= $maxRedirects + 1);
if ($this->getRedirectUri($response) !== null) {
throw new TooManyRedirectsException($response);
}
return $response;
}
private function createRedirectRequest(Request $originalRequest, Response $response) : \Generator
{
$redirectUri = $this->getRedirectUri($response);
if ($redirectUri === null) {
return null;
}
$originalUri = $response->getRequest()->getUri();
$isSameHost = $redirectUri->getAuthority() === $originalUri->getAuthority();
$request = clone $originalRequest;
$request->setMethod('GET');
$request->setUri($redirectUri);
$request->removeHeader('transfer-encoding');
$request->removeHeader('content-length');
$request->removeHeader('content-type');
$request->removeAttributes();
$request->setBody(null);
if (!$isSameHost) {
// Remove for security reasons, any interceptor headers will be added again,
// but application headers will be discarded.
foreach ($request->getRawHeaders() as [$field]) {
$request->removeHeader($field);
}
}
if ($this->autoReferrer) {
$this->assignRedirectRefererHeader($request, $originalUri, $redirectUri);
}
yield from $this->discardResponseBody($response);
return $request;
}
/**
* Clients must not add a Referer header when leaving an unencrypted resource and redirecting to an encrypted
* resource.
*
* @param Request $request
* @param PsrUri $referrerUri
* @param PsrUri $followUri
*
* @link http://www.w3.org/Protocols/rfc2616/rfc2616-sec15.html#sec15.1.3
*/
private function assignRedirectRefererHeader(Request $request, PsrUri $referrerUri, PsrUri $followUri) : void
{
$referrerIsEncrypted = $referrerUri->getScheme() === 'https';
$destinationIsEncrypted = $followUri->getScheme() === 'https';
if (!$referrerIsEncrypted || $destinationIsEncrypted) {
$request->setHeader('Referer', (string) $referrerUri->withUserInfo('')->withFragment(''));
} else {
$request->removeHeader('Referer');
}
}
private function getRedirectUri(Response $response) : ?PsrUri
{
if (\count($response->getHeaderArray('location')) !== 1) {
return null;
}
$status = $response->getStatus();
$request = $response->getRequest();
$method = $request->getMethod();
if ($method !== 'GET' && \in_array($status, [307, 308], \true)) {
return null;
}
// We don't automatically follow:
// - 300 (Multiple Choices)
// - 304 (Not Modified)
// - 305 (Use Proxy)
if (!\in_array($status, [301, 302, 303, 307, 308], \true)) {
return null;
}
try {
$location = $response->getHeader('location');
\assert($location !== null);
$locationUri = Uri\Http::createFromString($location);
} catch (\Exception $e) {
return null;
}
return self::resolve($request->getUri(), $locationUri);
}
private function discardResponseBody(Response $response) : \Generator
{
// Discard response body of redirect responses
$body = $response->getBody();
try {
/** @noinspection PhpStatementHasEmptyBodyInspection */
/** @noinspection LoopWhichDoesNotLoopInspection */
/** @noinspection MissingOrEmptyGroupStatementInspection */
while (null !== (yield $body->read())) {
// discard
}
} catch (HttpException|StreamException $e) {
// ignore streaming errors on previous responses
} finally {
unset($body);
}
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Interceptor;
use WP_Ultimo\Dependencies\Amp\Http\Client\InvalidRequestException;
use WP_Ultimo\Dependencies\Amp\Http\Client\Request;
final class ForbidUriUserInfo extends ModifyRequest
{
public function __construct()
{
parent::__construct(static function (Request $request) {
if ($request->getUri()->getUserInfo() !== '') {
throw new InvalidRequestException($request, 'The user information (username:password) component of URIs has been deprecated ' . '(see https://tools.ietf.org/html/rfc3986#section-3.2.1 and https://tools.ietf.org/html/rfc7230#section-2.7.1); ' . 'Instead, set an "Authorization" header containing "Basic " . \\base64_encode("username:password"). ' . 'If you used HttpClientBuilder, you can use HttpClientBuilder::allowDeprecatedUriUserInfo() to disable this protection. ' . 'Doing so is strongly discouraged and you need to be aware of any interceptor using UriInterface::__toString(), which might expose the password in headers or logs.');
}
});
}
}

View File

@ -0,0 +1,148 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Interceptor;
use WP_Ultimo\Dependencies\Amp\CancellationToken;
use WP_Ultimo\Dependencies\Amp\File;
use WP_Ultimo\Dependencies\Amp\File\Driver;
use WP_Ultimo\Dependencies\Amp\Http\Client\ApplicationInterceptor;
use WP_Ultimo\Dependencies\Amp\Http\Client\DelegateHttpClient;
use WP_Ultimo\Dependencies\Amp\Http\Client\EventListener;
use WP_Ultimo\Dependencies\Amp\Http\Client\EventListener\RecordHarAttributes;
use WP_Ultimo\Dependencies\Amp\Http\Client\HttpException;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidCloning;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidSerialization;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\HarAttributes;
use WP_Ultimo\Dependencies\Amp\Http\Client\Request;
use WP_Ultimo\Dependencies\Amp\Http\Client\Response;
use WP_Ultimo\Dependencies\Amp\Http\Message;
use WP_Ultimo\Dependencies\Amp\Promise;
use WP_Ultimo\Dependencies\Amp\Sync\LocalMutex;
use WP_Ultimo\Dependencies\Amp\Sync\Lock;
use function WP_Ultimo\Dependencies\Amp\call;
use function WP_Ultimo\Dependencies\Amp\Promise\rethrow;
final class LogHttpArchive implements ApplicationInterceptor
{
use ForbidCloning;
use ForbidSerialization;
private static function getTime(Request $request, string $start, string ...$ends) : int
{
if (!$request->hasAttribute($start)) {
return -1;
}
foreach ($ends as $end) {
if ($request->hasAttribute($end)) {
return $request->getAttribute($end) - $request->getAttribute($start);
}
}
return -1;
}
private static function formatHeaders(Message $message) : array
{
$headers = [];
foreach ($message->getHeaders() as $field => $values) {
foreach ($values as $value) {
$headers[] = ['name' => $field, 'value' => $value];
}
}
return $headers;
}
private static function formatEntry(Response $response) : array
{
$request = $response->getRequest();
$data = ['startedDateTime' => $request->getAttribute(HarAttributes::STARTED_DATE_TIME)->format(\DateTimeInterface::RFC3339_EXTENDED), 'time' => self::getTime($request, HarAttributes::TIME_START, HarAttributes::TIME_COMPLETE), 'request' => ['method' => $request->getMethod(), 'url' => (string) $request->getUri()->withUserInfo(''), 'httpVersion' => 'http/' . $request->getProtocolVersions()[0], 'headers' => self::formatHeaders($request), 'queryString' => [], 'cookies' => [], 'headersSize' => -1, 'bodySize' => -1], 'response' => ['status' => $response->getStatus(), 'statusText' => $response->getReason(), 'httpVersion' => 'http/' . $response->getProtocolVersion(), 'headers' => self::formatHeaders($response), 'cookies' => [], 'redirectURL' => $response->getHeader('location') ?? '', 'headersSize' => -1, 'bodySize' => -1, 'content' => ['size' => (int) ($response->getHeader('content-length') ?? '-1'), 'mimeType' => $response->getHeader('content-type') ?? '']], 'cache' => [], 'timings' => ['blocked' => self::getTime($request, HarAttributes::TIME_START, HarAttributes::TIME_CONNECT, HarAttributes::TIME_SEND), 'dns' => -1, 'connect' => self::getTime($request, HarAttributes::TIME_CONNECT, HarAttributes::TIME_SEND), 'ssl' => self::getTime($request, HarAttributes::TIME_SSL, HarAttributes::TIME_SEND), 'send' => self::getTime($request, HarAttributes::TIME_SEND, HarAttributes::TIME_WAIT), 'wait' => self::getTime($request, HarAttributes::TIME_WAIT, HarAttributes::TIME_RECEIVE), 'receive' => self::getTime($request, HarAttributes::TIME_RECEIVE, HarAttributes::TIME_COMPLETE)]];
if ($request->hasAttribute(HarAttributes::SERVER_IP_ADDRESS)) {
$data['serverIPAddress'] = $request->getAttribute(HarAttributes::SERVER_IP_ADDRESS);
}
return $data;
}
/** @var LocalMutex */
private $fileMutex;
/** @var File\File|null */
private $fileHandle;
/** @var string */
private $filePath;
/** @var \Throwable|null */
private $error;
/** @var EventListener */
private $eventListener;
public function __construct(string $filePath)
{
$this->filePath = $filePath;
$this->fileMutex = new LocalMutex();
$this->eventListener = new RecordHarAttributes();
if (!\interface_exists(Driver::class)) {
throw new \Error(__CLASS__ . ' requires amphp/file to be installed');
}
}
public function request(Request $request, CancellationToken $cancellation, DelegateHttpClient $httpClient) : Promise
{
return call(function () use($request, $cancellation, $httpClient) {
if ($this->error) {
throw $this->error;
}
$this->ensureEventListenerIsRegistered($request);
/** @var Response $response */
$response = (yield $httpClient->request($request, $cancellation));
rethrow($this->writeLog($response));
return $response;
});
}
public function reset() : Promise
{
return $this->rotate($this->filePath);
}
public function rotate(string $filePath) : Promise
{
return call(function () use($filePath) {
/** @var Lock $lock */
$lock = (yield $this->fileMutex->acquire());
// Will automatically reopen and reset the file
$this->fileHandle = null;
$this->filePath = $filePath;
$this->error = null;
$lock->release();
});
}
private function writeLog(Response $response) : Promise
{
return call(function () use($response) {
try {
(yield $response->getTrailers());
} catch (\Throwable $e) {
// ignore, still log the remaining response times
}
try {
/** @var Lock $lock */
$lock = (yield $this->fileMutex->acquire());
$firstEntry = $this->fileHandle === null;
if ($firstEntry) {
$this->fileHandle = (yield \function_exists('WP_Ultimo\\Dependencies\\Amp\\File\\openFile') ? File\openFile($this->filePath, 'w') : File\open($this->filePath, 'w'));
$header = '{"log":{"version":"1.2","creator":{"name":"amphp/http-client","version":"4.x"},"pages":[],"entries":[';
(yield $this->fileHandle->write($header));
} else {
\assert($this->fileHandle !== null);
(yield $this->fileHandle->seek(-3, \SEEK_CUR));
}
/** @noinspection PhpComposerExtensionStubsInspection */
$json = \json_encode(self::formatEntry($response));
(yield $this->fileHandle->write(($firstEntry ? '' : ',') . $json . ']}}'));
$lock->release();
} catch (HttpException $e) {
$this->error = $e;
} catch (\Throwable $e) {
$this->error = new HttpException('Writing HTTP archive log failed', 0, $e);
}
});
}
private function ensureEventListenerIsRegistered(Request $request) : void
{
foreach ($request->getEventListeners() as $eventListener) {
if ($eventListener instanceof RecordHarAttributes) {
return;
// user added it manually
}
}
$request->addEventListener($this->eventListener);
}
}

View File

@ -0,0 +1,80 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Interceptor;
use WP_Ultimo\Dependencies\Amp\CancellationToken;
use WP_Ultimo\Dependencies\Amp\Http\Client\ApplicationInterceptor;
use WP_Ultimo\Dependencies\Amp\Http\Client\DelegateHttpClient;
use WP_Ultimo\Dependencies\Amp\Http\Client\HttpException;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidCloning;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidSerialization;
use WP_Ultimo\Dependencies\Amp\Http\Client\Request;
use WP_Ultimo\Dependencies\Amp\Promise;
use WP_Ultimo\Dependencies\League\Uri\Http;
use WP_Ultimo\Dependencies\Psr\Http\Message\UriInterface;
final class MatchOrigin implements ApplicationInterceptor
{
use ForbidCloning;
use ForbidSerialization;
/** @var ApplicationInterceptor[] */
private $originMap = [];
/** @var ApplicationInterceptor|null */
private $default;
/**
* @param ApplicationInterceptor[] $originMap
* @param ApplicationInterceptor $default
*
* @throws HttpException
*/
public function __construct(array $originMap, ?ApplicationInterceptor $default = null)
{
foreach ($originMap as $origin => $interceptor) {
if (!$interceptor instanceof ApplicationInterceptor) {
$type = \is_object($interceptor) ? \get_class($interceptor) : \gettype($interceptor);
throw new HttpException('Origin map must be a map from origin to ApplicationInterceptor, got ' . $type);
}
$this->originMap[$this->checkOrigin($origin)] = $interceptor;
}
$this->default = $default;
}
public function request(Request $request, CancellationToken $cancellation, DelegateHttpClient $httpClient) : Promise
{
$interceptor = $this->originMap[$this->normalizeOrigin($request->getUri())] ?? $this->default;
if (!$interceptor) {
return $httpClient->request($request, $cancellation);
}
return $interceptor->request($request, $cancellation, $httpClient);
}
private function checkOrigin(string $origin) : string
{
try {
$originUri = Http::createFromString($origin);
} catch (\Exception $e) {
throw new HttpException("Invalid origin provided: parsing failed: " . $origin);
}
if (!\in_array($originUri->getScheme(), ['http', 'https'], \true)) {
throw new HttpException('Invalid origin with unsupported scheme: ' . $origin);
}
if ($originUri->getHost() === '') {
throw new HttpException('Invalid origin without host: ' . $origin);
}
if ($originUri->getUserInfo() !== '') {
throw new HttpException('Invalid origin with user info, which must not be present: ' . $origin);
}
if (!\in_array($originUri->getPath(), ['', '/'], \true)) {
throw new HttpException('Invalid origin with path, which must not be present: ' . $origin);
}
if ($originUri->getQuery() !== '') {
throw new HttpException('Invalid origin with query, which must not be present: ' . $origin);
}
if ($originUri->getFragment() !== '') {
throw new HttpException('Invalid origin with fragment, which must not be present: ' . $origin);
}
return $this->normalizeOrigin($originUri);
}
private function normalizeOrigin(UriInterface $uri) : string
{
$defaultPort = $uri->getScheme() === 'https' ? 443 : 80;
return $uri->getScheme() . '://' . $uri->getHost() . ':' . ($uri->getPort() ?? $defaultPort);
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Interceptor;
use WP_Ultimo\Dependencies\Amp\CancellationToken;
use WP_Ultimo\Dependencies\Amp\Http\Client\ApplicationInterceptor;
use WP_Ultimo\Dependencies\Amp\Http\Client\Connection\Stream;
use WP_Ultimo\Dependencies\Amp\Http\Client\DelegateHttpClient;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidCloning;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidSerialization;
use WP_Ultimo\Dependencies\Amp\Http\Client\NetworkInterceptor;
use WP_Ultimo\Dependencies\Amp\Http\Client\Request;
use WP_Ultimo\Dependencies\Amp\Http\Client\Response;
use WP_Ultimo\Dependencies\Amp\Promise;
use function WP_Ultimo\Dependencies\Amp\call;
class ModifyRequest implements NetworkInterceptor, ApplicationInterceptor
{
use ForbidCloning;
use ForbidSerialization;
/** @var callable(Request):(\Generator<mixed, mixed, mixed, Promise<Request|null>|Request|null>|Promise<Request|null>|Request|null) */
private $mapper;
/**
* @psalm-param callable(Request):(\Generator<mixed, mixed, mixed, Promise<Request|null>|Request|null>|Promise<Request|null>|Request|null) $mapper
*/
public function __construct(callable $mapper)
{
$this->mapper = $mapper;
}
/**
* @param Request $request
* @param CancellationToken $cancellation
* @param Stream $stream
*
* @return Promise<Response>
*/
public final function requestViaNetwork(Request $request, CancellationToken $cancellation, Stream $stream) : Promise
{
return call(function () use($request, $cancellation, $stream) {
$mappedRequest = (yield call($this->mapper, $request));
\assert($mappedRequest instanceof Request || $mappedRequest === null);
return (yield $stream->request($mappedRequest ?? $request, $cancellation));
});
}
public function request(Request $request, CancellationToken $cancellation, DelegateHttpClient $httpClient) : Promise
{
return call(function () use($request, $cancellation, $httpClient) {
$mappedRequest = (yield call($this->mapper, $request));
\assert($mappedRequest instanceof Request || $mappedRequest === null);
return $httpClient->request($mappedRequest ?? $request, $cancellation);
});
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Interceptor;
use WP_Ultimo\Dependencies\Amp\CancellationToken;
use WP_Ultimo\Dependencies\Amp\Http\Client\ApplicationInterceptor;
use WP_Ultimo\Dependencies\Amp\Http\Client\Connection\Stream;
use WP_Ultimo\Dependencies\Amp\Http\Client\DelegateHttpClient;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidCloning;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidSerialization;
use WP_Ultimo\Dependencies\Amp\Http\Client\NetworkInterceptor;
use WP_Ultimo\Dependencies\Amp\Http\Client\Request;
use WP_Ultimo\Dependencies\Amp\Http\Client\Response;
use WP_Ultimo\Dependencies\Amp\Promise;
use function WP_Ultimo\Dependencies\Amp\call;
class ModifyResponse implements NetworkInterceptor, ApplicationInterceptor
{
use ForbidCloning;
use ForbidSerialization;
/** @var callable(Response):(\Generator<mixed, mixed, mixed, Response|null>|Promise<Response>|Response|null) */
private $mapper;
/**
* @psalm-param callable(Response):(\Generator<mixed, mixed, mixed, Response|null>|Promise<Response>|Response|null) $mapper
*/
public function __construct(callable $mapper)
{
$this->mapper = $mapper;
}
public final function requestViaNetwork(Request $request, CancellationToken $cancellation, Stream $stream) : Promise
{
return call(function () use($request, $cancellation, $stream) {
$response = (yield $stream->request($request, $cancellation));
$mappedResponse = (yield call($this->mapper, $response));
\assert($mappedResponse instanceof Response || $mappedResponse === null);
return $mappedResponse ?? $response;
});
}
public function request(Request $request, CancellationToken $cancellation, DelegateHttpClient $httpClient) : Promise
{
return call(function () use($request, $cancellation, $httpClient) {
$request->interceptPush($this->mapper);
$response = (yield $httpClient->request($request, $cancellation));
$mappedResponse = (yield call($this->mapper, $response));
\assert($mappedResponse instanceof Response || $mappedResponse === null);
return $mappedResponse ?? $response;
});
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Interceptor;
use WP_Ultimo\Dependencies\Amp\Http\Client\Request;
final class RemoveRequestHeader extends ModifyRequest
{
public function __construct(string $headerName)
{
parent::__construct(static function (Request $request) use($headerName) {
$request->removeHeader($headerName);
return $request;
});
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Interceptor;
use WP_Ultimo\Dependencies\Amp\Http\Client\Response;
final class RemoveResponseHeader extends ModifyResponse
{
public function __construct(string $headerName)
{
parent::__construct(static function (Response $response) use($headerName) {
$response->removeHeader($headerName);
return $response;
});
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Interceptor;
use WP_Ultimo\Dependencies\Amp\CancellationToken;
use WP_Ultimo\Dependencies\Amp\Http\Client\ApplicationInterceptor;
use WP_Ultimo\Dependencies\Amp\Http\Client\Connection\Http2ConnectionException;
use WP_Ultimo\Dependencies\Amp\Http\Client\Connection\UnprocessedRequestException;
use WP_Ultimo\Dependencies\Amp\Http\Client\DelegateHttpClient;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidCloning;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidSerialization;
use WP_Ultimo\Dependencies\Amp\Http\Client\Request;
use WP_Ultimo\Dependencies\Amp\Http\Client\SocketException;
use WP_Ultimo\Dependencies\Amp\Promise;
use function WP_Ultimo\Dependencies\Amp\call;
final class RetryRequests implements ApplicationInterceptor
{
use ForbidCloning;
use ForbidSerialization;
/** @var int */
private $retryLimit;
public function __construct(int $retryLimit)
{
$this->retryLimit = $retryLimit;
}
public function request(Request $request, CancellationToken $cancellation, DelegateHttpClient $httpClient) : Promise
{
return call(function () use($request, $cancellation, $httpClient) {
$attempt = 1;
do {
try {
return (yield $httpClient->request(clone $request, $cancellation));
} catch (UnprocessedRequestException $exception) {
// Request was deemed retryable by connection, so carry on.
} catch (SocketException|Http2ConnectionException $exception) {
if (!$request->isIdempotent()) {
throw $exception;
}
// Request can safely be retried.
}
} while ($attempt++ <= $this->retryLimit);
throw $exception;
});
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Interceptor;
use WP_Ultimo\Dependencies\Amp\Http\Client\Request;
final class SetRequestHeader extends ModifyRequest
{
public function __construct(string $headerName, string $headerValue, string ...$headerValues)
{
\array_unshift($headerValues, $headerValue);
parent::__construct(static function (Request $request) use($headerName, $headerValues) {
$request->setHeader($headerName, $headerValues);
return $request;
});
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Interceptor;
use WP_Ultimo\Dependencies\Amp\Http\Client\Request;
final class SetRequestHeaderIfUnset extends ModifyRequest
{
public function __construct(string $headerName, string $headerValue, string ...$headerValues)
{
\array_unshift($headerValues, $headerValue);
parent::__construct(static function (Request $request) use($headerName, $headerValues) {
if (!$request->hasHeader($headerName)) {
$request->setHeader($headerName, $headerValues);
}
return $request;
});
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Interceptor;
use WP_Ultimo\Dependencies\Amp\Http\Client\Request;
final class SetRequestTimeout extends ModifyRequest
{
public function __construct(int $tcpConnectTimeout = 10000, int $tlsHandshakeTimeout = 10000, int $transferTimeout = 10000)
{
parent::__construct(static function (Request $request) use($tcpConnectTimeout, $tlsHandshakeTimeout, $transferTimeout) {
$request->setTcpConnectTimeout($tcpConnectTimeout);
$request->setTlsHandshakeTimeout($tlsHandshakeTimeout);
$request->setTransferTimeout($transferTimeout);
return $request;
});
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Interceptor;
use WP_Ultimo\Dependencies\Amp\Http\Client\Response;
final class SetResponseHeader extends ModifyResponse
{
public function __construct(string $headerName, string $headerValue, string ...$headerValues)
{
\array_unshift($headerValues, $headerValue);
parent::__construct(static function (Response $response) use($headerName, $headerValues) {
$response->setHeader($headerName, $headerValues);
return $response;
});
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Interceptor;
use WP_Ultimo\Dependencies\Amp\Http\Client\Response;
final class SetResponseHeaderIfUnset extends ModifyResponse
{
public function __construct(string $headerName, string $headerValue, string ...$headerValues)
{
\array_unshift($headerValues, $headerValue);
parent::__construct(static function (Response $response) use($headerName, $headerValues) {
if (!$response->hasHeader($headerName)) {
$response->setHeader($headerName, $headerValues);
}
return $response;
});
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Interceptor;
use WP_Ultimo\Dependencies\Amp\Http\Client\HttpException;
use WP_Ultimo\Dependencies\Amp\Http\Client\Response;
class TooManyRedirectsException extends HttpException
{
/** @var Response */
private $response;
public function __construct(Response $response)
{
parent::__construct("There were too many redirects");
$this->response = $response;
}
public function getResponse() : Response
{
return $this->response;
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Internal;
/** @internal */
trait ForbidCloning
{
protected final function __clone()
{
// clone is automatically denied to all external calls
// final protected instead of private to also force denial for all children
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Internal;
/** @internal */
trait ForbidSerialization
{
public final function __sleep()
{
throw new \Error(__CLASS__ . ' does not support serialization');
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Internal;
/** @internal */
final class HarAttributes
{
use ForbidCloning;
use ForbidSerialization;
public const STARTED_DATE_TIME = 'amp.http.client.har.startedDateTime';
public const SERVER_IP_ADDRESS = 'amp.http.client.har.serverIPAddress';
public const TIME_START = 'amp.http.client.har.timings.start';
public const TIME_SSL = 'amp.http.client.har.timings.ssl';
public const TIME_CONNECT = 'amp.http.client.har.timings.connect';
public const TIME_SEND = 'amp.http.client.har.timings.send';
public const TIME_WAIT = 'amp.http.client.har.timings.wait';
public const TIME_RECEIVE = 'amp.http.client.har.timings.receive';
public const TIME_COMPLETE = 'amp.http.client.har.timings.complete';
}

View File

@ -0,0 +1,40 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Internal;
use WP_Ultimo\Dependencies\Amp\ByteStream\InputStream;
use WP_Ultimo\Dependencies\Amp\CancellationTokenSource;
use WP_Ultimo\Dependencies\Amp\Promise;
/** @internal */
final class ResponseBodyStream implements InputStream
{
use ForbidSerialization;
use ForbidCloning;
/** @var InputStream */
private $body;
/** @var CancellationTokenSource */
private $bodyCancellation;
/** @var bool */
private $successfulEnd = \false;
public function __construct(InputStream $body, CancellationTokenSource $bodyCancellation)
{
$this->body = $body;
$this->bodyCancellation = $bodyCancellation;
}
public function read() : Promise
{
$promise = $this->body->read();
$promise->onResolve(function ($error, $value) {
if ($value === null && $error === null) {
$this->successfulEnd = \true;
}
});
return $promise;
}
public function __destruct()
{
if (!$this->successfulEnd) {
$this->bodyCancellation->cancel();
}
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Internal;
use WP_Ultimo\Dependencies\Amp\ByteStream\InputStream;
use WP_Ultimo\Dependencies\Amp\Failure;
use WP_Ultimo\Dependencies\Amp\Http\Client\ParseException;
use WP_Ultimo\Dependencies\Amp\Http\Status;
use WP_Ultimo\Dependencies\Amp\Promise;
/** @internal */
final class SizeLimitingInputStream implements InputStream
{
use ForbidSerialization;
use ForbidCloning;
/** @var InputStream|null */
private $source;
/** @var int */
private $bytesRead = 0;
/** @var int */
private $sizeLimit;
/** @var \Throwable|null */
private $exception;
public function __construct(InputStream $source, int $sizeLimit)
{
$this->source = $source;
$this->sizeLimit = $sizeLimit;
}
public function read() : Promise
{
if ($this->exception) {
return new Failure($this->exception);
}
\assert($this->source !== null);
$promise = $this->source->read();
$promise->onResolve(function ($error, $value) {
if ($value !== null) {
$this->bytesRead += \strlen($value);
if ($this->bytesRead > $this->sizeLimit) {
$this->exception = new ParseException("Configured body size exceeded: {$this->bytesRead} bytes received, while the configured limit is {$this->sizeLimit} bytes", Status::PAYLOAD_TOO_LARGE);
$this->source = null;
}
}
});
return $promise;
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client\Internal;
use WP_Ultimo\Dependencies\Amp\Http\Client\InvalidRequestException;
use WP_Ultimo\Dependencies\Amp\Http\Client\Request;
/**
* @param Request $request
*
* @return string
* @throws InvalidRequestException
*
* @internal
*/
function normalizeRequestPathWithQuery(Request $request) : string
{
$path = $request->getUri()->getPath();
$query = $request->getUri()->getQuery();
if ($path === '') {
return '/' . ($query !== '' ? '?' . $query : '');
}
if ($path[0] !== '/') {
throw new InvalidRequestException($request, 'Relative path (' . $path . ') is not allowed in the request URI');
}
return $path . ($query !== '' ? '?' . $query : '');
}

View File

@ -0,0 +1,18 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client;
final class InvalidRequestException extends HttpException
{
/** @var Request */
private $request;
public function __construct(Request $request, string $message, int $code = 0, \Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
$this->request = $request;
}
public function getRequest() : Request
{
return $this->request;
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client;
final class MissingAttributeError extends \Error
{
public function __construct(string $message)
{
parent::__construct($message);
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client;
use WP_Ultimo\Dependencies\Amp\CancellationToken;
use WP_Ultimo\Dependencies\Amp\Http\Client\Connection\Stream;
use WP_Ultimo\Dependencies\Amp\Promise;
/**
* Allows intercepting an HTTP request after the connection to the remote server has been established.
*/
interface NetworkInterceptor
{
/**
* Intercepts an HTTP request after the connection to the remote server has been established.
*
* The implementation might modify the request and/or modify the response after the promise returned from
* `$stream->request(...)` resolved.
*
* A NetworkInterceptor SHOULD NOT short-circuit and SHOULD delegate to the `$stream` passed as third argument
* exactly once. The only exception to this is throwing an exception, e.g. because the TLS settings used are
* unacceptable. If you need short circuits, use an {@see ApplicationInterceptor} instead.
*
* @param Request $request
* @param CancellationToken $cancellation
* @param Stream $stream
*
* @return Promise<Response>
*/
public function requestViaNetwork(Request $request, CancellationToken $cancellation, Stream $stream) : Promise;
}

View File

@ -0,0 +1,16 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client;
final class ParseException extends HttpException
{
/**
* @param string $message
* @param int $code
* @param \Throwable|null $previousException
*/
public function __construct(string $message, int $code, \Throwable $previousException = null)
{
parent::__construct($message, $code, $previousException);
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client;
use WP_Ultimo\Dependencies\Amp\CancellationToken;
use WP_Ultimo\Dependencies\Amp\Http\Client\Connection\ConnectionPool;
use WP_Ultimo\Dependencies\Amp\Http\Client\Connection\InterceptedStream;
use WP_Ultimo\Dependencies\Amp\Http\Client\Connection\Stream;
use WP_Ultimo\Dependencies\Amp\Http\Client\Connection\UnlimitedConnectionPool;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidCloning;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidSerialization;
use WP_Ultimo\Dependencies\Amp\Promise;
use function WP_Ultimo\Dependencies\Amp\call;
final class PooledHttpClient implements DelegateHttpClient
{
use ForbidCloning;
use ForbidSerialization;
/** @var ConnectionPool */
private $connectionPool;
/** @var NetworkInterceptor[] */
private $networkInterceptors = [];
public function __construct(?ConnectionPool $connectionPool = null)
{
$this->connectionPool = $connectionPool ?? new UnlimitedConnectionPool();
}
public function request(Request $request, CancellationToken $cancellation) : Promise
{
return call(function () use($request, $cancellation) {
foreach ($request->getEventListeners() as $eventListener) {
(yield $eventListener->startRequest($request));
}
$stream = (yield $this->connectionPool->getStream($request, $cancellation));
\assert($stream instanceof Stream);
foreach (\array_reverse($this->networkInterceptors) as $interceptor) {
$stream = new InterceptedStream($stream, $interceptor);
}
return (yield $stream->request($request, $cancellation));
});
}
/**
* Adds a network interceptor.
*
* Network interceptors are only invoked if the request requires network access, i.e. there's no short-circuit by
* an application interceptor, e.g. a cache.
*
* Whether the given network interceptor will be respected for currently running requests is undefined.
*
* Any new requests have to take the new interceptor into account.
*
* @param NetworkInterceptor $networkInterceptor
*
* @return self
*/
public function intercept(NetworkInterceptor $networkInterceptor) : self
{
$clone = clone $this;
$clone->networkInterceptors[] = $networkInterceptor;
return $clone;
}
}

View File

@ -0,0 +1,468 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client;
use WP_Ultimo\Dependencies\Amp\Http\Client\Body\StringBody;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidSerialization;
use WP_Ultimo\Dependencies\Amp\Http\Message;
use WP_Ultimo\Dependencies\Amp\Promise;
use WP_Ultimo\Dependencies\League\Uri;
use WP_Ultimo\Dependencies\Psr\Http\Message\UriInterface;
use function WP_Ultimo\Dependencies\Amp\call;
/**
* An HTTP request.
*/
final class Request extends Message
{
use ForbidSerialization;
public const DEFAULT_HEADER_SIZE_LIMIT = 2 * 8192;
public const DEFAULT_BODY_SIZE_LIMIT = 10485760;
/**
* @template TValue
*
* @param mixed $value
* @psalm-param TValue $value
*
* @return mixed
* @psalm-return TValue
*/
private static function clone($value)
{
if ($value === null || \is_scalar($value)) {
return $value;
}
// force deep cloning
return \unserialize(\serialize($value), ['allowed_classes' => \true]);
}
/** @var string[] */
private $protocolVersions = ['1.1', '2'];
/** @var string */
private $method;
/** @var UriInterface */
private $uri;
/** @var RequestBody */
private $body;
/** @var int */
private $tcpConnectTimeout = 10000;
/** @var int */
private $tlsHandshakeTimeout = 10000;
/** @var int */
private $transferTimeout = 10000;
/** @var int */
private $inactivityTimeout = 10000;
/** @var int */
private $bodySizeLimit = self::DEFAULT_BODY_SIZE_LIMIT;
/** @var int */
private $headerSizeLimit = self::DEFAULT_HEADER_SIZE_LIMIT;
/** @var callable|null */
private $onPush;
/** @var callable|null */
private $onUpgrade;
/** @var callable|null */
private $onInformationalResponse;
/** @var mixed[] */
private $attributes = [];
/** @var EventListener[] */
private $eventListeners = [];
/**
* Request constructor.
*
* @param string|UriInterface $uri
* @param string $method
* @param string $body
*/
public function __construct($uri, string $method = "GET", ?string $body = null)
{
$this->setUri($uri);
$this->setMethod($method);
$this->setBody($body);
}
public function addEventListener(EventListener $eventListener) : void
{
$this->eventListeners[] = $eventListener;
}
/**
* @return EventListener[]
*/
public function getEventListeners() : array
{
return $this->eventListeners;
}
/**
* Retrieve the requests's acceptable HTTP protocol versions.
*
* @return string[]
*/
public function getProtocolVersions() : array
{
return $this->protocolVersions;
}
/**
* Assign the requests's acceptable HTTP protocol versions.
*
* The HTTP client might choose any of these.
*
* @param string[] $versions
*/
public function setProtocolVersions(array $versions) : void
{
$versions = \array_unique($versions);
if (empty($versions)) {
/** @noinspection PhpUndefinedClassInspection */
throw new \Error("Empty array of protocol versions provided, must not be empty.");
}
foreach ($versions as $version) {
if (!\in_array($version, ["1.0", "1.1", "2"], \true)) {
/** @noinspection PhpUndefinedClassInspection */
throw new \Error("Invalid HTTP protocol version: " . $version);
}
}
$this->protocolVersions = $versions;
}
/**
* Retrieve the request's HTTP method verb.
*
* @return string
*/
public function getMethod() : string
{
return $this->method;
}
/**
* Specify the request's HTTP method verb.
*
* @param string $method
*/
public function setMethod(string $method) : void
{
$this->method = $method;
}
/**
* Retrieve the request's URI.
*
* @return UriInterface
*/
public function getUri() : UriInterface
{
return $this->uri;
}
/**
* Specify the request's HTTP URI.
*
* @param string|UriInterface $uri
*/
public function setUri($uri) : void
{
$this->uri = $uri instanceof UriInterface ? $uri : $this->createUriFromString($uri);
}
/**
* Assign a value for the specified header field by replacing any existing values for that field.
*
* @param string $name Header name.
* @param string|string[] $value Header value.
*/
public function setHeader(string $name, $value) : void
{
if (($name[0] ?? ":") === ":") {
throw new \Error("Header name cannot be empty or start with a colon (:)");
}
parent::setHeader($name, $value);
}
/**
* Assign a value for the specified header field by adding an additional header line.
*
* @param string $name Header name.
* @param string|string[] $value Header value.
*/
public function addHeader(string $name, $value) : void
{
if (($name[0] ?? ":") === ":") {
throw new \Error("Header name cannot be empty or start with a colon (:)");
}
parent::addHeader($name, $value);
}
public function setHeaders(array $headers) : void
{
/** @noinspection PhpUnhandledExceptionInspection */
parent::setHeaders($headers);
}
/**
* Remove the specified header field from the message.
*
* @param string $name Header name.
*/
public function removeHeader(string $name) : void
{
parent::removeHeader($name);
}
/**
* Retrieve the message entity body.
*/
public function getBody() : RequestBody
{
return $this->body;
}
/**
* Assign the message entity body.
*
* @param mixed $body
*/
public function setBody($body) : void
{
if ($body === null) {
$this->body = new StringBody("");
} elseif (\is_string($body)) {
$this->body = new StringBody($body);
} elseif (\is_scalar($body)) {
$this->body = new StringBody(\var_export($body, \true));
} elseif ($body instanceof RequestBody) {
$this->body = $body;
} else {
/** @noinspection PhpUndefinedClassInspection */
throw new \TypeError("Invalid body type: " . \gettype($body));
}
}
/**
* Registers a callback to the request that is invoked when the server pushes an additional resource.
* The callback is given two parameters: the Request generated from the pushed resource, and a promise for the
* Response containing the pushed resource. An HttpException, StreamException, or CancelledException can be thrown
* to refuse the push. If no callback is registered, pushes are automatically rejected.
*
* Interceptors can mostly use {@code interceptPush} instead.
*
* Example:
* function (Request $request, Promise $response): \Generator {
* $uri = $request->getUri(); // URI of pushed resource.
* $response = yield $promise; // Wait for resource to arrive.
* // Use Response object from resolved promise.
* }
*
* @param callable|null $onPush
*/
public function setPushHandler(?callable $onPush) : void
{
$this->onPush = $onPush;
}
/**
* Allows interceptors to modify also pushed responses.
*
* If no push callable has been set by the application, the interceptor won't be invoked. If you want to enable
* push in an interceptor without the application setting a push handler, you need to use {@code setPushHandler}.
*
* @param callable $interceptor Receives the response and might modify it or return a new instance.
*/
public function interceptPush(callable $interceptor) : void
{
if ($this->onPush === null) {
return;
}
$onPush = $this->onPush;
/** @psalm-suppress MissingClosureReturnType */
$this->onPush = static function (Request $request, Promise $response) use($onPush, $interceptor) {
$response = call(static function () use($response, $interceptor) : \Generator {
return (yield call($interceptor, (yield $response))) ?? $response;
});
return $onPush($request, $response);
};
}
/**
* @return callable|null
*/
public function getPushHandler() : ?callable
{
return $this->onPush;
}
/**
* Registers a callback invoked if a 101 response is returned to the request.
*
* @param callable|null $onUpgrade
*/
public function setUpgradeHandler(?callable $onUpgrade) : void
{
$this->onUpgrade = $onUpgrade;
}
/**
* @return callable|null
*/
public function getUpgradeHandler() : ?callable
{
return $this->onUpgrade;
}
/**
* Registers a callback invoked when a 1xx response is returned to the request (other than a 101).
*
* @param callable|null $onInformationalResponse
*/
public function setInformationalResponseHandler(?callable $onInformationalResponse) : void
{
$this->onInformationalResponse = $onInformationalResponse;
}
/**
* @return callable|null
*/
public function getInformationalResponseHandler() : ?callable
{
return $this->onInformationalResponse;
}
/**
* @return int Timeout in milliseconds for the TCP connection.
*/
public function getTcpConnectTimeout() : int
{
return $this->tcpConnectTimeout;
}
public function setTcpConnectTimeout(int $tcpConnectTimeout) : void
{
$this->tcpConnectTimeout = $tcpConnectTimeout;
}
/**
* @return int Timeout in milliseconds for the TLS handshake.
*/
public function getTlsHandshakeTimeout() : int
{
return $this->tlsHandshakeTimeout;
}
public function setTlsHandshakeTimeout(int $tlsHandshakeTimeout) : void
{
$this->tlsHandshakeTimeout = $tlsHandshakeTimeout;
}
/**
* @return int Timeout in milliseconds for the HTTP transfer (not counting TCP connect and TLS handshake)
*/
public function getTransferTimeout() : int
{
return $this->transferTimeout;
}
public function setTransferTimeout(int $transferTimeout) : void
{
$this->transferTimeout = $transferTimeout;
}
/**
* @return int Timeout in milliseconds since the last data was received before the request fails due to inactivity.
*/
public function getInactivityTimeout() : int
{
return $this->inactivityTimeout;
}
public function setInactivityTimeout(int $inactivityTimeout) : void
{
$this->inactivityTimeout = $inactivityTimeout;
}
public function getHeaderSizeLimit() : int
{
return $this->headerSizeLimit;
}
public function setHeaderSizeLimit(int $headerSizeLimit) : void
{
$this->headerSizeLimit = $headerSizeLimit;
}
public function getBodySizeLimit() : int
{
return $this->bodySizeLimit;
}
public function setBodySizeLimit(int $bodySizeLimit) : void
{
$this->bodySizeLimit = $bodySizeLimit;
}
/**
* Note: This method returns a deep clone of the request's attributes, so you can't modify the request attributes
* by modifying the returned value in any way.
*
* @return mixed[] An array of all request attributes in the request's local storage, indexed by name.
*/
public function getAttributes() : array
{
return self::clone($this->attributes);
}
/**
* Check whether a variable with the given name exists in the request's local storage.
*
* Each request has its own local storage to which applications and interceptors may read and write data.
* Other interceptors which are aware of this data can then access it without the server being tightly coupled to
* specific implementations.
*
* @param string $name Name of the attribute, should be namespaced with a vendor and package namespace like classes.
*
* @return bool
*/
public function hasAttribute(string $name) : bool
{
return \array_key_exists($name, $this->attributes);
}
/**
* Retrieve a variable from the request's local storage.
*
* Each request has its own local storage to which applications and interceptors may read and write data.
* Other interceptors which are aware of this data can then access it without the server being tightly coupled to
* specific implementations.
*
* Note: This method returns a deep clone of the request's attribute, so you can't modify the request attribute
* by modifying the returned value in any way.
*
* @param string $name Name of the attribute, should be namespaced with a vendor and package namespace like classes.
*
* @return mixed
*
* @throws MissingAttributeError If an attribute with the given name does not exist.
*/
public function getAttribute(string $name)
{
if (!$this->hasAttribute($name)) {
throw new MissingAttributeError("The requested attribute '{$name}' does not exist");
}
return self::clone($this->attributes[$name]);
}
/**
* Assign a variable to the request's local storage.
*
* Each request has its own local storage to which applications and interceptors may read and write data.
* Other interceptors which are aware of this data can then access it without the server being tightly coupled to
* specific implementations.
*
* Note: This method performs a deep clone of the value via serialization, so you can't modify the given value
* after setting it.
*
* **Example**
*
* ```php
* $request->setAttribute(Timing::class, $stopWatch);
* ```
*
* @param string $name Name of the attribute, should be namespaced with a vendor and package namespace like classes.
* @param mixed $value Value of the attribute, might be any serializable value.
*/
public function setAttribute(string $name, $value) : void
{
$this->attributes[$name] = self::clone($value);
}
/**
* Remove an attribute from the request's local storage.
*
* @param string $name Name of the attribute, should be namespaced with a vendor and package namespace like classes.
*
* @throws MissingAttributeError If an attribute with the given name does not exist.
*/
public function removeAttribute(string $name) : void
{
if (!$this->hasAttribute($name)) {
throw new MissingAttributeError("The requested attribute '{$name}' does not exist");
}
unset($this->attributes[$name]);
}
/**
* Remove all attributes from the request's local storage.
*/
public function removeAttributes() : void
{
$this->attributes = [];
}
public function isIdempotent() : bool
{
// https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html
return \in_array($this->method, ['GET', 'HEAD', 'PUT', 'DELETE'], \true);
}
private function createUriFromString(string $uri) : UriInterface
{
return Uri\Http::createFromString($uri);
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client;
use WP_Ultimo\Dependencies\Amp\ByteStream\InputStream;
use WP_Ultimo\Dependencies\Amp\Promise;
/**
* An interface for generating HTTP message bodies + headers.
*/
interface RequestBody
{
/**
* Retrieve a key-value array of headers to add to the outbound request.
*
* The resolved promise value must be a key-value array mapping header fields to values.
*
* @return Promise
*/
public function getHeaders() : Promise;
/**
* Create the HTTP message body to be sent.
*
* Further calls MUST return a new stream to make it possible to resend bodies on redirects.
*
* @return InputStream
*/
public function createBodyStream() : InputStream;
/**
* Retrieve the HTTP message body length. If not available, return -1.
*
* @return Promise
*/
public function getBodyLength() : Promise;
}

View File

@ -0,0 +1,227 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client;
use WP_Ultimo\Dependencies\Amp\ByteStream\InMemoryStream;
use WP_Ultimo\Dependencies\Amp\ByteStream\InputStream;
use WP_Ultimo\Dependencies\Amp\ByteStream\Payload;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidCloning;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidSerialization;
use WP_Ultimo\Dependencies\Amp\Http\Message;
use WP_Ultimo\Dependencies\Amp\Http\Status;
use WP_Ultimo\Dependencies\Amp\Promise;
use WP_Ultimo\Dependencies\Amp\Success;
/**
* An HTTP response.
*/
final class Response extends Message
{
use ForbidSerialization;
use ForbidCloning;
/** @var string */
private $protocolVersion;
/** @var int */
private $status;
/** @var string */
private $reason;
/** @var Request */
private $request;
/** @var Payload */
private $body;
/** @var Promise<Trailers> */
private $trailers;
/** @var Response|null */
private $previousResponse;
public function __construct(string $protocolVersion, int $status, ?string $reason, array $headers, InputStream $body, Request $request, ?Promise $trailerPromise = null, ?Response $previousResponse = null)
{
$this->setProtocolVersion($protocolVersion);
$this->setStatus($status, $reason);
$this->setHeaders($headers);
$this->setBody($body);
$this->request = $request;
/** @noinspection PhpUnhandledExceptionInspection */
$this->trailers = $trailerPromise ?? new Success(new Trailers([]));
$this->previousResponse = $previousResponse;
}
/**
* Retrieve the requests's HTTP protocol version.
*
* @return string
*/
public function getProtocolVersion() : string
{
return $this->protocolVersion;
}
public function setProtocolVersion(string $protocolVersion) : void
{
if (!\in_array($protocolVersion, ["1.0", "1.1", "2"], \true)) {
/** @noinspection PhpUndefinedClassInspection */
throw new \Error("Invalid HTTP protocol version: " . $protocolVersion);
}
$this->protocolVersion = $protocolVersion;
}
/**
* Retrieve the response's three-digit HTTP status code.
*
* @return int
*/
public function getStatus() : int
{
return $this->status;
}
public function setStatus(int $status, ?string $reason = null) : void
{
$this->status = $status;
$this->reason = $reason ?? Status::getReason($status);
}
/**
* Retrieve the response's (possibly empty) reason phrase.
*
* @return string
*/
public function getReason() : string
{
return $this->reason;
}
/**
* Retrieve the Request instance that resulted in this Response instance.
*
* @return Request
*/
public function getRequest() : Request
{
return $this->request;
}
public function setRequest(Request $request) : void
{
$this->request = $request;
}
/**
* Retrieve the original Request instance associated with this Response instance.
*
* A given Response may be the result of one or more redirects. This method is a shortcut to
* access information from the original Request that led to this response.
*
* @return Request
*/
public function getOriginalRequest() : Request
{
if (empty($this->previousResponse)) {
return $this->request;
}
return $this->previousResponse->getOriginalRequest();
}
/**
* Retrieve the original Response instance associated with this Response instance.
*
* A given Response may be the result of one or more redirects. This method is a shortcut to
* access information from the original Response that led to this response.
*
* @return Response
*/
public function getOriginalResponse() : Response
{
if (empty($this->previousResponse)) {
return $this;
}
return $this->previousResponse->getOriginalResponse();
}
/**
* If this Response is the result of a redirect traverse up the redirect history.
*
* @return Response|null
*/
public function getPreviousResponse() : ?Response
{
return $this->previousResponse;
}
public function setPreviousResponse(?Response $previousResponse) : void
{
$this->previousResponse = $previousResponse;
}
/**
* Assign a value for the specified header field by replacing any existing values for that field.
*
* @param string $name Header name.
* @param string|string[] $value Header value.
*/
public function setHeader(string $name, $value) : void
{
if (($name[0] ?? ":") === ":") {
throw new \Error("Header name cannot be empty or start with a colon (:)");
}
parent::setHeader($name, $value);
}
/**
* Assign a value for the specified header field by adding an additional header line.
*
* @param string $name Header name.
* @param string|string[] $value Header value.
*/
public function addHeader(string $name, $value) : void
{
if (($name[0] ?? ":") === ":") {
throw new \Error("Header name cannot be empty or start with a colon (:)");
}
parent::addHeader($name, $value);
}
public function setHeaders(array $headers) : void
{
/** @noinspection PhpUnhandledExceptionInspection */
parent::setHeaders($headers);
}
/**
* Remove the specified header field from the message.
*
* @param string $name Header name.
*/
public function removeHeader(string $name) : void
{
parent::removeHeader($name);
}
/**
* Retrieve the response body.
*
* Note: If you stream a Message, you can't consume the payload twice.
*
* @return Payload
*/
public function getBody() : Payload
{
return $this->body;
}
/**
* @param Payload|InputStream|string|int|float|bool $body
*/
public function setBody($body) : void
{
if ($body instanceof Payload) {
$this->body = $body;
} elseif ($body === null) {
$this->body = new Payload(new InMemoryStream());
} elseif (\is_string($body)) {
$this->body = new Payload(new InMemoryStream($body));
} elseif (\is_scalar($body)) {
$this->body = new Payload(new InMemoryStream(\var_export($body, \true)));
} elseif ($body instanceof InputStream) {
$this->body = new Payload($body);
} else {
/** @noinspection PhpUndefinedClassInspection */
throw new \TypeError("Invalid body type: " . \gettype($body));
}
}
/**
* @return Promise<Trailers>
*/
public function getTrailers() : Promise
{
return $this->trailers;
}
/**
* @param Promise<Trailers> $promise
*/
public function setTrailers(Promise $promise) : void
{
$this->trailers = $promise;
}
}

View File

@ -0,0 +1,7 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client;
final class SocketException extends HttpException
{
}

View File

@ -0,0 +1,7 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client;
final class TimeoutException extends HttpException
{
}

View File

@ -0,0 +1,27 @@
<?php
namespace WP_Ultimo\Dependencies\Amp\Http\Client;
use WP_Ultimo\Dependencies\Amp\Http\Client\Internal\ForbidSerialization;
use WP_Ultimo\Dependencies\Amp\Http\InvalidHeaderException;
use WP_Ultimo\Dependencies\Amp\Http\Message;
final class Trailers extends Message
{
use ForbidSerialization;
/** @see https://tools.ietf.org/html/rfc7230#section-4.1.2 */
public const DISALLOWED_TRAILERS = ["authorization" => \true, "content-encoding" => \true, "content-length" => \true, "content-range" => \true, "content-type" => \true, "cookie" => \true, "expect" => \true, "host" => \true, "pragma" => \true, "proxy-authenticate" => \true, "proxy-authorization" => \true, "range" => \true, "te" => \true, "trailer" => \true, "transfer-encoding" => \true, "www-authenticate" => \true];
/**
* @param string[]|string[][] $headers
*
* @throws InvalidHeaderException Thrown if a disallowed field is in the header values.
*/
public function __construct(array $headers)
{
if (!empty($headers)) {
$this->setHeaders($headers);
}
if (\array_intersect_key($this->getHeaders(), self::DISALLOWED_TRAILERS)) {
throw new InvalidHeaderException('Disallowed field in trailers');
}
}
}