diff --git a/AmpHttpClient.php b/AmpHttpClient.php new file mode 100644 index 0000000..c62b847 --- /dev/null +++ b/AmpHttpClient.php @@ -0,0 +1,163 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient; + +use Amp\CancelledException; +use Amp\Http\Client\DelegateHttpClient; +use Amp\Http\Client\InterceptedHttpClient; +use Amp\Http\Client\PooledHttpClient; +use Amp\Http\Client\Request; +use Amp\Http\Tunnel\Http1TunnelConnector; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerAwareTrait; +use Symfony\Component\HttpClient\Exception\TransportException; +use Symfony\Component\HttpClient\Internal\AmpClientState; +use Symfony\Component\HttpClient\Response\AmpResponse; +use Symfony\Component\HttpClient\Response\ResponseStream; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; +use Symfony\Contracts\HttpClient\ResponseStreamInterface; +use Symfony\Contracts\Service\ResetInterface; + +if (!interface_exists(DelegateHttpClient::class)) { + throw new \LogicException('You cannot use "Symfony\Component\HttpClient\AmpHttpClient" as the "amphp/http-client" package is not installed. Try running "composer require amphp/http-client".'); +} + +/** + * A portable implementation of the HttpClientInterface contracts based on Amp's HTTP client. + * + * @author Nicolas Grekas
+ */ +final class AmpHttpClient implements HttpClientInterface, LoggerAwareInterface, ResetInterface +{ + use HttpClientTrait; + use LoggerAwareTrait; + + private $defaultOptions = self::OPTIONS_DEFAULTS; + + /** @var AmpClientState */ + private $multi; + + /** + * @param array $defaultOptions Default requests' options + * @param callable $clientConfigurator A callable that builds a {@see DelegateHttpClient} from a {@see PooledHttpClient}; + * passing null builds an {@see InterceptedHttpClient} with 2 retries on failures + * @param int $maxHostConnections The maximum number of connections to a single host + * @param int $maxPendingPushes The maximum number of pushed responses to accept in the queue + * + * @see HttpClientInterface::OPTIONS_DEFAULTS for available options + */ + public function __construct(array $defaultOptions = [], callable $clientConfigurator = null, int $maxHostConnections = 6, int $maxPendingPushes = 50) + { + $this->defaultOptions['buffer'] = $this->defaultOptions['buffer'] ?? \Closure::fromCallable([__CLASS__, 'shouldBuffer']); + + if ($defaultOptions) { + [, $this->defaultOptions] = self::prepareRequest(null, null, $defaultOptions, $this->defaultOptions); + } + + $this->multi = new AmpClientState($clientConfigurator, $maxHostConnections, $maxPendingPushes, $this->logger); + } + + /** + * @see HttpClientInterface::OPTIONS_DEFAULTS for available options + * + * {@inheritdoc} + */ + public function request(string $method, string $url, array $options = []): ResponseInterface + { + [$url, $options] = self::prepareRequest($method, $url, $options, $this->defaultOptions); + + $options['proxy'] = self::getProxy($options['proxy'], $url, $options['no_proxy']); + + if (null !== $options['proxy'] && !class_exists(Http1TunnelConnector::class)) { + throw new \LogicException('You cannot use the "proxy" option as the "amphp/http-tunnel" package is not installed. Try running "composer require amphp/http-tunnel".'); + } + + if ('' !== $options['body'] && 'POST' === $method && !isset($options['normalized_headers']['content-type'])) { + $options['headers'][] = 'Content-Type: application/x-www-form-urlencoded'; + } + + if (!isset($options['normalized_headers']['user-agent'])) { + $options['headers'][] = 'User-Agent: Symfony HttpClient/Amp'; + } + + if (0 < $options['max_duration']) { + $options['timeout'] = min($options['max_duration'], $options['timeout']); + } + + if ($options['resolve']) { + $this->multi->dnsCache = $options['resolve'] + $this->multi->dnsCache; + } + + if ($options['peer_fingerprint'] && !isset($options['peer_fingerprint']['pin-sha256'])) { + throw new TransportException(__CLASS__.' supports only "pin-sha256" fingerprints.'); + } + + $request = new Request(implode('', $url), $method); + + if ($options['http_version']) { + switch ((float) $options['http_version']) { + case 1.0: $request->setProtocolVersions(['1.0']); break; + case 1.1: $request->setProtocolVersions(['1.1', '1.0']); break; + default: $request->setProtocolVersions(['2', '1.1', '1.0']); break; + } + } + + foreach ($options['headers'] as $v) { + $h = explode(': ', $v, 2); + $request->addHeader($h[0], $h[1]); + } + + $request->setTcpConnectTimeout(1000 * $options['timeout']); + $request->setTlsHandshakeTimeout(1000 * $options['timeout']); + $request->setTransferTimeout(1000 * $options['max_duration']); + + if ('' !== $request->getUri()->getUserInfo() && !$request->hasHeader('authorization')) { + $auth = explode(':', $request->getUri()->getUserInfo(), 2); + $auth = array_map('rawurldecode', $auth) + [1 => '']; + $request->setHeader('Authorization', 'Basic '.base64_encode(implode(':', $auth))); + } + + return new AmpResponse($this->multi, $request, $options, $this->logger); + } + + /** + * {@inheritdoc} + */ + public function stream($responses, float $timeout = null): ResponseStreamInterface + { + if ($responses instanceof AmpResponse) { + $responses = [$responses]; + } elseif (!is_iterable($responses)) { + throw new \TypeError(sprintf('%s() expects parameter 1 to be an iterable of AmpResponse objects, %s given.', __METHOD__, \is_object($responses) ? \get_class($responses) : \gettype($responses))); + } + + return new ResponseStream(AmpResponse::stream($responses, $timeout)); + } + + public function reset() + { + $this->multi->dnsCache = []; + + foreach ($this->multi->pushedResponses as $authority => $pushedResponses) { + foreach ($pushedResponses as [$pushedUrl, $pushDeferred]) { + $pushDeferred->fail(new CancelledException()); + + if ($this->logger) { + $this->logger->debug(sprintf('Unused pushed response: "%s"', $pushedUrl)); + } + } + } + + $this->multi->pushedResponses = []; + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 97491f1..2c4b706 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,9 @@ CHANGELOG 5.1.0 ----- -* added `NoPrivateNetworkHttpClient` decorator -* added `LoggerAwareInterface` to `ScopingHttpClient` and `TraceableHttpClient` + * added `NoPrivateNetworkHttpClient` decorator + * added `AmpHttpClient`, a portable HTTP/2 implementation based on Amp + * added `LoggerAwareInterface` to `ScopingHttpClient` and `TraceableHttpClient` 4.4.0 ----- diff --git a/HttpClientTrait.php b/HttpClientTrait.php index b44e4f8..0a25306 100644 --- a/HttpClientTrait.php +++ b/HttpClientTrait.php @@ -12,6 +12,7 @@ namespace Symfony\Component\HttpClient; use Symfony\Component\HttpClient\Exception\InvalidArgumentException; +use Symfony\Component\HttpClient\Exception\TransportException; /** * Provides the common logic from writing HttpClientInterface implementations. @@ -554,6 +555,48 @@ private static function mergeQueryString(?string $queryString, array $queryArray return implode('&', $replace ? array_replace($query, $queryArray) : ($query + $queryArray)); } + /** + * Loads proxy configuration from the same environment variables as curl when no proxy is explicitly set. + */ + private static function getProxy(?string $proxy, array $url, ?string $noProxy): ?array + { + if (null === $proxy) { + // Ignore HTTP_PROXY except on the CLI to work around httpoxy set of vulnerabilities + $proxy = $_SERVER['http_proxy'] ?? (\in_array(\PHP_SAPI, ['cli', 'phpdbg'], true) ? $_SERVER['HTTP_PROXY'] ?? null : null) ?? $_SERVER['all_proxy'] ?? $_SERVER['ALL_PROXY'] ?? null; + + if ('https:' === $url['scheme']) { + $proxy = $_SERVER['https_proxy'] ?? $_SERVER['HTTPS_PROXY'] ?? $proxy; + } + } + + if (null === $proxy) { + return null; + } + + $proxy = (parse_url($proxy) ?: []) + ['scheme' => 'http']; + + if (!isset($proxy['host'])) { + throw new TransportException('Invalid HTTP proxy: host is missing.'); + } + + if ('http' === $proxy['scheme']) { + $proxyUrl = 'tcp://'.$proxy['host'].':'.($proxy['port'] ?? '80'); + } elseif ('https' === $proxy['scheme']) { + $proxyUrl = 'ssl://'.$proxy['host'].':'.($proxy['port'] ?? '443'); + } else { + throw new TransportException(sprintf('Unsupported proxy scheme "%s": "http" or "https" expected.', $proxy['scheme'])); + } + + $noProxy = $noProxy ?? $_SERVER['no_proxy'] ?? $_SERVER['NO_PROXY'] ?? ''; + $noProxy = $noProxy ? preg_split('/[\s,]+/', $noProxy) : []; + + return [ + 'url' => $proxyUrl, + 'auth' => isset($proxy['user']) ? 'Basic '.base64_encode(rawurldecode($proxy['user']).':'.rawurldecode($proxy['pass'] ?? '')) : null, + 'no_proxy' => $noProxy, + ]; + } + private static function shouldBuffer(array $headers): bool { if (null === $contentType = $headers['content-type'][0] ?? null) { diff --git a/Internal/AmpBody.php b/Internal/AmpBody.php new file mode 100644 index 0000000..6f820e9 --- /dev/null +++ b/Internal/AmpBody.php @@ -0,0 +1,141 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient\Internal; + +use Amp\ByteStream\InputStream; +use Amp\ByteStream\ResourceInputStream; +use Amp\Http\Client\RequestBody; +use Amp\Promise; +use Amp\Success; +use Symfony\Component\HttpClient\Exception\TransportException; + +/** + * @author Nicolas Grekas
+ * + * @internal + */ +class AmpBody implements RequestBody, InputStream +{ + private $body; + private $onProgress; + private $offset = 0; + private $length = -1; + private $uploaded; + + public function __construct($body, &$info, \Closure $onProgress) + { + $this->body = $body; + $this->info = &$info; + $this->onProgress = $onProgress; + + if (\is_resource($body)) { + $this->offset = ftell($body); + $this->length = fstat($body)['size']; + $this->body = new ResourceInputStream($body); + } elseif (\is_string($body)) { + $this->length = \strlen($body); + } + } + + public function createBodyStream(): InputStream + { + if (null !== $this->uploaded) { + $this->uploaded = null; + + if (\is_string($this->body)) { + $this->offset = 0; + } elseif ($this->body instanceof ResourceInputStream) { + fseek($this->body->getResource(), $this->offset); + } + } + + return $this; + } + + public function getHeaders(): Promise + { + return new Success([]); + } + + public function getBodyLength(): Promise + { + return new Success($this->length - $this->offset); + } + + public function read(): Promise + { + $this->info['size_upload'] += $this->uploaded; + $this->uploaded = 0; + ($this->onProgress)(); + + $chunk = $this->doRead(); + $chunk->onResolve(function ($e, $data) { + if (null !== $data) { + $this->uploaded = \strlen($data); + } else { + $this->info['upload_content_length'] = $this->info['size_upload']; + } + }); + + return $chunk; + } + + public static function rewind(RequestBody $body): RequestBody + { + if (!$body instanceof self) { + return $body; + } + + $body->uploaded = null; + + if ($body->body instanceof ResourceInputStream) { + fseek($body->body->getResource(), $body->offset); + + return new $body($body->body, $body->info, $body->onProgress); + } + + if (\is_string($body->body)) { + $body->offset = 0; + } + + return $body; + } + + private function doRead(): Promise + { + if ($this->body instanceof ResourceInputStream) { + return $this->body->read(); + } + + if (null === $this->offset || !$this->length) { + return new Success(); + } + + if (\is_string($this->body)) { + $this->offset = null; + + return new Success($this->body); + } + + if ('' === $data = ($this->body)(16372)) { + $this->offset = null; + + return new Success(); + } + + if (!\is_string($data)) { + throw new TransportException(sprintf('Return value of the "body" option callback must be string, %s returned.', \gettype($data))); + } + + return new Success($data); + } +} diff --git a/Internal/AmpClientState.php b/Internal/AmpClientState.php new file mode 100644 index 0000000..6fa8a2f --- /dev/null +++ b/Internal/AmpClientState.php @@ -0,0 +1,215 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient\Internal; + +use Amp\CancellationToken; +use Amp\Deferred; +use Amp\Http\Client\Connection\ConnectionLimitingPool; +use Amp\Http\Client\Connection\DefaultConnectionFactory; +use Amp\Http\Client\InterceptedHttpClient; +use Amp\Http\Client\Interceptor\RetryRequests; +use Amp\Http\Client\PooledHttpClient; +use Amp\Http\Client\Request; +use Amp\Http\Client\Response; +use Amp\Http\Tunnel\Http1TunnelConnector; +use Amp\Http\Tunnel\Https1TunnelConnector; +use Amp\Promise; +use Amp\Socket\Certificate; +use Amp\Socket\ClientTlsContext; +use Amp\Socket\ConnectContext; +use Amp\Socket\Connector; +use Amp\Socket\DnsConnector; +use Amp\Socket\SocketAddress; +use Amp\Success; +use Psr\Log\LoggerInterface; + +/** + * Internal representation of the Amp client's state. + * + * @author Nicolas Grekas
+ *
+ * @internal
+ */
+final class AmpClientState extends ClientState
+{
+ public $dnsCache = [];
+ public $responseCount = 0;
+ public $pushedResponses = [];
+
+ private $clients = [];
+ private $clientConfigurator;
+ private $maxHostConnections;
+ private $maxPendingPushes;
+ private $logger;
+
+ public function __construct(?callable $clientConfigurator, int $maxHostConnections, int $maxPendingPushes, ?LoggerInterface &$logger)
+ {
+ $this->clientConfigurator = $clientConfigurator ?? static function (PooledHttpClient $client) {
+ return new InterceptedHttpClient($client, new RetryRequests(2));
+ };
+ $this->maxHostConnections = $maxHostConnections;
+ $this->maxPendingPushes = $maxPendingPushes;
+ $this->logger = &$logger;
+ }
+
+ /**
+ * @return Promise
+ *
+ * @internal
+ */
+class AmpListener implements EventListener
+{
+ private $info;
+ private $pinSha256;
+ private $onProgress;
+ private $handle;
+
+ public function __construct(array &$info, array $pinSha256, \Closure $onProgress, &$handle)
+ {
+ $info += [
+ 'connect_time' => 0.0,
+ 'pretransfer_time' => 0.0,
+ 'starttransfer_time' => 0.0,
+ 'total_time' => 0.0,
+ 'namelookup_time' => 0.0,
+ 'primary_ip' => '',
+ 'primary_port' => 0,
+ ];
+
+ $this->info = &$info;
+ $this->pinSha256 = $pinSha256;
+ $this->onProgress = $onProgress;
+ $this->handle = &$handle;
+ }
+
+ public function startRequest(Request $request): Promise
+ {
+ $this->info['start_time'] = $this->info['start_time'] ?? microtime(true);
+ ($this->onProgress)();
+
+ return new Success();
+ }
+
+ public function startDnsResolution(Request $request): Promise
+ {
+ ($this->onProgress)();
+
+ return new Success();
+ }
+
+ public function startConnectionCreation(Request $request): Promise
+ {
+ ($this->onProgress)();
+
+ return new Success();
+ }
+
+ public function startTlsNegotiation(Request $request): Promise
+ {
+ ($this->onProgress)();
+
+ return new Success();
+ }
+
+ public function startSendingRequest(Request $request, Stream $stream): Promise
+ {
+ $host = $stream->getRemoteAddress()->getHost();
+
+ if (false !== strpos($host, ':')) {
+ $host = '['.$host.']';
+ }
+
+ $this->info['primary_ip'] = $host;
+ $this->info['primary_port'] = $stream->getRemoteAddress()->getPort();
+ $this->info['pretransfer_time'] = microtime(true) - $this->info['start_time'];
+ $this->info['debug'] .= sprintf("* Connected to %s (%s) port %d\n", $request->getUri()->getHost(), $host, $this->info['primary_port']);
+
+ if ((isset($this->info['peer_certificate_chain']) || $this->pinSha256) && null !== $tlsInfo = $stream->getTlsInfo()) {
+ foreach ($tlsInfo->getPeerCertificates() as $cert) {
+ $this->info['peer_certificate_chain'][] = openssl_x509_read($cert->toPem());
+ }
+
+ if ($this->pinSha256) {
+ $pin = openssl_pkey_get_public($this->info['peer_certificate_chain'][0]);
+ $pin = openssl_pkey_get_details($pin)['key'];
+ $pin = \array_slice(explode("\n", $pin), 1, -2);
+ $pin = base64_decode(implode('', $pin));
+ $pin = base64_encode(hash('sha256', $pin, true));
+
+ if (!\in_array($pin, $this->pinSha256, true)) {
+ throw new TransportException(sprintf('SSL public key does not match pinned public key for "%s".', $this->info['url']));
+ }
+ }
+ }
+ ($this->onProgress)();
+
+ $uri = $request->getUri();
+ $requestUri = $uri->getPath() ?: '/';
+
+ if ('' !== $query = $uri->getQuery()) {
+ $requestUri .= '?'.$query;
+ }
+
+ if ('CONNECT' === $method = $request->getMethod()) {
+ $requestUri = $uri->getHost().': '.($uri->getPort() ?? ('https' === $uri->getScheme() ? 443 : 80));
+ }
+
+ $this->info['debug'] .= sprintf("> %s %s HTTP/%s \r\n", $method, $requestUri, $request->getProtocolVersions()[0]);
+
+ foreach ($request->getRawHeaders() as [$name, $value]) {
+ $this->info['debug'] .= $name.': '.$value."\r\n";
+ }
+ $this->info['debug'] .= "\r\n";
+
+ return new Success();
+ }
+
+ public function completeSendingRequest(Request $request, Stream $stream): Promise
+ {
+ ($this->onProgress)();
+
+ return new Success();
+ }
+
+ public function startReceivingResponse(Request $request, Stream $stream): Promise
+ {
+ $this->info['starttransfer_time'] = microtime(true) - $this->info['start_time'];
+ ($this->onProgress)();
+
+ return new Success();
+ }
+
+ public function completeReceivingResponse(Request $request, Stream $stream): Promise
+ {
+ $this->handle = null;
+ ($this->onProgress)();
+
+ return new Success();
+ }
+
+ public function completeDnsResolution(Request $request): Promise
+ {
+ $this->info['namelookup_time'] = microtime(true) - $this->info['start_time'];
+ ($this->onProgress)();
+
+ return new Success();
+ }
+
+ public function completeConnectionCreation(Request $request): Promise
+ {
+ $this->info['connect_time'] = microtime(true) - $this->info['start_time'];
+ ($this->onProgress)();
+
+ return new Success();
+ }
+
+ public function completeTlsNegotiation(Request $request): Promise
+ {
+ ($this->onProgress)();
+
+ return new Success();
+ }
+
+ public function abort(Request $request, \Throwable $cause): Promise
+ {
+ return new Success();
+ }
+}
diff --git a/Internal/AmpResolver.php b/Internal/AmpResolver.php
new file mode 100644
index 0000000..d31476a
--- /dev/null
+++ b/Internal/AmpResolver.php
@@ -0,0 +1,52 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpClient\Internal;
+
+use Amp\Dns;
+use Amp\Dns\Record;
+use Amp\Promise;
+use Amp\Success;
+
+/**
+ * Handles local overrides for the DNS resolver.
+ *
+ * @author Nicolas Grekas
+ *
+ * @internal
+ */
+class AmpResolver implements Dns\Resolver
+{
+ private $dnsMap;
+
+ public function __construct(array &$dnsMap)
+ {
+ $this->dnsMap = &$dnsMap;
+ }
+
+ public function resolve(string $name, int $typeRestriction = null): Promise
+ {
+ if (!isset($this->dnsMap[$name]) || !\in_array($typeRestriction, [Record::A, null], true)) {
+ return Dns\resolver()->resolve($name, $typeRestriction);
+ }
+
+ return new Success([new Record($this->dnsMap[$name], Record::A, null)]);
+ }
+
+ public function query(string $name, int $type): Promise
+ {
+ if (!isset($this->dnsMap[$name]) || Record::A !== $type) {
+ return Dns\resolver()->query($name, $type);
+ }
+
+ return new Success([new Record($this->dnsMap[$name], Record::A, null)]);
+ }
+}
diff --git a/NativeHttpClient.php b/NativeHttpClient.php
index d60f541..60bced5 100644
--- a/NativeHttpClient.php
+++ b/NativeHttpClient.php
@@ -219,13 +219,10 @@ public function request(string $method, string $url, array $options = []): Respo
],
];
- $proxy = self::getProxy($options['proxy'], $url);
- $noProxy = $options['no_proxy'] ?? $_SERVER['no_proxy'] ?? $_SERVER['NO_PROXY'] ?? '';
- $noProxy = $noProxy ? preg_split('/[\s,]+/', $noProxy) : [];
-
- $resolveRedirect = self::createRedirectResolver($options, $host, $proxy, $noProxy, $info, $onProgress);
+ $proxy = self::getProxy($options['proxy'], $url, $options['no_proxy']);
+ $resolveRedirect = self::createRedirectResolver($options, $host, $proxy, $info, $onProgress);
$context = stream_context_create($context, ['notification' => $notification]);
- self::configureHeadersAndProxy($context, $host, $options['headers'], $proxy, $noProxy);
+ self::configureHeadersAndProxy($context, $host, $options['headers'], $proxy);
return new NativeResponse($this->multi, $context, implode('', $url), $options, $info, $resolveRedirect, $onProgress, $this->logger);
}
@@ -267,44 +264,6 @@ private static function getBodyAsString($body): string
return $result;
}
- /**
- * Loads proxy configuration from the same environment variables as curl when no proxy is explicitly set.
- */
- private static function getProxy(?string $proxy, array $url): ?array
- {
- if (null === $proxy) {
- // Ignore HTTP_PROXY except on the CLI to work around httpoxy set of vulnerabilities
- $proxy = $_SERVER['http_proxy'] ?? (\in_array(\PHP_SAPI, ['cli', 'phpdbg'], true) ? $_SERVER['HTTP_PROXY'] ?? null : null) ?? $_SERVER['all_proxy'] ?? $_SERVER['ALL_PROXY'] ?? null;
-
- if ('https:' === $url['scheme']) {
- $proxy = $_SERVER['https_proxy'] ?? $_SERVER['HTTPS_PROXY'] ?? $proxy;
- }
- }
-
- if (null === $proxy) {
- return null;
- }
-
- $proxy = (parse_url($proxy) ?: []) + ['scheme' => 'http'];
-
- if (!isset($proxy['host'])) {
- throw new TransportException('Invalid HTTP proxy: host is missing.');
- }
-
- if ('http' === $proxy['scheme']) {
- $proxyUrl = 'tcp://'.$proxy['host'].':'.($proxy['port'] ?? '80');
- } elseif ('https' === $proxy['scheme']) {
- $proxyUrl = 'ssl://'.$proxy['host'].':'.($proxy['port'] ?? '443');
- } else {
- throw new TransportException(sprintf('Unsupported proxy scheme "%s": "http" or "https" expected.', $proxy['scheme']));
- }
-
- return [
- 'url' => $proxyUrl,
- 'auth' => isset($proxy['user']) ? 'Basic '.base64_encode(rawurldecode($proxy['user']).':'.rawurldecode($proxy['pass'] ?? '')) : null,
- ];
- }
-
/**
* Resolves the IP of the host using the local DNS cache if possible.
*/
@@ -347,7 +306,7 @@ private static function dnsResolve(array $url, NativeClientState $multi, array &
/**
* Handles redirects - the native logic is too buggy to be used.
*/
- private static function createRedirectResolver(array $options, string $host, ?array $proxy, array $noProxy, array &$info, ?\Closure $onProgress): \Closure
+ private static function createRedirectResolver(array $options, string $host, ?array $proxy, array &$info, ?\Closure $onProgress): \Closure
{
$redirectHeaders = [];
if (0 < $maxRedirects = $options['max_redirects']) {
@@ -363,7 +322,7 @@ private static function createRedirectResolver(array $options, string $host, ?ar
}
}
- return static function (NativeClientState $multi, ?string $location, $context) use ($redirectHeaders, $proxy, $noProxy, &$info, $maxRedirects, $onProgress): ?string {
+ return static function (NativeClientState $multi, ?string $location, $context) use ($redirectHeaders, $proxy, &$info, $maxRedirects, $onProgress): ?string {
if (null === $location || $info['http_code'] < 300 || 400 <= $info['http_code']) {
$info['redirect_url'] = null;
@@ -411,14 +370,14 @@ private static function createRedirectResolver(array $options, string $host, ?ar
// Authorization and Cookie headers MUST NOT follow except for the initial host name
$requestHeaders = $redirectHeaders['host'] === $host ? $redirectHeaders['with_auth'] : $redirectHeaders['no_auth'];
$requestHeaders[] = 'Host: '.$host.$port;
- self::configureHeadersAndProxy($context, $host, $requestHeaders, $proxy, $noProxy);
+ self::configureHeadersAndProxy($context, $host, $requestHeaders, $proxy);
}
return implode('', $url);
};
}
- private static function configureHeadersAndProxy($context, string $host, array $requestHeaders, ?array $proxy, array $noProxy): bool
+ private static function configureHeadersAndProxy($context, string $host, array $requestHeaders, ?array $proxy): bool
{
if (null === $proxy) {
return stream_context_set_option($context, 'http', 'header', $requestHeaders);
@@ -426,7 +385,7 @@ private static function configureHeadersAndProxy($context, string $host, array $
// Matching "no_proxy" should follow the behavior of curl
- foreach ($noProxy as $rule) {
+ foreach ($proxy['no_proxy'] as $rule) {
$dotRule = '.'.ltrim($rule, '.');
if ('*' === $rule || $host === $rule || substr($host, -\strlen($dotRule)) === $dotRule) {
diff --git a/Response/AmpResponse.php b/Response/AmpResponse.php
new file mode 100644
index 0000000..e5d0bb2
--- /dev/null
+++ b/Response/AmpResponse.php
@@ -0,0 +1,400 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpClient\Response;
+
+use Amp\ByteStream\StreamException;
+use Amp\CancellationTokenSource;
+use Amp\Http\Client\HttpException;
+use Amp\Http\Client\Request;
+use Amp\Http\Client\Response;
+use Amp\Loop;
+use Psr\Log\LoggerInterface;
+use Symfony\Component\HttpClient\Chunk\FirstChunk;
+use Symfony\Component\HttpClient\Chunk\InformationalChunk;
+use Symfony\Component\HttpClient\Exception\InvalidArgumentException;
+use Symfony\Component\HttpClient\Exception\TransportException;
+use Symfony\Component\HttpClient\HttpClientTrait;
+use Symfony\Component\HttpClient\Internal\AmpBody;
+use Symfony\Component\HttpClient\Internal\AmpClientState;
+use Symfony\Contracts\HttpClient\ResponseInterface;
+
+/**
+ * @author Nicolas Grekas
+ *
+ * @internal
+ */
+final class AmpResponse implements ResponseInterface
+{
+ use ResponseTrait;
+
+ private $multi;
+ private $options;
+ private $canceller;
+ private $onProgress;
+
+ /**
+ * @internal
+ */
+ public function __construct(AmpClientState $multi, Request $request, array $options, ?LoggerInterface $logger)
+ {
+ $this->multi = $multi;
+ $this->options = &$options;
+ $this->logger = $logger;
+ $this->timeout = $options['timeout'];
+ $this->shouldBuffer = $options['buffer'];
+
+ if ($this->inflate = \extension_loaded('zlib') && !$request->hasHeader('accept-encoding')) {
+ $request->setHeader('Accept-Encoding', 'gzip');
+ }
+
+ $this->initializer = static function (self $response) {
+ return null !== $response->options;
+ };
+
+ $info = &$this->info;
+ $headers = &$this->headers;
+ $canceller = $this->canceller = new CancellationTokenSource();
+ $handle = &$this->handle;
+
+ $info['url'] = (string) $request->getUri();
+ $info['http_method'] = $request->getMethod();
+ $info['start_time'] = null;
+ $info['redirect_url'] = null;
+ $info['redirect_time'] = 0.0;
+ $info['redirect_count'] = 0;
+ $info['size_upload'] = 0.0;
+ $info['size_download'] = 0.0;
+ $info['upload_content_length'] = -1.0;
+ $info['download_content_length'] = -1.0;
+ $info['user_data'] = $options['user_data'];
+ $info['debug'] = '';
+
+ $onProgress = $options['on_progress'] ?? static function () {};
+ $onProgress = $this->onProgress = static function () use (&$info, $onProgress) {
+ $info['total_time'] = microtime(true) - $info['start_time'];
+ $onProgress((int) $info['size_download'], ((int) (1 + $info['download_content_length']) ?: 1) - 1, (array) $info);
+ };
+
+ $this->id = $id = Loop::defer(static function () use ($request, $multi, &$id, &$info, &$headers, $canceller, &$options, $onProgress, &$handle, $logger) {
+ return self::generateResponse($request, $multi, $id, $info, $headers, $canceller, $options, $onProgress, $handle, $logger);
+ });
+
+ $multi->openHandles[$id] = $id;
+ ++$multi->responseCount;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getInfo(string $type = null)
+ {
+ return null !== $type ? $this->info[$type] ?? null : $this->info;
+ }
+
+ public function __destruct()
+ {
+ try {
+ $this->doDestruct();
+ } finally {
+ $this->close();
+
+ // Clear the DNS cache when all requests completed
+ if (0 >= --$this->multi->responseCount) {
+ $this->multi->responseCount = 0;
+ $this->multi->dnsCache = [];
+ }
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ private function close(): void
+ {
+ $this->canceller->cancel();
+ unset($this->multi->openHandles[$this->id], $this->multi->handlesActivity[$this->id]);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ private static function schedule(self $response, array &$runningResponses): void
+ {
+ if (isset($runningResponses[0])) {
+ $runningResponses[0][1][$response->id] = $response;
+ } else {
+ $runningResponses[0] = [$response->multi, [$response->id => $response]];
+ }
+
+ if (!isset($response->multi->openHandles[$response->id])) {
+ $response->multi->handlesActivity[$response->id][] = null;
+ $response->multi->handlesActivity[$response->id][] = null !== $response->info['error'] ? new TransportException($response->info['error']) : null;
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ private static function perform(AmpClientState $multi, array &$responses = null): void
+ {
+ if ($responses) {
+ foreach ($responses as $response) {
+ try {
+ if ($response->info['start_time']) {
+ $response->info['total_time'] = microtime(true) - $response->info['start_time'];
+ ($response->onProgress)();
+ }
+ } catch (\Throwable $e) {
+ $multi->handlesActivity[$response->id][] = null;
+ $multi->handlesActivity[$response->id][] = $e;
+ }
+ }
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ private static function select(AmpClientState $multi, float $timeout): int
+ {
+ $selected = 1;
+ $delay = Loop::delay(1000 * $timeout, static function () use (&$selected) {
+ $selected = 0;
+ Loop::stop();
+ });
+ Loop::run();
+
+ if ($selected) {
+ Loop::cancel($delay);
+ }
+
+ return $selected;
+ }
+
+ private static function generateResponse(Request $request, AmpClientState $multi, string $id, array &$info, array &$headers, CancellationTokenSource $canceller, array &$options, \Closure $onProgress, &$handle, ?LoggerInterface $logger)
+ {
+ $activity = &$multi->handlesActivity;
+
+ $request->setInformationalResponseHandler(static function (Response $response) use (&$activity, $id, &$info, &$headers) {
+ self::addResponseHeaders($response, $info, $headers);
+ $activity[$id][] = new InformationalChunk($response->getStatus(), $response->getHeaders());
+ Loop::defer([Loop::class, 'stop']);
+ });
+
+ try {
+ /* @var Response $response */
+ if (null === $response = yield from self::getPushedResponse($request, $multi, $info, $headers, $options, $logger)) {
+ $logger && $logger->info(sprintf('Request: "%s %s"', $info['http_method'], $info['url']));
+
+ $response = yield from self::followRedirects($request, $multi, $info, $headers, $canceller, $options, $onProgress, $handle, $logger);
+ }
+
+ $options = null;
+
+ $activity[$id] = [new FirstChunk()];
+
+ if ('HEAD' === $response->getRequest()->getMethod() || \in_array($info['http_code'], [204, 304], true)) {
+ $activity[$id][] = null;
+ $activity[$id][] = null;
+ Loop::defer([Loop::class, 'stop']);
+
+ return;
+ }
+
+ if ($response->hasHeader('content-length')) {
+ $info['download_content_length'] = (float) $response->getHeader('content-length');
+ }
+
+ $body = $response->getBody();
+
+ while (true) {
+ Loop::defer([Loop::class, 'stop']);
+
+ if (null === $data = yield $body->read()) {
+ break;
+ }
+
+ $info['size_download'] += \strlen($data);
+ $activity[$id][] = $data;
+ }
+
+ $activity[$id][] = null;
+ $activity[$id][] = null;
+ } catch (\Throwable $e) {
+ $activity[$id][] = null;
+ $activity[$id][] = $e;
+ } finally {
+ $info['download_content_length'] = $info['size_download'];
+ }
+
+ Loop::defer([Loop::class, 'stop']);
+ }
+
+ private static function followRedirects(Request $originRequest, AmpClientState $multi, array &$info, array &$headers, CancellationTokenSource $canceller, array $options, \Closure $onProgress, &$handle, ?LoggerInterface $logger)
+ {
+ $originRequest->setBody(new AmpBody($options['body'], $info, $onProgress));
+ $response = yield $multi->request($options, $originRequest, $canceller->getToken(), $info, $onProgress, $handle);
+ $previousUrl = null;
+
+ while (true) {
+ self::addResponseHeaders($response, $info, $headers);
+ $status = $response->getStatus();
+
+ if (!\in_array($status, [301, 302, 303, 307, 308], true) || null === $location = $response->getHeader('location')) {
+ return $response;
+ }
+
+ $urlResolver = new class() {
+ use HttpClientTrait {
+ parseUrl as public;
+ resolveUrl as public;
+ }
+ };
+
+ try {
+ $previousUrl = $previousUrl ?? $urlResolver::parseUrl($info['url']);
+ $location = $urlResolver::parseUrl($location);
+ $location = $urlResolver::resolveUrl($location, $previousUrl);
+ $info['redirect_url'] = implode('', $location);
+ } catch (InvalidArgumentException $e) {
+ return $response;
+ }
+
+ if (0 >= $options['max_redirects'] || $info['redirect_count'] >= $options['max_redirects']) {
+ return $response;
+ }
+
+ $logger && $logger->info(sprintf('Redirecting: "%s %s"', $status, $info['url']));
+
+ try {
+ // Discard body of redirects
+ while (null !== yield $response->getBody()->read()) {
+ }
+ } catch (HttpException | StreamException $e) {
+ // Ignore streaming errors on previous responses
+ }
+
+ ++$info['redirect_count'];
+ $info['url'] = $info['redirect_url'];
+ $info['redirect_url'] = null;
+ $previousUrl = $location;
+
+ $request = new Request($info['url'], $info['http_method']);
+ $request->setProtocolVersions($originRequest->getProtocolVersions());
+ $request->setTcpConnectTimeout($originRequest->getTcpConnectTimeout());
+ $request->setTlsHandshakeTimeout($originRequest->getTlsHandshakeTimeout());
+ $request->setTransferTimeout($originRequest->getTransferTimeout());
+
+ if (\in_array($status, [301, 302, 303], true)) {
+ $originRequest->removeHeader('transfer-encoding');
+ $originRequest->removeHeader('content-length');
+ $originRequest->removeHeader('content-type');
+
+ // Do like curl and browsers: turn POST to GET on 301, 302 and 303
+ if ('POST' === $response->getRequest()->getMethod() || 303 === $status) {
+ $info['http_method'] = 'HEAD' === $response->getRequest()->getMethod() ? 'HEAD' : 'GET';
+ $request->setMethod($info['http_method']);
+ }
+ } else {
+ $request->setBody(AmpBody::rewind($response->getRequest()->getBody()));
+ }
+
+ foreach ($originRequest->getRawHeaders() as [$name, $value]) {
+ $request->setHeader($name, $value);
+ }
+
+ if ($request->getUri()->getAuthority() !== $originRequest->getUri()->getAuthority()) {
+ $request->removeHeader('authorization');
+ $request->removeHeader('cookie');
+ $request->removeHeader('host');
+ }
+
+ $response = yield $multi->request($options, $request, $canceller->getToken(), $info, $onProgress, $handle);
+ $info['redirect_time'] = microtime(true) - $info['start_time'];
+ }
+ }
+
+ private static function addResponseHeaders(Response $response, array &$info, array &$headers): void
+ {
+ $info['http_code'] = $response->getStatus();
+
+ if ($headers) {
+ $info['debug'] .= "< \r\n";
+ $headers = [];
+ }
+
+ $h = sprintf('HTTP/%s %s %s', $response->getProtocolVersion(), $response->getStatus(), $response->getReason());
+ $info['debug'] .= "< {$h}\r\n";
+ $info['response_headers'][] = $h;
+
+ foreach ($response->getRawHeaders() as [$name, $value]) {
+ $headers[strtolower($name)][] = $value;
+ $h = $name.': '.$value;
+ $info['debug'] .= "< {$h}\r\n";
+ $info['response_headers'][] = $h;
+ }
+
+ $info['debug'] .= "< \r\n";
+ }
+
+ /**
+ * Accepts pushed responses only if their headers related to authentication match the request.
+ */
+ private static function getPushedResponse(Request $request, AmpClientState $multi, array &$info, array &$headers, array $options, ?LoggerInterface $logger)
+ {
+ if ('' !== $options['body']) {
+ return null;
+ }
+
+ $authority = $request->getUri()->getAuthority();
+
+ foreach ($multi->pushedResponses[$authority] ?? [] as $i => [$pushedUrl, $pushDeferred, $pushedRequest, $pushedResponse, $parentOptions]) {
+ if ($info['url'] !== $pushedUrl || $info['http_method'] !== $pushedRequest->getMethod()) {
+ continue;
+ }
+
+ foreach ($parentOptions as $k => $v) {
+ if ($options[$k] !== $v) {
+ continue 2;
+ }
+ }
+
+ foreach (['authorization', 'cookie', 'range', 'proxy-authorization'] as $k) {
+ if ($pushedRequest->getHeaderArray($k) !== $request->getHeaderArray($k)) {
+ continue 2;
+ }
+ }
+
+ $response = yield $pushedResponse;
+
+ foreach ($response->getHeaderArray('vary') as $vary) {
+ foreach (preg_split('/\s*+,\s*+/', $vary) as $v) {
+ if ('*' === $v || ($pushedRequest->getHeaderArray($v) !== $request->getHeaderArray($v) && 'accept-encoding' !== strtolower($v))) {
+ $logger && $logger->debug(sprintf('Skipping pushed response: "%s"', $info['url']));
+ continue 3;
+ }
+ }
+ }
+
+ $pushDeferred->resolve();
+ $logger && $logger->debug(sprintf('Accepting pushed response: "%s %s"', $info['http_method'], $info['url']));
+ self::addResponseHeaders($response, $info, $headers);
+ unset($multi->pushedResponses[$authority][$i]);
+
+ if (!$multi->pushedResponses[$authority]) {
+ unset($multi->pushedResponses[$authority]);
+ }
+
+ return $response;
+ }
+ }
+}
diff --git a/Tests/AmpHttpClientTest.php b/Tests/AmpHttpClientTest.php
new file mode 100644
index 0000000..e17b45a
--- /dev/null
+++ b/Tests/AmpHttpClientTest.php
@@ -0,0 +1,28 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpClient\Tests;
+
+use Symfony\Component\HttpClient\AmpHttpClient;
+use Symfony\Contracts\HttpClient\HttpClientInterface;
+
+class AmpHttpClientTest extends HttpClientTestCase
+{
+ protected function getHttpClient(string $testCase): HttpClientInterface
+ {
+ return new AmpHttpClient(['verify_peer' => false, 'verify_host' => false, 'timeout' => 5]);
+ }
+
+ public function testProxy()
+ {
+ $this->markTestSkipped('A real proxy server would be needed.');
+ }
+}
diff --git a/Tests/CurlHttpClientTest.php b/Tests/CurlHttpClientTest.php
index f83edf9..d238034 100644
--- a/Tests/CurlHttpClientTest.php
+++ b/Tests/CurlHttpClientTest.php
@@ -11,155 +11,26 @@
namespace Symfony\Component\HttpClient\Tests;
-use Psr\Log\AbstractLogger;
use Symfony\Component\HttpClient\CurlHttpClient;
-use Symfony\Component\Process\Exception\ProcessFailedException;
-use Symfony\Component\Process\Process;
use Symfony\Contracts\HttpClient\HttpClientInterface;
-/*
-Tests for HTTP2 Push need a recent version of both PHP and curl. This docker command should run them:
-docker run -it --rm -v $(pwd):/app -v /path/to/vulcain:/usr/local/bin/vulcain -w /app php:7.3-alpine ./phpunit src/Symfony/Component/HttpClient/Tests/CurlHttpClientTest.php --filter testHttp2Push
-The vulcain binary can be found at https://github.com/symfony/binary-utils/releases/download/v0.1/vulcain_0.1.3_Linux_x86_64.tar.gz - see https://github.com/dunglas/vulcain for source
-*/
-
/**
* @requires extension curl
*/
class CurlHttpClientTest extends HttpClientTestCase
{
- private static $vulcainStarted = false;
-
protected function getHttpClient(string $testCase): HttpClientInterface
{
- return new CurlHttpClient();
- }
-
- /**
- * @requires PHP 7.2.17
- */
- public function testHttp2PushVulcain()
- {
- $client = $this->getVulcainClient();
- $logger = new TestLogger();
- $client->setLogger($logger);
-
- $responseAsArray = $client->request('GET', 'https://127.0.0.1:3000/json', [
- 'headers' => [
- 'Preload' => '/documents/*/id',
- ],
- ])->toArray();
-
- foreach ($responseAsArray['documents'] as $document) {
- $client->request('GET', 'https://127.0.0.1:3000'.$document['id'])->toArray();
- }
-
- $client->reset();
-
- $expected = [
- 'Request: "GET https://127.0.0.1:3000/json"',
- 'Queueing pushed response: "https://127.0.0.1:3000/json/1"',
- 'Queueing pushed response: "https://127.0.0.1:3000/json/2"',
- 'Queueing pushed response: "https://127.0.0.1:3000/json/3"',
- 'Response: "200 https://127.0.0.1:3000/json"',
- 'Accepting pushed response: "GET https://127.0.0.1:3000/json/1"',
- 'Response: "200 https://127.0.0.1:3000/json/1"',
- 'Accepting pushed response: "GET https://127.0.0.1:3000/json/2"',
- 'Response: "200 https://127.0.0.1:3000/json/2"',
- 'Accepting pushed response: "GET https://127.0.0.1:3000/json/3"',
- 'Response: "200 https://127.0.0.1:3000/json/3"',
- ];
- $this->assertSame($expected, $logger->logs);
- }
-
- /**
- * @requires PHP 7.2.17
- */
- public function testHttp2PushVulcainWithUnusedResponse()
- {
- $client = $this->getVulcainClient();
- $logger = new TestLogger();
- $client->setLogger($logger);
-
- $responseAsArray = $client->request('GET', 'https://127.0.0.1:3000/json', [
- 'headers' => [
- 'Preload' => '/documents/*/id',
- ],
- ])->toArray();
-
- $i = 0;
- foreach ($responseAsArray['documents'] as $document) {
- $client->request('GET', 'https://127.0.0.1:3000'.$document['id'])->toArray();
- if (++$i >= 2) {
- break;
+ if (false !== strpos($testCase, 'Push')) {
+ if (\PHP_VERSION_ID >= 70300 && \PHP_VERSION_ID < 70304) {
+ $this->markTestSkipped('PHP 7.3.0 to 7.3.3 don\'t support HTTP/2 PUSH');
}
- }
-
- $client->reset();
-
- $expected = [
- 'Request: "GET https://127.0.0.1:3000/json"',
- 'Queueing pushed response: "https://127.0.0.1:3000/json/1"',
- 'Queueing pushed response: "https://127.0.0.1:3000/json/2"',
- 'Queueing pushed response: "https://127.0.0.1:3000/json/3"',
- 'Response: "200 https://127.0.0.1:3000/json"',
- 'Accepting pushed response: "GET https://127.0.0.1:3000/json/1"',
- 'Response: "200 https://127.0.0.1:3000/json/1"',
- 'Accepting pushed response: "GET https://127.0.0.1:3000/json/2"',
- 'Response: "200 https://127.0.0.1:3000/json/2"',
- 'Unused pushed response: "https://127.0.0.1:3000/json/3"',
- ];
- $this->assertSame($expected, $logger->logs);
- }
- private function getVulcainClient(): CurlHttpClient
- {
- if (\PHP_VERSION_ID >= 70300 && \PHP_VERSION_ID < 70304) {
- $this->markTestSkipped('PHP 7.3.0 to 7.3.3 don\'t support HTTP/2 PUSH');
- }
-
- if (!\defined('CURLMOPT_PUSHFUNCTION') || 0x073d00 > ($v = curl_version())['version_number'] || !(CURL_VERSION_HTTP2 & $v['features'])) {
- $this->markTestSkipped('curl <7.61 is used or it is not compiled with support for HTTP/2 PUSH');
- }
-
- $client = new CurlHttpClient(['verify_peer' => false, 'verify_host' => false]);
-
- if (static::$vulcainStarted) {
- return $client;
- }
-
- if (['application/json'] !== $client->request('GET', 'http://127.0.0.1:8057/json')->getHeaders()['content-type']) {
- $this->markTestSkipped('symfony/http-client-contracts >= 2.0.1 required');
- }
-
- $process = new Process(['vulcain'], null, [
- 'DEBUG' => 1,
- 'UPSTREAM' => 'http://127.0.0.1:8057',
- 'ADDR' => ':3000',
- 'KEY_FILE' => __DIR__.'/Fixtures/tls/server.key',
- 'CERT_FILE' => __DIR__.'/Fixtures/tls/server.crt',
- ]);
- $process->start();
-
- register_shutdown_function([$process, 'stop']);
- sleep('\\' === \DIRECTORY_SEPARATOR ? 10 : 1);
-
- if (!$process->isRunning()) {
- throw new ProcessFailedException($process);
+ if (!\defined('CURLMOPT_PUSHFUNCTION') || 0x073d00 > ($v = curl_version())['version_number'] || !(CURL_VERSION_HTTP2 & $v['features'])) {
+ $this->markTestSkipped('curl <7.61 is used or it is not compiled with support for HTTP/2 PUSH');
+ }
}
- static::$vulcainStarted = true;
-
- return $client;
- }
-}
-
-class TestLogger extends AbstractLogger
-{
- public $logs = [];
-
- public function log($level, $message, array $context = []): void
- {
- $this->logs[] = $message;
+ return new CurlHttpClient(['verify_peer' => false, 'verify_host' => false]);
}
}
diff --git a/Tests/HttpClientTest.php b/Tests/HttpClientTest.php
index 9f70b74..e2b0d9f 100644
--- a/Tests/HttpClientTest.php
+++ b/Tests/HttpClientTest.php
@@ -20,7 +20,7 @@ class HttpClientTest extends TestCase
{
public function testCreateClient()
{
- if (\extension_loaded('curl')) {
+ if (\extension_loaded('curl') && ('\\' !== \DIRECTORY_SEPARATOR || ini_get('curl.cainfo') || ini_get('openssl.cafile') || ini_get('openssl.capath'))) {
$this->assertInstanceOf(CurlHttpClient::class, HttpClient::create());
} else {
$this->assertInstanceOf(NativeHttpClient::class, HttpClient::create());
diff --git a/Tests/HttpClientTestCase.php b/Tests/HttpClientTestCase.php
index 5017b2e..c9c667c 100644
--- a/Tests/HttpClientTestCase.php
+++ b/Tests/HttpClientTestCase.php
@@ -13,10 +13,21 @@
use Symfony\Component\HttpClient\Exception\ClientException;
use Symfony\Component\HttpClient\Response\StreamWrapper;
+use Symfony\Component\Process\Exception\ProcessFailedException;
+use Symfony\Component\Process\Process;
+use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\Test\HttpClientTestCase as BaseHttpClientTestCase;
+/*
+Tests for HTTP2 Push need a recent version of both PHP and curl. This docker command should run them:
+docker run -it --rm -v $(pwd):/app -v /path/to/vulcain:/usr/local/bin/vulcain -w /app php:7.3-alpine ./phpunit src/Symfony/Component/HttpClient --filter Push
+The vulcain binary can be found at https://github.com/symfony/binary-utils/releases/download/v0.1/vulcain_0.1.3_Linux_x86_64.tar.gz - see https://github.com/dunglas/vulcain for source
+*/
+
abstract class HttpClientTestCase extends BaseHttpClientTestCase
{
+ private static $vulcainStarted = false;
+
public function testAcceptHeader()
{
$client = $this->getHttpClient(__FUNCTION__);
@@ -128,4 +139,110 @@ public function testStreamWrapperWithClientStreamRewind()
rewind($stream);
$this->assertSame('Here the body', stream_get_contents($stream));
}
+
+ public function testHttp2PushVulcain()
+ {
+ $client = $this->getHttpClient(__FUNCTION__);
+ self::startVulcain($client);
+ $logger = new TestLogger();
+ $client->setLogger($logger);
+
+ $responseAsArray = $client->request('GET', 'https://127.0.0.1:3000/json', [
+ 'headers' => [
+ 'Preload' => '/documents/*/id',
+ ],
+ ])->toArray();
+
+ foreach ($responseAsArray['documents'] as $document) {
+ $client->request('GET', 'https://127.0.0.1:3000'.$document['id'])->toArray();
+ }
+
+ $client->reset();
+
+ $expected = [
+ 'Request: "GET https://127.0.0.1:3000/json"',
+ 'Queueing pushed response: "https://127.0.0.1:3000/json/1"',
+ 'Queueing pushed response: "https://127.0.0.1:3000/json/2"',
+ 'Queueing pushed response: "https://127.0.0.1:3000/json/3"',
+ 'Response: "200 https://127.0.0.1:3000/json"',
+ 'Accepting pushed response: "GET https://127.0.0.1:3000/json/1"',
+ 'Response: "200 https://127.0.0.1:3000/json/1"',
+ 'Accepting pushed response: "GET https://127.0.0.1:3000/json/2"',
+ 'Response: "200 https://127.0.0.1:3000/json/2"',
+ 'Accepting pushed response: "GET https://127.0.0.1:3000/json/3"',
+ 'Response: "200 https://127.0.0.1:3000/json/3"',
+ ];
+ $this->assertSame($expected, $logger->logs);
+ }
+
+ public function testHttp2PushVulcainWithUnusedResponse()
+ {
+ $client = $this->getHttpClient(__FUNCTION__);
+ self::startVulcain($client);
+ $logger = new TestLogger();
+ $client->setLogger($logger);
+
+ $responseAsArray = $client->request('GET', 'https://127.0.0.1:3000/json', [
+ 'headers' => [
+ 'Preload' => '/documents/*/id',
+ ],
+ ])->toArray();
+
+ $i = 0;
+ foreach ($responseAsArray['documents'] as $document) {
+ $client->request('GET', 'https://127.0.0.1:3000'.$document['id'])->toArray();
+ if (++$i >= 2) {
+ break;
+ }
+ }
+
+ $client->reset();
+
+ $expected = [
+ 'Request: "GET https://127.0.0.1:3000/json"',
+ 'Queueing pushed response: "https://127.0.0.1:3000/json/1"',
+ 'Queueing pushed response: "https://127.0.0.1:3000/json/2"',
+ 'Queueing pushed response: "https://127.0.0.1:3000/json/3"',
+ 'Response: "200 https://127.0.0.1:3000/json"',
+ 'Accepting pushed response: "GET https://127.0.0.1:3000/json/1"',
+ 'Response: "200 https://127.0.0.1:3000/json/1"',
+ 'Accepting pushed response: "GET https://127.0.0.1:3000/json/2"',
+ 'Response: "200 https://127.0.0.1:3000/json/2"',
+ 'Unused pushed response: "https://127.0.0.1:3000/json/3"',
+ ];
+ $this->assertSame($expected, $logger->logs);
+ }
+
+ private static function startVulcain(HttpClientInterface $client)
+ {
+ if (self::$vulcainStarted) {
+ return;
+ }
+
+ if ('\\' === \DIRECTORY_SEPARATOR) {
+ self::markTestSkipped('Testing with the "vulcain" is not supported on Windows.');
+ }
+
+ if (['application/json'] !== $client->request('GET', 'http://127.0.0.1:8057/json')->getHeaders()['content-type']) {
+ self::markTestSkipped('symfony/http-client-contracts >= 2.0.1 required');
+ }
+
+ $process = new Process(['vulcain'], null, [
+ 'DEBUG' => 1,
+ 'UPSTREAM' => 'http://127.0.0.1:8057',
+ 'ADDR' => ':3000',
+ 'KEY_FILE' => __DIR__.'/Fixtures/tls/server.key',
+ 'CERT_FILE' => __DIR__.'/Fixtures/tls/server.crt',
+ ]);
+ $process->start();
+
+ register_shutdown_function([$process, 'stop']);
+ sleep('\\' === \DIRECTORY_SEPARATOR ? 10 : 1);
+
+ if (!$process->isRunning()) {
+ throw new ProcessFailedException($process);
+ }
+
+ self::$vulcainStarted = true;
+ }
}
diff --git a/Tests/MockHttpClientTest.php b/Tests/MockHttpClientTest.php
index d21e3f5..9669718 100644
--- a/Tests/MockHttpClientTest.php
+++ b/Tests/MockHttpClientTest.php
@@ -312,4 +312,14 @@ protected function getHttpClient(string $testCase): HttpClientInterface
return new MockHttpClient($responses);
}
+
+ public function testHttp2PushVulcain()
+ {
+ $this->markTestSkipped('MockHttpClient doesn\'t support HTTP/2 PUSH.');
+ }
+
+ public function testHttp2PushVulcainWithUnusedResponse()
+ {
+ $this->markTestSkipped('MockHttpClient doesn\'t support HTTP/2 PUSH.');
+ }
}
diff --git a/Tests/NativeHttpClientTest.php b/Tests/NativeHttpClientTest.php
index bcfab64..819124f 100644
--- a/Tests/NativeHttpClientTest.php
+++ b/Tests/NativeHttpClientTest.php
@@ -25,4 +25,14 @@ public function testInformationalResponseStream()
{
$this->markTestSkipped('NativeHttpClient doesn\'t support informational status codes.');
}
+
+ public function testHttp2PushVulcain()
+ {
+ $this->markTestSkipped('NativeHttpClient doesn\'t support HTTP/2.');
+ }
+
+ public function testHttp2PushVulcainWithUnusedResponse()
+ {
+ $this->markTestSkipped('NativeHttpClient doesn\'t support HTTP/2.');
+ }
}
diff --git a/Tests/TestLogger.php b/Tests/TestLogger.php
new file mode 100644
index 0000000..83aa096
--- /dev/null
+++ b/Tests/TestLogger.php
@@ -0,0 +1,24 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpClient\Tests;
+
+use Psr\Log\AbstractLogger;
+
+class TestLogger extends AbstractLogger
+{
+ public $logs = [];
+
+ public function log($level, $message, array $context = []): void
+ {
+ $this->logs[] = $message;
+ }
+}
diff --git a/composer.json b/composer.json
index 22f34cd..e6fadb1 100644
--- a/composer.json
+++ b/composer.json
@@ -28,6 +28,9 @@
"symfony/service-contracts": "^1.0|^2"
},
"require-dev": {
+ "amphp/http-client": "^4.2",
+ "amphp/http-tunnel": "^1.0",
+ "amphp/socket": "^1.1",
"guzzlehttp/promises": "^1.3.1",
"nyholm/psr7": "^1.0",
"php-http/httplug": "^1.0|^2.0",