From d2d63a28e1e2ff7fa22423969382da5642c4b917 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Sun, 27 Jan 2019 21:00:39 +0100 Subject: [PATCH] [Contracts] introduce HttpClient contracts --- src/Symfony/Contracts/CHANGELOG.md | 5 + .../Contracts/HttpClient/ChunkInterface.php | 66 ++ .../Exception/ClientExceptionInterface.php | 23 + .../Exception/ExceptionInterface.php | 23 + .../RedirectionExceptionInterface.php | 23 + .../Exception/ServerExceptionInterface.php | 23 + .../Exception/TransportExceptionInterface.php | 23 + .../HttpClient/HttpClientInterface.php | 87 +++ .../HttpClient/ResponseInterface.php | 88 +++ .../HttpClient/ResponseStreamInterface.php | 26 + .../HttpClient/Test/Fixtures/web/index.php | 124 ++++ .../HttpClient/Test/HttpClientTestCase.php | 677 ++++++++++++++++++ .../HttpClient/Test/TestHttpServer.php | 54 ++ src/Symfony/Contracts/composer.json | 4 +- 14 files changed, 1245 insertions(+), 1 deletion(-) create mode 100644 src/Symfony/Contracts/HttpClient/ChunkInterface.php create mode 100644 src/Symfony/Contracts/HttpClient/Exception/ClientExceptionInterface.php create mode 100644 src/Symfony/Contracts/HttpClient/Exception/ExceptionInterface.php create mode 100644 src/Symfony/Contracts/HttpClient/Exception/RedirectionExceptionInterface.php create mode 100644 src/Symfony/Contracts/HttpClient/Exception/ServerExceptionInterface.php create mode 100644 src/Symfony/Contracts/HttpClient/Exception/TransportExceptionInterface.php create mode 100644 src/Symfony/Contracts/HttpClient/HttpClientInterface.php create mode 100644 src/Symfony/Contracts/HttpClient/ResponseInterface.php create mode 100644 src/Symfony/Contracts/HttpClient/ResponseStreamInterface.php create mode 100644 src/Symfony/Contracts/HttpClient/Test/Fixtures/web/index.php create mode 100644 src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php create mode 100644 src/Symfony/Contracts/HttpClient/Test/TestHttpServer.php diff --git a/src/Symfony/Contracts/CHANGELOG.md b/src/Symfony/Contracts/CHANGELOG.md index fba42d5954..b37bc2a705 100644 --- a/src/Symfony/Contracts/CHANGELOG.md +++ b/src/Symfony/Contracts/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +1.1.0 +----- + + * added `HttpClient` namespace with contracts for implementing flexible HTTP clients + 1.0.0 ----- diff --git a/src/Symfony/Contracts/HttpClient/ChunkInterface.php b/src/Symfony/Contracts/HttpClient/ChunkInterface.php new file mode 100644 index 0000000000..bbec2cdfa6 --- /dev/null +++ b/src/Symfony/Contracts/HttpClient/ChunkInterface.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Contracts\HttpClient; + +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; + +/** + * The interface of chunks returned by ResponseStreamInterface::current(). + * + * When the chunk is first, last or timeout, the content MUST be empty. + * When an unchecked timeout or a network error occurs, a TransportExceptionInterface + * MUST be thrown by the destructor unless one was already thrown by another method. + * + * @author Nicolas Grekas + * + * @experimental in 1.1 + */ +interface ChunkInterface +{ + /** + * Tells when the inactivity timeout has been reached. + * + * @throws TransportExceptionInterface on a network error + */ + public function isTimeout(): bool; + + /** + * Tells when headers just arrived. + * + * @throws TransportExceptionInterface on a network error or when the inactivity timeout is reached + */ + public function isFirst(): bool; + + /** + * Tells when the body just completed. + * + * @throws TransportExceptionInterface on a network error or when the inactivity timeout is reached + */ + public function isLast(): bool; + + /** + * Returns the content of the response chunk. + * + * @throws TransportExceptionInterface on a network error or when the inactivity timeout is reached + */ + public function getContent(): string; + + /** + * Returns the offset of the chunk in the response body. + */ + public function getOffset(): int; + + /** + * In case of error, returns the message that describes it. + */ + public function getError(): ?string; +} diff --git a/src/Symfony/Contracts/HttpClient/Exception/ClientExceptionInterface.php b/src/Symfony/Contracts/HttpClient/Exception/ClientExceptionInterface.php new file mode 100644 index 0000000000..a5f81dc146 --- /dev/null +++ b/src/Symfony/Contracts/HttpClient/Exception/ClientExceptionInterface.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Contracts\HttpClient\Exception; + +/** + * When a 4xx response is returned. + * + * @author Nicolas Grekas + * + * @experimental in 1.1 + */ +interface ClientExceptionInterface extends ExceptionInterface +{ +} diff --git a/src/Symfony/Contracts/HttpClient/Exception/ExceptionInterface.php b/src/Symfony/Contracts/HttpClient/Exception/ExceptionInterface.php new file mode 100644 index 0000000000..6d59715f70 --- /dev/null +++ b/src/Symfony/Contracts/HttpClient/Exception/ExceptionInterface.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Contracts\HttpClient\Exception; + +/** + * The base interface for all exceptions in the contract. + * + * @author Nicolas Grekas + * + * @experimental in 1.1 + */ +interface ExceptionInterface extends \Throwable +{ +} diff --git a/src/Symfony/Contracts/HttpClient/Exception/RedirectionExceptionInterface.php b/src/Symfony/Contracts/HttpClient/Exception/RedirectionExceptionInterface.php new file mode 100644 index 0000000000..208d692c62 --- /dev/null +++ b/src/Symfony/Contracts/HttpClient/Exception/RedirectionExceptionInterface.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Contracts\HttpClient\Exception; + +/** + * When a 3xx response is returned and the "max_redirects" option has been reached. + * + * @author Nicolas Grekas + * + * @experimental in 1.1 + */ +interface RedirectionExceptionInterface extends ExceptionInterface +{ +} diff --git a/src/Symfony/Contracts/HttpClient/Exception/ServerExceptionInterface.php b/src/Symfony/Contracts/HttpClient/Exception/ServerExceptionInterface.php new file mode 100644 index 0000000000..a1be7d4f7f --- /dev/null +++ b/src/Symfony/Contracts/HttpClient/Exception/ServerExceptionInterface.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Contracts\HttpClient\Exception; + +/** + * When a 5xx response is returned. + * + * @author Nicolas Grekas + * + * @experimental in 1.1 + */ +interface ServerExceptionInterface extends ExceptionInterface +{ +} diff --git a/src/Symfony/Contracts/HttpClient/Exception/TransportExceptionInterface.php b/src/Symfony/Contracts/HttpClient/Exception/TransportExceptionInterface.php new file mode 100644 index 0000000000..1cdc30a2fc --- /dev/null +++ b/src/Symfony/Contracts/HttpClient/Exception/TransportExceptionInterface.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Contracts\HttpClient\Exception; + +/** + * When any error happens at the transport level. + * + * @author Nicolas Grekas + * + * @experimental in 1.1 + */ +interface TransportExceptionInterface extends ExceptionInterface +{ +} diff --git a/src/Symfony/Contracts/HttpClient/HttpClientInterface.php b/src/Symfony/Contracts/HttpClient/HttpClientInterface.php new file mode 100644 index 0000000000..8d58e813e8 --- /dev/null +++ b/src/Symfony/Contracts/HttpClient/HttpClientInterface.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Contracts\HttpClient; + +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +use Symfony\Contracts\HttpClient\Test\HttpClientTestCase; + +/** + * Provides flexible methods for requesting HTTP resources synchronously or asynchronously. + * + * @see HttpClientTestCase for a reference test suite + * + * @author Nicolas Grekas + * + * @experimental in 1.1 + */ +interface HttpClientInterface +{ + public const OPTIONS_DEFAULTS = [ + 'auth' => null, // string - a username:password enabling HTTP Basic authentication + 'query' => [], // string[] - associative array of query string values to merge with the request's URL + 'headers' => [], // iterable|string[]|string[][] - headers names provided as keys or as part of values + 'body' => '', // array|string|resource|\Traversable|\Closure - the callback SHOULD yield a string + // smaller than the amount requested as argument; the empty string signals EOF; when + // an array is passed, it is meant as a form payload of field names and values + 'json' => null, // array|\JsonSerializable - when set, implementations MUST set the "body" option to + // the JSON-encoded value and set the "content-type" headers to a JSON-compatible + // value it is they are not defined - typically "application/json" + 'user_data' => null, // mixed - any extra data to attach to the request (scalar, callable, object...) that + // MUST be available via $response->getInfo('data') - not used internally + 'max_redirects' => 20, // int - the maximum number of redirects to follow; a value lower or equal to 0 means + // redirects should not be followed; "Authorization" and "Cookie" headers MUST + // NOT follow except for the initial host name + 'http_version' => null, // string - defaults to the best supported version, typically 1.1 or 2.0 + 'base_uri' => null, // string - the URI to resolve relative URLs, following rules in RFC 3986, section 2 + 'buffer' => true, // bool - whether the content of the response should be buffered or not + 'on_progress' => null, // callable(int $dlNow, int $dlSize, array $info) - throwing any exceptions MUST abort + // the request; it MUST be called on DNS resolution, on arrival of headers and on + // completion; it SHOULD be called on upload/download of data and at least 1/s + 'resolve' => [], // string[] - a map of host to IP address that SHOULD replace DNS resolution + 'proxy' => null, // string - by default, the proxy-related env vars handled by curl SHOULD be honored + 'no_proxy' => null, // string - a comma separated list of hosts that do not require a proxy to be reached + 'timeout' => null, // float - the inactivity timeout - defaults to ini_get('default_socket_timeout') + 'bindto' => '0', // string - the interface or the local socket to bind to + 'verify_peer' => true, // see https://php.net/context.ssl for the following options + 'verify_host' => true, + 'cafile' => null, + 'capath' => null, + 'local_cert' => null, + 'local_pk' => null, + 'passphrase' => null, + 'ciphers' => null, + 'peer_fingerprint' => null, + 'capture_peer_cert_chain' => false, + ]; + + /** + * Requests an HTTP resource. + * + * Responses MUST be lazy, but their status code MUST be + * checked even if none of their public methods are called. + * + * Implementations are not required to support all options described above; they can also + * support more custom options; but in any case, they MUST throw a TransportExceptionInterface + * when an unsupported option is passed. + * + * @throws TransportExceptionInterface When an unsupported option is passed + */ + public function request(string $method, string $url, array $options = []): ResponseInterface; + + /** + * Yields responses chunk by chunk as they complete. + * + * @param ResponseInterface|ResponseInterface[]|iterable $responses One or more responses created by the current HTTP client + * @param float|null $timeout The inactivity timeout before exiting the iterator + */ + public function stream($responses, float $timeout = null): ResponseStreamInterface; +} diff --git a/src/Symfony/Contracts/HttpClient/ResponseInterface.php b/src/Symfony/Contracts/HttpClient/ResponseInterface.php new file mode 100644 index 0000000000..7aeed6d510 --- /dev/null +++ b/src/Symfony/Contracts/HttpClient/ResponseInterface.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Contracts\HttpClient; + +use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\ExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; + +/** + * A (lazily retrieved) HTTP response. + * + * @author Nicolas Grekas + * + * @experimental in 1.1 + */ +interface ResponseInterface +{ + /** + * Gets the HTTP status code of the response. + * + * @throws TransportExceptionInterface when a network error occurs + */ + public function getStatusCode(): int; + + /** + * Gets the HTTP headers of the response. + * + * @param bool $throw Whether an exception should be thrown on 3/4/5xx status codes + * + * @return string[][] The headers of the response keyed by header names in lowercase + * + * @throws TransportExceptionInterface When a network error occurs + * @throws RedirectionExceptionInterface On a 3xx when $throw is true and the "max_redirects" option has been reached + * @throws ClientExceptionInterface On a 4xx when $throw is true + * @throws ServerExceptionInterface On a 5xx when $throw is true + */ + public function getHeaders(bool $throw = true): array; + + /** + * Gets the response body as a string. + * + * @param bool $throw Whether an exception should be thrown on 3/4/5xx status codes + * + * @throws TransportExceptionInterface When a network error occurs + * @throws RedirectionExceptionInterface On a 3xx when $throw is true and the "max_redirects" option has been reached + * @throws ClientExceptionInterface On a 4xx when $throw is true + * @throws ServerExceptionInterface On a 5xx when $throw is true + */ + public function getContent(bool $throw = true): string; + + /** + * Returns info coming from the transport layer. + * + * This method SHOULD NOT throw any ExceptionInterface and SHOULD be non-blocking. + * The returned info is "live": it can be empty and can change from one call to + * another, as the request/response progresses. + * + * The following info MUST be returned: + * - raw_headers - an array modelled after the special $http_response_header variable + * - redirect_count - the number of redirects followed while executing the request + * - redirect_url - the resolved location of redirect responses, null otherwise + * - start_time - the time when the request was sent or 0.0 when it's pending + * - http_code - the last response code or 0 when it is not known yet + * - error - the error message when the transfer was aborted, null otherwise + * - data - the value of the "data" request option, null if not set + * - url - the last effective URL of the request + * + * When the "capture_peer_cert_chain" option is true, the "peer_certificate_chain" + * attribute SHOULD list the peer certificates as an array of OpenSSL X.509 resources. + * + * Other info SHOULD be named after curl_getinfo()'s associative return value. + * + * @return array|mixed|null An array of all available info, or one of them when $type is + * provided, or null when an unsupported type is requested + */ + public function getInfo(string $type = null); +} diff --git a/src/Symfony/Contracts/HttpClient/ResponseStreamInterface.php b/src/Symfony/Contracts/HttpClient/ResponseStreamInterface.php new file mode 100644 index 0000000000..dfff554dfe --- /dev/null +++ b/src/Symfony/Contracts/HttpClient/ResponseStreamInterface.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Contracts\HttpClient; + +/** + * Yields response chunks, returned by HttpClientInterface::stream(). + * + * @author Nicolas Grekas + * + * @experimental in 1.1 + */ +interface ResponseStreamInterface extends \Iterator +{ + public function key(): ResponseInterface; + + public function current(): ChunkInterface; +} diff --git a/src/Symfony/Contracts/HttpClient/Test/Fixtures/web/index.php b/src/Symfony/Contracts/HttpClient/Test/Fixtures/web/index.php new file mode 100644 index 0000000000..ec15196c65 --- /dev/null +++ b/src/Symfony/Contracts/HttpClient/Test/Fixtures/web/index.php @@ -0,0 +1,124 @@ + $v) { + switch ($k) { + default: + if (0 !== strpos($k, 'HTTP_')) { + continue 2; + } + // no break + case 'SERVER_NAME': + case 'SERVER_PROTOCOL': + case 'REQUEST_URI': + case 'REQUEST_METHOD': + case 'PHP_AUTH_USER': + case 'PHP_AUTH_PW': + $vars[$k] = $v; + } +} + +switch ($vars['REQUEST_URI']) { + default: + exit; + + case '/': + case '/?a=a&b=b': + case 'http://127.0.0.1:8057/': + case 'http://localhost:8057/': + header('Content-Type: application/json'); + ob_start('ob_gzhandler'); + break; + + case '/404': + header('Content-Type: application/json', true, 404); + break; + + case '/301': + if ('Basic Zm9vOmJhcg==' === $vars['HTTP_AUTHORIZATION']) { + header('Location: http://127.0.0.1:8057/302', true, 301); + } + break; + + case '/301/bad-tld': + header('Location: http://foo.example.', true, 301); + break; + + case '/302': + if (!isset($vars['HTTP_AUTHORIZATION'])) { + header('Location: http://localhost:8057/', true, 302); + } + break; + + case '/302/relative': + header('Location: ..', true, 302); + break; + + case '/307': + header('Location: http://localhost:8057/post', true, 307); + break; + + case '/length-broken': + header('Content-Length: 1000'); + break; + + case '/post': + $output = json_encode($_POST + ['REQUEST_METHOD' => $vars['REQUEST_METHOD']], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + header('Content-Type: application/json', true); + header('Content-Length: '.\strlen($output)); + echo $output; + exit; + + case '/timeout-header': + usleep(300000); + break; + + case '/timeout-body': + echo '<1>'; + ob_flush(); + flush(); + usleep(500000); + echo '<2>'; + exit; + + case '/timeout-long': + ignore_user_abort(false); + sleep(1); + while (true) { + echo '<1>'; + ob_flush(); + flush(); + usleep(500); + } + exit; + + case '/chunked': + header('Transfer-Encoding: chunked'); + echo "8\r\nSymfony \r\n5\r\nis aw\r\n6\r\nesome!\r\n0\r\n\r\n"; + exit; + + case '/chunked-broken': + header('Transfer-Encoding: chunked'); + echo "8\r\nSymfony \r\n5\r\nis aw\r\n6\r\ne"; + exit; + + case '/gzip-broken': + header('Content-Encoding: gzip'); + echo str_repeat('-', 1000); + exit; +} + +header('Content-Type: application/json', true); + +echo json_encode($vars, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); diff --git a/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php b/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php new file mode 100644 index 0000000000..59703d0ffb --- /dev/null +++ b/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php @@ -0,0 +1,677 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Contracts\HttpClient\Test; + +use PHPUnit\Framework\TestCase; +use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * A reference test suite for HttpClientInterface implementations. + * + * @experimental in 1.1 + */ +abstract class HttpClientTestCase extends TestCase +{ + private static $server; + + public static function setUpBeforeClass() + { + TestHttpServer::start(); + } + + abstract protected function getHttpClient(): HttpClientInterface; + + public function testGetRequest() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://localhost:8057', [ + 'headers' => ['Foo' => 'baR'], + 'user_data' => $data = new \stdClass(), + ]); + + $this->assertSame([], $response->getInfo('raw_headers')); + $this->assertSame($data, $response->getInfo()['user_data']); + $this->assertSame(200, $response->getStatusCode()); + + $info = $response->getInfo(); + $this->assertNull($info['error']); + $this->assertSame(0, $info['redirect_count']); + $this->assertSame('HTTP/1.1 200 OK', $info['raw_headers'][0]); + $this->assertSame('Host: localhost:8057', $info['raw_headers'][1]); + $this->assertSame('http://localhost:8057/', $info['url']); + + $headers = $response->getHeaders(); + + $this->assertSame('localhost:8057', $headers['host'][0]); + $this->assertSame(['application/json'], $headers['content-type']); + + $body = json_decode($response->getContent(), true); + + $this->assertSame('HTTP/1.1', $body['SERVER_PROTOCOL']); + $this->assertSame('/', $body['REQUEST_URI']); + $this->assertSame('GET', $body['REQUEST_METHOD']); + $this->assertSame('localhost:8057', $body['HTTP_HOST']); + $this->assertSame('baR', $body['HTTP_FOO']); + + $response = $client->request('GET', 'http://localhost:8057/length-broken'); + + $this->expectException(TransportExceptionInterface::class); + $response->getContent(); + } + + public function testNonBufferedGetRequest() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://localhost:8057', [ + 'buffer' => false, + 'headers' => ['Foo' => 'baR'], + ]); + + $body = json_decode($response->getContent(), true); + $this->assertSame('baR', $body['HTTP_FOO']); + + $this->expectException(TransportExceptionInterface::class); + $response->getContent(); + } + + public function testUnsupportedOption() + { + $client = $this->getHttpClient(); + + $this->expectException(\InvalidArgumentException::class); + $client->request('GET', 'http://localhost:8057', [ + 'capture_peer_cert' => 1.0, + ]); + } + + public function testHttpVersion() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://localhost:8057', [ + 'http_version' => 1.0, + ]); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('HTTP/1.0 200 OK', $response->getInfo('raw_headers')[0]); + + $body = json_decode($response->getContent(), true); + + $this->assertSame('HTTP/1.0', $body['SERVER_PROTOCOL']); + $this->assertSame('GET', $body['REQUEST_METHOD']); + $this->assertSame('/', $body['REQUEST_URI']); + } + + public function testChunkedEncoding() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://localhost:8057/chunked'); + + $this->assertSame(['chunked'], $response->getHeaders()['transfer-encoding']); + $this->assertSame('Symfony is awesome!', $response->getContent()); + + $response = $client->request('GET', 'http://localhost:8057/chunked-broken'); + + $this->expectException(TransportExceptionInterface::class); + $response->getContent(); + } + + public function testClientError() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://localhost:8057/404'); + + $client->stream($response)->valid(); + + $this->assertSame(404, $response->getInfo('http_code')); + + try { + $response->getHeaders(); + $this->fail(ClientExceptionInterface::class.' expected'); + } catch (ClientExceptionInterface $e) { + } + + try { + $response->getContent(); + $this->fail(ClientExceptionInterface::class.' expected'); + } catch (ClientExceptionInterface $e) { + } + + $this->assertSame(404, $response->getStatusCode()); + $this->assertSame(['application/json'], $response->getHeaders(false)['content-type']); + $this->assertNotEmpty($response->getContent(false)); + } + + public function testIgnoreErrors() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://localhost:8057/404'); + + $this->assertSame(404, $response->getStatusCode()); + } + + public function testDnsError() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://localhost:8057/301/bad-tld'); + + try { + $response->getStatusCode(); + $this->fail(TransportExceptionInterface::class.' expected'); + } catch (TransportExceptionInterface $e) { + $this->addToAssertionCount(1); + } + + try { + $response->getStatusCode(); + $this->fail(TransportExceptionInterface::class.' still expected'); + } catch (TransportExceptionInterface $e) { + $this->addToAssertionCount(1); + } + + $response = $client->request('GET', 'http://localhost:8057/301/bad-tld'); + + try { + foreach ($client->stream($response) as $r => $chunk) { + } + $this->fail(TransportExceptionInterface::class.' expected'); + } catch (TransportExceptionInterface $e) { + $this->addToAssertionCount(1); + } + + $this->assertSame($response, $r); + $this->assertNotNull($chunk->getError()); + + foreach ($client->stream($response) as $chunk) { + $this->fail('Already errored responses shouldn\'t be yielded'); + } + } + + public function testInlineAuth() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://foo:bar%3Dbar@localhost:8057'); + + $body = json_decode($response->getContent(), true); + + $this->assertSame('foo', $body['PHP_AUTH_USER']); + $this->assertSame('bar=bar', $body['PHP_AUTH_PW']); + } + + public function testRedirects() + { + $client = $this->getHttpClient(); + $response = $client->request('POST', 'http://localhost:8057/301', [ + 'auth' => 'foo:bar', + 'body' => 'foo=bar', + ]); + + $body = json_decode($response->getContent(), true); + $this->assertSame('GET', $body['REQUEST_METHOD']); + $this->assertSame('Basic Zm9vOmJhcg==', $body['HTTP_AUTHORIZATION']); + $this->assertSame('http://localhost:8057/', $response->getInfo('url')); + + $this->assertSame(2, $response->getInfo('redirect_count')); + $this->assertNull($response->getInfo('redirect_url')); + + $expected = [ + 'HTTP/1.1 301 Moved Permanently', + 'Location: http://127.0.0.1:8057/302', + 'Content-Type: application/json', + 'HTTP/1.1 302 Found', + 'Location: http://localhost:8057/', + 'Content-Type: application/json', + 'HTTP/1.1 200 OK', + 'Content-Type: application/json', + ]; + + $filteredHeaders = array_intersect($expected, $response->getInfo('raw_headers')); + + $this->assertSame($expected, $filteredHeaders); + } + + public function testRelativeRedirects() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://localhost:8057/302/relative'); + + $body = json_decode($response->getContent(), true); + $this->assertSame('/', $body['REQUEST_URI']); + $this->assertNull($response->getInfo('redirect_url')); + + $response = $client->request('GET', 'http://localhost:8057/302/relative', [ + 'max_redirects' => 0, + ]); + + $this->assertSame(302, $response->getStatusCode()); + $this->assertSame('http://localhost:8057/', $response->getInfo('redirect_url')); + } + + public function testRedirect307() + { + $client = $this->getHttpClient(); + $response = $client->request('POST', 'http://localhost:8057/307', [ + 'body' => 'foo=bar', + ]); + + $body = json_decode($response->getContent(), true); + + $this->assertSame(['foo' => 'bar', 'REQUEST_METHOD' => 'POST'], $body); + } + + public function testMaxRedirects() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://localhost:8057/301', [ + 'max_redirects' => 1, + 'auth' => 'foo:bar', + ]); + + try { + $response->getHeaders(); + $this->fail(RedirectionExceptionInterface::class.' expected'); + } catch (RedirectionExceptionInterface $e) { + } + + $this->assertSame(302, $response->getStatusCode()); + $this->assertSame(1, $response->getInfo('redirect_count')); + $this->assertSame('http://localhost:8057/', $response->getInfo('redirect_url')); + + $expected = [ + 'HTTP/1.1 301 Moved Permanently', + 'Location: http://127.0.0.1:8057/302', + 'Content-Type: application/json', + 'HTTP/1.1 302 Found', + 'Location: http://localhost:8057/', + 'Content-Type: application/json', + ]; + + $filteredHeaders = array_intersect($expected, $response->getInfo('raw_headers')); + + $this->assertSame($expected, $filteredHeaders); + } + + public function testStream() + { + $client = $this->getHttpClient(); + + $response = $client->request('GET', 'http://localhost:8057'); + $chunks = $client->stream($response); + $result = []; + + foreach ($chunks as $r => $chunk) { + if ($chunk->isTimeout()) { + $result[] = 't'; + } elseif ($chunk->isLast()) { + $result[] = 'l'; + } elseif ($chunk->isFirst()) { + $result[] = 'f'; + } + } + + $this->assertSame($response, $r); + $this->assertSame(['f', 'l'], $result); + } + + public function testAddToStream() + { + $client = $this->getHttpClient(); + + $r1 = $client->request('GET', 'http://localhost:8057'); + + $completed = []; + + $pool = [$r1]; + + while ($pool) { + $chunks = $client->stream($pool); + $pool = []; + + foreach ($chunks as $r => $chunk) { + if (!$chunk->isLast()) { + continue; + } + + if ($r1 === $r) { + $r2 = $client->request('GET', 'http://localhost:8057'); + $pool[] = $r2; + } + + $completed[] = $r; + } + } + + $this->assertSame([$r1, $r2], $completed); + } + + public function testCompleteTypeError() + { + $client = $this->getHttpClient(); + + $this->expectException(\TypeError::class); + $client->stream(123); + } + + public function testOnProgress() + { + $client = $this->getHttpClient(); + $response = $client->request('POST', 'http://localhost:8057/post', [ + 'headers' => ['Content-Length' => 14], + 'body' => 'foo=0123456789', + 'on_progress' => function (...$state) use (&$steps) { $steps[] = $state; }, + ]); + + $body = json_decode($response->getContent(), true); + + $this->assertSame(['foo' => '0123456789', 'REQUEST_METHOD' => 'POST'], $body); + $this->assertSame([0, 0], \array_slice($steps[0], 0, 2)); + $lastStep = \array_slice($steps, -1)[0]; + $this->assertSame([57, 57], \array_slice($lastStep, 0, 2)); + $this->assertSame('http://localhost:8057/post', $steps[0][2]['url']); + } + + public function testPostArray() + { + $client = $this->getHttpClient(); + + $response = $client->request('POST', 'http://localhost:8057/post', [ + 'body' => ['foo' => 'bar'], + ]); + + $this->assertSame(['foo' => 'bar', 'REQUEST_METHOD' => 'POST'], json_decode($response->getContent(), true)); + } + + public function testPostResource() + { + $client = $this->getHttpClient(); + + $h = fopen('php://temp', 'w+'); + fwrite($h, 'foo=0123456789'); + rewind($h); + + $response = $client->request('POST', 'http://localhost:8057/post', [ + 'body' => $h, + ]); + + $body = json_decode($response->getContent(), true); + + $this->assertSame(['foo' => '0123456789', 'REQUEST_METHOD' => 'POST'], $body); + } + + public function testPostCallback() + { + $client = $this->getHttpClient(); + + $response = $client->request('POST', 'http://localhost:8057/post', [ + 'body' => function () { + yield 'foo'; + yield '='; + yield '0123456789'; + }, + ]); + + $this->assertSame(['foo' => '0123456789', 'REQUEST_METHOD' => 'POST'], json_decode($response->getContent(), true)); + } + + public function testOnProgressCancel() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://localhost:8057/timeout-body', [ + 'on_progress' => function ($dlNow) { + if (0 < $dlNow) { + throw new \Exception('Aborting the request'); + } + }, + ]); + + try { + foreach ($client->stream([$response]) as $chunk) { + } + $this->fail(ClientExceptionInterface::class.' expected'); + } catch (TransportExceptionInterface $e) { + $this->assertSame('Aborting the request', $e->getPrevious()->getMessage()); + } + + $this->assertNotNull($response->getInfo('error')); + $this->expectException(TransportExceptionInterface::class); + $response->getContent(); + } + + public function testOnProgressError() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://localhost:8057/timeout-body', [ + 'on_progress' => function ($dlNow) { + if (0 < $dlNow) { + throw new \Error('BUG'); + } + }, + ]); + + try { + foreach ($client->stream([$response]) as $chunk) { + } + $this->fail('Error expected'); + } catch (\Error $e) { + $this->assertSame('BUG', $e->getMessage()); + } + + $this->assertNotNull($response->getInfo('error')); + $this->expectException(TransportExceptionInterface::class); + $response->getContent(); + } + + public function testResolve() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://symfony.com:8057/', [ + 'resolve' => ['symfony.com' => '127.0.0.1'], + ]); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(200, $client->request('GET', 'http://symfony.com:8057/')->getStatusCode()); + + $response = null; + $this->expectException(TransportExceptionInterface::class); + $client->request('GET', 'http://symfony.com:8057/'); + } + + public function testTimeoutOnAccess() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://localhost:8057/timeout-header', [ + 'timeout' => 0.1, + ]); + + $this->expectException(TransportExceptionInterface::class); + $response->getHeaders(); + } + + public function testTimeoutOnStream() + { + usleep(300000); // wait for the previous test to release the server + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://localhost:8057/timeout-body'); + + $this->assertSame(200, $response->getStatusCode()); + $chunks = $client->stream([$response], 0.2); + + $result = []; + + foreach ($chunks as $r => $chunk) { + if ($chunk->isTimeout()) { + $result[] = 't'; + } else { + $result[] = $chunk->getContent(); + } + } + + $this->assertSame(['<1>', 't'], $result); + + $chunks = $client->stream([$response]); + + foreach ($chunks as $r => $chunk) { + $this->assertSame('<2>', $chunk->getContent()); + $this->assertSame('<1><2>', $r->getContent()); + + return; + } + + $this->fail('The response should have completed'); + } + + public function testUncheckedTimeoutThrows() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://localhost:8057/timeout-body'); + $chunks = $client->stream([$response], 0.1); + + $this->expectException(TransportExceptionInterface::class); + + foreach ($chunks as $r => $chunk) { + } + } + + public function testDestruct() + { + $client = $this->getHttpClient(); + + $downloaded = 0; + $start = microtime(true); + $client->request('GET', 'http://localhost:8057/timeout-long'); + $client = null; + $duration = microtime(true) - $start; + + $this->assertGreaterThan(1, $duration); + $this->assertLessThan(3, $duration); + } + + public function testProxy() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://localhost:8057/', [ + 'proxy' => 'http://localhost:8057', + ]); + + $body = json_decode($response->getContent(), true); + $this->assertSame('localhost:8057', $body['HTTP_HOST']); + $this->assertRegexp('#^http://(localhost|127\.0\.0\.1):8057/$#', $body['REQUEST_URI']); + + $response = $client->request('GET', 'http://localhost:8057/', [ + 'proxy' => 'http://foo:b%3Dar@localhost:8057', + ]); + + $body = json_decode($response->getContent(), true); + $this->assertSame('Basic Zm9vOmI9YXI=', $body['HTTP_PROXY_AUTHORIZATION']); + } + + public function testNoProxy() + { + putenv('no_proxy='.$_SERVER['no_proxy'] = 'example.com, localhost'); + + try { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://localhost:8057/', [ + 'proxy' => 'http://localhost:8057', + ]); + + $body = json_decode($response->getContent(), true); + + $this->assertSame('HTTP/1.1', $body['SERVER_PROTOCOL']); + $this->assertSame('/', $body['REQUEST_URI']); + $this->assertSame('GET', $body['REQUEST_METHOD']); + } finally { + putenv('no_proxy'); + unset($_SERVER['no_proxy']); + } + } + + /** + * @requires extension zlib + */ + public function testAutoEncodingRequest() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://localhost:8057'); + + $this->assertSame(200, $response->getStatusCode()); + + $headers = $response->getHeaders(); + + $this->assertSame(['Accept-Encoding'], $headers['vary']); + $this->assertContains('gzip', $headers['content-encoding'][0]); + + $body = json_decode($response->getContent(), true); + + $this->assertContains('gzip', $body['HTTP_ACCEPT_ENCODING']); + } + + public function testBaseUri() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', '../404', [ + 'base_uri' => 'http://localhost:8057/abc/', + ]); + + $this->assertSame(404, $response->getStatusCode()); + $this->assertSame(['application/json'], $response->getHeaders(false)['content-type']); + } + + public function testQuery() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://localhost:8057/?a=a', [ + 'query' => ['b' => 'b'], + ]); + + $body = json_decode($response->getContent(), true); + $this->assertSame('GET', $body['REQUEST_METHOD']); + $this->assertSame('/?a=a&b=b', $body['REQUEST_URI']); + } + + /** + * @requires extension zlib + */ + public function testUserlandEncodingRequest() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://localhost:8057', [ + 'headers' => ['Accept-Encoding' => 'gzip'], + ]); + + $headers = $response->getHeaders(); + + $this->assertSame(['Accept-Encoding'], $headers['vary']); + $this->assertContains('gzip', $headers['content-encoding'][0]); + + $body = $response->getContent(); + + $this->assertSame("\x1F", $body[0]); + $body = json_decode(gzdecode($body), true); + + $this->assertSame('gzip', $body['HTTP_ACCEPT_ENCODING']); + } + + /** + * @requires extension zlib + */ + public function testGzipBroken() + { + $client = $this->getHttpClient(); + $response = $client->request('GET', 'http://localhost:8057/gzip-broken'); + + $this->expectException(TransportExceptionInterface::class); + $response->getContent(); + } +} diff --git a/src/Symfony/Contracts/HttpClient/Test/TestHttpServer.php b/src/Symfony/Contracts/HttpClient/Test/TestHttpServer.php new file mode 100644 index 0000000000..5b05f50562 --- /dev/null +++ b/src/Symfony/Contracts/HttpClient/Test/TestHttpServer.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Contracts\HttpClient\Test; + +use Symfony\Component\Process\PhpExecutableFinder; +use Symfony\Component\Process\Process; + +/** + * @experimental in 1.1 + */ +class TestHttpServer +{ + private static $server; + + public static function start() + { + if (null !== self::$server) { + return; + } + + $spec = [ + 1 => ['file', '\\' === \DIRECTORY_SEPARATOR ? 'NUL' : '/dev/null', 'w'], + 2 => ['file', '\\' === \DIRECTORY_SEPARATOR ? 'NUL' : '/dev/null', 'w'], + ]; + + $finder = new PhpExecutableFinder(); + $process = new Process(array_merge([$finder->find(false)], $finder->findArguments(), ['-dopcache.enable=0', '-dvariables_order=EGPCS', '-S', '127.0.0.1:8057'])); + $process->setWorkingDirectory(__DIR__.'/Fixtures/web'); + $process->setTimeout(300); + $process->start(); + + self::$server = new class() { + public $process; + + public function __destruct() + { + $this->process->stop(); + } + }; + + self::$server->process = $process; + + sleep('\\' === \DIRECTORY_SEPARATOR ? 10 : 1); + } +} diff --git a/src/Symfony/Contracts/composer.json b/src/Symfony/Contracts/composer.json index b01744783f..f881258dc1 100644 --- a/src/Symfony/Contracts/composer.json +++ b/src/Symfony/Contracts/composer.json @@ -20,12 +20,14 @@ }, "require-dev": { "psr/cache": "^1.0", - "psr/container": "^1.0" + "psr/container": "^1.0", + "symfony/polyfill-intl-idn": "^1.10" }, "suggest": { "psr/cache": "When using the Cache contracts", "psr/container": "When using the Service contracts", "symfony/cache-contracts-implementation": "", + "symfony/http-client-contracts-implementation": "", "symfony/service-contracts-implementation": "", "symfony/translation-contracts-implementation": "" },