feature #30413 [HttpClient][Contracts] introduce component and related contracts (nicolas-grekas)

This PR was squashed before being merged into the 4.3-dev branch (closes #30413).

Discussion
----------

[HttpClient][Contracts] introduce component and related contracts

| Q             | A
| ------------- | ---
| Branch?       | master
| Bug fix?      | no
| New feature?  | yes
| BC breaks?    | no
| Deprecations? | no
| Tests pass?   | yes
| Fixed tickets | #28628
| License       | MIT
| Doc PR        | -

This PR introduces new `HttpClient` contracts and
component. It makes no compromises between DX, performance, and design.
Its surface should be very simple to use, while still flexible enough
to cover most advanced use cases thanks to streaming+laziness.

Common existing HTTP clients for PHP rely on PSR-7, which is complex
and orthogonal to the way Symfony is designed. More reasons we need
this in core are the [package principles](https://en.wikipedia.org/wiki/Package_principles): if we want to be able to keep our
BC+deprecation promises, we have to build on more stable and more
abstract dependencies than Symfony itself. And we need an HTTP client
for e.g. Symfony Mailer or #27738.

The existing state-of-the-art puts a quite high bar in terms of features we must
support if we want any adoption. The code in this PR aims at implementing an
even better HTTP client for PHP than existing ones, with more (useful) features
and a better architecture. What a pitch :)

Two full implementations are provided:
 - `NativeHttpClient` is based on the native "http" stream wrapper.
   It's the most portable one but relies on a blocking `fopen()`.
 - `CurlHttpClient` relies on the curl extension. It supports full
   concurrency and HTTP/2, including server push.

Here are some examples that work with both clients.

For simple cases, all the methods on responses are synchronous:

```php
$client = new NativeHttpClient();

$response = $client->get('https://google.com');

$statusCode = $response->getStatusCode();
$headers = $response->getHeaders();
$content = $response->getContent();
```

By default, clients follow redirects. On `3xx`, `4xx` or `5xx`, the `getHeaders()` and `getContent()` methods throw an exception, unless their `$throw` argument is set to `false`.
This is part of the "failsafe" design of the component. Another example of this
failsafe property is that broken dechunk or gzip streams always trigger an exception,
unlike most other HTTP clients who can silently ignore the situations.

An array of options allows adjusting the behavior when sending requests.
They are documented in `HttpClientInterface`.

When several responses are 1) first requested in batch, 2) then accessed
via any of their public methods, requests are done concurrently while
waiting for one.

For more advanced use cases, when streaming is needed:

Streaming the request body is possible via the "body" request option.
Streaming the response content is done via client's `stream()` method:

```php
$client = new CurlHttpClient();

$response = $client->request('GET', 'http://...');

$output = fopen('output.file', 'w');

foreach ($client->stream($response) as $chunk) {
    fwrite($output, $chunk->getContent());
}
```

The `stream()` method also works with multiple responses:

```php
$client = new CurlHttpClient();
$pool = [];

for ($i = 0; $i < 379; ++$i) {
    $uri = "https://http2.akamai.com/demo/tile-$i.png";
    $pool[] = $client->get($uri);
}

$chunks = $client->stream($pool);

foreach ($chunks as $response => $chunk) {
    // $chunk is a ChunkInterface object
    if ($chunk->isLast()) {
        $content = $response->getContent();
    }
}
```

The `stream()` method accepts a second `$timeout` argument: responses that
are *inactive* for longer than the timeout will emit an empty chunk to signal
it. Providing `0` as timeout allows monitoring responses in a non-blocking way.

Implemented:
 - flexible contracts for HTTP clients
 - `fopen()` + `curl`-based clients with close feature parity
 - gzip compression enabled when possible
 - streaming multiple responses concurrently
 - `base_uri` option for scoped clients
 - progress callback with detailed info and able to cancel the request
 - more flexible options for precise behavior control
 - flexible timeout management allowing e.g. server sent events
 - public key pinning
 - auto proxy configuration via env vars
 - transparent IDN support
 - `HttpClient::create()` factory
 - extensive error handling, e.g. on broken dechunk/gzip streams
 - time stats, primary_ip and other info inspired from `curl_getinfo()`
 - transparent HTTP/2-push support with authority validation
 - `Psr18Client` for integration with libs relying on PSR-18
 - free from memory leaks by avoiding circular references
 - fixed handling of redirects when using the `fopen`-based client
 - DNS cache pre-population with `resolve` option

Help wanted (can be done after merge):
 - `FrameworkBundle` integration: autowireable alias + semantic configuration for default options
 - add `TraceableHttpClient` and integrate with the profiler
 - logger integration
 - add a mock client

More ideas:
 - record/replay like CsaGuzzleBundle
 - use raw sockets instead of the HTTP stream wrapper
 - `cookie_jar` option
 - HTTP/HSTS cache
 - using the symfony CLI binary to test ssl-related options, HTTP/2-push, etc.
 - add "auto" mode to the "buffer" option, based on the content-type? or array of content-types to buffer
 - *etc.*

Commits
-------

fc83120691 [HttpClient] Add Psr18Client - aka a PSR-18 adapter
8610668c1c [HttpClient] introduce the component
d2d63a28e1 [Contracts] introduce HttpClient contracts
This commit is contained in:
Fabien Potencier 2019-03-07 17:32:39 +01:00
commit 790854989e
47 changed files with 4758 additions and 2 deletions

View File

@ -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",

View File

@ -0,0 +1,7 @@
CHANGELOG
=========
4.3.0
-----
* added the component

View File

@ -0,0 +1,79 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
*
* @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;
}
}

View File

@ -0,0 +1,106 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
*
* @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);
}
}
}

View File

@ -0,0 +1,28 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
*
* @internal
*/
class FirstChunk extends DataChunk
{
/**
* {@inheritdoc}
*/
public function isFirst(): bool
{
return true;
}
}

View File

@ -0,0 +1,28 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
*
* @internal
*/
class LastChunk extends DataChunk
{
/**
* {@inheritdoc}
*/
public function isLast(): bool
{
return true;
}
}

View File

@ -0,0 +1,374 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
*
* @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));
};
}
}

View File

@ -0,0 +1,26 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
*
* @internal
*/
final class ClientException extends \RuntimeException implements ClientExceptionInterface
{
use HttpExceptionTrait;
}

View File

@ -0,0 +1,38 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
*
* @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);
}
}

View File

@ -0,0 +1,23 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
*
* @internal
*/
final class InvalidArgumentException extends \InvalidArgumentException implements TransportExceptionInterface
{
}

View File

@ -0,0 +1,26 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
*
* @internal
*/
final class RedirectionException extends \RuntimeException implements RedirectionExceptionInterface
{
use HttpExceptionTrait;
}

View File

@ -0,0 +1,26 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
*
* @internal
*/
final class ServerException extends \RuntimeException implements ServerExceptionInterface
{
use HttpExceptionTrait;
}

View File

@ -0,0 +1,23 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
*
* @internal
*/
final class TransportException extends \RuntimeException implements TransportExceptionInterface
{
}

View File

@ -0,0 +1,39 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
*
* @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);
}
}

View File

@ -0,0 +1,457 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
*
* @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));
}
}

View File

@ -0,0 +1,299 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
*
* @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;
}
}

View File

@ -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.

View File

@ -0,0 +1,420 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
*
* @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);
}
}

View File

@ -0,0 +1,109 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
*
* @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;
}

View File

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

View File

@ -0,0 +1,298 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
*
* @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);
}
}

View File

@ -0,0 +1,305 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
*
* @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)));
}
}

View File

@ -0,0 +1,56 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
*
* @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();
}
}

View File

@ -0,0 +1,299 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
*
* @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;
}
}
}
}

View File

@ -0,0 +1,27 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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();
}
}

View File

@ -0,0 +1,29 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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());
}
}
}

View File

@ -0,0 +1,166 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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'];
}
}

View File

@ -0,0 +1,24 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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();
}
}

View File

@ -0,0 +1,77 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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'));
}
}

View File

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

View File

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/5.2/phpunit.xsd"
backupGlobals="false"
colors="true"
bootstrap="vendor/autoload.php"
failOnRisky="true"
failOnWarning="true"
>
<php>
<ini name="error_reporting" value="-1" />
</php>
<testsuites>
<testsuite name="Symfony HttpClient Component Test Suite">
<directory>./Tests/</directory>
</testsuite>
</testsuites>
<filter>
<whitelist>
<directory>./</directory>
<exclude>
<directory>./Tests</directory>
<directory>./vendor</directory>
</exclude>
</whitelist>
</filter>
</phpunit>

View File

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

View File

@ -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'],

View File

@ -1,6 +1,11 @@
CHANGELOG
=========
1.1.0
-----
* added `HttpClient` namespace with contracts for implementing flexible HTTP clients
1.0.0
-----

View File

@ -0,0 +1,66 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
*
* @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;
}

View File

@ -0,0 +1,23 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
*
* @experimental in 1.1
*/
interface ClientExceptionInterface extends ExceptionInterface
{
}

View File

@ -0,0 +1,23 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
*
* @experimental in 1.1
*/
interface ExceptionInterface extends \Throwable
{
}

View File

@ -0,0 +1,23 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
*
* @experimental in 1.1
*/
interface RedirectionExceptionInterface extends ExceptionInterface
{
}

View File

@ -0,0 +1,23 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
*
* @experimental in 1.1
*/
interface ServerExceptionInterface extends ExceptionInterface
{
}

View File

@ -0,0 +1,23 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
*
* @experimental in 1.1
*/
interface TransportExceptionInterface extends ExceptionInterface
{
}

View File

@ -0,0 +1,87 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
*
* @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;
}

View File

@ -0,0 +1,88 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
*
* @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);
}

View File

@ -0,0 +1,26 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
*
* @experimental in 1.1
*/
interface ResponseStreamInterface extends \Iterator
{
public function key(): ResponseInterface;
public function current(): ChunkInterface;
}

View File

@ -0,0 +1,124 @@
<?php
if ('cli-server' !== \PHP_SAPI) {
// safe guard against unwanted execution
throw new \Exception("You cannot run this script directly, it's a fixture for TestHttpServer.");
}
$vars = [];
if (!$_POST) {
$_POST = json_decode(file_get_contents('php://input'), true);
$_POST['content-type'] = $_SERVER['HTTP_CONTENT_TYPE'] ?? '?';
}
foreach ($_SERVER as $k => $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);

View File

@ -0,0 +1,677 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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();
}
}

View File

@ -0,0 +1,54 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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);
}
}

View File

@ -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": ""
},