diff --git a/.appveyor.yml b/.appveyor.yml index 67f2fcafc63f..dbf83731eaff 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -59,7 +59,10 @@ test_script: - SET SYMFONY_PHPUNIT_SKIPPED_TESTS=phpunit.skipped - copy /Y c:\php\php.ini-min c:\php\php.ini - IF %APPVEYOR_REPO_BRANCH% neq master (rm -Rf src\Symfony\Bridge\PhpUnit) + - mv src\Symfony\Component\HttpClient\phpunit.xml.dist src\Symfony\Component\HttpClient\phpunit.xml - php phpunit src\Symfony --exclude-group tty,benchmark,intl-data || SET X=!errorlevel! + - php phpunit src\Symfony\Component\HttpClient || SET X=!errorlevel! - copy /Y c:\php\php.ini-max c:\php\php.ini - php phpunit src\Symfony --exclude-group tty,benchmark,intl-data || SET X=!errorlevel! + - php phpunit src\Symfony\Component\HttpClient || SET X=!errorlevel! - exit %X% diff --git a/composer.json b/composer.json index 4b2932efdce2..ff1bebc174e3 100644 --- a/composer.json +++ b/composer.json @@ -99,6 +99,8 @@ "symfony/yaml": "self.version" }, "require-dev": { + "amphp/http-client": "^4.2", + "amphp/http-tunnel": "^1.0", "cache/integration-tests": "dev-master", "doctrine/annotations": "~1.0", "doctrine/cache": "~1.6", diff --git a/src/Symfony/Component/HttpClient/AmpHttpClient.php b/src/Symfony/Component/HttpClient/AmpHttpClient.php new file mode 100644 index 000000000000..c62b847910c2 --- /dev/null +++ b/src/Symfony/Component/HttpClient/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/src/Symfony/Component/HttpClient/CHANGELOG.md b/src/Symfony/Component/HttpClient/CHANGELOG.md index 97491f1196af..2c4b7069142c 100644 --- a/src/Symfony/Component/HttpClient/CHANGELOG.md +++ b/src/Symfony/Component/HttpClient/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/src/Symfony/Component/HttpClient/HttpClientTrait.php b/src/Symfony/Component/HttpClient/HttpClientTrait.php index b44e4f8dbbc9..0a25306cb760 100644 --- a/src/Symfony/Component/HttpClient/HttpClientTrait.php +++ b/src/Symfony/Component/HttpClient/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/src/Symfony/Component/HttpClient/Internal/AmpBody.php b/src/Symfony/Component/HttpClient/Internal/AmpBody.php new file mode 100644 index 000000000000..6f820e932e7f --- /dev/null +++ b/src/Symfony/Component/HttpClient/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/src/Symfony/Component/HttpClient/Internal/AmpClientState.php b/src/Symfony/Component/HttpClient/Internal/AmpClientState.php new file mode 100644 index 000000000000..6fa8a2fc20e5 --- /dev/null +++ b/src/Symfony/Component/HttpClient/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 + */ + public function request(array $options, Request $request, CancellationToken $cancellation, array &$info, \Closure $onProgress, &$handle): Promise + { + if ($options['proxy']) { + if ($request->hasHeader('proxy-authorization')) { + $options['proxy']['auth'] = $request->getHeader('proxy-authorization'); + } + + // Matching "no_proxy" should follow the behavior of curl + $host = $request->getUri()->getHost(); + foreach ($options['proxy']['no_proxy'] as $rule) { + $dotRule = '.'.ltrim($rule, '.'); + + if ('*' === $rule || $host === $rule || substr($host, -\strlen($dotRule)) === $dotRule) { + $options['proxy'] = null; + break; + } + } + } + + $request = clone $request; + + if ($request->hasHeader('proxy-authorization')) { + $request->removeHeader('proxy-authorization'); + } + + if ($options['capture_peer_cert_chain']) { + $info['peer_certificate_chain'] = []; + } + + $request->addEventListener(new AmpListener($info, $options['peer_fingerprint']['pin-sha256'] ?? [], $onProgress, $handle)); + $request->setPushHandler(function ($request, $response) use ($options): Promise { + return $this->handlePush($request, $response, $options); + }); + + ($request->hasHeader('content-length') ? new Success((int) $request->getHeader('content-length')) : $request->getBody()->getBodyLength()) + ->onResolve(static function ($e, $bodySize) use (&$info) { + if (null !== $bodySize && 0 <= $bodySize) { + $info['upload_content_length'] = ((1 + $info['upload_content_length']) ?? 1) - 1 + $bodySize; + } + }); + + [$client, $connector] = $this->getClient($options); + $response = $client->request($request, $cancellation); + $response->onResolve(static function ($e) use ($connector, &$handle) { + if (null === $e) { + $handle = $connector->handle; + } + }); + + return $response; + } + + private function getClient(array $options): array + { + $options = [ + 'bindto' => $options['bindto'] ?: '0', + 'verify_peer' => $options['verify_peer'], + 'capath' => $options['capath'], + 'cafile' => $options['cafile'], + 'local_cert' => $options['local_cert'], + 'local_pk' => $options['local_pk'], + 'ciphers' => $options['ciphers'], + 'capture_peer_cert_chain' => $options['capture_peer_cert_chain'] || $options['peer_fingerprint'], + 'proxy' => $options['proxy'], + ]; + + $key = md5(serialize($options)); + + if (isset($this->clients[$key])) { + return $this->clients[$key]; + } + + $context = new ClientTlsContext(''); + $options['verify_peer'] || $context = $context->withoutPeerVerification(); + $options['cafile'] && $context = $context->withCaFile($options['cafile']); + $options['capath'] && $context = $context->withCaPath($options['capath']); + $options['local_cert'] && $context = $context->withCertificate(new Certificate($options['local_cert'], $options['local_pk'])); + $options['ciphers'] && $context = $context->withCiphers($options['ciphers']); + $options['capture_peer_cert_chain'] && $context = $context->withPeerCapturing(); + + $connector = $handleConnector = new class() implements Connector { + public $connector; + public $uri; + public $handle; + + public function connect(string $uri, ?ConnectContext $context = null, ?CancellationToken $token = null): Promise + { + $result = $this->connector->connect($this->uri ?? $uri, $context, $token); + $result->onResolve(function ($e, $socket) { + $this->handle = null !== $socket ? $socket->getResource() : false; + }); + + return $result; + } + }; + $connector->connector = new DnsConnector(new AmpResolver($this->dnsCache)); + + $context = (new ConnectContext())->withTlsContext($context); + + if ($options['bindto']) { + if (file_exists($options['bindto'])) { + $connector->uri = 'unix://'.$options['bindto']; + } else { + $context = $context->withBindTo($options['bindto']); + } + } + + if ($options['proxy']) { + $proxyUrl = parse_url($options['proxy']['url']); + $proxySocket = new SocketAddress($proxyUrl['host'], $proxyUrl['port']); + $proxyHeaders = $options['proxy']['auth'] ? ['Proxy-Authorization' => $options['proxy']['auth']] : []; + + if ('ssl' === $proxyUrl['scheme']) { + $connector = new Https1TunnelConnector($proxySocket, $context->getTlsContext(), $proxyHeaders, $connector); + } else { + $connector = new Http1TunnelConnector($proxySocket, $proxyHeaders, $connector); + } + } + + $maxHostConnections = 0 < $this->maxHostConnections ? $this->maxHostConnections : PHP_INT_MAX; + $pool = new DefaultConnectionFactory($connector, $context); + $pool = ConnectionLimitingPool::byAuthority($maxHostConnections, $pool); + + return $this->clients[$key] = [($this->clientConfigurator)(new PooledHttpClient($pool)), $handleConnector]; + } + + private function handlePush(Request $request, Promise $response, array $options): Promise + { + $deferred = new Deferred(); + $authority = $request->getUri()->getAuthority(); + + if ($this->maxPendingPushes <= \count($this->pushedResponses[$authority] ?? [])) { + $fifoUrl = key($this->pushedResponses[$authority]); + unset($this->pushedResponses[$authority][$fifoUrl]); + $this->logger && $this->logger->debug(sprintf('Evicting oldest pushed response: "%s"', $fifoUrl)); + } + + $url = (string) $request->getUri(); + $this->logger && $this->logger->debug(sprintf('Queueing pushed response: "%s"', $url)); + $this->pushedResponses[$authority][] = [$url, $deferred, $request, $response, [ + 'proxy' => $options['proxy'], + 'bindto' => $options['bindto'], + 'local_cert' => $options['local_cert'], + 'local_pk' => $options['local_pk'], + ]]; + + return $deferred->promise(); + } +} diff --git a/src/Symfony/Component/HttpClient/Internal/AmpListener.php b/src/Symfony/Component/HttpClient/Internal/AmpListener.php new file mode 100644 index 000000000000..cb3235bca3ff --- /dev/null +++ b/src/Symfony/Component/HttpClient/Internal/AmpListener.php @@ -0,0 +1,183 @@ + + * + * 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\Http\Client\Connection\Stream; +use Amp\Http\Client\EventListener; +use Amp\Http\Client\Request; +use Amp\Promise; +use Amp\Success; +use Symfony\Component\HttpClient\Exception\TransportException; + +/** + * @author Nicolas Grekas + * + * @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/src/Symfony/Component/HttpClient/Internal/AmpResolver.php b/src/Symfony/Component/HttpClient/Internal/AmpResolver.php new file mode 100644 index 000000000000..d31476a5832b --- /dev/null +++ b/src/Symfony/Component/HttpClient/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/src/Symfony/Component/HttpClient/NativeHttpClient.php b/src/Symfony/Component/HttpClient/NativeHttpClient.php index d60f5415271c..60bced566d00 100644 --- a/src/Symfony/Component/HttpClient/NativeHttpClient.php +++ b/src/Symfony/Component/HttpClient/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/src/Symfony/Component/HttpClient/Response/AmpResponse.php b/src/Symfony/Component/HttpClient/Response/AmpResponse.php new file mode 100644 index 000000000000..e5d0bb213fc8 --- /dev/null +++ b/src/Symfony/Component/HttpClient/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/src/Symfony/Component/HttpClient/Tests/AmpHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/AmpHttpClientTest.php new file mode 100644 index 000000000000..e17b45a0ce18 --- /dev/null +++ b/src/Symfony/Component/HttpClient/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/src/Symfony/Component/HttpClient/Tests/CurlHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/CurlHttpClientTest.php index f83edf91b6f3..d238034f451e 100644 --- a/src/Symfony/Component/HttpClient/Tests/CurlHttpClientTest.php +++ b/src/Symfony/Component/HttpClient/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/src/Symfony/Component/HttpClient/Tests/HttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/HttpClientTest.php index 9f70b743965d..e2b0d9f6ebef 100644 --- a/src/Symfony/Component/HttpClient/Tests/HttpClientTest.php +++ b/src/Symfony/Component/HttpClient/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/src/Symfony/Component/HttpClient/Tests/HttpClientTestCase.php b/src/Symfony/Component/HttpClient/Tests/HttpClientTestCase.php index 5017b2e3c415..c9c667c11d1a 100644 --- a/src/Symfony/Component/HttpClient/Tests/HttpClientTestCase.php +++ b/src/Symfony/Component/HttpClient/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/src/Symfony/Component/HttpClient/Tests/MockHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/MockHttpClientTest.php index d21e3f50a996..966971823e8c 100644 --- a/src/Symfony/Component/HttpClient/Tests/MockHttpClientTest.php +++ b/src/Symfony/Component/HttpClient/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/src/Symfony/Component/HttpClient/Tests/NativeHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/NativeHttpClientTest.php index bcfab64bdcac..819124f1745b 100644 --- a/src/Symfony/Component/HttpClient/Tests/NativeHttpClientTest.php +++ b/src/Symfony/Component/HttpClient/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/src/Symfony/Component/HttpClient/Tests/TestLogger.php b/src/Symfony/Component/HttpClient/Tests/TestLogger.php new file mode 100644 index 000000000000..83aa096889fa --- /dev/null +++ b/src/Symfony/Component/HttpClient/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/src/Symfony/Component/HttpClient/composer.json b/src/Symfony/Component/HttpClient/composer.json index 22f34cd4e74a..e6fadb19416c 100644 --- a/src/Symfony/Component/HttpClient/composer.json +++ b/src/Symfony/Component/HttpClient/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",