diff --git a/composer.json b/composer.json index e0544b61fb..46fdef1121 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ "psr/link": "^1.0", "psr/log": "~1.0", "psr/simple-cache": "^1.0", - "symfony/contracts": "^1.0.2", + "symfony/contracts": "^1.1", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-intl-icu": "~1.0", "symfony/polyfill-intl-idn": "^1.10", @@ -55,6 +55,7 @@ "symfony/finder": "self.version", "symfony/form": "self.version", "symfony/framework-bundle": "self.version", + "symfony/http-client": "self.version", "symfony/http-foundation": "self.version", "symfony/http-kernel": "self.version", "symfony/inflector": "self.version", @@ -101,8 +102,10 @@ "doctrine/reflection": "~1.0", "doctrine/doctrine-bundle": "~1.4", "monolog/monolog": "~1.11", + "nyholm/psr7": "^1.0", "ocramius/proxy-manager": "~0.4|~1.0|~2.0", "predis/predis": "~1.1", + "psr/http-client": "^1.0", "egulias/email-validator": "~1.2,>=1.2.8|~2.0", "symfony/phpunit-bridge": "~3.4|~4.0", "symfony/security-acl": "~2.8|~3.0", diff --git a/src/Symfony/Component/HttpClient/CHANGELOG.md b/src/Symfony/Component/HttpClient/CHANGELOG.md new file mode 100644 index 0000000000..44594a71d0 --- /dev/null +++ b/src/Symfony/Component/HttpClient/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +4.3.0 +----- + + * added the component diff --git a/src/Symfony/Component/HttpClient/Chunk/DataChunk.php b/src/Symfony/Component/HttpClient/Chunk/DataChunk.php new file mode 100644 index 0000000000..618112834d --- /dev/null +++ b/src/Symfony/Component/HttpClient/Chunk/DataChunk.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient\Chunk; + +use Symfony\Contracts\HttpClient\ChunkInterface; + +/** + * @author Nicolas Grekas + * + * @internal + */ +class DataChunk implements ChunkInterface +{ + private $offset; + private $content; + + public function __construct(int $offset = 0, string $content = '') + { + $this->offset = $offset; + $this->content = $content; + } + + /** + * {@inheritdoc} + */ + public function isTimeout(): bool + { + return false; + } + + /** + * {@inheritdoc} + */ + public function isFirst(): bool + { + return false; + } + + /** + * {@inheritdoc} + */ + public function isLast(): bool + { + return false; + } + + /** + * {@inheritdoc} + */ + public function getContent(): string + { + return $this->content; + } + + /** + * {@inheritdoc} + */ + public function getOffset(): int + { + return $this->offset; + } + + /** + * {@inheritdoc} + */ + public function getError(): ?string + { + return null; + } +} diff --git a/src/Symfony/Component/HttpClient/Chunk/ErrorChunk.php b/src/Symfony/Component/HttpClient/Chunk/ErrorChunk.php new file mode 100644 index 0000000000..0c3f8dfc62 --- /dev/null +++ b/src/Symfony/Component/HttpClient/Chunk/ErrorChunk.php @@ -0,0 +1,106 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient\Chunk; + +use Symfony\Component\HttpClient\Exception\TransportException; +use Symfony\Contracts\HttpClient\ChunkInterface; + +/** + * @author Nicolas Grekas + * + * @internal + */ +class ErrorChunk implements ChunkInterface +{ + protected $didThrow; + + private $offset; + private $errorMessage; + private $error; + + /** + * @param bool &$didThrow Allows monitoring when the $error has been thrown or not + */ + public function __construct(bool &$didThrow, int $offset, \Throwable $error = null) + { + $didThrow = false; + $this->didThrow = &$didThrow; + $this->offset = $offset; + $this->error = $error; + $this->errorMessage = null !== $error ? $error->getMessage() : 'Reading from the response stream reached the inactivity timeout.'; + } + + /** + * {@inheritdoc} + */ + public function isTimeout(): bool + { + $this->didThrow = true; + + if (null !== $this->error) { + throw new TransportException($this->errorMessage, 0, $this->error); + } + + return true; + } + + /** + * {@inheritdoc} + */ + public function isFirst(): bool + { + $this->didThrow = true; + throw new TransportException($this->errorMessage, 0, $this->error); + } + + /** + * {@inheritdoc} + */ + public function isLast(): bool + { + $this->didThrow = true; + throw new TransportException($this->errorMessage, 0, $this->error); + } + + /** + * {@inheritdoc} + */ + public function getContent(): string + { + $this->didThrow = true; + throw new TransportException($this->errorMessage, 0, $this->error); + } + + /** + * {@inheritdoc} + */ + public function getOffset(): int + { + return $this->offset; + } + + /** + * {@inheritdoc} + */ + public function getError(): ?string + { + return $this->errorMessage; + } + + public function __destruct() + { + if (!$this->didThrow) { + $this->didThrow = true; + throw new TransportException($this->errorMessage, 0, $this->error); + } + } +} diff --git a/src/Symfony/Component/HttpClient/Chunk/FirstChunk.php b/src/Symfony/Component/HttpClient/Chunk/FirstChunk.php new file mode 100644 index 0000000000..d891ca856d --- /dev/null +++ b/src/Symfony/Component/HttpClient/Chunk/FirstChunk.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\Chunk; + +/** + * @author Nicolas Grekas + * + * @internal + */ +class FirstChunk extends DataChunk +{ + /** + * {@inheritdoc} + */ + public function isFirst(): bool + { + return true; + } +} diff --git a/src/Symfony/Component/HttpClient/Chunk/LastChunk.php b/src/Symfony/Component/HttpClient/Chunk/LastChunk.php new file mode 100644 index 0000000000..84095d3925 --- /dev/null +++ b/src/Symfony/Component/HttpClient/Chunk/LastChunk.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\Chunk; + +/** + * @author Nicolas Grekas + * + * @internal + */ +class LastChunk extends DataChunk +{ + /** + * {@inheritdoc} + */ + public function isLast(): bool + { + return true; + } +} diff --git a/src/Symfony/Component/HttpClient/CurlHttpClient.php b/src/Symfony/Component/HttpClient/CurlHttpClient.php new file mode 100644 index 0000000000..08bbb46d08 --- /dev/null +++ b/src/Symfony/Component/HttpClient/CurlHttpClient.php @@ -0,0 +1,374 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient; + +use Symfony\Component\HttpClient\Exception\TransportException; +use Symfony\Component\HttpClient\Response\CurlResponse; +use Symfony\Component\HttpClient\Response\ResponseStream; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; +use Symfony\Contracts\HttpClient\ResponseStreamInterface; + +/** + * A performant implementation of the HttpClientInterface contracts based on the curl extension. + * + * This provides fully concurrent HTTP requests, with transparent + * HTTP/2 push when a curl version that supports it is installed. + * + * @author Nicolas Grekas + * + * @experimental in 4.3 + */ +final class CurlHttpClient implements HttpClientInterface +{ + use HttpClientTrait; + + private $defaultOptions = self::OPTIONS_DEFAULTS; + private $multi; + + /** + * @param array $defaultOptions Default requests' options + * @param int $maxHostConnections The maximum number of connections to a single host + * + * @see HttpClientInterface::OPTIONS_DEFAULTS for available options + */ + public function __construct(array $defaultOptions = [], int $maxHostConnections = 6) + { + if ($defaultOptions) { + [, $this->defaultOptions] = self::prepareRequest(null, null, $defaultOptions, self::OPTIONS_DEFAULTS); + } + + $mh = curl_multi_init(); + + // Don't enable HTTP/1.1 pipelining: it forces responses to be sent in order + if (\defined('CURLPIPE_MULTIPLEX')) { + curl_multi_setopt($mh, CURLMOPT_PIPELINING, CURLPIPE_MULTIPLEX); + } + curl_multi_setopt($mh, CURLMOPT_MAX_HOST_CONNECTIONS, $maxHostConnections); + + // Use an internal stdClass object to share state between the client and its responses + $this->multi = $multi = (object) [ + 'openHandles' => [], + 'handlesActivity' => [], + 'handle' => $mh, + 'pushedResponses' => [], + 'dnsCache' => [[], [], []], + ]; + + // Skip configuring HTTP/2 push when it's unsupported or buggy, see https://bugs.php.net/76675 + if (\PHP_VERSION_ID < 70215 || \PHP_VERSION_ID === 70300 || \PHP_VERSION_ID === 70301) { + return; + } + + // HTTP/2 push crashes before curl 7.61 + if (!\defined('CURLMOPT_PUSHFUNCTION') || 0x073d00 > ($v = curl_version())['version_number'] || !(CURL_VERSION_HTTP2 & $v['features'])) { + return; + } + + // Keep a dummy "onPush" reference to work around a refcount bug in PHP + curl_multi_setopt($mh, CURLMOPT_PUSHFUNCTION, $multi->onPush = static function ($parent, $pushed, array $rawHeaders) use ($multi) { + return self::handlePush($parent, $pushed, $rawHeaders, $multi); + }); + } + + /** + * @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); + $scheme = $url['scheme']; + $authority = $url['authority']; + $host = parse_url($authority, PHP_URL_HOST); + $url = implode('', $url); + + if ([$pushedResponse, $pushedHeaders] = $this->multi->pushedResponses[$url] ?? null) { + unset($this->multi->pushedResponses[$url]); + // Accept pushed responses only if their headers related to authentication match the request + $expectedHeaders = [ + $options['headers']['authorization'] ?? null, + $options['headers']['cookie'] ?? null, + $options['headers']['x-requested-with'] ?? null, + $options['headers']['range'] ?? null, + ]; + + if ('GET' === $method && !$options['body'] && $expectedHeaders === $pushedHeaders) { + // Reinitialize the pushed response with request's options + $pushedResponse->__construct($this->multi, $url, $options); + + return $pushedResponse; + } + } + + $curlopts = [ + CURLOPT_URL => $url, + CURLOPT_USERAGENT => 'Symfony HttpClient/Curl', + CURLOPT_TCP_NODELAY => true, + CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, + CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 0 < $options['max_redirects'] ? $options['max_redirects'] : 0, + CURLOPT_COOKIEFILE => '', // Keep track of cookies during redirects + CURLOPT_CONNECTTIMEOUT_MS => 1000 * $options['timeout'], + CURLOPT_PROXY => $options['proxy'], + CURLOPT_NOPROXY => $options['no_proxy'] ?? $_SERVER['no_proxy'] ?? $_SERVER['NO_PROXY'] ?? '', + CURLOPT_SSL_VERIFYPEER => $options['verify_peer'], + CURLOPT_SSL_VERIFYHOST => $options['verify_host'] ? 2 : 0, + CURLOPT_CAINFO => $options['cafile'], + CURLOPT_CAPATH => $options['capath'], + CURLOPT_SSL_CIPHER_LIST => $options['ciphers'], + CURLOPT_SSLCERT => $options['local_cert'], + CURLOPT_SSLKEY => $options['local_pk'], + CURLOPT_KEYPASSWD => $options['passphrase'], + CURLOPT_CERTINFO => $options['capture_peer_cert_chain'], + ]; + + if (!ZEND_THREAD_SAFE) { + $curlopts[CURLOPT_DNS_USE_GLOBAL_CACHE] = false; + } + + if (\defined('CURLOPT_HEADEROPT')) { + $curlopts[CURLOPT_HEADEROPT] = CURLHEADER_SEPARATE; + } + + // curl's resolve feature varies by host:port but ours varies by host only, let's handle this with our own DNS map + if (isset($this->multi->dnsCache[0][$host])) { + $options['resolve'] += [$host => $this->multi->dnsCache[0][$host]]; + } + + if ($options['resolve'] || $this->multi->dnsCache[2]) { + // First reset any old DNS cache entries then add the new ones + $resolve = $this->multi->dnsCache[2]; + $this->multi->dnsCache[2] = []; + $port = parse_url($authority, PHP_URL_PORT) ?: ('http:' === $scheme ? 80 : 443); + + if ($resolve && 0x072a00 > curl_version()['version_number']) { + // DNS cache removals require curl 7.42 or higher + // On lower versions, we have to create a new multi handle + curl_multi_close($this->multi->handle); + $this->multi->handle = (new self())->multi->handle; + } + + foreach ($options['resolve'] as $host => $ip) { + $resolve[] = null === $ip ? "-$host:$port" : "$host:$port:$ip"; + $this->multi->dnsCache[0][$host] = $ip; + $this->multi->dnsCache[1]["-$host:$port"] = "-$host:$port"; + } + + $curlopts[CURLOPT_RESOLVE] = $resolve; + } + + if (1.0 === (float) $options['http_version']) { + $curlopts[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_1_0; + } elseif (1.1 === (float) $options['http_version'] || 'https:' !== $scheme) { + $curlopts[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_1_1; + } elseif (CURL_VERSION_HTTP2 & curl_version()['features']) { + $curlopts[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_2_0; + } + + if ('POST' === $method) { + // Use CURLOPT_POST to have browser-like POST-to-GET redirects for 301, 302 and 303 + $curlopts[CURLOPT_POST] = true; + } else { + $curlopts[CURLOPT_CUSTOMREQUEST] = $method; + } + + if ('\\' !== \DIRECTORY_SEPARATOR && $options['timeout'] < 1) { + $curlopts[CURLOPT_NOSIGNAL] = true; + } + + if (!isset($options['headers']['accept-encoding'])) { + $curlopts[CURLOPT_ENCODING] = ''; // Enable HTTP compression + } + + foreach ($options['raw_headers'] as $header) { + if (':' === $header[-2] && \strlen($header) - 2 === strpos($header, ': ')) { + // curl requires a special syntax to send empty headers + $curlopts[CURLOPT_HTTPHEADER][] = substr_replace($header, ';', -2); + } else { + $curlopts[CURLOPT_HTTPHEADER][] = $header; + } + } + + // Prevent curl from sending its default Accept and Expect headers + foreach (['accept', 'expect'] as $header) { + if (!isset($options['headers'][$header])) { + $curlopts[CURLOPT_HTTPHEADER][] = $header.':'; + } + } + + if (!\is_string($body = $options['body'])) { + if (\is_resource($body)) { + $curlopts[CURLOPT_INFILE] = $body; + } else { + $eof = false; + $buffer = ''; + $curlopts[CURLOPT_READFUNCTION] = static function ($ch, $fd, $length) use ($body, &$buffer, &$eof) { + return self::readRequestBody($length, $body, $buffer, $eof); + }; + } + + if (isset($options['headers']['content-length'][0])) { + $curlopts[CURLOPT_INFILESIZE] = $options['headers']['content-length'][0]; + } elseif (!isset($options['headers']['transfer-encoding'])) { + $curlopts[CURLOPT_HTTPHEADER][] = 'Transfer-Encoding: chunked'; // Enable chunked request bodies + } + + if ('POST' !== $method) { + $curlopts[CURLOPT_UPLOAD] = true; + } + } elseif ('' !== $body) { + $curlopts[CURLOPT_POSTFIELDS] = $body; + } + + if ($options['peer_fingerprint']) { + if (!isset($options['peer_fingerprint']['pin-sha256'])) { + throw new TransportException(__CLASS__.' supports only "pin-sha256" fingerprints.'); + } + + $curlopts[CURLOPT_PINNEDPUBLICKEY] = 'sha256//'.implode(';sha256//', $options['peer_fingerprint']['pin-sha256']); + } + + if ($options['bindto']) { + $curlopts[file_exists($options['bindto']) ? CURLOPT_UNIX_SOCKET_PATH : CURLOPT_INTERFACE] = $options['bindto']; + } + + $ch = curl_init(); + + foreach ($curlopts as $opt => $value) { + if (null !== $value && !curl_setopt($ch, $opt, $value) && CURLOPT_CERTINFO !== $opt) { + $constants = array_filter(get_defined_constants(), static function ($v, $k) use ($opt) { + return $v === $opt && 'C' === $k[0] && (0 === strpos($k, 'CURLOPT_') || 0 === strpos($k, 'CURLINFO_')); + }, ARRAY_FILTER_USE_BOTH); + + throw new TransportException(sprintf('Curl option "%s" is not supported.', key($constants) ?? $opt)); + } + } + + return new CurlResponse($this->multi, $ch, $options, self::createRedirectResolver($options, $host)); + } + + /** + * {@inheritdoc} + */ + public function stream($responses, float $timeout = null): ResponseStreamInterface + { + if ($responses instanceof CurlResponse) { + $responses = [$responses]; + } elseif (!\is_iterable($responses)) { + throw new \TypeError(sprintf('%s() expects parameter 1 to be an iterable of CurlResponse objects, %s given.', __METHOD__, \is_object($responses) ? \get_class($responses) : \gettype($responses))); + } + + while (CURLM_CALL_MULTI_PERFORM === curl_multi_exec($this->multi->handle, $active)); + + return new ResponseStream(CurlResponse::stream($responses, $timeout)); + } + + public function __destruct() + { + $this->multi->pushedResponses = []; + if (\defined('CURLMOPT_PUSHFUNCTION')) { + curl_multi_setopt($this->multi->handle, CURLMOPT_PUSHFUNCTION, null); + } + } + + private static function handlePush($parent, $pushed, array $rawHeaders, \stdClass $multi): int + { + $headers = []; + + foreach ($rawHeaders as $h) { + if (false !== $i = strpos($h, ':', 1)) { + $headers[substr($h, 0, $i)] = substr($h, 1 + $i); + } + } + + if ('GET' !== $headers[':method'] || isset($headers['range'])) { + return CURL_PUSH_DENY; + } + + $url = $headers[':scheme'].'://'.$headers[':authority']; + + // curl before 7.65 doesn't validate the pushed ":authority" header, + // but this is a MUST in the HTTP/2 RFC; let's restrict pushes to the original host, + // ignoring domains mentioned as alt-name in the certificate for now (same as curl). + if (0 !== strpos(curl_getinfo($parent, CURLINFO_EFFECTIVE_URL), $url.'/')) { + return CURL_PUSH_DENY; + } + + $multi->pushedResponses[$url.$headers[':path']] = [ + new CurlResponse($multi, $pushed), + [ + $headers['authorization'] ?? null, + $headers['cookie'] ?? null, + $headers['x-requested-with'] ?? null, + null, + ], + ]; + + return CURL_PUSH_OK; + } + + /** + * Wraps the request's body callback to allow it to return strings longer than curl requested. + */ + private static function readRequestBody(int $length, \Closure $body, string &$buffer, bool &$eof): string + { + if (!$eof && \strlen($buffer) < $length) { + if (!\is_string($data = $body($length))) { + throw new TransportException(sprintf('The return value of the "body" option callback must be a string, %s returned.', \gettype($data))); + } + + $buffer .= $data; + $eof = '' === $data; + } + + $data = substr($buffer, 0, $length); + $buffer = substr($buffer, $length); + + return $data; + } + + /** + * Resolves relative URLs on redirects and deals with authentication headers. + * + * Work around CVE-2018-1000007: Authorization and Cookie headers should not follow redirects - fixed in Curl 7.64 + */ + private static function createRedirectResolver(array $options, string $host): \Closure + { + $redirectHeaders = []; + if (0 < $options['max_redirects']) { + $redirectHeaders['host'] = $host; + $redirectHeaders['with_auth'] = $redirectHeaders['no_auth'] = array_filter($options['raw_headers'], static function ($h) { + return 0 !== stripos($h, 'Host:'); + }); + + if (isset($options['headers']['authorization']) || isset($options['headers']['cookie'])) { + $redirectHeaders['no_auth'] = array_filter($options['raw_headers'], static function ($h) { + return 0 !== stripos($h, 'Authorization:') && 0 !== stripos($h, 'Cookie:'); + }); + } + } + + return static function ($ch, string $location) use ($redirectHeaders) { + if ($host = parse_url($location, PHP_URL_HOST)) { + $rawHeaders = $redirectHeaders['host'] === $host ? $redirectHeaders['with_auth'] : $redirectHeaders['no_auth']; + curl_setopt($ch, CURLOPT_HTTPHEADER, $rawHeaders); + } + + $url = self::parseUrl(curl_getinfo($ch, CURLINFO_EFFECTIVE_URL)); + + return implode('', self::resolveUrl(self::parseUrl($location), $url)); + }; + } +} diff --git a/src/Symfony/Component/HttpClient/Exception/ClientException.php b/src/Symfony/Component/HttpClient/Exception/ClientException.php new file mode 100644 index 0000000000..3eb997432b --- /dev/null +++ b/src/Symfony/Component/HttpClient/Exception/ClientException.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient\Exception; + +use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; + +/** + * Represents a 4xx response. + * + * @author Nicolas Grekas + * + * @internal + */ +final class ClientException extends \RuntimeException implements ClientExceptionInterface +{ + use HttpExceptionTrait; +} diff --git a/src/Symfony/Component/HttpClient/Exception/HttpExceptionTrait.php b/src/Symfony/Component/HttpClient/Exception/HttpExceptionTrait.php new file mode 100644 index 0000000000..48f7b880eb --- /dev/null +++ b/src/Symfony/Component/HttpClient/Exception/HttpExceptionTrait.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient\Exception; + +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @author Nicolas Grekas + * + * @internal + */ +trait HttpExceptionTrait +{ + public function __construct(ResponseInterface $response) + { + $code = $response->getInfo('http_code'); + $url = $response->getInfo('url'); + $message = sprintf('HTTP %d returned for URL "%s".', $code, $url); + + foreach (array_reverse($response->getInfo('raw_headers')) as $h) { + if (0 === strpos($h, 'HTTP/')) { + $message = sprintf('%s returned for URL "%s".', $h, $url); + break; + } + } + + parent::__construct($message, $code); + } +} diff --git a/src/Symfony/Component/HttpClient/Exception/InvalidArgumentException.php b/src/Symfony/Component/HttpClient/Exception/InvalidArgumentException.php new file mode 100644 index 0000000000..59afbd6ad0 --- /dev/null +++ b/src/Symfony/Component/HttpClient/Exception/InvalidArgumentException.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient\Exception; + +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; + +/** + * @author Nicolas Grekas + * + * @internal + */ +final class InvalidArgumentException extends \InvalidArgumentException implements TransportExceptionInterface +{ +} diff --git a/src/Symfony/Component/HttpClient/Exception/RedirectionException.php b/src/Symfony/Component/HttpClient/Exception/RedirectionException.php new file mode 100644 index 0000000000..1c62f973b8 --- /dev/null +++ b/src/Symfony/Component/HttpClient/Exception/RedirectionException.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient\Exception; + +use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; + +/** + * Represents a 3xx response. + * + * @author Nicolas Grekas + * + * @internal + */ +final class RedirectionException extends \RuntimeException implements RedirectionExceptionInterface +{ + use HttpExceptionTrait; +} diff --git a/src/Symfony/Component/HttpClient/Exception/ServerException.php b/src/Symfony/Component/HttpClient/Exception/ServerException.php new file mode 100644 index 0000000000..7b91de4865 --- /dev/null +++ b/src/Symfony/Component/HttpClient/Exception/ServerException.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient\Exception; + +use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; + +/** + * Represents a 5xx response. + * + * @author Nicolas Grekas + * + * @internal + */ +final class ServerException extends \RuntimeException implements ServerExceptionInterface +{ + use HttpExceptionTrait; +} diff --git a/src/Symfony/Component/HttpClient/Exception/TransportException.php b/src/Symfony/Component/HttpClient/Exception/TransportException.php new file mode 100644 index 0000000000..85606aa3b8 --- /dev/null +++ b/src/Symfony/Component/HttpClient/Exception/TransportException.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient\Exception; + +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; + +/** + * @author Nicolas Grekas + * + * @internal + */ +final class TransportException extends \RuntimeException implements TransportExceptionInterface +{ +} diff --git a/src/Symfony/Component/HttpClient/HttpClient.php b/src/Symfony/Component/HttpClient/HttpClient.php new file mode 100644 index 0000000000..6d680d9b49 --- /dev/null +++ b/src/Symfony/Component/HttpClient/HttpClient.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient; + +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * A factory to instantiate the best possible HTTP client for the runtime. + * + * @author Nicolas Grekas + * + * @experimental in 4.3 + */ +final class HttpClient +{ + /** + * @param array $defaultOptions Default requests' options + * @param int $maxHostConnections The maximum number of connections to a single host + * + * @see HttpClientInterface::OPTIONS_DEFAULTS for available options + */ + public static function create(array $defaultOptions = [], int $maxHostConnections = 6): HttpClientInterface + { + if (\extension_loaded('curl')) { + return new CurlHttpClient($defaultOptions, $maxHostConnections); + } + + return new NativeHttpClient($defaultOptions, $maxHostConnections); + } +} diff --git a/src/Symfony/Component/HttpClient/HttpClientTrait.php b/src/Symfony/Component/HttpClient/HttpClientTrait.php new file mode 100644 index 0000000000..ab9bbca5c9 --- /dev/null +++ b/src/Symfony/Component/HttpClient/HttpClientTrait.php @@ -0,0 +1,457 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient; + +use Symfony\Component\HttpClient\Exception\InvalidArgumentException; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * Provides the common logic from writing HttpClientInterface implementations. + * + * All methods are static to prevent implementers from creating memory leaks via circular references. + * + * @author Nicolas Grekas + * + * @experimental in 4.3 + */ +trait HttpClientTrait +{ + private static $CHUNK_SIZE = 16372; + + /** + * Validates and normalizes method, URL and options, and merges them with defaults. + * + * @throws InvalidArgumentException When a not-supported option is found + */ + private static function prepareRequest(?string $method, ?string $url, array $options, array $defaultOptions = [], bool $allowExtraOptions = false): array + { + if (null !== $method && \strlen($method) !== strspn($method, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ')) { + throw new InvalidArgumentException(sprintf('Invalid HTTP method "%s", only uppercase letters are accepted.', $method)); + } + + $options = self::mergeDefaultOptions($options, $defaultOptions, $allowExtraOptions); + + if (isset($options['json'])) { + $options['body'] = self::jsonEncode($options['json']); + $options['headers']['content-type'] = $options['headers']['content-type'] ?? ['application/json']; + } + + if (isset($options['body'])) { + $options['body'] = self::normalizeBody($options['body']); + } + + if (isset($options['peer_fingerprint'])) { + $options['peer_fingerprint'] = self::normalizePeerFingerprint($options['peer_fingerprint']); + } + + // Compute raw headers + $rawHeaders = $headers = []; + + foreach ($options['headers'] as $name => $values) { + foreach ($values as $value) { + $rawHeaders[] = $name.': '.$headers[$name][] = $value = (string) $value; + + if (\strlen($value) !== strcspn($value, "\r\n\0")) { + throw new InvalidArgumentException(sprintf('Invalid header value: CR/LF/NUL found in "%s".', $value)); + } + } + } + + // Validate on_progress + if (!\is_callable($onProgress = $options['on_progress'] ?? 'var_dump')) { + throw new InvalidArgumentException(sprintf('Option "on_progress" must be callable, %s given.', \is_object($onProgress) ? \get_class($onProgress) : \gettype($onProgress))); + } + + if (!\is_string($options['auth'] ?? '')) { + throw new InvalidArgumentException(sprintf('Option "auth" must be string, %s given.', \gettype($options['auth']))); + } + + if (null !== $url) { + // Merge auth with headers + if (($options['auth'] ?? false) && !($headers['authorization'] ?? false)) { + $rawHeaders[] = 'authorization: '.$headers['authorization'][] = 'Basic '.base64_encode($options['auth']); + } + + $options['raw_headers'] = $rawHeaders; + unset($options['auth']); + + // Parse base URI + if (\is_string($options['base_uri'])) { + $options['base_uri'] = self::parseUrl($options['base_uri']); + } + + // Validate and resolve URL + $url = self::parseUrl($url, $options['query']); + $url = self::resolveUrl($url, $options['base_uri'], $defaultOptions['query'] ?? []); + } + + // Finalize normalization of options + $options['headers'] = $headers; + $options['http_version'] = (string) ($options['http_version'] ?? ''); + $options['timeout'] = (float) ($options['timeout'] ?? ini_get('default_socket_timeout')); + + return [$url, $options]; + } + + /** + * @throws InvalidArgumentException When an invalid option is found + */ + private static function mergeDefaultOptions(array $options, array $defaultOptions, bool $allowExtraOptions = false): array + { + $options['headers'] = self::normalizeHeaders($options['headers'] ?? []); + + if ($defaultOptions['headers'] ?? false) { + $options['headers'] += self::normalizeHeaders($defaultOptions['headers']); + } + + if ($options['resolve'] ?? false) { + $options['resolve'] = array_change_key_case($options['resolve']); + } + + // Option "query" is never inherited from defaults + $options['query'] = $options['query'] ?? []; + + $options += $defaultOptions; + + if ($defaultOptions['resolve'] ?? false) { + $options['resolve'] += array_change_key_case($defaultOptions['resolve']); + } + + if ($allowExtraOptions || !$defaultOptions) { + return $options; + } + + // Look for unsupported options + foreach ($options as $name => $v) { + if (\array_key_exists($name, $defaultOptions)) { + continue; + } + + $alternatives = []; + + foreach ($defaultOptions as $key => $v) { + if (levenshtein($name, $key) <= \strlen($name) / 3 || false !== strpos($key, $name)) { + $alternatives[] = $key; + } + } + + throw new InvalidArgumentException(sprintf('Unsupported option "%s" passed to %s, did you mean "%s"?', $name, __CLASS__, implode('", "', $alternatives ?: array_keys($defaultOptions)))); + } + + return $options; + } + + /** + * Normalizes headers by putting their names as lowercased keys. + * + * @return string[][] + */ + private static function normalizeHeaders(array $headers): array + { + $normalizedHeaders = []; + + foreach ($headers as $name => $values) { + if (\is_int($name)) { + [$name, $values] = explode(':', $values, 2); + $values = [ltrim($values)]; + } elseif (!\is_iterable($values)) { + $values = (array) $values; + } + + $normalizedHeaders[$name = strtolower($name)] = []; + + foreach ($values as $value) { + $normalizedHeaders[$name][] = $value; + } + } + + return $normalizedHeaders; + } + + /** + * @param array|string|resource|\Traversable|\Closure $body + * + * @return string|resource|\Closure + * + * @throws InvalidArgumentException When an invalid body is passed + */ + private static function normalizeBody($body) + { + if (\is_array($body)) { + return http_build_query($body, '', '&', PHP_QUERY_RFC1738); + } + + if ($body instanceof \Traversable) { + $body = function () use ($body) { yield from $body; }; + } + + if ($body instanceof \Closure) { + $r = new \ReflectionFunction($body); + $body = $r->getClosure(); + + if ($r->isGenerator()) { + $body = $body(self::$CHUNK_SIZE); + $body = function () use ($body) { + $chunk = $body->valid() ? $body->current() : ''; + $body->next(); + + return $chunk; + }; + } + + return $body; + } + + if (!\is_string($body) && !\is_array(@stream_get_meta_data($body))) { + throw new InvalidArgumentException(sprintf('Option "body" must be string, stream resource, iterable or callable, %s given.', \is_resource($body) ? get_resource_type($body) : \gettype($body))); + } + + return $body; + } + + /** + * @param string|string[] $fingerprint + * + * @throws InvalidArgumentException When an invalid fingerprint is passed + */ + private static function normalizePeerFingerprint($fingerprint): array + { + if (\is_string($fingerprint)) { + switch (\strlen($fingerprint = str_replace(':', '', $fingerprint))) { + case 32: $fingerprint = ['md5' => $fingerprint]; break; + case 40: $fingerprint = ['sha1' => $fingerprint]; break; + case 44: $fingerprint = ['pin-sha256' => [$fingerprint]]; break; + case 64: $fingerprint = ['sha256' => $fingerprint]; break; + default: throw new InvalidArgumentException(sprintf('Cannot auto-detect fingerprint algorithm for "%s".', $fingerprint)); + } + } elseif (\is_array($fingerprint)) { + foreach ($fingerprint as $algo => $hash) { + $fingerprint[$algo] = 'pin-sha256' === $algo ? (array) $hash : str_replace(':', '', $hash); + } + } else { + throw new InvalidArgumentException(sprintf('Option "peer_fingerprint" must be string or array, %s given.', \gettype($body))); + } + + return $fingerprint; + } + + /** + * @param array|\JsonSerializable $value + * + * @throws InvalidArgumentException When the value cannot be json-encoded + */ + private static function jsonEncode($value, int $flags = null, int $maxDepth = 512): string + { + $flags = $flags ?? (JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT | JSON_PRESERVE_ZERO_FRACTION); + + if (!\is_array($value) && !$value instanceof \JsonSerializable) { + throw new InvalidArgumentException(sprintf('Option "json" must be array or JsonSerializable, %s given.', __CLASS__, \is_object($value) ? \get_class($value) : \gettype($value))); + } + + try { + $value = json_encode($value, $flags | (\PHP_VERSION_ID >= 70300 ? JSON_THROW_ON_ERROR : 0), $maxDepth); + } catch (\JsonException $e) { + throw new InvalidArgumentException(sprintf('Invalid value for "json" option: %s.', $e->getMessage())); + } + + if (\PHP_VERSION_ID < 70300 && JSON_ERROR_NONE !== json_last_error() && (false === $value || !($flags & JSON_PARTIAL_OUTPUT_ON_ERROR))) { + throw new InvalidArgumentException(sprintf('Invalid value for "json" option: %s.', json_last_error_msg())); + } + + return $value; + } + + /** + * Resolves a URL against a base URI. + * + * @see https://tools.ietf.org/html/rfc3986#section-5.2.2 + * + * @throws InvalidArgumentException When an invalid URL is passed + */ + private static function resolveUrl(array $url, ?array $base, array $queryDefaults = []): array + { + if (null !== $base && '' === ($base['scheme'] ?? '').($base['authority'] ?? '')) { + throw new InvalidArgumentException(sprintf('Invalid "base_uri" option: host or scheme is missing in "%s".', implode('', $base))); + } + + if (null === $base && '' === $url['scheme'].$url['authority']) { + throw new InvalidArgumentException(sprintf('Invalid URL: no "base_uri" option was provided and host or scheme is missing in "%s".', implode('', $url))); + } + + if (null !== $url['scheme']) { + $url['path'] = self::removeDotSegments($url['path'] ?? ''); + } else { + if (null !== $url['authority']) { + $url['path'] = self::removeDotSegments($url['path'] ?? ''); + } else { + if (null === $url['path']) { + $url['path'] = $base['path']; + $url['query'] = $url['query'] ?? $base['query']; + } else { + if ('/' !== $url['path'][0]) { + if (null === $base['path']) { + $url['path'] = '/'.$url['path']; + } else { + $segments = explode('/', $base['path']); + array_splice($segments, -1, 1, [$url['path']]); + $url['path'] = implode('/', $segments); + } + } + + $url['path'] = self::removeDotSegments($url['path']); + } + + $url['authority'] = $base['authority']; + + if ($queryDefaults) { + $url['query'] = '?'.self::mergeQueryString(substr($url['query'] ?? '', 1), $queryDefaults, false); + } + } + + $url['scheme'] = $base['scheme']; + } + + if ('' === ($url['path'] ?? '')) { + $url['path'] = '/'; + } + + return $url; + } + + /** + * Parses a URL and fixes its encoding if needed. + * + * @throws InvalidArgumentException When an invalid URL is passed + */ + private static function parseUrl(string $url, array $query = [], array $allowedSchemes = ['http' => 80, 'https' => 443]): array + { + if (false === $parts = parse_url($url)) { + throw new InvalidArgumentException(sprintf('Malformed URL "%s".', $url)); + } + + if ($query) { + $parts['query'] = self::mergeQueryString($parts['query'] ?? null, $query, true); + } + + $port = $parts['port'] ?? 0; + + if (null !== $scheme = $parts['scheme'] ?? null) { + if (!isset($allowedSchemes[$scheme = strtolower($scheme)])) { + throw new InvalidArgumentException(sprintf('Unsupported scheme in "%s".', $url)); + } + + $port = $allowedSchemes[$scheme] === $port ? 0 : $port; + $scheme .= ':'; + } + + if (null !== $host = $parts['host'] ?? null) { + if (!\defined('INTL_IDNA_VARIANT_UTS46') && preg_match('/[\x80-\xFF]/', $host)) { + throw new InvalidArgumentException(sprintf('Unsupported IDN "%s", try enabling the "intl" PHP extension or running "composer require symfony/polyfill-intl-idn".', $host)); + } + + if (false === $host = \defined('INTL_IDNA_VARIANT_UTS46') ? idn_to_ascii($host, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46) : strtolower($host)) { + throw new InvalidArgumentException(sprintf('Unsupported host in "%s".', $url)); + } + + $host .= $port ? ':'.$port : ''; + } + + foreach (['user', 'pass', 'path', 'query', 'fragment'] as $part) { + if (!isset($parts[$part])) { + continue; + } + + if (false !== strpos($parts[$part], '%')) { + // https://tools.ietf.org/html/rfc3986#section-2.3 + $parts[$part] = preg_replace_callback('/%(?:2[DE]|3[0-9]|[46][1-9A-F]|5F|[57][0-9A]|7E)++/i', function ($m) { return rawurldecode($m[0]); }, $parts[$part]); + } + + // https://tools.ietf.org/html/rfc3986#section-3.3 + $parts[$part] = preg_replace_callback("#[^-A-Za-z0-9._~!$&/'()*+,;=:@%]++#", function ($m) { return rawurlencode($m[0]); }, $parts[$part]); + } + + return [ + 'scheme' => $scheme, + 'authority' => null !== $host ? '//'.(isset($parts['user']) ? $parts['user'].(isset($parts['pass']) ? ':'.$parts['pass'] : '').'@' : '').$host : null, + 'path' => isset($parts['path'][0]) ? $parts['path'] : null, + 'query' => isset($parts['query']) ? '?'.$parts['query'] : null, + 'fragment' => isset($parts['fragment']) ? '#'.$parts['fragment'] : null, + ]; + } + + /** + * Removes dot-segments from a path. + * + * @see https://tools.ietf.org/html/rfc3986#section-5.2.4 + */ + private static function removeDotSegments(string $path) + { + $result = ''; + + while (!\in_array($path, ['', '.', '..'], true)) { + if ('.' === $path[0] && (0 === strpos($path, $p = '../') || 0 === strpos($path, $p = './'))) { + $path = substr($path, \strlen($p)); + } elseif ('/.' === $path || 0 === strpos($path, '/./')) { + $path = substr_replace($path, '/', 0, 3); + } elseif ('/..' === $path || 0 === strpos($path, '/../')) { + $i = strrpos($result, '/'); + $result = $i ? substr($result, 0, $i) : ''; + $path = substr_replace($path, '/', 0, 4); + } else { + $i = strpos($path, '/', 1) ?: \strlen($path); + $result .= substr($path, 0, $i); + $path = substr($path, $i); + } + } + + return $result; + } + + /** + * Merges and encodes a query array with a query string. + * + * @throws InvalidArgumentException When an invalid query-string value is passed + */ + private static function mergeQueryString(?string $queryString, array $queryArray, bool $replace): ?string + { + if (!$queryArray) { + return $queryString; + } + + $query = []; + + if (null !== $queryString) { + foreach (explode('&', $queryString) as $v) { + if ('' !== $v) { + $k = urldecode(explode('=', $v, 2)[0]); + $query[$k] = (isset($query[$k]) ? $query[$k].'&' : '').$v; + } + } + } + + foreach ($queryArray as $k => $v) { + if (is_scalar($v)) { + $queryArray[$k] = rawurlencode($k).'='.rawurlencode($v); + } elseif (null === $v) { + unset($queryArray[$k]); + + if ($replace) { + unset($query[$k]); + } + } else { + throw new InvalidArgumentException(sprintf('Unsupported value for query parameter "%s": scalar or null expected, %s given.', $k, \gettype($v))); + } + } + + return implode('&', $replace ? array_replace($query, $queryArray) : ($query + $queryArray)); + } +} diff --git a/src/Symfony/Component/HttpClient/HttpOptions.php b/src/Symfony/Component/HttpClient/HttpOptions.php new file mode 100644 index 0000000000..ce85eaf274 --- /dev/null +++ b/src/Symfony/Component/HttpClient/HttpOptions.php @@ -0,0 +1,299 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient; + +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * A helper providing autocompletion for available options. + * + * @see HttpClientInterface for a description of each options. + * + * @author Nicolas Grekas + * + * @experimental in 4.3 + */ +class HttpOptions +{ + private $options = []; + + public function toArray(): array + { + return $this->options; + } + + /** + * @return $this + */ + public function setAuth(string $userinfo) + { + $this->options['auth'] = $userinfo; + + return $this; + } + + /** + * @return $this + */ + public function setQuery(array $query) + { + $this->options['query'] = $query; + + return $this; + } + + /** + * @return $this + */ + public function setHeaders(iterable $headers) + { + $this->options['headers'] = $headers; + + return $this; + } + + /** + * @param array|string|resource|\Traversable|\Closure $body + * + * @return $this + */ + public function setBody($body) + { + $this->options['body'] = $body; + + return $this; + } + + /** + * @param array|\JsonSerializable $json + * + * @return $this + */ + public function setJson($json) + { + $this->options['json'] = $json; + + return $this; + } + + /** + * @return $this + */ + public function setUserData($data) + { + $this->options['user_data'] = $data; + + return $this; + } + + /** + * @return $this + */ + public function setMaxRedirects(int $max) + { + $this->options['max_redirects'] = $max; + + return $this; + } + + /** + * @return $this + */ + public function setHttpVersion(string $version) + { + $this->options['http_version'] = $version; + + return $this; + } + + /** + * @return $this + */ + public function setBaseUri(string $uri) + { + $this->options['base_uri'] = $uri; + + return $this; + } + + /** + * @return $this + */ + public function buffer(bool $buffer) + { + $this->options['buffer'] = $buffer; + + return $this; + } + + /** + * @return $this + */ + public function setOnProgress(callable $callback) + { + $this->options['on_progress'] = $callback; + + return $this; + } + + /** + * @return $this + */ + public function resolve(array $hostIps) + { + $this->options['resolve'] = $hostIps; + + return $this; + } + + /** + * @return $this + */ + public function setProxy(string $proxy) + { + $this->options['proxy'] = $proxy; + + return $this; + } + + /** + * @return $this + */ + public function setNoProxy(string $noProxy) + { + $this->options['no_proxy'] = $noProxy; + + return $this; + } + + /** + * @return $this + */ + public function setTimeout(float $timeout) + { + $this->options['timeout'] = $timeout; + + return $this; + } + + /** + * @return $this + */ + public function bindTo(string $bindto) + { + $this->options['bindto'] = $bindto; + + return $this; + } + + /** + * @return $this + */ + public function verifyPeer(bool $verify) + { + $this->options['verify_peer'] = $verify; + + return $this; + } + + /** + * @return $this + */ + public function verifyHost(bool $verify) + { + $this->options['verify_host'] = $verify; + + return $this; + } + + /** + * @return $this + */ + public function setCaFile(string $cafile) + { + $this->options['cafile'] = $cafile; + + return $this; + } + + /** + * @return $this + */ + public function setCaPath(string $capath) + { + $this->options['capath'] = $capath; + + return $this; + } + + /** + * @return $this + */ + public function setLocalCert(string $cert) + { + $this->options['local_cert'] = $cert; + + return $this; + } + + /** + * @return $this + */ + public function setLocalPk(string $pk) + { + $this->options['local_pk'] = $pk; + + return $this; + } + + /** + * @return $this + */ + public function setPassphrase(string $passphrase) + { + $this->options['passphrase'] = $passphrase; + + return $this; + } + + /** + * @return $this + */ + public function setCiphers(string $ciphers) + { + $this->options['ciphers'] = $ciphers; + + return $this; + } + + /** + * @param string|array $fingerprint + * + * @return $this + */ + public function setPeerFingerprint($fingerprint) + { + $this->options['peer_fingerprint'] = $fingerprint; + + return $this; + } + + /** + * @return $this + */ + public function capturePeerCertChain(bool $capture) + { + $this->options['capture_peer_cert_chain'] = $capture; + + return $this; + } +} diff --git a/src/Symfony/Component/HttpClient/LICENSE b/src/Symfony/Component/HttpClient/LICENSE new file mode 100644 index 0000000000..3f853aaf35 --- /dev/null +++ b/src/Symfony/Component/HttpClient/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2018-2019 Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Symfony/Component/HttpClient/NativeHttpClient.php b/src/Symfony/Component/HttpClient/NativeHttpClient.php new file mode 100644 index 0000000000..c79f9af7b3 --- /dev/null +++ b/src/Symfony/Component/HttpClient/NativeHttpClient.php @@ -0,0 +1,420 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient; + +use Symfony\Component\HttpClient\Exception\TransportException; +use Symfony\Component\HttpClient\Response\NativeResponse; +use Symfony\Component\HttpClient\Response\ResponseStream; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; +use Symfony\Contracts\HttpClient\ResponseStreamInterface; + +/** + * A portable implementation of the HttpClientInterface contracts based on PHP stream wrappers. + * + * PHP stream wrappers are able to fetch response bodies concurrently, + * but each request is opened synchronously. + * + * @author Nicolas Grekas + * + * @experimental in 4.3 + */ +final class NativeHttpClient implements HttpClientInterface +{ + use HttpClientTrait; + + private $defaultOptions = self::OPTIONS_DEFAULTS; + private $multi; + + /** + * @param array $defaultOptions Default requests' options + * @param int $maxHostConnections The maximum number of connections to open + * + * @see HttpClientInterface::OPTIONS_DEFAULTS for available options + */ + public function __construct(array $defaultOptions = [], int $maxHostConnections = 6) + { + if ($defaultOptions) { + [, $this->defaultOptions] = self::prepareRequest(null, null, $defaultOptions, self::OPTIONS_DEFAULTS); + } + + // Use an internal stdClass object to share state between the client and its responses + $this->multi = (object) [ + 'openHandles' => [], + 'handlesActivity' => [], + 'pendingResponses' => [], + 'maxHostConnections' => $maxHostConnections, + 'responseCount' => 0, + 'dnsCache' => [], + 'handles' => [], + 'sleep' => false, + 'id' => random_int(PHP_INT_MIN, PHP_INT_MAX), + ]; + } + + /** + * @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); + + if ($options['bindto'] && file_exists($options['bindto'])) { + throw new TransportException(__CLASS__.' cannot bind to local Unix sockets, use e.g. CurlHttpClient instead.'); + } + + $options['body'] = self::getBodyAsString($options['body']); + + if ('' !== $options['body'] && 'POST' === $method && !isset($options['headers']['content-type'])) { + $options['raw_headers'][] = 'content-type: application/x-www-form-urlencoded'; + } + + if ($gzipEnabled = \extension_loaded('zlib') && !isset($options['headers']['accept-encoding'])) { + // gzip is the most widely available algo, no need to deal with deflate + $options['raw_headers'][] = 'accept-encoding: gzip'; + } + + if ($options['peer_fingerprint']) { + if (isset($options['peer_fingerprint']['pin-sha256']) && 1 === \count($options['peer_fingerprint'])) { + throw new TransportException(__CLASS__.' cannot verify "pin-sha256" fingerprints, please provide a "sha256" one.'); + } + + unset($options['peer_fingerprint']['pin-sha256']); + } + + $info = [ + 'raw_headers' => [], + 'url' => $url, + 'error' => null, + 'http_code' => 0, + 'redirect_count' => 0, + 'start_time' => 0.0, + 'fopen_time' => 0.0, + 'connect_time' => 0.0, + 'redirect_time' => 0.0, + 'starttransfer_time' => 0.0, + 'total_time' => 0.0, + 'namelookup_time' => 0.0, + 'size_upload' => 0, + 'size_download' => 0, + 'size_body' => \strlen($options['body']), + 'primary_ip' => '', + 'primary_port' => 'http:' === $url['scheme'] ? 80 : 443, + ]; + + if ($onProgress = $options['on_progress']) { + // Memoize the last progress to ease calling the callback periodically when no network transfer happens + $lastProgress = [0, 0]; + $onProgress = static function (...$progress) use ($onProgress, &$lastProgress, &$info) { + $progressInfo = $info; + $progressInfo['url'] = implode('', $info['url']); + unset($progressInfo['fopen_time'], $progressInfo['size_body']); + + if ($progress && -1 === $progress[0]) { + // Response completed + $lastProgress[0] = max($lastProgress); + } else { + $lastProgress = $progress ?: $lastProgress; + } + + $onProgress($lastProgress[0], $lastProgress[1], $progressInfo); + }; + } + + // Always register a notification callback to compute live stats about the response + $notification = static function (int $code, int $severity, ?string $msg, int $msgCode, int $dlNow, int $dlSize) use ($onProgress, &$info) { + $now = microtime(true); + $info['total_time'] = $now - $info['start_time']; + + if (STREAM_NOTIFY_PROGRESS === $code) { + $info['size_upload'] += $dlNow ? 0 : $info['size_body']; + $info['size_download'] = $dlNow; + } elseif (STREAM_NOTIFY_CONNECT === $code) { + $info['connect_time'] += $now - $info['fopen_time']; + } else { + return; + } + + if ($onProgress) { + $onProgress($dlNow, $dlSize); + } + }; + + if ($options['resolve']) { + $this->multi->dnsCache = $options['resolve'] + $this->multi->dnsCache; + } + + [$host, $port, $url['authority']] = self::dnsResolve($url, $this->multi, $info, $onProgress); + + if (!isset($options['headers']['host'])) { + $options['raw_headers'][] = 'host: '.$host.$port; + } + + $context = [ + 'http' => [ + 'protocol_version' => $options['http_version'] ?: '1.1', + 'method' => $method, + 'content' => $options['body'], + 'ignore_errors' => true, + 'user_agent' => 'Symfony HttpClient/Native', + 'curl_verify_ssl_peer' => $options['verify_peer'], + 'curl_verify_ssl_host' => $options['verify_host'], + 'auto_decode' => false, // Disable dechunk filter, it's incompatible with stream_select() + 'timeout' => $options['timeout'], + 'follow_location' => false, // We follow redirects ourselves - the native logic is too limited + ], + 'ssl' => array_filter([ + 'peer_name' => $host, + 'verify_peer' => $options['verify_peer'], + 'verify_peer_name' => $options['verify_host'], + 'cafile' => $options['cafile'], + 'capath' => $options['capath'], + 'local_cert' => $options['local_cert'], + 'local_pk' => $options['local_pk'], + 'passphrase' => $options['passphrase'], + 'ciphers' => $options['ciphers'], + 'peer_fingerprint' => $options['peer_fingerprint'], + 'capture_peer_cert_chain' => $options['capture_peer_cert_chain'], + 'allow_self_signed' => (bool) $options['peer_fingerprint'], + 'SNI_enabled' => true, + 'disable_compression' => true, + ], static function ($v) { return null !== $v; }), + 'socket' => [ + 'bindto' => $options['bindto'], + 'tcp_nodelay' => true, + ], + ]; + + $proxy = self::getProxy($options['proxy'], $url); + $noProxy = $noProxy ?? $_SERVER['no_proxy'] ?? $_SERVER['NO_PROXY'] ?? ''; + $noProxy = $noProxy ? preg_split('/[\s,]+/', $noProxy) : []; + + $resolveRedirect = self::createRedirectResolver($options, $host, $proxy, $noProxy, $info, $onProgress); + $context = stream_context_create($context, ['notification' => $notification]); + self::configureHeadersAndProxy($context, $host, $options['raw_headers'], $proxy, $noProxy); + + return new NativeResponse($this->multi, $context, implode('', $url), $options, $gzipEnabled, $info, $resolveRedirect, $onProgress); + } + + /** + * {@inheritdoc} + */ + public function stream($responses, float $timeout = null): ResponseStreamInterface + { + if ($responses instanceof NativeResponse) { + $responses = [$responses]; + } elseif (!\is_iterable($responses)) { + throw new \TypeError(sprintf('%s() expects parameter 1 to be an iterable of NativeResponse objects, %s given.', __METHOD__, \is_object($responses) ? \get_class($responses) : \gettype($responses))); + } + + return new ResponseStream(NativeResponse::stream($responses, $timeout)); + } + + private static function getBodyAsString($body): string + { + if (\is_resource($body)) { + return stream_get_contents($body); + } + + if (!$body instanceof \Closure) { + return $body; + } + + $result = ''; + + while ('' !== $data = $body(self::$CHUNK_SIZE)) { + if (!\is_string($data)) { + throw new TransportException(sprintf('Return value of the "body" option callback must be string, %s returned.', \gettype($data))); + } + + $result .= $data; + } + + 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'] ?? ('cli' === \PHP_SAPI ? $_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. + */ + private static function dnsResolve(array $url, \stdClass $multi, array &$info, ?\Closure $onProgress): array + { + if ($port = parse_url($url['authority'], PHP_URL_PORT) ?: '') { + $info['primary_port'] = $port; + $port = ':'.$port; + } else { + $info['primary_port'] = 'http:' === $url['scheme'] ? 80 : 443; + } + + $host = parse_url($url['authority'], PHP_URL_HOST); + + if (null === $ip = $multi->dnsCache[$host] ?? null) { + $now = microtime(true); + + if (!$ip = gethostbynamel($host)) { + throw new TransportException(sprintf('Could not resolve host "%s".', $host)); + } + + $info['namelookup_time'] += microtime(true) - $now; + $multi->dnsCache[$host] = $ip = $ip[0]; + } + + $info['primary_ip'] = $ip; + + if ($onProgress) { + // Notify DNS resolution + $onProgress(); + } + + return [$host, $port, substr_replace($url['authority'], $ip, -\strlen($host) - \strlen($port), \strlen($host))]; + } + + /** + * 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 + { + $redirectHeaders = []; + if (0 < $maxRedirects = $options['max_redirects']) { + $redirectHeaders = ['host' => $host]; + $redirectHeaders['with_auth'] = $redirectHeaders['no_auth'] = array_filter($options['raw_headers'], static function ($h) { + return 0 !== stripos($h, 'Host:'); + }); + + if (isset($options['headers']['authorization']) || isset($options['headers']['cookie'])) { + $redirectHeaders['no_auth'] = array_filter($options['raw_headers'], static function ($h) { + return 0 !== stripos($h, 'Authorization:') && 0 !== stripos($h, 'Cookie:'); + }); + } + } + + return static function (\stdClass $multi, int $statusCode, ?string $location, $context) use ($redirectHeaders, $proxy, $noProxy, &$info, $maxRedirects, $onProgress): ?string { + if (null === $location || $statusCode < 300 || 400 <= $statusCode) { + $info['redirect_url'] = null; + + return null; + } + + $url = self::resolveUrl(self::parseUrl($location), $info['url']); + $info['redirect_url'] = implode('', $url); + + if ($info['redirect_count'] >= $maxRedirects) { + return null; + } + + $now = microtime(true); + $info['url'] = $url; + ++$info['redirect_count']; + $info['redirect_time'] = $now - $info['start_time']; + + // Do like curl and browsers: turn POST to GET on 301, 302 and 303 + if (\in_array($statusCode, [301, 302, 303], true)) { + $options = stream_context_get_options($context)['http']; + + if ('POST' === $options['method'] || 303 === $statusCode) { + $options['method'] = 'HEAD' === $options['method'] ? 'HEAD' : 'GET'; + $options['content'] = ''; + $options['header'] = array_filter($options['header'], static function ($h) { + return 0 !== stripos($h, 'Content-Length:') && 0 !== stripos($h, 'Content-Type:'); + }); + + stream_context_set_option($context, ['http' => $options]); + } + } + + [$host, $port, $url['authority']] = self::dnsResolve($url, $multi, $info, $onProgress); + stream_context_set_option($context, 'ssl', 'peer_name', $host); + + if (false !== (parse_url($location, PHP_URL_HOST) ?? false)) { + // Authorization and Cookie headers MUST NOT follow except for the initial host name + $rawHeaders = $redirectHeaders['host'] === $host ? $redirectHeaders['with_auth'] : $redirectHeaders['no_auth']; + $rawHeaders[] = 'host: '.$host.$port; + self::configureHeadersAndProxy($context, $host, $rawHeaders, $proxy, $noProxy); + } + + return implode('', $url); + }; + } + + private static function configureHeadersAndProxy($context, string $host, array $rawHeaders, ?array $proxy, array $noProxy) + { + if (null === $proxy) { + return stream_context_set_option($context, 'http', 'header', $rawHeaders); + } + + // Matching "no_proxy" should follow the behavior of curl + + foreach ($noProxy as $rule) { + if ('*' === $rule) { + return stream_context_set_option($context, 'http', 'header', $rawHeaders); + } + + if ($host === $rule) { + return stream_context_set_option($context, 'http', 'header', $rawHeaders); + } + + $rule = '.'.ltrim($rule, '.'); + + if (substr($host, -\strlen($rule)) === $rule) { + return stream_context_set_option($context, 'http', 'header', $rawHeaders); + } + } + + stream_context_set_option($context, 'http', 'proxy', $proxy['url']); + stream_context_set_option($context, 'http', 'request_fulluri', true); + + if (null !== $proxy['auth']) { + $rawHeaders[] = 'Proxy-Authorization: '.$proxy['auth']; + } + + return stream_context_set_option($context, 'http', 'header', $rawHeaders); + } +} diff --git a/src/Symfony/Component/HttpClient/Psr18Client.php b/src/Symfony/Component/HttpClient/Psr18Client.php new file mode 100644 index 0000000000..9c00940339 --- /dev/null +++ b/src/Symfony/Component/HttpClient/Psr18Client.php @@ -0,0 +1,109 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient; + +use Psr\Http\Client\ClientInterface; +use Psr\Http\Client\NetworkExceptionInterface; +use Psr\Http\Client\RequestExceptionInterface; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseFactoryInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\StreamFactoryInterface; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * An adapter to turn a Symfony HttpClientInterface into a PSR-18 ClientInterface. + * + * Run "composer require psr/http-client" to install the base ClientInterface. Run + * "composer require nyholm/psr7" to install an efficient implementation of response + * and stream factories with flex-provided autowiring aliases. + * + * @author Nicolas Grekas + * + * @experimental in 4.3 + */ +final class Psr18Client implements ClientInterface +{ + private $client; + private $responseFactory; + private $streamFactory; + + public function __construct(HttpClientInterface $client, ResponseFactoryInterface $responseFactory, StreamFactoryInterface $streamFactory) + { + $this->client = $client; + $this->responseFactory = $responseFactory; + $this->streamFactory = $streamFactory; + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + try { + $response = $this->client->request($request->getMethod(), (string) $request->getUri(), [ + 'headers' => $request->getHeaders(), + 'body' => (string) $request->getBody(), + 'http_version' => '1.0' === $request->getProtocolVersion() ? '1.0' : null, + ]); + + $psrResponse = $this->responseFactory->createResponse($response->getStatusCode()); + + foreach ($response->getHeaders() as $name => $values) { + foreach ($values as $value) { + $psrResponse = $psrResponse->withAddedHeader($name, $value); + } + } + + return $psrResponse->withBody($this->streamFactory->createStream($response->getContent())); + } catch (TransportExceptionInterface $e) { + if ($e instanceof \InvalidArgumentException) { + throw new Psr18RequestException($e, $request); + } + + throw new Psr18NetworkException($e, $request); + } + } +} + +/** + * @internal + */ +trait Psr18ExceptionTrait +{ + private $request; + + public function __construct(TransportExceptionInterface $e, RequestInterface $request) + { + parent::__construct($e->getMessage(), 0, $e); + $this->request = $request; + } + + public function getRequest(): RequestInterface + { + return $this->request; + } +} + +/** + * @internal + */ +class Psr18NetworkException extends \RuntimeException implements NetworkExceptionInterface +{ + use Psr18ExceptionTrait; +} + +/** + * @internal + */ +class Psr18RequestException extends \InvalidArgumentException implements RequestExceptionInterface +{ + use Psr18ExceptionTrait; +} diff --git a/src/Symfony/Component/HttpClient/README.md b/src/Symfony/Component/HttpClient/README.md new file mode 100644 index 0000000000..55a0c78ebd --- /dev/null +++ b/src/Symfony/Component/HttpClient/README.md @@ -0,0 +1,17 @@ +HttpClient component +==================== + +The HttpClient component provides powerful methods to fetch HTTP resources synchronously or asynchronously. + +**This Component is experimental**. [Experimental +features](https://symfony.com/doc/current/contributing/code/experimental.html) +are not covered by Symfony's BC-break policy. + +Resources +--------- + + * [Documentation](https://symfony.com/doc/current/components/http_client.html) + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/HttpClient/Response/CurlResponse.php b/src/Symfony/Component/HttpClient/Response/CurlResponse.php new file mode 100644 index 0000000000..dadd841d8e --- /dev/null +++ b/src/Symfony/Component/HttpClient/Response/CurlResponse.php @@ -0,0 +1,298 @@ + + * + * 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 Symfony\Component\HttpClient\Chunk\FirstChunk; +use Symfony\Component\HttpClient\Exception\TransportException; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @author Nicolas Grekas + * + * @internal + */ +final class CurlResponse implements ResponseInterface +{ + use ResponseTrait; + + private static $performing = false; + + /** + * @internal + */ + public function __construct(\stdClass $multi, $ch, array $options = null, callable $resolveRedirect = null) + { + $this->multi = $multi; + + if (\is_resource($ch)) { + $this->handle = $ch; + } else { + $this->info['url'] = $ch; + $ch = $this->handle; + } + + $this->id = $id = (int) $ch; + $this->timeout = $options['timeout'] ?? null; + $this->info['user_data'] = $options['user_data'] ?? null; + $this->info['start_time'] = $this->info['start_time'] ?? microtime(true); + $info = &$this->info; + + if (!$info['raw_headers']) { + // Used to keep track of what we're waiting for + curl_setopt($ch, CURLOPT_PRIVATE, 'headers'); + } + + if (null === $content = &$this->content) { + $content = ($options['buffer'] ?? true) ? fopen('php://temp', 'w+') : null; + } else { + // Move the pushed response to the activity list + if (ftell($content)) { + rewind($content); + $multi->handlesActivity[$id][] = stream_get_contents($content); + } + $content = ($options['buffer'] ?? true) ? $content : null; + } + + curl_setopt($ch, CURLOPT_HEADERFUNCTION, static function ($ch, string $data) use (&$info, $options, $multi, $id, &$location, $resolveRedirect): int { + return self::parseHeaderLine($ch, $data, $info, $options, $multi, $id, $location, $resolveRedirect); + }); + + if (null === $options) { + // Pushed response: buffer until requested + curl_setopt($ch, CURLOPT_WRITEFUNCTION, static function ($ch, string $data) use (&$content): int { + return fwrite($content, $data); + }); + + return; + } + + if ($onProgress = $options['on_progress']) { + $url = isset($info['url']) ? ['url' => $info['url']] : []; + curl_setopt($ch, CURLOPT_NOPROGRESS, false); + curl_setopt($ch, CURLOPT_PROGRESSFUNCTION, static function ($ch, $dlSize, $dlNow) use ($onProgress, &$info, $url) { + try { + $onProgress($dlNow, $dlSize, $url + curl_getinfo($ch) + $info); + } catch (\Throwable $e) { + $info['error'] = $e; + + return 1; // Abort the request + } + }); + } + + curl_setopt($ch, CURLOPT_WRITEFUNCTION, static function ($ch, string $data) use (&$content, $multi, $id): int { + $multi->handlesActivity[$id][] = $data; + + return null !== $content ? fwrite($content, $data) : \strlen($data); + }); + + $this->initializer = static function (self $response) { + if (null !== $response->info['error']) { + throw new TransportException($response->info['error']); + } + + if (\in_array(curl_getinfo($ch = $response->handle, CURLINFO_PRIVATE), ['headers', 'destruct'], true)) { + try { + if (\defined('CURLOPT_STREAM_WEIGHT')) { + curl_setopt($ch, CURLOPT_STREAM_WEIGHT, 32); + } + self::stream([$response])->current(); + } catch (\Throwable $e) { + $response->info['error'] = $e->getMessage(); + $response->close(); + throw $e; + } + } + + curl_setopt($ch, CURLOPT_HEADERFUNCTION, null); + curl_setopt($ch, CURLOPT_READFUNCTION, null); + curl_setopt($ch, CURLOPT_INFILE, null); + + $response->addRawHeaders($response->info['raw_headers']); + }; + + // Schedule the request in a non-blocking way + $multi->openHandles[$id] = $ch; + curl_multi_add_handle($multi->handle, $ch); + self::perform($multi); + } + + /** + * {@inheritdoc} + */ + public function getInfo(string $type = null) + { + if (!$info = $this->finalInfo) { + self::perform($this->multi); + $info = array_merge($this->info, curl_getinfo($this->handle)); + $info['url'] = $this->info['url'] ?? $info['url']; + $info['redirect_url'] = $this->info['redirect_url'] ?? null; + + // workaround curl not subtracting the time offset for pushed responses + if (isset($this->info['url']) && $info['start_time'] / 1000 < $info['total_time']) { + $info['total_time'] -= $info['starttransfer_time'] ?: $info['total_time']; + $info['starttransfer_time'] = 0.0; + } + + if (!\in_array(curl_getinfo($this->handle, CURLINFO_PRIVATE), ['headers', 'content'], true)) { + $this->finalInfo = $info; + } + } + + return null !== $type ? $info[$type] ?? null : $info; + } + + public function __destruct() + { + try { + if (null === $this->timeout || isset($this->info['url'])) { + return; // pushed response + } + + if ('content' === $waitFor = curl_getinfo($this->handle, CURLINFO_PRIVATE)) { + $this->close(); + } elseif ('headers' === $waitFor) { + curl_setopt($this->handle, CURLOPT_PRIVATE, 'destruct'); + } + + $this->doDestruct(); + } finally { + $this->close(); + + // Clear local caches when the only remaining handles are about pushed responses + if (\count($this->multi->openHandles) === \count($this->multi->pushedResponses)) { + $this->multi->pushedResponses = []; + // Schedule DNS cache eviction for the next request + $this->multi->dnsCache[2] = $this->multi->dnsCache[2] ?: $this->multi->dnsCache[1]; + $this->multi->dnsCache[1] = $this->multi->dnsCache[0] = []; + } + } + } + + /** + * {@inheritdoc} + */ + protected function close(): void + { + unset($this->multi->openHandles[$this->id], $this->multi->handlesActivity[$this->id]); + curl_multi_remove_handle($this->multi->handle, $this->handle); + curl_setopt_array($this->handle, [ + CURLOPT_PRIVATE => '', + CURLOPT_NOPROGRESS => true, + CURLOPT_PROGRESSFUNCTION => null, + CURLOPT_HEADERFUNCTION => null, + CURLOPT_WRITEFUNCTION => null, + CURLOPT_READFUNCTION => null, + CURLOPT_INFILE => null, + ]); + } + + /** + * {@inheritdoc} + */ + protected static function schedule(self $response, array &$runningResponses): void + { + if ('' === curl_getinfo($ch = $response->handle, CURLINFO_PRIVATE)) { + // no-op - response already completed + } elseif (isset($runningResponses[$i = (int) $response->multi->handle])) { + $runningResponses[$i][1][$response->id] = $response; + } else { + $runningResponses[$i] = [$response->multi, [$response->id => $response]]; + } + } + + /** + * {@inheritdoc} + */ + protected static function perform(\stdClass $multi, array &$responses = null): void + { + if (self::$performing) { + return; + } + + try { + self::$performing = true; + while (CURLM_CALL_MULTI_PERFORM === curl_multi_exec($multi->handle, $active)); + + while ($info = curl_multi_info_read($multi->handle)) { + $multi->handlesActivity[(int) $info['handle']][] = null; + $multi->handlesActivity[(int) $info['handle']][] = \in_array($info['result'], [\CURLE_OK, \CURLE_TOO_MANY_REDIRECTS], true) ? null : new TransportException(curl_error($info['handle'])); + } + } finally { + self::$performing = false; + } + } + + /** + * {@inheritdoc} + */ + protected static function select(\stdClass $multi, float $timeout): int + { + return curl_multi_select($multi->handle, $timeout); + } + + /** + * Parses header lines as curl yields them to us. + */ + private static function parseHeaderLine($ch, string $data, array &$info, ?array $options, \stdClass $multi, int $id, ?string &$location, ?callable $resolveRedirect): int + { + if (!\in_array($waitFor = @curl_getinfo($ch, CURLINFO_PRIVATE), ['headers', 'destruct'], true)) { + return \strlen($data); // Ignore HTTP trailers + } + + if ("\r\n" !== $data) { + // Regular header line: add it to the list + $info['raw_headers'][] = substr($data, 0, -2); + + if (0 === stripos($data, 'Location:')) { + $location = trim(substr($data, 9, -2)); + } + + return \strlen($data); + } + + // End of headers: handle redirects and add to the activity list + $statusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $info['redirect_url'] = null; + + if (300 <= $statusCode && $statusCode < 400 && null !== $location) { + $info['redirect_url'] = $resolveRedirect($ch, $location); + $url = parse_url($location ?? ':'); + + if (isset($url['host']) && null !== $ip = $multi->dnsCache[0][$url['host'] = strtolower($url['host'])] ?? null) { + // Populate DNS cache for redirects if needed + $port = $url['port'] ?? ('http' === ($url['scheme'] ?? parse_url(curl_getinfo($ch, CURLINFO_EFFECTIVE_URL), PHP_URL_SCHEME)) ? 80 : 443); + curl_setopt($ch, CURLOPT_RESOLVE, ["{$url['host']}:$port:$ip"]); + $multi->dnsCache[1]["-{$url['host']}:$port"] = "-{$url['host']}:$port"; + } + } + + $location = null; + + if ($statusCode < 300 || 400 <= $statusCode || curl_getinfo($ch, CURLINFO_REDIRECT_COUNT) === $options['max_redirects']) { + // Headers and redirects completed, time to get the response's body + $multi->handlesActivity[$id] = [new FirstChunk()]; + + if ('destruct' === $waitFor) { + return 0; + } + + if ($certinfo = curl_getinfo($ch, CURLINFO_CERTINFO)) { + $info['peer_certificate_chain'] = array_map('openssl_x509_read', array_column($certinfo, 'Cert')); + } + + curl_setopt($ch, CURLOPT_PRIVATE, 'content'); + } + + return \strlen($data); + } +} diff --git a/src/Symfony/Component/HttpClient/Response/NativeResponse.php b/src/Symfony/Component/HttpClient/Response/NativeResponse.php new file mode 100644 index 0000000000..3e9ee3a375 --- /dev/null +++ b/src/Symfony/Component/HttpClient/Response/NativeResponse.php @@ -0,0 +1,305 @@ + + * + * 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 Symfony\Component\HttpClient\Chunk\FirstChunk; +use Symfony\Component\HttpClient\Exception\TransportException; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @author Nicolas Grekas + * + * @internal + */ +final class NativeResponse implements ResponseInterface +{ + use ResponseTrait; + + private $context; + private $url; + private $resolveRedirect; + private $onProgress; + private $remaining; + private $buffer; + private $inflate; + + /** + * @internal + */ + public function __construct(\stdClass $multi, $context, string $url, $options, bool $gzipEnabled, array &$info, callable $resolveRedirect, ?callable $onProgress) + { + $this->multi = $multi; + $this->id = (int) $context; + $this->context = $context; + $this->url = $url; + $this->timeout = $options['timeout']; + $this->info = &$info; + $this->resolveRedirect = $resolveRedirect; + $this->onProgress = $onProgress; + $this->content = $options['buffer'] ? fopen('php://temp', 'w+') : null; + + // Temporary resources to dechunk/inflate the response stream + $this->buffer = fopen('php://temp', 'w+'); + $this->inflate = $gzipEnabled ? inflate_init(ZLIB_ENCODING_GZIP) : null; + + $info['user_data'] = $options['user_data']; + ++$multi->responseCount; + + $this->initializer = static function (self $response) { + if (null !== $response->info['error']) { + throw new TransportException($response->info['error']); + } + + if (null === $response->remaining) { + self::stream([$response])->current(); + } + }; + } + + /** + * {@inheritdoc} + */ + public function getInfo(string $type = null) + { + if (!$info = $this->finalInfo) { + self::perform($this->multi); + $info = $this->info; + $info['url'] = implode('', $info['url']); + unset($info['fopen_time'], $info['size_body']); + + if (null === $this->buffer) { + $this->finalInfo = $info; + } + } + + return null !== $type ? $info[$type] ?? null : $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 = []; + } + } + } + + private function open(): void + { + set_error_handler(function ($type, $msg) { throw new TransportException($msg); }); + + try { + $this->info['start_time'] = microtime(true); + $url = $this->url; + + do { + // Send request and follow redirects when needed + $this->info['fopen_time'] = microtime(true); + $this->handle = $h = fopen($url, 'r', false, $this->context); + $this->addRawHeaders($http_response_header); + $url = ($this->resolveRedirect)($this->multi, $this->statusCode, $this->headers['location'][0] ?? null, $this->context); + } while (null !== $url); + } catch (\Throwable $e) { + $this->statusCode = 0; + $this->close(); + $this->multi->handlesActivity[$this->id][] = null; + $this->multi->handlesActivity[$this->id][] = $e; + + return; + } finally { + $this->info['starttransfer_time'] = $this->info['total_time'] = microtime(true) - $this->info['start_time']; + restore_error_handler(); + } + + stream_set_blocking($h, false); + $context = stream_context_get_options($this->context); + $this->context = $this->resolveRedirect = null; + + if (isset($context['ssl']['peer_certificate_chain'])) { + $this->info['peer_certificate_chain'] = $context['ssl']['peer_certificate_chain']; + } + + // Create dechunk and inflate buffers + if (isset($this->headers['content-length'])) { + $this->remaining = (int) $this->headers['content-length'][0]; + } elseif ('chunked' === ($this->headers['transfer-encoding'][0] ?? null)) { + stream_filter_append($this->buffer, 'dechunk', STREAM_FILTER_WRITE); + $this->remaining = -1; + } else { + $this->remaining = -2; + } + + if ($this->inflate && 'gzip' !== ($this->headers['content-encoding'][0] ?? null)) { + $this->inflate = null; + } + + $this->multi->openHandles[$this->id] = [$h, $this->buffer, $this->inflate, &$this->content, $this->onProgress, &$this->remaining, &$this->info]; + $this->multi->handlesActivity[$this->id] = [new FirstChunk()]; + } + + /** + * {@inheritdoc} + */ + private function close(): void + { + unset($this->multi->openHandles[$this->id], $this->multi->handlesActivity[$this->id]); + $this->handle = $this->buffer = $this->inflate = $this->onProgress = null; + } + + /** + * {@inheritdoc} + */ + private static function schedule(self $response, array &$runningResponses): void + { + if (null === $response->buffer) { + return; + } + + if (!isset($runningResponses[$i = $response->multi->id])) { + $runningResponses[$i] = [$response->multi, []]; + } + + if (null === $response->remaining) { + $response->multi->pendingResponses[] = $response; + } else { + $runningResponses[$i][1][$response->id] = $response; + } + } + + /** + * {@inheritdoc} + */ + private static function perform(\stdClass $multi, array &$responses = null): void + { + // List of native handles for stream_select() + if (null !== $responses) { + $multi->handles = []; + } + + foreach ($multi->openHandles as $i => [$h, $buffer, $inflate, $content, $onProgress]) { + $hasActivity = false; + $remaining = &$multi->openHandles[$i][5]; + $info = &$multi->openHandles[$i][6]; + $e = null; + + // Read incoming buffer and write it to the dechunk one + try { + while ($remaining && '' !== $data = (string) fread($h, 0 > $remaining ? 16372 : $remaining)) { + fwrite($buffer, $data); + $hasActivity = true; + $multi->sleep = false; + + if (-1 !== $remaining) { + $remaining -= \strlen($data); + } + } + } catch (\Throwable $e) { + $hasActivity = $onProgress = false; + } + + if (!$hasActivity) { + if ($onProgress) { + try { + // Notify the progress callback so that it can e.g. cancel + // the request if the stream is inactive for too long + $onProgress(); + } catch (\Throwable $e) { + // no-op + } + } + } elseif ('' !== $data = stream_get_contents($buffer, -1, 0)) { + rewind($buffer); + ftruncate($buffer, 0); + + if (null !== $inflate && false === $data = @inflate_add($inflate, $data)) { + $e = new TransportException('Error while processing content unencoding.'); + } + + if ('' !== $data && null === $e) { + $multi->handlesActivity[$i][] = $data; + + if (null !== $content && \strlen($data) !== fwrite($content, $data)) { + $e = new TransportException(sprintf('Failed writing %d bytes to the response buffer.', \strlen($data))); + } + } + } + + if (null !== $e || !$remaining || feof($h)) { + // Stream completed + $info['total_time'] = microtime(true) - $info['start_time']; + + if ($onProgress) { + try { + $onProgress(-1); + } catch (\Throwable $e) { + // no-op + } + } + + if (null === $e) { + if (0 < $remaining) { + $e = new TransportException(sprintf('Transfer closed with %s bytes remaining to read.', $remaining)); + } elseif (-1 === $remaining && fwrite($buffer, '-') && '' !== stream_get_contents($buffer, -1, 0)) { + $e = new TransportException('Transfer closed with outstanding data remaining from chunked response.'); + } + } + + $multi->handlesActivity[$i][] = null; + $multi->handlesActivity[$i][] = $e; + unset($multi->openHandles[$i]); + $multi->sleep = false; + } elseif (null !== $responses) { + $multi->handles[] = $h; + } + } + + if (null === $responses) { + return; + } + + if ($multi->pendingResponses && \count($multi->handles) < $multi->maxHostConnections) { + // Open the next pending request - this is a blocking operation so we do only one of them + $response = array_shift($multi->pendingResponses); + $response->open(); + $responses[$response->id] = $response; + $multi->sleep = false; + self::perform($response->multi); + + if (null !== $response->handle) { + $multi->handles[] = $response->handle; + } + } + + if ($multi->pendingResponses) { + // Create empty activity list to tell ResponseTrait::stream() we still have pending requests + $response = $multi->pendingResponses[0]; + $responses[$response->id] = $response; + $multi->handlesActivity[$response->id] = []; + } + } + + /** + * {@inheritdoc} + */ + private static function select(\stdClass $multi, float $timeout): int + { + $_ = []; + + return (!$multi->sleep = !$multi->sleep) ? -1 : stream_select($multi->handles, $_, $_, (int) $timeout, (int) (1E6 * ($timeout - (int) $timeout))); + } +} diff --git a/src/Symfony/Component/HttpClient/Response/ResponseStream.php b/src/Symfony/Component/HttpClient/Response/ResponseStream.php new file mode 100644 index 0000000000..cf53abcded --- /dev/null +++ b/src/Symfony/Component/HttpClient/Response/ResponseStream.php @@ -0,0 +1,56 @@ + + * + * 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 Symfony\Contracts\HttpClient\ChunkInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; +use Symfony\Contracts\HttpClient\ResponseStreamInterface; + +/** + * @author Nicolas Grekas + * + * @internal + */ +final class ResponseStream implements ResponseStreamInterface +{ + private $generator; + + public function __construct(\Generator $generator) + { + $this->generator = $generator; + } + + public function key(): ResponseInterface + { + return $this->generator->key(); + } + + public function current(): ChunkInterface + { + return $this->generator->current(); + } + + public function next(): void + { + $this->generator->next(); + } + + public function rewind(): void + { + $this->generator->rewind(); + } + + public function valid(): bool + { + return $this->generator->valid(); + } +} diff --git a/src/Symfony/Component/HttpClient/Response/ResponseTrait.php b/src/Symfony/Component/HttpClient/Response/ResponseTrait.php new file mode 100644 index 0000000000..b31af23d4c --- /dev/null +++ b/src/Symfony/Component/HttpClient/Response/ResponseTrait.php @@ -0,0 +1,299 @@ + + * + * 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 Symfony\Component\HttpClient\Chunk\DataChunk; +use Symfony\Component\HttpClient\Chunk\ErrorChunk; +use Symfony\Component\HttpClient\Chunk\LastChunk; +use Symfony\Component\HttpClient\Exception\ClientException; +use Symfony\Component\HttpClient\Exception\RedirectionException; +use Symfony\Component\HttpClient\Exception\ServerException; +use Symfony\Component\HttpClient\Exception\TransportException; + +/** + * Implements the common logic for response classes. + * + * @author Nicolas Grekas + * + * @internal + */ +trait ResponseTrait +{ + private $statusCode = 0; + private $headers = []; + + /** + * @var callable|null A callback that initializes the two previous properties + */ + private $initializer; + + /** + * @var resource A php://temp stream typically + */ + private $content; + + private $info = [ + 'raw_headers' => [], + 'error' => null, + ]; + + private $multi; + private $handle; + private $id; + private $timeout; + private $finalInfo; + private $offset = 0; + + /** + * {@inheritdoc} + */ + public function getStatusCode(): int + { + if ($this->initializer) { + ($this->initializer)($this); + $this->initializer = null; + } + + return $this->statusCode; + } + + /** + * {@inheritdoc} + */ + public function getHeaders(bool $throw = true): array + { + if ($this->initializer) { + ($this->initializer)($this); + $this->initializer = null; + } + + if ($throw) { + $this->checkStatusCode(); + } + + return $this->headers; + } + + /** + * {@inheritdoc} + */ + public function getContent(bool $throw = true): string + { + if ($this->initializer) { + ($this->initializer)($this); + $this->initializer = null; + } + + if ($throw) { + $this->checkStatusCode(); + } + + if (null === $this->content) { + $content = ''; + $chunk = null; + + foreach (self::stream([$this]) as $chunk) { + $content .= $chunk->getContent(); + } + + if (null === $chunk) { + throw new TransportException('Cannot get the content of the response twice: the request was issued with option "buffer" set to false.'); + } + + return $content; + } + + foreach (self::stream([$this]) as $chunk) { + // Chunks are buffered in $this->content already + } + + rewind($this->content); + + return stream_get_contents($this->content); + } + + /** + * Closes the response and all its network handles. + */ + abstract protected function close(): void; + + /** + * Adds pending responses to the activity list. + */ + abstract protected static function schedule(self $response, array &$runningResponses): void; + + /** + * Performs all pending non-blocking operations. + */ + abstract protected static function perform(\stdClass $multi, array &$responses): void; + + /** + * Waits for network activity. + */ + abstract protected static function select(\stdClass $multi, float $timeout): int; + + private function addRawHeaders(array $rawHeaders): void + { + foreach ($rawHeaders as $h) { + if (11 <= \strlen($h) && '/' === $h[4] && preg_match('#^HTTP/\d+(?:\.\d+)? ([12345]\d\d) .*#', $h, $m)) { + $this->headers = []; + $this->info['http_code'] = $this->statusCode = (int) $m[1]; + } elseif (2 === \count($m = explode(':', $h, 2))) { + $this->headers[strtolower($m[0])][] = ltrim($m[1]); + } + + $this->info['raw_headers'][] = $h; + } + + if (!$this->statusCode) { + throw new TransportException('Invalid or missing HTTP status line.'); + } + } + + private function checkStatusCode() + { + if (500 <= $this->statusCode) { + throw new ServerException($this); + } + + if (400 <= $this->statusCode) { + throw new ClientException($this); + } + + if (300 <= $this->statusCode) { + throw new RedirectionException($this); + } + } + + /** + * Ensures the request is always sent and that the response code was checked. + */ + private function doDestruct() + { + if ($this->initializer && null === $this->info['error']) { + ($this->initializer)($this); + $this->initializer = null; + $this->checkStatusCode(); + } + } + + /** + * Implements an event loop based on a buffer activity queue. + * + * @internal + */ + public static function stream(iterable $responses, float $timeout = null): \Generator + { + $runningResponses = []; + + foreach ($responses as $response) { + self::schedule($response, $runningResponses); + } + + $lastActivity = microtime(true); + $isTimeout = false; + + while (true) { + $hasActivity = false; + $timeoutMax = 0; + $timeoutMin = $timeout ?? INF; + + foreach ($runningResponses as $i => [$multi]) { + $responses = &$runningResponses[$i][1]; + self::perform($multi, $responses); + + foreach ($responses as $j => $response) { + $timeoutMax = $timeout ?? max($timeoutMax, $response->timeout); + $timeoutMin = min($timeoutMin, $response->timeout, 1); + // ErrorChunk instances will set $didThrow to true when the + // exception they wrap has been thrown after yielding + $chunk = $didThrow = false; + + if (isset($multi->handlesActivity[$j])) { + // no-op + } elseif (!isset($multi->openHandles[$j])) { + unset($responses[$j]); + continue; + } elseif ($isTimeout) { + $multi->handlesActivity[$j] = [new ErrorChunk($didThrow, $response->offset)]; + } else { + continue; + } + + while ($multi->handlesActivity[$j] ?? false) { + $hasActivity = true; + $isTimeout = false; + + if (\is_string($chunk = array_shift($multi->handlesActivity[$j]))) { + $response->offset += \strlen($chunk); + $chunk = new DataChunk($response->offset, $chunk); + } elseif (null === $chunk) { + if (null !== $e = $response->info['error'] ?? $multi->handlesActivity[$j][0]) { + $response->info['error'] = $e->getMessage(); + + if ($e instanceof \Error) { + unset($responses[$j], $multi->handlesActivity[$j]); + $response->close(); + throw $e; + } + + $chunk = new ErrorChunk($didThrow, $response->offset, $e); + } else { + $chunk = new LastChunk($response->offset); + } + + unset($responses[$j]); + $response->close(); + } elseif ($chunk instanceof ErrorChunk) { + unset($responses[$j]); + $isTimeout = true; + } + + yield $response => $chunk; + } + + unset($multi->handlesActivity[$j]); + + if ($chunk instanceof FirstChunk && null === $response->initializer) { + // Ensure the HTTP status code is always checked + $response->getHeaders(true); + } elseif ($chunk instanceof ErrorChunk && !$didThrow) { + // Ensure transport exceptions are always thrown + $chunk->getContent(); + } + } + + if (!$responses) { + unset($runningResponses[$i]); + } + + // Prevent memory leaks + $multi->handlesActivity = $multi->handlesActivity ?: []; + $multi->openHandles = $multi->openHandles ?: []; + } + + if (!$runningResponses) { + break; + } + + if ($hasActivity) { + $lastActivity = microtime(true); + continue; + } + + switch (self::select($multi, $timeoutMin)) { + case -1: usleep(min(500, 1E6 * $timeoutMin)); break; + case 0: $isTimeout = microtime(true) - $lastActivity > $timeoutMax; break; + } + } + } +} diff --git a/src/Symfony/Component/HttpClient/Tests/CurlHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/CurlHttpClientTest.php new file mode 100644 index 0000000000..7c51d42236 --- /dev/null +++ b/src/Symfony/Component/HttpClient/Tests/CurlHttpClientTest.php @@ -0,0 +1,27 @@ + + * + * 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\CurlHttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\Test\HttpClientTestCase; + +/** + * @requires extension curl + */ +class CurlHttpClientTest extends HttpClientTestCase +{ + protected function getHttpClient(): HttpClientInterface + { + return new CurlHttpClient(); + } +} diff --git a/src/Symfony/Component/HttpClient/Tests/HttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/HttpClientTest.php new file mode 100644 index 0000000000..9f70b74396 --- /dev/null +++ b/src/Symfony/Component/HttpClient/Tests/HttpClientTest.php @@ -0,0 +1,29 @@ + + * + * 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 PHPUnit\Framework\TestCase; +use Symfony\Component\HttpClient\CurlHttpClient; +use Symfony\Component\HttpClient\HttpClient; +use Symfony\Component\HttpClient\NativeHttpClient; + +class HttpClientTest extends TestCase +{ + public function testCreateClient() + { + if (\extension_loaded('curl')) { + $this->assertInstanceOf(CurlHttpClient::class, HttpClient::create()); + } else { + $this->assertInstanceOf(NativeHttpClient::class, HttpClient::create()); + } + } +} diff --git a/src/Symfony/Component/HttpClient/Tests/HttpClientTraitTest.php b/src/Symfony/Component/HttpClient/Tests/HttpClientTraitTest.php new file mode 100644 index 0000000000..815a1ab617 --- /dev/null +++ b/src/Symfony/Component/HttpClient/Tests/HttpClientTraitTest.php @@ -0,0 +1,166 @@ + + * + * 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 PHPUnit\Framework\TestCase; +use Symfony\Component\HttpClient\HttpClientTrait; + +class HttpClientTraitTest extends TestCase +{ + use HttpClientTrait; + + private const RFC3986_BASE = 'http://a/b/c/d;p?q'; + + /** + * @dataProvider providePrepareRequestUrl + */ + public function testPrepareRequestUrl($expected, $url, $query = []) + { + $defaults = [ + 'base_uri' => 'http://example.com?c=c', + 'query' => ['a' => 1, 'b' => 'b'], + ]; + [, $defaults] = self::prepareRequest(null, null, $defaults); + + [$url] = self::prepareRequest(null, $url, ['query' => $query], $defaults); + $this->assertSame($expected, implode('', $url)); + } + + public function providePrepareRequestUrl() + { + yield ['http://example.com/', 'http://example.com/']; + yield ['http://example.com/?a=1&b=b', '.']; + yield ['http://example.com/?a=2&b=b', '.?a=2']; + yield ['http://example.com/?a=3&b=b', '.', ['a' => 3]]; + yield ['http://example.com/?a=3&b=b', '.?a=0', ['a' => 3]]; + } + + /** + * @dataProvider provideResolveUrl + */ + public function testResolveUrl($base, $url, $expected) + { + $this->assertSame($expected, implode('', self::resolveUrl(self::parseUrl($url), self::parseUrl($base)))); + } + + /** + * From https://github.com/guzzle/psr7/blob/master/tests/UriResoverTest.php. + */ + public function provideResolveUrl() + { + return [ + [self::RFC3986_BASE, 'http:h', 'http:h'], + [self::RFC3986_BASE, 'g', 'http://a/b/c/g'], + [self::RFC3986_BASE, './g', 'http://a/b/c/g'], + [self::RFC3986_BASE, 'g/', 'http://a/b/c/g/'], + [self::RFC3986_BASE, '/g', 'http://a/g'], + [self::RFC3986_BASE, '//g', 'http://g/'], + [self::RFC3986_BASE, '?y', 'http://a/b/c/d;p?y'], + [self::RFC3986_BASE, 'g?y', 'http://a/b/c/g?y'], + [self::RFC3986_BASE, '#s', 'http://a/b/c/d;p?q#s'], + [self::RFC3986_BASE, 'g#s', 'http://a/b/c/g#s'], + [self::RFC3986_BASE, 'g?y#s', 'http://a/b/c/g?y#s'], + [self::RFC3986_BASE, ';x', 'http://a/b/c/;x'], + [self::RFC3986_BASE, 'g;x', 'http://a/b/c/g;x'], + [self::RFC3986_BASE, 'g;x?y#s', 'http://a/b/c/g;x?y#s'], + [self::RFC3986_BASE, '', self::RFC3986_BASE], + [self::RFC3986_BASE, '.', 'http://a/b/c/'], + [self::RFC3986_BASE, './', 'http://a/b/c/'], + [self::RFC3986_BASE, '..', 'http://a/b/'], + [self::RFC3986_BASE, '../', 'http://a/b/'], + [self::RFC3986_BASE, '../g', 'http://a/b/g'], + [self::RFC3986_BASE, '../..', 'http://a/'], + [self::RFC3986_BASE, '../../', 'http://a/'], + [self::RFC3986_BASE, '../../g', 'http://a/g'], + [self::RFC3986_BASE, '../../../g', 'http://a/g'], + [self::RFC3986_BASE, '../../../../g', 'http://a/g'], + [self::RFC3986_BASE, '/./g', 'http://a/g'], + [self::RFC3986_BASE, '/../g', 'http://a/g'], + [self::RFC3986_BASE, 'g.', 'http://a/b/c/g.'], + [self::RFC3986_BASE, '.g', 'http://a/b/c/.g'], + [self::RFC3986_BASE, 'g..', 'http://a/b/c/g..'], + [self::RFC3986_BASE, '..g', 'http://a/b/c/..g'], + [self::RFC3986_BASE, './../g', 'http://a/b/g'], + [self::RFC3986_BASE, 'foo////g', 'http://a/b/c/foo////g'], + [self::RFC3986_BASE, './g/.', 'http://a/b/c/g/'], + [self::RFC3986_BASE, 'g/./h', 'http://a/b/c/g/h'], + [self::RFC3986_BASE, 'g/../h', 'http://a/b/c/h'], + [self::RFC3986_BASE, 'g;x=1/./y', 'http://a/b/c/g;x=1/y'], + [self::RFC3986_BASE, 'g;x=1/../y', 'http://a/b/c/y'], + // dot-segments in the query or fragment + [self::RFC3986_BASE, 'g?y/./x', 'http://a/b/c/g?y/./x'], + [self::RFC3986_BASE, 'g?y/../x', 'http://a/b/c/g?y/../x'], + [self::RFC3986_BASE, 'g#s/./x', 'http://a/b/c/g#s/./x'], + [self::RFC3986_BASE, 'g#s/../x', 'http://a/b/c/g#s/../x'], + [self::RFC3986_BASE, 'g#s/../x', 'http://a/b/c/g#s/../x'], + [self::RFC3986_BASE, '?y#s', 'http://a/b/c/d;p?y#s'], + // base with fragment + ['http://a/b/c?q#s', '?y', 'http://a/b/c?y'], + // base with user info + ['http://u@a/b/c/d;p?q', '.', 'http://u@a/b/c/'], + ['http://u:p@a/b/c/d;p?q', '.', 'http://u:p@a/b/c/'], + // path ending with slash or no slash at all + ['http://a/b/c/d/', 'e', 'http://a/b/c/d/e'], + ['http:no-slash', 'e', 'http:e'], + // falsey relative parts + [self::RFC3986_BASE, '//0', 'http://0/'], + [self::RFC3986_BASE, '0', 'http://a/b/c/0'], + [self::RFC3986_BASE, '?0', 'http://a/b/c/d;p?0'], + [self::RFC3986_BASE, '#0', 'http://a/b/c/d;p?q#0'], + ]; + } + + /** + * @dataProvider provideParseUrl + */ + public function testParseUrl($expected, $url, $query = []) + { + $expected = array_combine(['scheme', 'authority', 'path', 'query', 'fragment'], $expected); + + $this->assertSame($expected, self::parseUrl($url, $query)); + } + + public function provideParseUrl() + { + yield [['http:', '//example.com', null, null, null], 'http://Example.coM:80']; + yield [['https:', '//xn--dj-kia8a.example.com:8000', '/', null, null], 'https://DÉjà.Example.com:8000/']; + yield [[null, null, '/f%20o.o', '?a=b', '#c'], '/f o%2Eo?a=b#c']; + yield [[null, '//a:b@foo', '/bar', null, null], '//a:b@foo/bar']; + yield [['http:', null, null, null, null], 'http:']; + yield [['http:', null, 'bar', null, null], 'http:bar']; + yield [[null, null, 'bar', '?a=1&c=c', null], 'bar?a=a&b=b', ['b' => null, 'c' => 'c', 'a' => 1]]; + yield [[null, null, 'bar', '?a=b+c&b=b', null], 'bar?a=b+c', ['b' => 'b']]; + yield [[null, null, 'bar', '?a=b%2B%20c', null], 'bar?a=b+c', ['a' => 'b+ c']]; + } + + /** + * @dataProvider provideRemoveDotSegments + */ + public function testRemoveDotSegments($expected, $url) + { + $this->assertSame($expected, self::removeDotSegments($url)); + } + + public function provideRemoveDotSegments() + { + yield ['', '']; + yield ['', '.']; + yield ['', '..']; + yield ['a', './a']; + yield ['a', '../a']; + yield ['/a/b', '/a/./b']; + yield ['/b/', '/a/../b/.']; + yield ['/a//b/', '/a///../b/.']; + yield ['/a/', '/a/b/..']; + yield ['/a///b', '/a///b']; + } +} diff --git a/src/Symfony/Component/HttpClient/Tests/NativeHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/NativeHttpClientTest.php new file mode 100644 index 0000000000..d2af0584f9 --- /dev/null +++ b/src/Symfony/Component/HttpClient/Tests/NativeHttpClientTest.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 Symfony\Component\HttpClient\NativeHttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\Test\HttpClientTestCase; + +class NativeHttpClientTest extends HttpClientTestCase +{ + protected function getHttpClient(): HttpClientInterface + { + return new NativeHttpClient(); + } +} diff --git a/src/Symfony/Component/HttpClient/Tests/Psr18ClientTest.php b/src/Symfony/Component/HttpClient/Tests/Psr18ClientTest.php new file mode 100644 index 0000000000..edb2891a37 --- /dev/null +++ b/src/Symfony/Component/HttpClient/Tests/Psr18ClientTest.php @@ -0,0 +1,77 @@ + + * + * 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 Nyholm\Psr7\Factory\Psr17Factory; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpClient\NativeHttpClient; +use Symfony\Component\HttpClient\Psr18Client; +use Symfony\Component\HttpClient\Psr18NetworkException; +use Symfony\Component\HttpClient\Psr18RequestException; +use Symfony\Contracts\HttpClient\Test\TestHttpServer; + +class Psr18ClientTest extends TestCase +{ + private static $server; + + public static function setUpBeforeClass() + { + TestHttpServer::start(); + } + + public function testSendRequest() + { + $factory = new Psr17Factory(); + $client = new Psr18Client(new NativeHttpClient(), $factory, $factory); + + $response = $client->sendRequest($factory->createRequest('GET', 'http://localhost:8057')); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('application/json', $response->getHeaderLine('content-type')); + + $body = json_decode((string) $response->getBody(), true); + + $this->assertSame('HTTP/1.1', $body['SERVER_PROTOCOL']); + } + + public function testPostRequest() + { + $factory = new Psr17Factory(); + $client = new Psr18Client(new NativeHttpClient(), $factory, $factory); + + $request = $factory->createRequest('POST', 'http://localhost:8057/post') + ->withBody($factory->createStream('foo=0123456789')); + + $response = $client->sendRequest($request); + $body = json_decode((string) $response->getBody(), true); + + $this->assertSame(['foo' => '0123456789', 'REQUEST_METHOD' => 'POST'], $body); + } + + public function testNetworkException() + { + $factory = new Psr17Factory(); + $client = new Psr18Client(new NativeHttpClient(), $factory, $factory); + + $this->expectException(Psr18NetworkException::class); + $client->sendRequest($factory->createRequest('GET', 'http://localhost:8058')); + } + + public function testRequestException() + { + $factory = new Psr17Factory(); + $client = new Psr18Client(new NativeHttpClient(), $factory, $factory); + + $this->expectException(Psr18RequestException::class); + $client->sendRequest($factory->createRequest('BAD.METHOD', 'http://localhost:8057')); + } +} diff --git a/src/Symfony/Component/HttpClient/composer.json b/src/Symfony/Component/HttpClient/composer.json new file mode 100644 index 0000000000..854c2c64fe --- /dev/null +++ b/src/Symfony/Component/HttpClient/composer.json @@ -0,0 +1,42 @@ +{ + "name": "symfony/http-client", + "type": "library", + "description": "Symfony HttpClient component", + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "provide": { + "psr/http-client-implementation": "1.0", + "symfony/http-client-contracts-implementation": "1.1" + }, + "require": { + "php": "^7.1.3", + "symfony/contracts": "^1.1" + }, + "require-dev": { + "nyholm/psr7": "^1.0", + "psr/http-client": "^1.0", + "symfony/process": "~4.2" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\HttpClient\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "4.3-dev" + } + } +} diff --git a/src/Symfony/Component/HttpClient/phpunit.xml.dist b/src/Symfony/Component/HttpClient/phpunit.xml.dist new file mode 100644 index 0000000000..4a055dcf50 --- /dev/null +++ b/src/Symfony/Component/HttpClient/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Tests + ./vendor + + + + diff --git a/src/Symfony/Component/VarDumper/Caster/SymfonyCaster.php b/src/Symfony/Component/VarDumper/Caster/SymfonyCaster.php index ae7134f55b..78acb90b66 100644 --- a/src/Symfony/Component/VarDumper/Caster/SymfonyCaster.php +++ b/src/Symfony/Component/VarDumper/Caster/SymfonyCaster.php @@ -40,4 +40,12 @@ class SymfonyCaster return $a; } + + public static function castHttpClient($client, array $a, Stub $stub, $isNested) + { + $multiKey = sprintf("\0%s\0multi", \get_class($client)); + $a[$multiKey] = new CutStub($a[$multiKey]); + + return $a; + } } diff --git a/src/Symfony/Component/VarDumper/Cloner/AbstractCloner.php b/src/Symfony/Component/VarDumper/Cloner/AbstractCloner.php index 9836040e34..e2393c4dbe 100644 --- a/src/Symfony/Component/VarDumper/Cloner/AbstractCloner.php +++ b/src/Symfony/Component/VarDumper/Cloner/AbstractCloner.php @@ -75,6 +75,10 @@ abstract class AbstractCloner implements ClonerInterface 'Exception' => ['Symfony\Component\VarDumper\Caster\ExceptionCaster', 'castException'], 'Error' => ['Symfony\Component\VarDumper\Caster\ExceptionCaster', 'castError'], 'Symfony\Component\DependencyInjection\ContainerInterface' => ['Symfony\Component\VarDumper\Caster\StubCaster', 'cutInternals'], + 'Symfony\Component\HttpClient\CurlHttpClient' => ['Symfony\Component\VarDumper\Caster\SymfonyCaster', 'castHttpClient'], + 'Symfony\Component\HttpClient\NativeHttpClient' => ['Symfony\Component\VarDumper\Caster\SymfonyCaster', 'castHttpClient'], + 'Symfony\Component\HttpClient\Response\CurlResponse' => ['Symfony\Component\VarDumper\Caster\SymfonyCaster', 'castHttpClient'], + 'Symfony\Component\HttpClient\Response\NativeResponse' => ['Symfony\Component\VarDumper\Caster\SymfonyCaster', 'castHttpClient'], 'Symfony\Component\HttpFoundation\Request' => ['Symfony\Component\VarDumper\Caster\SymfonyCaster', 'castRequest'], 'Symfony\Component\VarDumper\Exception\ThrowingCasterException' => ['Symfony\Component\VarDumper\Caster\ExceptionCaster', 'castThrowingCasterException'], 'Symfony\Component\VarDumper\Caster\TraceStub' => ['Symfony\Component\VarDumper\Caster\ExceptionCaster', 'castTraceStub'], diff --git a/src/Symfony/Contracts/CHANGELOG.md b/src/Symfony/Contracts/CHANGELOG.md index fba42d5954..b37bc2a705 100644 --- a/src/Symfony/Contracts/CHANGELOG.md +++ b/src/Symfony/Contracts/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +1.1.0 +----- + + * added `HttpClient` namespace with contracts for implementing flexible HTTP clients + 1.0.0 ----- diff --git a/src/Symfony/Contracts/HttpClient/ChunkInterface.php b/src/Symfony/Contracts/HttpClient/ChunkInterface.php new file mode 100644 index 0000000000..bbec2cdfa6 --- /dev/null +++ b/src/Symfony/Contracts/HttpClient/ChunkInterface.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Contracts\HttpClient; + +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; + +/** + * The interface of chunks returned by ResponseStreamInterface::current(). + * + * When the chunk is first, last or timeout, the content MUST be empty. + * When an unchecked timeout or a network error occurs, a TransportExceptionInterface + * MUST be thrown by the destructor unless one was already thrown by another method. + * + * @author Nicolas Grekas + * + * @experimental in 1.1 + */ +interface ChunkInterface +{ + /** + * Tells when the inactivity timeout has been reached. + * + * @throws TransportExceptionInterface on a network error + */ + public function isTimeout(): bool; + + /** + * Tells when headers just arrived. + * + * @throws TransportExceptionInterface on a network error or when the inactivity timeout is reached + */ + public function isFirst(): bool; + + /** + * Tells when the body just completed. + * + * @throws TransportExceptionInterface on a network error or when the inactivity timeout is reached + */ + public function isLast(): bool; + + /** + * Returns the content of the response chunk. + * + * @throws TransportExceptionInterface on a network error or when the inactivity timeout is reached + */ + public function getContent(): string; + + /** + * Returns the offset of the chunk in the response body. + */ + public function getOffset(): int; + + /** + * In case of error, returns the message that describes it. + */ + public function getError(): ?string; +} diff --git a/src/Symfony/Contracts/HttpClient/Exception/ClientExceptionInterface.php b/src/Symfony/Contracts/HttpClient/Exception/ClientExceptionInterface.php new file mode 100644 index 0000000000..a5f81dc146 --- /dev/null +++ b/src/Symfony/Contracts/HttpClient/Exception/ClientExceptionInterface.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Contracts\HttpClient\Exception; + +/** + * When a 4xx response is returned. + * + * @author Nicolas Grekas + * + * @experimental in 1.1 + */ +interface ClientExceptionInterface extends ExceptionInterface +{ +} diff --git a/src/Symfony/Contracts/HttpClient/Exception/ExceptionInterface.php b/src/Symfony/Contracts/HttpClient/Exception/ExceptionInterface.php new file mode 100644 index 0000000000..6d59715f70 --- /dev/null +++ b/src/Symfony/Contracts/HttpClient/Exception/ExceptionInterface.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Contracts\HttpClient\Exception; + +/** + * The base interface for all exceptions in the contract. + * + * @author Nicolas Grekas + * + * @experimental in 1.1 + */ +interface ExceptionInterface extends \Throwable +{ +} diff --git a/src/Symfony/Contracts/HttpClient/Exception/RedirectionExceptionInterface.php b/src/Symfony/Contracts/HttpClient/Exception/RedirectionExceptionInterface.php new file mode 100644 index 0000000000..208d692c62 --- /dev/null +++ b/src/Symfony/Contracts/HttpClient/Exception/RedirectionExceptionInterface.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Contracts\HttpClient\Exception; + +/** + * When a 3xx response is returned and the "max_redirects" option has been reached. + * + * @author Nicolas Grekas + * + * @experimental in 1.1 + */ +interface RedirectionExceptionInterface extends ExceptionInterface +{ +} diff --git a/src/Symfony/Contracts/HttpClient/Exception/ServerExceptionInterface.php b/src/Symfony/Contracts/HttpClient/Exception/ServerExceptionInterface.php new file mode 100644 index 0000000000..a1be7d4f7f --- /dev/null +++ b/src/Symfony/Contracts/HttpClient/Exception/ServerExceptionInterface.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Contracts\HttpClient\Exception; + +/** + * When a 5xx response is returned. + * + * @author Nicolas Grekas + * + * @experimental in 1.1 + */ +interface ServerExceptionInterface extends ExceptionInterface +{ +} diff --git a/src/Symfony/Contracts/HttpClient/Exception/TransportExceptionInterface.php b/src/Symfony/Contracts/HttpClient/Exception/TransportExceptionInterface.php new file mode 100644 index 0000000000..1cdc30a2fc --- /dev/null +++ b/src/Symfony/Contracts/HttpClient/Exception/TransportExceptionInterface.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Contracts\HttpClient\Exception; + +/** + * When any error happens at the transport level. + * + * @author Nicolas Grekas + * + * @experimental in 1.1 + */ +interface TransportExceptionInterface extends ExceptionInterface +{ +} diff --git a/src/Symfony/Contracts/HttpClient/HttpClientInterface.php b/src/Symfony/Contracts/HttpClient/HttpClientInterface.php new file mode 100644 index 0000000000..8d58e813e8 --- /dev/null +++ b/src/Symfony/Contracts/HttpClient/HttpClientInterface.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Contracts\HttpClient; + +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +use Symfony\Contracts\HttpClient\Test\HttpClientTestCase; + +/** + * Provides flexible methods for requesting HTTP resources synchronously or asynchronously. + * + * @see HttpClientTestCase for a reference test suite + * + * @author Nicolas Grekas + * + * @experimental in 1.1 + */ +interface HttpClientInterface +{ + public const OPTIONS_DEFAULTS = [ + 'auth' => null, // string - a username:password enabling HTTP Basic authentication + 'query' => [], // string[] - associative array of query string values to merge with the request's URL + 'headers' => [], // iterable|string[]|string[][] - headers names provided as keys or as part of values + 'body' => '', // array|string|resource|\Traversable|\Closure - the callback SHOULD yield a string + // smaller than the amount requested as argument; the empty string signals EOF; when + // an array is passed, it is meant as a form payload of field names and values + 'json' => null, // array|\JsonSerializable - when set, implementations MUST set the "body" option to + // the JSON-encoded value and set the "content-type" headers to a JSON-compatible + // value it is they are not defined - typically "application/json" + 'user_data' => null, // mixed - any extra data to attach to the request (scalar, callable, object...) that + // MUST be available via $response->getInfo('data') - not used internally + 'max_redirects' => 20, // int - the maximum number of redirects to follow; a value lower or equal to 0 means + // redirects should not be followed; "Authorization" and "Cookie" headers MUST + // NOT follow except for the initial host name + 'http_version' => null, // string - defaults to the best supported version, typically 1.1 or 2.0 + 'base_uri' => null, // string - the URI to resolve relative URLs, following rules in RFC 3986, section 2 + 'buffer' => true, // bool - whether the content of the response should be buffered or not + 'on_progress' => null, // callable(int $dlNow, int $dlSize, array $info) - throwing any exceptions MUST abort + // the request; it MUST be called on DNS resolution, on arrival of headers and on + // completion; it SHOULD be called on upload/download of data and at least 1/s + 'resolve' => [], // string[] - a map of host to IP address that SHOULD replace DNS resolution + 'proxy' => null, // string - by default, the proxy-related env vars handled by curl SHOULD be honored + 'no_proxy' => null, // string - a comma separated list of hosts that do not require a proxy to be reached + 'timeout' => null, // float - the inactivity timeout - defaults to ini_get('default_socket_timeout') + 'bindto' => '0', // string - the interface or the local socket to bind to + 'verify_peer' => true, // see https://php.net/context.ssl for the following options + 'verify_host' => true, + 'cafile' => null, + 'capath' => null, + 'local_cert' => null, + 'local_pk' => null, + 'passphrase' => null, + 'ciphers' => null, + 'peer_fingerprint' => null, + 'capture_peer_cert_chain' => false, + ]; + + /** + * Requests an HTTP resource. + * + * Responses MUST be lazy, but their status code MUST be + * checked even if none of their public methods are called. + * + * Implementations are not required to support all options described above; they can also + * support more custom options; but in any case, they MUST throw a TransportExceptionInterface + * when an unsupported option is passed. + * + * @throws TransportExceptionInterface When an unsupported option is passed + */ + public function request(string $method, string $url, array $options = []): ResponseInterface; + + /** + * Yields responses chunk by chunk as they complete. + * + * @param ResponseInterface|ResponseInterface[]|iterable $responses One or more responses created by the current HTTP client + * @param float|null $timeout The inactivity timeout before exiting the iterator + */ + public function stream($responses, float $timeout = null): ResponseStreamInterface; +} diff --git a/src/Symfony/Contracts/HttpClient/ResponseInterface.php b/src/Symfony/Contracts/HttpClient/ResponseInterface.php new file mode 100644 index 0000000000..7aeed6d510 --- /dev/null +++ b/src/Symfony/Contracts/HttpClient/ResponseInterface.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Contracts\HttpClient; + +use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\ExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; + +/** + * A (lazily retrieved) HTTP response. + * + * @author Nicolas Grekas + * + * @experimental in 1.1 + */ +interface ResponseInterface +{ + /** + * Gets the HTTP status code of the response. + * + * @throws TransportExceptionInterface when a network error occurs + */ + public function getStatusCode(): int; + + /** + * Gets the HTTP headers of the response. + * + * @param bool $throw Whether an exception should be thrown on 3/4/5xx status codes + * + * @return string[][] The headers of the response keyed by header names in lowercase + * + * @throws TransportExceptionInterface When a network error occurs + * @throws RedirectionExceptionInterface On a 3xx when $throw is true and the "max_redirects" option has been reached + * @throws ClientExceptionInterface On a 4xx when $throw is true + * @throws ServerExceptionInterface On a 5xx when $throw is true + */ + public function getHeaders(bool $throw = true): array; + + /** + * Gets the response body as a string. + * + * @param bool $throw Whether an exception should be thrown on 3/4/5xx status codes + * + * @throws TransportExceptionInterface When a network error occurs + * @throws RedirectionExceptionInterface On a 3xx when $throw is true and the "max_redirects" option has been reached + * @throws ClientExceptionInterface On a 4xx when $throw is true + * @throws ServerExceptionInterface On a 5xx when $throw is true + */ + public function getContent(bool $throw = true): string; + + /** + * Returns info coming from the transport layer. + * + * This method SHOULD NOT throw any ExceptionInterface and SHOULD be non-blocking. + * The returned info is "live": it can be empty and can change from one call to + * another, as the request/response progresses. + * + * The following info MUST be returned: + * - raw_headers - an array modelled after the special $http_response_header variable + * - redirect_count - the number of redirects followed while executing the request + * - redirect_url - the resolved location of redirect responses, null otherwise + * - start_time - the time when the request was sent or 0.0 when it's pending + * - http_code - the last response code or 0 when it is not known yet + * - error - the error message when the transfer was aborted, null otherwise + * - data - the value of the "data" request option, null if not set + * - url - the last effective URL of the request + * + * When the "capture_peer_cert_chain" option is true, the "peer_certificate_chain" + * attribute SHOULD list the peer certificates as an array of OpenSSL X.509 resources. + * + * Other info SHOULD be named after curl_getinfo()'s associative return value. + * + * @return array|mixed|null An array of all available info, or one of them when $type is + * provided, or null when an unsupported type is requested + */ + public function getInfo(string $type = null); +} diff --git a/src/Symfony/Contracts/HttpClient/ResponseStreamInterface.php b/src/Symfony/Contracts/HttpClient/ResponseStreamInterface.php new file mode 100644 index 0000000000..dfff554dfe --- /dev/null +++ b/src/Symfony/Contracts/HttpClient/ResponseStreamInterface.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Contracts\HttpClient; + +/** + * Yields response chunks, returned by HttpClientInterface::stream(). + * + * @author Nicolas Grekas + * + * @experimental in 1.1 + */ +interface ResponseStreamInterface extends \Iterator +{ + public function key(): ResponseInterface; + + public function current(): ChunkInterface; +} diff --git a/src/Symfony/Contracts/HttpClient/Test/Fixtures/web/index.php b/src/Symfony/Contracts/HttpClient/Test/Fixtures/web/index.php new file mode 100644 index 0000000000..ec15196c65 --- /dev/null +++ b/src/Symfony/Contracts/HttpClient/Test/Fixtures/web/index.php @@ -0,0 +1,124 @@ + $v) { + switch ($k) { + default: + if (0 !== strpos($k, 'HTTP_')) { + continue 2; + } + // no break + case 'SERVER_NAME': + case 'SERVER_PROTOCOL': + case 'REQUEST_URI': + case 'REQUEST_METHOD': + case 'PHP_AUTH_USER': + case 'PHP_AUTH_PW': + $vars[$k] = $v; + } +} + +switch ($vars['REQUEST_URI']) { + default: + exit; + + case '/': + case '/?a=a&b=b': + case 'http://127.0.0.1:8057/': + case 'http://localhost:8057/': + header('Content-Type: application/json'); + ob_start('ob_gzhandler'); + break; + + case '/404': + header('Content-Type: application/json', true, 404); + break; + + case '/301': + if ('Basic Zm9vOmJhcg==' === $vars['HTTP_AUTHORIZATION']) { + header('Location: http://127.0.0.1:8057/302', true, 301); + } + break; + + case '/301/bad-tld': + header('Location: http://foo.example.', true, 301); + break; + + case '/302': + if (!isset($vars['HTTP_AUTHORIZATION'])) { + header('Location: http://localhost:8057/', true, 302); + } + break; + + case '/302/relative': + header('Location: ..', true, 302); + break; + + case '/307': + header('Location: http://localhost:8057/post', true, 307); + break; + + case '/length-broken': + header('Content-Length: 1000'); + break; + + case '/post': + $output = json_encode($_POST + ['REQUEST_METHOD' => $vars['REQUEST_METHOD']], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + header('Content-Type: application/json', true); + header('Content-Length: '.\strlen($output)); + echo $output; + exit; + + case '/timeout-header': + usleep(300000); + break; + + case '/timeout-body': + echo '<1>'; + ob_flush(); + flush(); + usleep(500000); + echo '<2>'; + exit; + + case '/timeout-long': + ignore_user_abort(false); + sleep(1); + while (true) { + echo '<1>'; + ob_flush(); + flush(); + usleep(500); + } + exit; + + case '/chunked': + header('Transfer-Encoding: chunked'); + echo "8\r\nSymfony \r\n5\r\nis aw\r\n6\r\nesome!\r\n0\r\n\r\n"; + exit; + + case '/chunked-broken': + header('Transfer-Encoding: chunked'); + echo "8\r\nSymfony \r\n5\r\nis aw\r\n6\r\ne"; + exit; + + case '/gzip-broken': + header('Content-Encoding: gzip'); + echo str_repeat('-', 1000); + exit; +} + +header('Content-Type: application/json', true); + +echo json_encode($vars, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); diff --git a/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php b/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php new file mode 100644 index 0000000000..59703d0ffb --- /dev/null +++ b/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php @@ -0,0 +1,677 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Contracts\HttpClient\Test; + +use PHPUnit\Framework\TestCase; +use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * A reference test suite for HttpClientInterface implementations. + * + * @experimental in 1.1 + */ +abstract class HttpClientTestCase extends TestCase +{ + private static $server; + + public static function setUpBeforeClass() + { + TestHttpServer::start(); + } + + abstract protected function getHttpClient(): HttpClientInterface; + + public function testGetRequest() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://localhost:8057', [ + 'headers' => ['Foo' => 'baR'], + 'user_data' => $data = new \stdClass(), + ]); + + $this->assertSame([], $response->getInfo('raw_headers')); + $this->assertSame($data, $response->getInfo()['user_data']); + $this->assertSame(200, $response->getStatusCode()); + + $info = $response->getInfo(); + $this->assertNull($info['error']); + $this->assertSame(0, $info['redirect_count']); + $this->assertSame('HTTP/1.1 200 OK', $info['raw_headers'][0]); + $this->assertSame('Host: localhost:8057', $info['raw_headers'][1]); + $this->assertSame('http://localhost:8057/', $info['url']); + + $headers = $response->getHeaders(); + + $this->assertSame('localhost:8057', $headers['host'][0]); + $this->assertSame(['application/json'], $headers['content-type']); + + $body = json_decode($response->getContent(), true); + + $this->assertSame('HTTP/1.1', $body['SERVER_PROTOCOL']); + $this->assertSame('/', $body['REQUEST_URI']); + $this->assertSame('GET', $body['REQUEST_METHOD']); + $this->assertSame('localhost:8057', $body['HTTP_HOST']); + $this->assertSame('baR', $body['HTTP_FOO']); + + $response = $client->request('GET', 'http://localhost:8057/length-broken'); + + $this->expectException(TransportExceptionInterface::class); + $response->getContent(); + } + + public function testNonBufferedGetRequest() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://localhost:8057', [ + 'buffer' => false, + 'headers' => ['Foo' => 'baR'], + ]); + + $body = json_decode($response->getContent(), true); + $this->assertSame('baR', $body['HTTP_FOO']); + + $this->expectException(TransportExceptionInterface::class); + $response->getContent(); + } + + public function testUnsupportedOption() + { + $client = $this->getHttpClient(); + + $this->expectException(\InvalidArgumentException::class); + $client->request('GET', 'http://localhost:8057', [ + 'capture_peer_cert' => 1.0, + ]); + } + + public function testHttpVersion() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://localhost:8057', [ + 'http_version' => 1.0, + ]); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('HTTP/1.0 200 OK', $response->getInfo('raw_headers')[0]); + + $body = json_decode($response->getContent(), true); + + $this->assertSame('HTTP/1.0', $body['SERVER_PROTOCOL']); + $this->assertSame('GET', $body['REQUEST_METHOD']); + $this->assertSame('/', $body['REQUEST_URI']); + } + + public function testChunkedEncoding() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://localhost:8057/chunked'); + + $this->assertSame(['chunked'], $response->getHeaders()['transfer-encoding']); + $this->assertSame('Symfony is awesome!', $response->getContent()); + + $response = $client->request('GET', 'http://localhost:8057/chunked-broken'); + + $this->expectException(TransportExceptionInterface::class); + $response->getContent(); + } + + public function testClientError() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://localhost:8057/404'); + + $client->stream($response)->valid(); + + $this->assertSame(404, $response->getInfo('http_code')); + + try { + $response->getHeaders(); + $this->fail(ClientExceptionInterface::class.' expected'); + } catch (ClientExceptionInterface $e) { + } + + try { + $response->getContent(); + $this->fail(ClientExceptionInterface::class.' expected'); + } catch (ClientExceptionInterface $e) { + } + + $this->assertSame(404, $response->getStatusCode()); + $this->assertSame(['application/json'], $response->getHeaders(false)['content-type']); + $this->assertNotEmpty($response->getContent(false)); + } + + public function testIgnoreErrors() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://localhost:8057/404'); + + $this->assertSame(404, $response->getStatusCode()); + } + + public function testDnsError() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://localhost:8057/301/bad-tld'); + + try { + $response->getStatusCode(); + $this->fail(TransportExceptionInterface::class.' expected'); + } catch (TransportExceptionInterface $e) { + $this->addToAssertionCount(1); + } + + try { + $response->getStatusCode(); + $this->fail(TransportExceptionInterface::class.' still expected'); + } catch (TransportExceptionInterface $e) { + $this->addToAssertionCount(1); + } + + $response = $client->request('GET', 'http://localhost:8057/301/bad-tld'); + + try { + foreach ($client->stream($response) as $r => $chunk) { + } + $this->fail(TransportExceptionInterface::class.' expected'); + } catch (TransportExceptionInterface $e) { + $this->addToAssertionCount(1); + } + + $this->assertSame($response, $r); + $this->assertNotNull($chunk->getError()); + + foreach ($client->stream($response) as $chunk) { + $this->fail('Already errored responses shouldn\'t be yielded'); + } + } + + public function testInlineAuth() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://foo:bar%3Dbar@localhost:8057'); + + $body = json_decode($response->getContent(), true); + + $this->assertSame('foo', $body['PHP_AUTH_USER']); + $this->assertSame('bar=bar', $body['PHP_AUTH_PW']); + } + + public function testRedirects() + { + $client = $this->getHttpClient(); + $response = $client->request('POST', 'http://localhost:8057/301', [ + 'auth' => 'foo:bar', + 'body' => 'foo=bar', + ]); + + $body = json_decode($response->getContent(), true); + $this->assertSame('GET', $body['REQUEST_METHOD']); + $this->assertSame('Basic Zm9vOmJhcg==', $body['HTTP_AUTHORIZATION']); + $this->assertSame('http://localhost:8057/', $response->getInfo('url')); + + $this->assertSame(2, $response->getInfo('redirect_count')); + $this->assertNull($response->getInfo('redirect_url')); + + $expected = [ + 'HTTP/1.1 301 Moved Permanently', + 'Location: http://127.0.0.1:8057/302', + 'Content-Type: application/json', + 'HTTP/1.1 302 Found', + 'Location: http://localhost:8057/', + 'Content-Type: application/json', + 'HTTP/1.1 200 OK', + 'Content-Type: application/json', + ]; + + $filteredHeaders = array_intersect($expected, $response->getInfo('raw_headers')); + + $this->assertSame($expected, $filteredHeaders); + } + + public function testRelativeRedirects() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://localhost:8057/302/relative'); + + $body = json_decode($response->getContent(), true); + $this->assertSame('/', $body['REQUEST_URI']); + $this->assertNull($response->getInfo('redirect_url')); + + $response = $client->request('GET', 'http://localhost:8057/302/relative', [ + 'max_redirects' => 0, + ]); + + $this->assertSame(302, $response->getStatusCode()); + $this->assertSame('http://localhost:8057/', $response->getInfo('redirect_url')); + } + + public function testRedirect307() + { + $client = $this->getHttpClient(); + $response = $client->request('POST', 'http://localhost:8057/307', [ + 'body' => 'foo=bar', + ]); + + $body = json_decode($response->getContent(), true); + + $this->assertSame(['foo' => 'bar', 'REQUEST_METHOD' => 'POST'], $body); + } + + public function testMaxRedirects() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://localhost:8057/301', [ + 'max_redirects' => 1, + 'auth' => 'foo:bar', + ]); + + try { + $response->getHeaders(); + $this->fail(RedirectionExceptionInterface::class.' expected'); + } catch (RedirectionExceptionInterface $e) { + } + + $this->assertSame(302, $response->getStatusCode()); + $this->assertSame(1, $response->getInfo('redirect_count')); + $this->assertSame('http://localhost:8057/', $response->getInfo('redirect_url')); + + $expected = [ + 'HTTP/1.1 301 Moved Permanently', + 'Location: http://127.0.0.1:8057/302', + 'Content-Type: application/json', + 'HTTP/1.1 302 Found', + 'Location: http://localhost:8057/', + 'Content-Type: application/json', + ]; + + $filteredHeaders = array_intersect($expected, $response->getInfo('raw_headers')); + + $this->assertSame($expected, $filteredHeaders); + } + + public function testStream() + { + $client = $this->getHttpClient(); + + $response = $client->request('GET', 'http://localhost:8057'); + $chunks = $client->stream($response); + $result = []; + + foreach ($chunks as $r => $chunk) { + if ($chunk->isTimeout()) { + $result[] = 't'; + } elseif ($chunk->isLast()) { + $result[] = 'l'; + } elseif ($chunk->isFirst()) { + $result[] = 'f'; + } + } + + $this->assertSame($response, $r); + $this->assertSame(['f', 'l'], $result); + } + + public function testAddToStream() + { + $client = $this->getHttpClient(); + + $r1 = $client->request('GET', 'http://localhost:8057'); + + $completed = []; + + $pool = [$r1]; + + while ($pool) { + $chunks = $client->stream($pool); + $pool = []; + + foreach ($chunks as $r => $chunk) { + if (!$chunk->isLast()) { + continue; + } + + if ($r1 === $r) { + $r2 = $client->request('GET', 'http://localhost:8057'); + $pool[] = $r2; + } + + $completed[] = $r; + } + } + + $this->assertSame([$r1, $r2], $completed); + } + + public function testCompleteTypeError() + { + $client = $this->getHttpClient(); + + $this->expectException(\TypeError::class); + $client->stream(123); + } + + public function testOnProgress() + { + $client = $this->getHttpClient(); + $response = $client->request('POST', 'http://localhost:8057/post', [ + 'headers' => ['Content-Length' => 14], + 'body' => 'foo=0123456789', + 'on_progress' => function (...$state) use (&$steps) { $steps[] = $state; }, + ]); + + $body = json_decode($response->getContent(), true); + + $this->assertSame(['foo' => '0123456789', 'REQUEST_METHOD' => 'POST'], $body); + $this->assertSame([0, 0], \array_slice($steps[0], 0, 2)); + $lastStep = \array_slice($steps, -1)[0]; + $this->assertSame([57, 57], \array_slice($lastStep, 0, 2)); + $this->assertSame('http://localhost:8057/post', $steps[0][2]['url']); + } + + public function testPostArray() + { + $client = $this->getHttpClient(); + + $response = $client->request('POST', 'http://localhost:8057/post', [ + 'body' => ['foo' => 'bar'], + ]); + + $this->assertSame(['foo' => 'bar', 'REQUEST_METHOD' => 'POST'], json_decode($response->getContent(), true)); + } + + public function testPostResource() + { + $client = $this->getHttpClient(); + + $h = fopen('php://temp', 'w+'); + fwrite($h, 'foo=0123456789'); + rewind($h); + + $response = $client->request('POST', 'http://localhost:8057/post', [ + 'body' => $h, + ]); + + $body = json_decode($response->getContent(), true); + + $this->assertSame(['foo' => '0123456789', 'REQUEST_METHOD' => 'POST'], $body); + } + + public function testPostCallback() + { + $client = $this->getHttpClient(); + + $response = $client->request('POST', 'http://localhost:8057/post', [ + 'body' => function () { + yield 'foo'; + yield '='; + yield '0123456789'; + }, + ]); + + $this->assertSame(['foo' => '0123456789', 'REQUEST_METHOD' => 'POST'], json_decode($response->getContent(), true)); + } + + public function testOnProgressCancel() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://localhost:8057/timeout-body', [ + 'on_progress' => function ($dlNow) { + if (0 < $dlNow) { + throw new \Exception('Aborting the request'); + } + }, + ]); + + try { + foreach ($client->stream([$response]) as $chunk) { + } + $this->fail(ClientExceptionInterface::class.' expected'); + } catch (TransportExceptionInterface $e) { + $this->assertSame('Aborting the request', $e->getPrevious()->getMessage()); + } + + $this->assertNotNull($response->getInfo('error')); + $this->expectException(TransportExceptionInterface::class); + $response->getContent(); + } + + public function testOnProgressError() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://localhost:8057/timeout-body', [ + 'on_progress' => function ($dlNow) { + if (0 < $dlNow) { + throw new \Error('BUG'); + } + }, + ]); + + try { + foreach ($client->stream([$response]) as $chunk) { + } + $this->fail('Error expected'); + } catch (\Error $e) { + $this->assertSame('BUG', $e->getMessage()); + } + + $this->assertNotNull($response->getInfo('error')); + $this->expectException(TransportExceptionInterface::class); + $response->getContent(); + } + + public function testResolve() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://symfony.com:8057/', [ + 'resolve' => ['symfony.com' => '127.0.0.1'], + ]); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(200, $client->request('GET', 'http://symfony.com:8057/')->getStatusCode()); + + $response = null; + $this->expectException(TransportExceptionInterface::class); + $client->request('GET', 'http://symfony.com:8057/'); + } + + public function testTimeoutOnAccess() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://localhost:8057/timeout-header', [ + 'timeout' => 0.1, + ]); + + $this->expectException(TransportExceptionInterface::class); + $response->getHeaders(); + } + + public function testTimeoutOnStream() + { + usleep(300000); // wait for the previous test to release the server + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://localhost:8057/timeout-body'); + + $this->assertSame(200, $response->getStatusCode()); + $chunks = $client->stream([$response], 0.2); + + $result = []; + + foreach ($chunks as $r => $chunk) { + if ($chunk->isTimeout()) { + $result[] = 't'; + } else { + $result[] = $chunk->getContent(); + } + } + + $this->assertSame(['<1>', 't'], $result); + + $chunks = $client->stream([$response]); + + foreach ($chunks as $r => $chunk) { + $this->assertSame('<2>', $chunk->getContent()); + $this->assertSame('<1><2>', $r->getContent()); + + return; + } + + $this->fail('The response should have completed'); + } + + public function testUncheckedTimeoutThrows() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://localhost:8057/timeout-body'); + $chunks = $client->stream([$response], 0.1); + + $this->expectException(TransportExceptionInterface::class); + + foreach ($chunks as $r => $chunk) { + } + } + + public function testDestruct() + { + $client = $this->getHttpClient(); + + $downloaded = 0; + $start = microtime(true); + $client->request('GET', 'http://localhost:8057/timeout-long'); + $client = null; + $duration = microtime(true) - $start; + + $this->assertGreaterThan(1, $duration); + $this->assertLessThan(3, $duration); + } + + public function testProxy() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://localhost:8057/', [ + 'proxy' => 'http://localhost:8057', + ]); + + $body = json_decode($response->getContent(), true); + $this->assertSame('localhost:8057', $body['HTTP_HOST']); + $this->assertRegexp('#^http://(localhost|127\.0\.0\.1):8057/$#', $body['REQUEST_URI']); + + $response = $client->request('GET', 'http://localhost:8057/', [ + 'proxy' => 'http://foo:b%3Dar@localhost:8057', + ]); + + $body = json_decode($response->getContent(), true); + $this->assertSame('Basic Zm9vOmI9YXI=', $body['HTTP_PROXY_AUTHORIZATION']); + } + + public function testNoProxy() + { + putenv('no_proxy='.$_SERVER['no_proxy'] = 'example.com, localhost'); + + try { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://localhost:8057/', [ + 'proxy' => 'http://localhost:8057', + ]); + + $body = json_decode($response->getContent(), true); + + $this->assertSame('HTTP/1.1', $body['SERVER_PROTOCOL']); + $this->assertSame('/', $body['REQUEST_URI']); + $this->assertSame('GET', $body['REQUEST_METHOD']); + } finally { + putenv('no_proxy'); + unset($_SERVER['no_proxy']); + } + } + + /** + * @requires extension zlib + */ + public function testAutoEncodingRequest() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://localhost:8057'); + + $this->assertSame(200, $response->getStatusCode()); + + $headers = $response->getHeaders(); + + $this->assertSame(['Accept-Encoding'], $headers['vary']); + $this->assertContains('gzip', $headers['content-encoding'][0]); + + $body = json_decode($response->getContent(), true); + + $this->assertContains('gzip', $body['HTTP_ACCEPT_ENCODING']); + } + + public function testBaseUri() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', '../404', [ + 'base_uri' => 'http://localhost:8057/abc/', + ]); + + $this->assertSame(404, $response->getStatusCode()); + $this->assertSame(['application/json'], $response->getHeaders(false)['content-type']); + } + + public function testQuery() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://localhost:8057/?a=a', [ + 'query' => ['b' => 'b'], + ]); + + $body = json_decode($response->getContent(), true); + $this->assertSame('GET', $body['REQUEST_METHOD']); + $this->assertSame('/?a=a&b=b', $body['REQUEST_URI']); + } + + /** + * @requires extension zlib + */ + public function testUserlandEncodingRequest() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://localhost:8057', [ + 'headers' => ['Accept-Encoding' => 'gzip'], + ]); + + $headers = $response->getHeaders(); + + $this->assertSame(['Accept-Encoding'], $headers['vary']); + $this->assertContains('gzip', $headers['content-encoding'][0]); + + $body = $response->getContent(); + + $this->assertSame("\x1F", $body[0]); + $body = json_decode(gzdecode($body), true); + + $this->assertSame('gzip', $body['HTTP_ACCEPT_ENCODING']); + } + + /** + * @requires extension zlib + */ + public function testGzipBroken() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://localhost:8057/gzip-broken'); + + $this->expectException(TransportExceptionInterface::class); + $response->getContent(); + } +} diff --git a/src/Symfony/Contracts/HttpClient/Test/TestHttpServer.php b/src/Symfony/Contracts/HttpClient/Test/TestHttpServer.php new file mode 100644 index 0000000000..5b05f50562 --- /dev/null +++ b/src/Symfony/Contracts/HttpClient/Test/TestHttpServer.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Contracts\HttpClient\Test; + +use Symfony\Component\Process\PhpExecutableFinder; +use Symfony\Component\Process\Process; + +/** + * @experimental in 1.1 + */ +class TestHttpServer +{ + private static $server; + + public static function start() + { + if (null !== self::$server) { + return; + } + + $spec = [ + 1 => ['file', '\\' === \DIRECTORY_SEPARATOR ? 'NUL' : '/dev/null', 'w'], + 2 => ['file', '\\' === \DIRECTORY_SEPARATOR ? 'NUL' : '/dev/null', 'w'], + ]; + + $finder = new PhpExecutableFinder(); + $process = new Process(array_merge([$finder->find(false)], $finder->findArguments(), ['-dopcache.enable=0', '-dvariables_order=EGPCS', '-S', '127.0.0.1:8057'])); + $process->setWorkingDirectory(__DIR__.'/Fixtures/web'); + $process->setTimeout(300); + $process->start(); + + self::$server = new class() { + public $process; + + public function __destruct() + { + $this->process->stop(); + } + }; + + self::$server->process = $process; + + sleep('\\' === \DIRECTORY_SEPARATOR ? 10 : 1); + } +} diff --git a/src/Symfony/Contracts/composer.json b/src/Symfony/Contracts/composer.json index b01744783f..f881258dc1 100644 --- a/src/Symfony/Contracts/composer.json +++ b/src/Symfony/Contracts/composer.json @@ -20,12 +20,14 @@ }, "require-dev": { "psr/cache": "^1.0", - "psr/container": "^1.0" + "psr/container": "^1.0", + "symfony/polyfill-intl-idn": "^1.10" }, "suggest": { "psr/cache": "When using the Cache contracts", "psr/container": "When using the Service contracts", "symfony/cache-contracts-implementation": "", + "symfony/http-client-contracts-implementation": "", "symfony/service-contracts-implementation": "", "symfony/translation-contracts-implementation": "" },