├── CHANGELOG.md ├── ChunkInterface.php ├── Exception ├── ClientExceptionInterface.php ├── DecodingExceptionInterface.php ├── ExceptionInterface.php ├── HttpExceptionInterface.php ├── RedirectionExceptionInterface.php ├── ServerExceptionInterface.php ├── TimeoutExceptionInterface.php └── TransportExceptionInterface.php ├── HttpClientInterface.php ├── LICENSE ├── README.md ├── ResponseInterface.php ├── ResponseStreamInterface.php ├── Test ├── Fixtures │ └── web │ │ └── index.php ├── HttpClientTestCase.php └── TestHttpServer.php └── composer.json /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | The changelog is maintained for all Symfony contracts at the following URL: 5 | https://github.com/symfony/contracts/blob/main/CHANGELOG.md 6 | -------------------------------------------------------------------------------- /ChunkInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\HttpClient; 13 | 14 | use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; 15 | 16 | /** 17 | * The interface of chunks returned by ResponseStreamInterface::current(). 18 | * 19 | * When the chunk is first, last or timeout, the content MUST be empty. 20 | * When an unchecked timeout or a network error occurs, a TransportExceptionInterface 21 | * MUST be thrown by the destructor unless one was already thrown by another method. 22 | * 23 | * @author Nicolas Grekas 24 | */ 25 | interface ChunkInterface 26 | { 27 | /** 28 | * Tells when the idle timeout has been reached. 29 | * 30 | * @throws TransportExceptionInterface on a network error 31 | */ 32 | public function isTimeout(): bool; 33 | 34 | /** 35 | * Tells when headers just arrived. 36 | * 37 | * @throws TransportExceptionInterface on a network error or when the idle timeout is reached 38 | */ 39 | public function isFirst(): bool; 40 | 41 | /** 42 | * Tells when the body just completed. 43 | * 44 | * @throws TransportExceptionInterface on a network error or when the idle timeout is reached 45 | */ 46 | public function isLast(): bool; 47 | 48 | /** 49 | * Returns a [status code, headers] tuple when a 1xx status code was just received. 50 | * 51 | * @throws TransportExceptionInterface on a network error or when the idle timeout is reached 52 | */ 53 | public function getInformationalStatus(): ?array; 54 | 55 | /** 56 | * Returns the content of the response chunk. 57 | * 58 | * @throws TransportExceptionInterface on a network error or when the idle timeout is reached 59 | */ 60 | public function getContent(): string; 61 | 62 | /** 63 | * Returns the offset of the chunk in the response body. 64 | */ 65 | public function getOffset(): int; 66 | 67 | /** 68 | * In case of error, returns the message that describes it. 69 | */ 70 | public function getError(): ?string; 71 | } 72 | -------------------------------------------------------------------------------- /Exception/ClientExceptionInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\HttpClient\Exception; 13 | 14 | /** 15 | * When a 4xx response is returned. 16 | * 17 | * @author Nicolas Grekas 18 | */ 19 | interface ClientExceptionInterface extends HttpExceptionInterface 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /Exception/DecodingExceptionInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\HttpClient\Exception; 13 | 14 | /** 15 | * When a content-type cannot be decoded to the expected representation. 16 | * 17 | * @author Nicolas Grekas 18 | */ 19 | interface DecodingExceptionInterface extends ExceptionInterface 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /Exception/ExceptionInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\HttpClient\Exception; 13 | 14 | /** 15 | * The base interface for all exceptions in the contract. 16 | * 17 | * @author Nicolas Grekas 18 | */ 19 | interface ExceptionInterface extends \Throwable 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /Exception/HttpExceptionInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\HttpClient\Exception; 13 | 14 | use Symfony\Contracts\HttpClient\ResponseInterface; 15 | 16 | /** 17 | * Base interface for HTTP-related exceptions. 18 | * 19 | * @author Anton Chernikov 20 | */ 21 | interface HttpExceptionInterface extends ExceptionInterface 22 | { 23 | public function getResponse(): ResponseInterface; 24 | } 25 | -------------------------------------------------------------------------------- /Exception/RedirectionExceptionInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\HttpClient\Exception; 13 | 14 | /** 15 | * When a 3xx response is returned and the "max_redirects" option has been reached. 16 | * 17 | * @author Nicolas Grekas 18 | */ 19 | interface RedirectionExceptionInterface extends HttpExceptionInterface 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /Exception/ServerExceptionInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\HttpClient\Exception; 13 | 14 | /** 15 | * When a 5xx response is returned. 16 | * 17 | * @author Nicolas Grekas 18 | */ 19 | interface ServerExceptionInterface extends HttpExceptionInterface 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /Exception/TimeoutExceptionInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\HttpClient\Exception; 13 | 14 | /** 15 | * When an idle timeout occurs. 16 | * 17 | * @author Nicolas Grekas 18 | */ 19 | interface TimeoutExceptionInterface extends TransportExceptionInterface 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /Exception/TransportExceptionInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\HttpClient\Exception; 13 | 14 | /** 15 | * When any error happens at the transport level. 16 | * 17 | * @author Nicolas Grekas 18 | */ 19 | interface TransportExceptionInterface extends ExceptionInterface 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /HttpClientInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\HttpClient; 13 | 14 | use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; 15 | use Symfony\Contracts\HttpClient\Test\HttpClientTestCase; 16 | 17 | /** 18 | * Provides flexible methods for requesting HTTP resources synchronously or asynchronously. 19 | * 20 | * @see HttpClientTestCase for a reference test suite 21 | * 22 | * @author Nicolas Grekas 23 | */ 24 | interface HttpClientInterface 25 | { 26 | public const OPTIONS_DEFAULTS = [ 27 | 'auth_basic' => null, // array|string - an array containing the username as first value, and optionally the 28 | // password as the second one; or string like username:password - enabling HTTP Basic 29 | // authentication (RFC 7617) 30 | 'auth_bearer' => null, // string - a token enabling HTTP Bearer authorization (RFC 6750) 31 | 'query' => [], // string[] - associative array of query string values to merge with the request's URL 32 | 'headers' => [], // iterable|string[]|string[][] - headers names provided as keys or as part of values 33 | 'body' => '', // array|string|resource|\Traversable|\Closure - the callback SHOULD yield a string 34 | // smaller than the amount requested as argument; the empty string signals EOF; if 35 | // an array is passed, it is meant as a form payload of field names and values 36 | 'json' => null, // mixed - if set, implementations MUST set the "body" option to the JSON-encoded 37 | // value and set the "content-type" header to a JSON-compatible value if it is not 38 | // explicitly defined in the headers option - typically "application/json" 39 | 'user_data' => null, // mixed - any extra data to attach to the request (scalar, callable, object...) that 40 | // MUST be available via $response->getInfo('user_data') - not used internally 41 | 'max_redirects' => 20, // int - the maximum number of redirects to follow; a value lower than or equal to 0 42 | // means redirects should not be followed; "Authorization" and "Cookie" headers MUST 43 | // NOT follow except for the initial host name 44 | 'http_version' => null, // string - defaults to the best supported version, typically 1.1 or 2.0 45 | 'base_uri' => null, // string - the URI to resolve relative URLs, following rules in RFC 3986, section 2 46 | 'buffer' => true, // bool|resource|\Closure - whether the content of the response should be buffered or not, 47 | // or a stream resource where the response body should be written, 48 | // or a closure telling if/where the response should be buffered based on its headers 49 | 'on_progress' => null, // callable(int $dlNow, int $dlSize, array $info) - throwing any exceptions MUST abort the 50 | // request; it MUST be called on connection, on headers and on completion; it SHOULD be 51 | // called on upload/download of data and at least 1/s 52 | 'resolve' => [], // string[] - a map of host to IP address that SHOULD replace DNS resolution 53 | 'proxy' => null, // string - by default, the proxy-related env vars handled by curl SHOULD be honored 54 | 'no_proxy' => null, // string - a comma separated list of hosts that do not require a proxy to be reached 55 | 'timeout' => null, // float - the idle timeout (in seconds) - defaults to ini_get('default_socket_timeout') 56 | 'max_duration' => 0, // float - the maximum execution time (in seconds) for the request+response as a whole; 57 | // a value lower than or equal to 0 means it is unlimited 58 | 'bindto' => '0', // string - the interface or the local socket to bind to 59 | 'verify_peer' => true, // see https://php.net/context.ssl for the following options 60 | 'verify_host' => true, 61 | 'cafile' => null, 62 | 'capath' => null, 63 | 'local_cert' => null, 64 | 'local_pk' => null, 65 | 'passphrase' => null, 66 | 'ciphers' => null, 67 | 'peer_fingerprint' => null, 68 | 'capture_peer_cert_chain' => false, 69 | 'crypto_method' => \STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT, // STREAM_CRYPTO_METHOD_TLSv*_CLIENT - minimum TLS version 70 | 'extra' => [], // array - additional options that can be ignored if unsupported, unlike regular options 71 | ]; 72 | 73 | /** 74 | * Requests an HTTP resource. 75 | * 76 | * Responses MUST be lazy, but their status code MUST be 77 | * checked even if none of their public methods are called. 78 | * 79 | * Implementations are not required to support all options described above; they can also 80 | * support more custom options; but in any case, they MUST throw a TransportExceptionInterface 81 | * when an unsupported option is passed. 82 | * 83 | * @throws TransportExceptionInterface When an unsupported option is passed 84 | */ 85 | public function request(string $method, string $url, array $options = []): ResponseInterface; 86 | 87 | /** 88 | * Yields responses chunk by chunk as they complete. 89 | * 90 | * @param ResponseInterface|iterable $responses One or more responses created by the current HTTP client 91 | * @param float|null $timeout The idle timeout before yielding timeout chunks 92 | */ 93 | public function stream(ResponseInterface|iterable $responses, ?float $timeout = null): ResponseStreamInterface; 94 | 95 | /** 96 | * Returns a new instance of the client with new default options. 97 | */ 98 | public function withOptions(array $options): static; 99 | } 100 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018-present Fabien Potencier 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Symfony HttpClient Contracts 2 | ============================ 3 | 4 | A set of abstractions extracted out of the Symfony components. 5 | 6 | Can be used to build on semantics that the Symfony components proved useful and 7 | that already have battle tested implementations. 8 | 9 | See https://github.com/symfony/contracts/blob/main/README.md for more information. 10 | -------------------------------------------------------------------------------- /ResponseInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\HttpClient; 13 | 14 | use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; 15 | use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; 16 | use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; 17 | use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; 18 | use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; 19 | 20 | /** 21 | * A (lazily retrieved) HTTP response. 22 | * 23 | * @author Nicolas Grekas 24 | */ 25 | interface ResponseInterface 26 | { 27 | /** 28 | * Gets the HTTP status code of the response. 29 | * 30 | * @throws TransportExceptionInterface when a network error occurs 31 | */ 32 | public function getStatusCode(): int; 33 | 34 | /** 35 | * Gets the HTTP headers of the response. 36 | * 37 | * @param bool $throw Whether an exception should be thrown on 3/4/5xx status codes 38 | * 39 | * @return array> The headers of the response keyed by header names in lowercase 40 | * 41 | * @throws TransportExceptionInterface When a network error occurs 42 | * @throws RedirectionExceptionInterface On a 3xx when $throw is true and the "max_redirects" option has been reached 43 | * @throws ClientExceptionInterface On a 4xx when $throw is true 44 | * @throws ServerExceptionInterface On a 5xx when $throw is true 45 | */ 46 | public function getHeaders(bool $throw = true): array; 47 | 48 | /** 49 | * Gets the response body as a string. 50 | * 51 | * @param bool $throw Whether an exception should be thrown on 3/4/5xx status codes 52 | * 53 | * @throws TransportExceptionInterface When a network error occurs 54 | * @throws RedirectionExceptionInterface On a 3xx when $throw is true and the "max_redirects" option has been reached 55 | * @throws ClientExceptionInterface On a 4xx when $throw is true 56 | * @throws ServerExceptionInterface On a 5xx when $throw is true 57 | */ 58 | public function getContent(bool $throw = true): string; 59 | 60 | /** 61 | * Gets the response body decoded as array, typically from a JSON payload. 62 | * 63 | * @param bool $throw Whether an exception should be thrown on 3/4/5xx status codes 64 | * 65 | * @throws DecodingExceptionInterface When the body cannot be decoded to an array 66 | * @throws TransportExceptionInterface When a network error occurs 67 | * @throws RedirectionExceptionInterface On a 3xx when $throw is true and the "max_redirects" option has been reached 68 | * @throws ClientExceptionInterface On a 4xx when $throw is true 69 | * @throws ServerExceptionInterface On a 5xx when $throw is true 70 | */ 71 | public function toArray(bool $throw = true): array; 72 | 73 | /** 74 | * Closes the response stream and all related buffers. 75 | * 76 | * No further chunk will be yielded after this method has been called. 77 | */ 78 | public function cancel(): void; 79 | 80 | /** 81 | * Returns info coming from the transport layer. 82 | * 83 | * This method SHOULD NOT throw any ExceptionInterface and SHOULD be non-blocking. 84 | * The returned info is "live": it can be empty and can change from one call to 85 | * another, as the request/response progresses. 86 | * 87 | * The following info MUST be returned: 88 | * - canceled (bool) - true if the response was canceled using ResponseInterface::cancel(), false otherwise 89 | * - error (string|null) - the error message when the transfer was aborted, null otherwise 90 | * - http_code (int) - the last response code or 0 when it is not known yet 91 | * - http_method (string) - the HTTP verb of the last request 92 | * - redirect_count (int) - the number of redirects followed while executing the request 93 | * - redirect_url (string|null) - the resolved location of redirect responses, null otherwise 94 | * - response_headers (array) - an array modelled after the special $http_response_header variable 95 | * - start_time (float) - the time when the request was sent or 0.0 when it's pending 96 | * - url (string) - the last effective URL of the request 97 | * - user_data (mixed) - the value of the "user_data" request option, null if not set 98 | * 99 | * When the "capture_peer_cert_chain" option is true, the "peer_certificate_chain" 100 | * attribute SHOULD list the peer certificates as an array of OpenSSL X.509 resources. 101 | * 102 | * Other info SHOULD be named after curl_getinfo()'s associative return value. 103 | * 104 | * @return mixed An array of all available info, or one of them when $type is 105 | * provided, or null when an unsupported type is requested 106 | */ 107 | public function getInfo(?string $type = null): mixed; 108 | } 109 | -------------------------------------------------------------------------------- /ResponseStreamInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\HttpClient; 13 | 14 | /** 15 | * Yields response chunks, returned by HttpClientInterface::stream(). 16 | * 17 | * @author Nicolas Grekas 18 | * 19 | * @extends \Iterator 20 | */ 21 | interface ResponseStreamInterface extends \Iterator 22 | { 23 | public function key(): ResponseInterface; 24 | 25 | public function current(): ChunkInterface; 26 | } 27 | -------------------------------------------------------------------------------- /Test/Fixtures/web/index.php: -------------------------------------------------------------------------------- 1 | $v) { 33 | if (str_starts_with($k, 'HTTP_')) { 34 | $vars[$k] = $v; 35 | } 36 | } 37 | 38 | $json = json_encode($vars, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE); 39 | 40 | switch (parse_url($vars['REQUEST_URI'], \PHP_URL_PATH)) { 41 | default: 42 | exit; 43 | 44 | case '/head': 45 | header('X-Request-Vars: '.json_encode($vars, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE)); 46 | header('Content-Length: '.strlen($json), true); 47 | break; 48 | 49 | case '/': 50 | case '/?a=a&b=b': 51 | case 'http://127.0.0.1:8057/': 52 | case 'http://localhost:8057/': 53 | ob_start('ob_gzhandler'); 54 | break; 55 | 56 | case '/103': 57 | header('HTTP/1.1 103 Early Hints'); 58 | header('Link: ; rel=preload; as=style', false); 59 | header('Link: ; rel=preload; as=script', false); 60 | flush(); 61 | usleep(1000); 62 | echo "HTTP/1.1 200 OK\r\n"; 63 | echo "Date: Fri, 26 May 2017 10:02:11 GMT\r\n"; 64 | echo "Content-Length: 13\r\n"; 65 | echo "\r\n"; 66 | echo 'Here the body'; 67 | exit; 68 | 69 | case '/404': 70 | header('Content-Type: application/json', true, 404); 71 | break; 72 | 73 | case '/404-gzipped': 74 | header('Content-Type: text/plain', true, 404); 75 | ob_start('ob_gzhandler'); 76 | @ob_flush(); 77 | flush(); 78 | usleep(300000); 79 | echo 'some text'; 80 | exit; 81 | 82 | case '/301': 83 | if ('Basic Zm9vOmJhcg==' === $vars['HTTP_AUTHORIZATION']) { 84 | header('Location: http://127.0.0.1:8057/302', true, 301); 85 | } 86 | break; 87 | 88 | case '/301/bad-tld': 89 | header('Location: http://foo.example.', true, 301); 90 | break; 91 | 92 | case '/301/invalid': 93 | header('Location: //?foo=bar', true, 301); 94 | break; 95 | 96 | case '/301/proxy': 97 | case 'http://localhost:8057/301/proxy': 98 | case 'http://127.0.0.1:8057/301/proxy': 99 | header('Location: http://localhost:8057/', true, 301); 100 | break; 101 | 102 | case '/302': 103 | if (!isset($vars['HTTP_AUTHORIZATION'])) { 104 | $location = $_GET['location'] ?? 'http://localhost:8057/'; 105 | header('Location: '.$location, true, 302); 106 | } 107 | break; 108 | 109 | case '/302/relative': 110 | header('Location: ..', true, 302); 111 | break; 112 | 113 | case '/304': 114 | header('Content-Length: 10', true, 304); 115 | echo '12345'; 116 | 117 | return; 118 | 119 | case '/307': 120 | header('Location: http://localhost:8057/post', true, 307); 121 | break; 122 | 123 | case '/length-broken': 124 | header('Content-Length: 1000'); 125 | break; 126 | 127 | case '/post': 128 | $output = json_encode($_POST + ['REQUEST_METHOD' => $vars['REQUEST_METHOD']], \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE); 129 | header('Content-Type: application/json', true); 130 | header('Content-Length: '.strlen($output)); 131 | echo $output; 132 | exit; 133 | 134 | case '/timeout-header': 135 | usleep(300000); 136 | break; 137 | 138 | case '/timeout-body': 139 | echo '<1>'; 140 | @ob_flush(); 141 | flush(); 142 | usleep(500000); 143 | echo '<2>'; 144 | exit; 145 | 146 | case '/timeout-long': 147 | ignore_user_abort(false); 148 | sleep(1); 149 | while (true) { 150 | echo '<1>'; 151 | @ob_flush(); 152 | flush(); 153 | usleep(500); 154 | } 155 | exit; 156 | 157 | case '/chunked': 158 | header('Transfer-Encoding: chunked'); 159 | echo "8\r\nSymfony \r\n5\r\nis aw\r\n6\r\nesome!\r\n0\r\n\r\n"; 160 | exit; 161 | 162 | case '/chunked-broken': 163 | header('Transfer-Encoding: chunked'); 164 | echo "8\r\nSymfony \r\n5\r\nis aw\r\n6\r\ne"; 165 | exit; 166 | 167 | case '/gzip-broken': 168 | header('Content-Encoding: gzip'); 169 | echo str_repeat('-', 1000); 170 | exit; 171 | 172 | case '/max-duration': 173 | ignore_user_abort(false); 174 | while (true) { 175 | echo '<1>'; 176 | @ob_flush(); 177 | flush(); 178 | usleep(500); 179 | } 180 | exit; 181 | 182 | case '/json': 183 | header('Content-Type: application/json'); 184 | echo json_encode([ 185 | 'documents' => [ 186 | ['id' => '/json/1'], 187 | ['id' => '/json/2'], 188 | ['id' => '/json/3'], 189 | ], 190 | ]); 191 | exit; 192 | 193 | case '/json/1': 194 | case '/json/2': 195 | case '/json/3': 196 | header('Content-Type: application/json'); 197 | echo json_encode([ 198 | 'title' => $vars['REQUEST_URI'], 199 | ]); 200 | 201 | exit; 202 | 203 | case '/custom': 204 | if (isset($_GET['status'])) { 205 | http_response_code((int) $_GET['status']); 206 | } 207 | if (isset($_GET['headers']) && is_array($_GET['headers'])) { 208 | foreach ($_GET['headers'] as $header) { 209 | header($header); 210 | } 211 | } 212 | } 213 | 214 | header('Content-Type: application/json', true); 215 | 216 | echo $json; 217 | -------------------------------------------------------------------------------- /Test/HttpClientTestCase.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\HttpClient\Test; 13 | 14 | use PHPUnit\Framework\Attributes\RequiresPhpExtension; 15 | use PHPUnit\Framework\TestCase; 16 | use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; 17 | use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; 18 | use Symfony\Contracts\HttpClient\Exception\TimeoutExceptionInterface; 19 | use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; 20 | use Symfony\Contracts\HttpClient\HttpClientInterface; 21 | 22 | /** 23 | * A reference test suite for HttpClientInterface implementations. 24 | */ 25 | abstract class HttpClientTestCase extends TestCase 26 | { 27 | public static function setUpBeforeClass(): void 28 | { 29 | if (!\function_exists('ob_gzhandler')) { 30 | static::markTestSkipped('The "ob_gzhandler" function is not available.'); 31 | } 32 | 33 | TestHttpServer::start(); 34 | } 35 | 36 | public static function tearDownAfterClass(): void 37 | { 38 | TestHttpServer::stop(8067); 39 | TestHttpServer::stop(8077); 40 | } 41 | 42 | abstract protected function getHttpClient(string $testCase): HttpClientInterface; 43 | 44 | public function testGetRequest() 45 | { 46 | $client = $this->getHttpClient(__FUNCTION__); 47 | $response = $client->request('GET', 'http://localhost:8057', [ 48 | 'headers' => ['Foo' => 'baR'], 49 | 'user_data' => $data = new \stdClass(), 50 | ]); 51 | 52 | $this->assertSame([], $response->getInfo('response_headers')); 53 | $this->assertSame($data, $response->getInfo()['user_data']); 54 | $this->assertSame(200, $response->getStatusCode()); 55 | 56 | $info = $response->getInfo(); 57 | $this->assertNull($info['error']); 58 | $this->assertSame(0, $info['redirect_count']); 59 | $this->assertSame('HTTP/1.1 200 OK', $info['response_headers'][0]); 60 | $this->assertSame('Host: localhost:8057', $info['response_headers'][1]); 61 | $this->assertSame('http://localhost:8057/', $info['url']); 62 | 63 | $headers = $response->getHeaders(); 64 | 65 | $this->assertSame('localhost:8057', $headers['host'][0]); 66 | $this->assertSame(['application/json'], $headers['content-type']); 67 | 68 | $body = json_decode($response->getContent(), true); 69 | $this->assertSame($body, $response->toArray()); 70 | 71 | $this->assertSame('HTTP/1.1', $body['SERVER_PROTOCOL']); 72 | $this->assertSame('/', $body['REQUEST_URI']); 73 | $this->assertSame('GET', $body['REQUEST_METHOD']); 74 | $this->assertSame('localhost:8057', $body['HTTP_HOST']); 75 | $this->assertSame('baR', $body['HTTP_FOO']); 76 | 77 | $response = $client->request('GET', 'http://localhost:8057/length-broken'); 78 | 79 | $this->expectException(TransportExceptionInterface::class); 80 | $response->getContent(); 81 | } 82 | 83 | public function testHeadRequest() 84 | { 85 | $client = $this->getHttpClient(__FUNCTION__); 86 | $response = $client->request('HEAD', 'http://localhost:8057/head', [ 87 | 'headers' => ['Foo' => 'baR'], 88 | 'user_data' => $data = new \stdClass(), 89 | 'buffer' => false, 90 | ]); 91 | 92 | $this->assertSame([], $response->getInfo('response_headers')); 93 | $this->assertSame(200, $response->getStatusCode()); 94 | 95 | $info = $response->getInfo(); 96 | $this->assertSame('HTTP/1.1 200 OK', $info['response_headers'][0]); 97 | $this->assertSame('Host: localhost:8057', $info['response_headers'][1]); 98 | 99 | $headers = $response->getHeaders(); 100 | 101 | $this->assertSame('localhost:8057', $headers['host'][0]); 102 | $this->assertSame(['application/json'], $headers['content-type']); 103 | $this->assertTrue(0 < $headers['content-length'][0]); 104 | 105 | $this->assertSame('', $response->getContent()); 106 | } 107 | 108 | public function testNonBufferedGetRequest() 109 | { 110 | $client = $this->getHttpClient(__FUNCTION__); 111 | $response = $client->request('GET', 'http://localhost:8057', [ 112 | 'buffer' => false, 113 | 'headers' => ['Foo' => 'baR'], 114 | ]); 115 | 116 | $body = $response->toArray(); 117 | $this->assertSame('baR', $body['HTTP_FOO']); 118 | 119 | $this->expectException(TransportExceptionInterface::class); 120 | $response->getContent(); 121 | } 122 | 123 | public function testBufferSink() 124 | { 125 | $sink = fopen('php://temp', 'w+'); 126 | $client = $this->getHttpClient(__FUNCTION__); 127 | $response = $client->request('GET', 'http://localhost:8057', [ 128 | 'buffer' => $sink, 129 | 'headers' => ['Foo' => 'baR'], 130 | ]); 131 | 132 | $body = $response->toArray(); 133 | $this->assertSame('baR', $body['HTTP_FOO']); 134 | 135 | rewind($sink); 136 | $sink = stream_get_contents($sink); 137 | $this->assertSame($sink, $response->getContent()); 138 | } 139 | 140 | public function testConditionalBuffering() 141 | { 142 | $client = $this->getHttpClient(__FUNCTION__); 143 | $response = $client->request('GET', 'http://localhost:8057'); 144 | $firstContent = $response->getContent(); 145 | $secondContent = $response->getContent(); 146 | 147 | $this->assertSame($firstContent, $secondContent); 148 | 149 | $response = $client->request('GET', 'http://localhost:8057', ['buffer' => fn () => false]); 150 | $response->getContent(); 151 | 152 | $this->expectException(TransportExceptionInterface::class); 153 | $response->getContent(); 154 | } 155 | 156 | public function testReentrantBufferCallback() 157 | { 158 | $client = $this->getHttpClient(__FUNCTION__); 159 | 160 | $response = $client->request('GET', 'http://localhost:8057', ['buffer' => function () use (&$response) { 161 | $response->cancel(); 162 | 163 | return true; 164 | }]); 165 | 166 | $this->assertSame(200, $response->getStatusCode()); 167 | 168 | $this->expectException(TransportExceptionInterface::class); 169 | $response->getContent(); 170 | } 171 | 172 | public function testThrowingBufferCallback() 173 | { 174 | $client = $this->getHttpClient(__FUNCTION__); 175 | 176 | $response = $client->request('GET', 'http://localhost:8057', ['buffer' => function () { 177 | throw new \Exception('Boo.'); 178 | }]); 179 | 180 | $this->assertSame(200, $response->getStatusCode()); 181 | 182 | $this->expectException(TransportExceptionInterface::class); 183 | $this->expectExceptionMessage('Boo'); 184 | $response->getContent(); 185 | } 186 | 187 | public function testUnsupportedOption() 188 | { 189 | $client = $this->getHttpClient(__FUNCTION__); 190 | 191 | $this->expectException(\InvalidArgumentException::class); 192 | $client->request('GET', 'http://localhost:8057', [ 193 | 'capture_peer_cert' => 1.0, 194 | ]); 195 | } 196 | 197 | public function testHttpVersion() 198 | { 199 | $client = $this->getHttpClient(__FUNCTION__); 200 | $response = $client->request('GET', 'http://localhost:8057', [ 201 | 'http_version' => 1.0, 202 | ]); 203 | 204 | $this->assertSame(200, $response->getStatusCode()); 205 | $this->assertSame('HTTP/1.0 200 OK', $response->getInfo('response_headers')[0]); 206 | 207 | $body = $response->toArray(); 208 | 209 | $this->assertSame('HTTP/1.0', $body['SERVER_PROTOCOL']); 210 | $this->assertSame('GET', $body['REQUEST_METHOD']); 211 | $this->assertSame('/', $body['REQUEST_URI']); 212 | } 213 | 214 | public function testChunkedEncoding() 215 | { 216 | $client = $this->getHttpClient(__FUNCTION__); 217 | $response = $client->request('GET', 'http://localhost:8057/chunked'); 218 | 219 | $this->assertSame(['chunked'], $response->getHeaders()['transfer-encoding']); 220 | $this->assertSame('Symfony is awesome!', $response->getContent()); 221 | 222 | $response = $client->request('GET', 'http://localhost:8057/chunked-broken'); 223 | 224 | $this->expectException(TransportExceptionInterface::class); 225 | $response->getContent(); 226 | } 227 | 228 | public function testClientError() 229 | { 230 | $client = $this->getHttpClient(__FUNCTION__); 231 | $response = $client->request('GET', 'http://localhost:8057/404'); 232 | 233 | $client->stream($response)->valid(); 234 | 235 | $this->assertSame(404, $response->getInfo('http_code')); 236 | 237 | try { 238 | $response->getHeaders(); 239 | $this->fail(ClientExceptionInterface::class.' expected'); 240 | } catch (ClientExceptionInterface) { 241 | } 242 | 243 | try { 244 | $response->getContent(); 245 | $this->fail(ClientExceptionInterface::class.' expected'); 246 | } catch (ClientExceptionInterface) { 247 | } 248 | 249 | $this->assertSame(404, $response->getStatusCode()); 250 | $this->assertSame(['application/json'], $response->getHeaders(false)['content-type']); 251 | $this->assertNotEmpty($response->getContent(false)); 252 | 253 | $response = $client->request('GET', 'http://localhost:8057/404'); 254 | 255 | try { 256 | foreach ($client->stream($response) as $chunk) { 257 | $this->assertTrue($chunk->isFirst()); 258 | } 259 | $this->fail(ClientExceptionInterface::class.' expected'); 260 | } catch (ClientExceptionInterface) { 261 | } 262 | } 263 | 264 | public function testIgnoreErrors() 265 | { 266 | $client = $this->getHttpClient(__FUNCTION__); 267 | $response = $client->request('GET', 'http://localhost:8057/404'); 268 | 269 | $this->assertSame(404, $response->getStatusCode()); 270 | } 271 | 272 | public function testDnsError() 273 | { 274 | $client = $this->getHttpClient(__FUNCTION__); 275 | $response = $client->request('GET', 'http://localhost:8057/301/bad-tld'); 276 | 277 | try { 278 | $response->getStatusCode(); 279 | $this->fail(TransportExceptionInterface::class.' expected'); 280 | } catch (TransportExceptionInterface) { 281 | $this->addToAssertionCount(1); 282 | } 283 | 284 | try { 285 | $response->getStatusCode(); 286 | $this->fail(TransportExceptionInterface::class.' still expected'); 287 | } catch (TransportExceptionInterface) { 288 | $this->addToAssertionCount(1); 289 | } 290 | 291 | $response = $client->request('GET', 'http://localhost:8057/301/bad-tld'); 292 | 293 | try { 294 | foreach ($client->stream($response) as $r => $chunk) { 295 | } 296 | $this->fail(TransportExceptionInterface::class.' expected'); 297 | } catch (TransportExceptionInterface) { 298 | $this->addToAssertionCount(1); 299 | } 300 | 301 | $this->assertSame($response, $r); 302 | $this->assertNotNull($chunk->getError()); 303 | 304 | $this->expectException(TransportExceptionInterface::class); 305 | foreach ($client->stream($response) as $chunk) { 306 | } 307 | } 308 | 309 | public function testInlineAuth() 310 | { 311 | $client = $this->getHttpClient(__FUNCTION__); 312 | $response = $client->request('GET', 'http://foo:bar%3Dbar@localhost:8057'); 313 | 314 | $body = $response->toArray(); 315 | 316 | $this->assertSame('foo', $body['PHP_AUTH_USER']); 317 | $this->assertSame('bar=bar', $body['PHP_AUTH_PW']); 318 | } 319 | 320 | public function testBadRequestBody() 321 | { 322 | $client = $this->getHttpClient(__FUNCTION__); 323 | 324 | $this->expectException(TransportExceptionInterface::class); 325 | 326 | $response = $client->request('POST', 'http://localhost:8057/', [ 327 | 'body' => function () { yield []; }, 328 | ]); 329 | 330 | $response->getStatusCode(); 331 | } 332 | 333 | public function test304() 334 | { 335 | $client = $this->getHttpClient(__FUNCTION__); 336 | $response = $client->request('GET', 'http://localhost:8057/304', [ 337 | 'headers' => ['If-Match' => '"abc"'], 338 | 'buffer' => false, 339 | ]); 340 | 341 | $this->assertSame(304, $response->getStatusCode()); 342 | $this->assertSame('', $response->getContent(false)); 343 | } 344 | 345 | /** 346 | * @testWith [[]] 347 | * [["Content-Length: 7"]] 348 | */ 349 | public function testRedirects(array $headers = []) 350 | { 351 | $client = $this->getHttpClient(__FUNCTION__); 352 | $response = $client->request('POST', 'http://localhost:8057/301', [ 353 | 'auth_basic' => 'foo:bar', 354 | 'headers' => $headers, 355 | 'body' => function () { 356 | yield 'foo=bar'; 357 | }, 358 | ]); 359 | 360 | $body = $response->toArray(); 361 | $this->assertSame('GET', $body['REQUEST_METHOD']); 362 | $this->assertSame('Basic Zm9vOmJhcg==', $body['HTTP_AUTHORIZATION']); 363 | $this->assertSame('http://localhost:8057/', $response->getInfo('url')); 364 | 365 | $this->assertSame(2, $response->getInfo('redirect_count')); 366 | $this->assertNull($response->getInfo('redirect_url')); 367 | 368 | $expected = [ 369 | 'HTTP/1.1 301 Moved Permanently', 370 | 'Location: http://127.0.0.1:8057/302', 371 | 'Content-Type: application/json', 372 | 'HTTP/1.1 302 Found', 373 | 'Location: http://localhost:8057/', 374 | 'Content-Type: application/json', 375 | 'HTTP/1.1 200 OK', 376 | 'Content-Type: application/json', 377 | ]; 378 | 379 | $filteredHeaders = array_values(array_filter($response->getInfo('response_headers'), function ($h) { 380 | return \in_array(substr($h, 0, 4), ['HTTP', 'Loca', 'Cont'], true) && 'Content-Encoding: gzip' !== $h; 381 | })); 382 | 383 | $this->assertSame($expected, $filteredHeaders); 384 | } 385 | 386 | public function testInvalidRedirect() 387 | { 388 | $client = $this->getHttpClient(__FUNCTION__); 389 | $response = $client->request('GET', 'http://localhost:8057/301/invalid'); 390 | 391 | $this->assertSame(301, $response->getStatusCode()); 392 | $this->assertSame(['//?foo=bar'], $response->getHeaders(false)['location']); 393 | $this->assertSame(0, $response->getInfo('redirect_count')); 394 | $this->assertNull($response->getInfo('redirect_url')); 395 | 396 | $this->expectException(RedirectionExceptionInterface::class); 397 | $response->getHeaders(); 398 | } 399 | 400 | public function testRelativeRedirects() 401 | { 402 | $client = $this->getHttpClient(__FUNCTION__); 403 | $response = $client->request('GET', 'http://localhost:8057/302/relative'); 404 | 405 | $body = $response->toArray(); 406 | 407 | $this->assertSame('/', $body['REQUEST_URI']); 408 | $this->assertNull($response->getInfo('redirect_url')); 409 | 410 | $response = $client->request('GET', 'http://localhost:8057/302/relative', [ 411 | 'max_redirects' => 0, 412 | ]); 413 | 414 | $this->assertSame(302, $response->getStatusCode()); 415 | $this->assertSame('http://localhost:8057/', $response->getInfo('redirect_url')); 416 | } 417 | 418 | public function testRedirect307() 419 | { 420 | $client = $this->getHttpClient(__FUNCTION__); 421 | 422 | $response = $client->request('POST', 'http://localhost:8057/307', [ 423 | 'body' => function () { 424 | yield 'foo=bar'; 425 | }, 426 | 'max_redirects' => 0, 427 | ]); 428 | 429 | $this->assertSame(307, $response->getStatusCode()); 430 | 431 | $response = $client->request('POST', 'http://localhost:8057/307', [ 432 | 'body' => 'foo=bar', 433 | ]); 434 | 435 | $body = $response->toArray(); 436 | 437 | $this->assertSame(['foo' => 'bar', 'REQUEST_METHOD' => 'POST'], $body); 438 | } 439 | 440 | public function testMaxRedirects() 441 | { 442 | $client = $this->getHttpClient(__FUNCTION__); 443 | $response = $client->request('GET', 'http://localhost:8057/301', [ 444 | 'max_redirects' => 1, 445 | 'auth_basic' => 'foo:bar', 446 | ]); 447 | 448 | try { 449 | $response->getHeaders(); 450 | $this->fail(RedirectionExceptionInterface::class.' expected'); 451 | } catch (RedirectionExceptionInterface) { 452 | } 453 | 454 | $this->assertSame(302, $response->getStatusCode()); 455 | $this->assertSame(1, $response->getInfo('redirect_count')); 456 | $this->assertSame('http://localhost:8057/', $response->getInfo('redirect_url')); 457 | 458 | $expected = [ 459 | 'HTTP/1.1 301 Moved Permanently', 460 | 'Location: http://127.0.0.1:8057/302', 461 | 'Content-Type: application/json', 462 | 'HTTP/1.1 302 Found', 463 | 'Location: http://localhost:8057/', 464 | 'Content-Type: application/json', 465 | ]; 466 | 467 | $filteredHeaders = array_values(array_filter($response->getInfo('response_headers'), function ($h) { 468 | return \in_array(substr($h, 0, 4), ['HTTP', 'Loca', 'Cont'], true); 469 | })); 470 | 471 | $this->assertSame($expected, $filteredHeaders); 472 | } 473 | 474 | public function testStream() 475 | { 476 | $client = $this->getHttpClient(__FUNCTION__); 477 | 478 | $response = $client->request('GET', 'http://localhost:8057'); 479 | $chunks = $client->stream($response); 480 | $result = []; 481 | 482 | foreach ($chunks as $r => $chunk) { 483 | if ($chunk->isTimeout()) { 484 | $result[] = 't'; 485 | } elseif ($chunk->isLast()) { 486 | $result[] = 'l'; 487 | } elseif ($chunk->isFirst()) { 488 | $result[] = 'f'; 489 | } 490 | } 491 | 492 | $this->assertSame($response, $r); 493 | $this->assertSame(['f', 'l'], $result); 494 | 495 | $chunk = null; 496 | $i = 0; 497 | 498 | foreach ($client->stream($response) as $chunk) { 499 | ++$i; 500 | } 501 | 502 | $this->assertSame(1, $i); 503 | $this->assertTrue($chunk->isLast()); 504 | } 505 | 506 | public function testAddToStream() 507 | { 508 | $client = $this->getHttpClient(__FUNCTION__); 509 | 510 | $r1 = $client->request('GET', 'http://localhost:8057'); 511 | 512 | $completed = []; 513 | 514 | $pool = [$r1]; 515 | 516 | while ($pool) { 517 | $chunks = $client->stream($pool); 518 | $pool = []; 519 | 520 | foreach ($chunks as $r => $chunk) { 521 | if (!$chunk->isLast()) { 522 | continue; 523 | } 524 | 525 | if ($r1 === $r) { 526 | $r2 = $client->request('GET', 'http://localhost:8057'); 527 | $pool[] = $r2; 528 | } 529 | 530 | $completed[] = $r; 531 | } 532 | } 533 | 534 | $this->assertSame([$r1, $r2], $completed); 535 | } 536 | 537 | public function testCompleteTypeError() 538 | { 539 | $client = $this->getHttpClient(__FUNCTION__); 540 | 541 | $this->expectException(\TypeError::class); 542 | $client->stream(123); 543 | } 544 | 545 | public function testOnProgress() 546 | { 547 | $client = $this->getHttpClient(__FUNCTION__); 548 | $response = $client->request('POST', 'http://localhost:8057/post', [ 549 | 'headers' => ['Content-Length' => 14], 550 | 'body' => 'foo=0123456789', 551 | 'on_progress' => function (...$state) use (&$steps) { $steps[] = $state; }, 552 | ]); 553 | 554 | $body = $response->toArray(); 555 | 556 | $this->assertSame(['foo' => '0123456789', 'REQUEST_METHOD' => 'POST'], $body); 557 | $this->assertSame([0, 0], \array_slice($steps[0], 0, 2)); 558 | $lastStep = \array_slice($steps, -1)[0]; 559 | $this->assertSame([57, 57], \array_slice($lastStep, 0, 2)); 560 | $this->assertSame('http://localhost:8057/post', $steps[0][2]['url']); 561 | } 562 | 563 | public function testPostJson() 564 | { 565 | $client = $this->getHttpClient(__FUNCTION__); 566 | 567 | $response = $client->request('POST', 'http://localhost:8057/post', [ 568 | 'json' => ['foo' => 'bar'], 569 | ]); 570 | 571 | $body = $response->toArray(); 572 | 573 | $this->assertStringContainsString('json', $body['content-type']); 574 | unset($body['content-type']); 575 | $this->assertSame(['foo' => 'bar', 'REQUEST_METHOD' => 'POST'], $body); 576 | } 577 | 578 | public function testPostArray() 579 | { 580 | $client = $this->getHttpClient(__FUNCTION__); 581 | 582 | $response = $client->request('POST', 'http://localhost:8057/post', [ 583 | 'body' => ['foo' => 'bar'], 584 | ]); 585 | 586 | $this->assertSame(['foo' => 'bar', 'REQUEST_METHOD' => 'POST'], $response->toArray()); 587 | } 588 | 589 | public function testPostResource() 590 | { 591 | $client = $this->getHttpClient(__FUNCTION__); 592 | 593 | $h = fopen('php://temp', 'w+'); 594 | fwrite($h, 'foo=0123456789'); 595 | rewind($h); 596 | 597 | $response = $client->request('POST', 'http://localhost:8057/post', [ 598 | 'body' => $h, 599 | ]); 600 | 601 | $body = $response->toArray(); 602 | 603 | $this->assertSame(['foo' => '0123456789', 'REQUEST_METHOD' => 'POST'], $body); 604 | } 605 | 606 | public function testPostCallback() 607 | { 608 | $client = $this->getHttpClient(__FUNCTION__); 609 | 610 | $response = $client->request('POST', 'http://localhost:8057/post', [ 611 | 'body' => function () { 612 | yield 'foo'; 613 | yield ''; 614 | yield '='; 615 | yield '0123456789'; 616 | }, 617 | ]); 618 | 619 | $this->assertSame(['foo' => '0123456789', 'REQUEST_METHOD' => 'POST'], $response->toArray()); 620 | } 621 | 622 | public function testCancel() 623 | { 624 | $client = $this->getHttpClient(__FUNCTION__); 625 | $response = $client->request('GET', 'http://localhost:8057/timeout-header'); 626 | 627 | $response->cancel(); 628 | $this->expectException(TransportExceptionInterface::class); 629 | $response->getHeaders(); 630 | } 631 | 632 | public function testInfoOnCanceledResponse() 633 | { 634 | $client = $this->getHttpClient(__FUNCTION__); 635 | 636 | $response = $client->request('GET', 'http://localhost:8057/timeout-header'); 637 | 638 | $this->assertFalse($response->getInfo('canceled')); 639 | $response->cancel(); 640 | $this->assertTrue($response->getInfo('canceled')); 641 | } 642 | 643 | public function testCancelInStream() 644 | { 645 | $client = $this->getHttpClient(__FUNCTION__); 646 | $response = $client->request('GET', 'http://localhost:8057/404'); 647 | 648 | foreach ($client->stream($response) as $chunk) { 649 | $response->cancel(); 650 | } 651 | 652 | $this->expectException(TransportExceptionInterface::class); 653 | 654 | foreach ($client->stream($response) as $chunk) { 655 | } 656 | } 657 | 658 | public function testOnProgressCancel() 659 | { 660 | $client = $this->getHttpClient(__FUNCTION__); 661 | $response = $client->request('GET', 'http://localhost:8057/timeout-body', [ 662 | 'on_progress' => function ($dlNow) { 663 | if (0 < $dlNow) { 664 | throw new \Exception('Aborting the request.'); 665 | } 666 | }, 667 | ]); 668 | 669 | try { 670 | foreach ($client->stream([$response]) as $chunk) { 671 | } 672 | $this->fail(ClientExceptionInterface::class.' expected'); 673 | } catch (TransportExceptionInterface $e) { 674 | $this->assertSame('Aborting the request.', $e->getPrevious()->getMessage()); 675 | } 676 | 677 | $this->assertNotNull($response->getInfo('error')); 678 | $this->expectException(TransportExceptionInterface::class); 679 | $response->getContent(); 680 | } 681 | 682 | public function testOnProgressError() 683 | { 684 | $client = $this->getHttpClient(__FUNCTION__); 685 | $response = $client->request('GET', 'http://localhost:8057/timeout-body', [ 686 | 'on_progress' => function ($dlNow) { 687 | if (0 < $dlNow) { 688 | throw new \Error('BUG.'); 689 | } 690 | }, 691 | ]); 692 | 693 | try { 694 | foreach ($client->stream([$response]) as $chunk) { 695 | } 696 | $this->fail('Error expected'); 697 | } catch (\Error $e) { 698 | $this->assertSame('BUG.', $e->getMessage()); 699 | } 700 | 701 | $this->assertNotNull($response->getInfo('error')); 702 | $this->expectException(TransportExceptionInterface::class); 703 | $response->getContent(); 704 | } 705 | 706 | public function testResolve() 707 | { 708 | $client = $this->getHttpClient(__FUNCTION__); 709 | $response = $client->request('GET', 'http://symfony.com:8057/', [ 710 | 'resolve' => ['symfony.com' => '127.0.0.1'], 711 | ]); 712 | 713 | $this->assertSame(200, $response->getStatusCode()); 714 | $this->assertSame(200, $client->request('GET', 'http://symfony.com:8057/')->getStatusCode()); 715 | 716 | $response = null; 717 | $this->expectException(TransportExceptionInterface::class); 718 | $client->request('GET', 'http://symfony.com:8057/', ['timeout' => 1]); 719 | } 720 | 721 | public function testIdnResolve() 722 | { 723 | $client = $this->getHttpClient(__FUNCTION__); 724 | 725 | $response = $client->request('GET', 'http://0-------------------------------------------------------------0.com:8057/', [ 726 | 'resolve' => ['0-------------------------------------------------------------0.com' => '127.0.0.1'], 727 | ]); 728 | 729 | $this->assertSame(200, $response->getStatusCode()); 730 | 731 | $response = $client->request('GET', 'http://Bücher.example:8057/', [ 732 | 'resolve' => ['xn--bcher-kva.example' => '127.0.0.1'], 733 | ]); 734 | 735 | $this->assertSame(200, $response->getStatusCode()); 736 | } 737 | 738 | public function testIPv6Resolve() 739 | { 740 | TestHttpServer::start(-8087); 741 | 742 | $client = $this->getHttpClient(__FUNCTION__); 743 | $response = $client->request('GET', 'http://symfony.com:8087/', [ 744 | 'resolve' => ['symfony.com' => '::1'], 745 | ]); 746 | 747 | $this->assertSame(200, $response->getStatusCode()); 748 | } 749 | 750 | public function testNotATimeout() 751 | { 752 | $client = $this->getHttpClient(__FUNCTION__); 753 | $response = $client->request('GET', 'http://localhost:8057/timeout-header', [ 754 | 'timeout' => 0.9, 755 | ]); 756 | sleep(1); 757 | $this->assertSame(200, $response->getStatusCode()); 758 | } 759 | 760 | public function testTimeoutOnAccess() 761 | { 762 | $client = $this->getHttpClient(__FUNCTION__); 763 | $response = $client->request('GET', 'http://localhost:8057/timeout-header', [ 764 | 'timeout' => 0.1, 765 | ]); 766 | 767 | $this->expectException(TransportExceptionInterface::class); 768 | $response->getHeaders(); 769 | } 770 | 771 | public function testTimeoutIsNotAFatalError() 772 | { 773 | usleep(300000); // wait for the previous test to release the server 774 | $client = $this->getHttpClient(__FUNCTION__); 775 | $response = $client->request('GET', 'http://localhost:8057/timeout-body', [ 776 | 'timeout' => 0.25, 777 | ]); 778 | 779 | try { 780 | $response->getContent(); 781 | $this->fail(TimeoutExceptionInterface::class.' expected'); 782 | } catch (TimeoutExceptionInterface $e) { 783 | } 784 | 785 | for ($i = 0; $i < 10; ++$i) { 786 | try { 787 | $this->assertSame('<1><2>', $response->getContent()); 788 | break; 789 | } catch (TimeoutExceptionInterface $e) { 790 | } 791 | } 792 | 793 | if (10 === $i) { 794 | throw $e; 795 | } 796 | } 797 | 798 | public function testTimeoutOnStream() 799 | { 800 | $client = $this->getHttpClient(__FUNCTION__); 801 | $response = $client->request('GET', 'http://localhost:8057/timeout-body'); 802 | 803 | $this->assertSame(200, $response->getStatusCode()); 804 | $chunks = $client->stream([$response], 0.2); 805 | 806 | $result = []; 807 | 808 | foreach ($chunks as $r => $chunk) { 809 | if ($chunk->isTimeout()) { 810 | $result[] = 't'; 811 | } else { 812 | $result[] = $chunk->getContent(); 813 | } 814 | } 815 | 816 | $this->assertSame(['<1>', 't'], $result); 817 | 818 | $chunks = $client->stream([$response]); 819 | 820 | foreach ($chunks as $r => $chunk) { 821 | $this->assertSame('<2>', $chunk->getContent()); 822 | $this->assertSame('<1><2>', $r->getContent()); 823 | 824 | return; 825 | } 826 | 827 | $this->fail('The response should have completed'); 828 | } 829 | 830 | public function testUncheckedTimeoutThrows() 831 | { 832 | $client = $this->getHttpClient(__FUNCTION__); 833 | $response = $client->request('GET', 'http://localhost:8057/timeout-body'); 834 | $chunks = $client->stream([$response], 0.1); 835 | 836 | $this->expectException(TransportExceptionInterface::class); 837 | 838 | foreach ($chunks as $r => $chunk) { 839 | } 840 | } 841 | 842 | public function testTimeoutWithActiveConcurrentStream() 843 | { 844 | $p1 = TestHttpServer::start(8067); 845 | $p2 = TestHttpServer::start(8077); 846 | 847 | $client = $this->getHttpClient(__FUNCTION__); 848 | $streamingResponse = $client->request('GET', 'http://localhost:8067/max-duration'); 849 | $blockingResponse = $client->request('GET', 'http://localhost:8077/timeout-body', [ 850 | 'timeout' => 0.25, 851 | ]); 852 | 853 | $this->assertSame(200, $streamingResponse->getStatusCode()); 854 | $this->assertSame(200, $blockingResponse->getStatusCode()); 855 | 856 | $this->expectException(TransportExceptionInterface::class); 857 | 858 | try { 859 | $blockingResponse->getContent(); 860 | } finally { 861 | $p1->stop(); 862 | $p2->stop(); 863 | } 864 | } 865 | 866 | public function testTimeoutOnInitialize() 867 | { 868 | $p1 = TestHttpServer::start(8067); 869 | $p2 = TestHttpServer::start(8077); 870 | 871 | $client = $this->getHttpClient(__FUNCTION__); 872 | $start = microtime(true); 873 | $responses = []; 874 | 875 | $responses[] = $client->request('GET', 'http://localhost:8067/timeout-header', ['timeout' => 0.25]); 876 | $responses[] = $client->request('GET', 'http://localhost:8077/timeout-header', ['timeout' => 0.25]); 877 | $responses[] = $client->request('GET', 'http://localhost:8067/timeout-header', ['timeout' => 0.25]); 878 | $responses[] = $client->request('GET', 'http://localhost:8077/timeout-header', ['timeout' => 0.25]); 879 | 880 | try { 881 | foreach ($responses as $response) { 882 | try { 883 | $response->getContent(); 884 | $this->fail(TransportExceptionInterface::class.' expected'); 885 | } catch (TransportExceptionInterface) { 886 | } 887 | } 888 | $responses = []; 889 | 890 | $duration = microtime(true) - $start; 891 | 892 | $this->assertLessThan(1.0, $duration); 893 | } finally { 894 | $p1->stop(); 895 | $p2->stop(); 896 | } 897 | } 898 | 899 | public function testTimeoutOnDestruct() 900 | { 901 | $p1 = TestHttpServer::start(8067); 902 | $p2 = TestHttpServer::start(8077); 903 | 904 | $client = $this->getHttpClient(__FUNCTION__); 905 | $start = microtime(true); 906 | $responses = []; 907 | 908 | $responses[] = $client->request('GET', 'http://localhost:8067/timeout-header', ['timeout' => 0.25]); 909 | $responses[] = $client->request('GET', 'http://localhost:8077/timeout-header', ['timeout' => 0.25]); 910 | $responses[] = $client->request('GET', 'http://localhost:8067/timeout-header', ['timeout' => 0.25]); 911 | $responses[] = $client->request('GET', 'http://localhost:8077/timeout-header', ['timeout' => 0.25]); 912 | 913 | try { 914 | while ($response = array_shift($responses)) { 915 | try { 916 | unset($response); 917 | $this->fail(TransportExceptionInterface::class.' expected'); 918 | } catch (TransportExceptionInterface) { 919 | } 920 | } 921 | 922 | $duration = microtime(true) - $start; 923 | 924 | $this->assertLessThan(1.0, $duration); 925 | } finally { 926 | $p1->stop(); 927 | $p2->stop(); 928 | } 929 | } 930 | 931 | public function testDestruct() 932 | { 933 | $client = $this->getHttpClient(__FUNCTION__); 934 | 935 | $start = microtime(true); 936 | $client->request('GET', 'http://localhost:8057/timeout-long'); 937 | $client = null; 938 | $duration = microtime(true) - $start; 939 | 940 | $this->assertGreaterThan(1, $duration); 941 | $this->assertLessThan(4, $duration); 942 | } 943 | 944 | public function testGetContentAfterDestruct() 945 | { 946 | $client = $this->getHttpClient(__FUNCTION__); 947 | 948 | try { 949 | $client->request('GET', 'http://localhost:8057/404'); 950 | $this->fail(ClientExceptionInterface::class.' expected'); 951 | } catch (ClientExceptionInterface $e) { 952 | $this->assertSame('GET', $e->getResponse()->toArray(false)['REQUEST_METHOD']); 953 | } 954 | } 955 | 956 | public function testGetEncodedContentAfterDestruct() 957 | { 958 | $client = $this->getHttpClient(__FUNCTION__); 959 | 960 | try { 961 | $client->request('GET', 'http://localhost:8057/404-gzipped'); 962 | $this->fail(ClientExceptionInterface::class.' expected'); 963 | } catch (ClientExceptionInterface $e) { 964 | $this->assertSame('some text', $e->getResponse()->getContent(false)); 965 | } 966 | } 967 | 968 | public function testProxy() 969 | { 970 | $client = $this->getHttpClient(__FUNCTION__); 971 | $response = $client->request('GET', 'http://localhost:8057/', [ 972 | 'proxy' => 'http://localhost:8057', 973 | ]); 974 | 975 | $body = $response->toArray(); 976 | $this->assertSame('localhost:8057', $body['HTTP_HOST']); 977 | $this->assertMatchesRegularExpression('#^http://(localhost|127\.0\.0\.1):8057/$#', $body['REQUEST_URI']); 978 | 979 | $response = $client->request('GET', 'http://localhost:8057/', [ 980 | 'proxy' => 'http://foo:b%3Dar@localhost:8057', 981 | ]); 982 | 983 | $body = $response->toArray(); 984 | $this->assertSame('Basic Zm9vOmI9YXI=', $body['HTTP_PROXY_AUTHORIZATION']); 985 | 986 | $_SERVER['http_proxy'] = 'http://localhost:8057'; 987 | try { 988 | $response = $client->request('GET', 'http://localhost:8057/'); 989 | $body = $response->toArray(); 990 | $this->assertSame('localhost:8057', $body['HTTP_HOST']); 991 | $this->assertMatchesRegularExpression('#^http://(localhost|127\.0\.0\.1):8057/$#', $body['REQUEST_URI']); 992 | } finally { 993 | unset($_SERVER['http_proxy']); 994 | } 995 | 996 | $response = $client->request('GET', 'http://localhost:8057/301/proxy', [ 997 | 'proxy' => 'http://localhost:8057', 998 | ]); 999 | 1000 | $body = $response->toArray(); 1001 | $this->assertSame('localhost:8057', $body['HTTP_HOST']); 1002 | $this->assertMatchesRegularExpression('#^http://(localhost|127\.0\.0\.1):8057/$#', $body['REQUEST_URI']); 1003 | } 1004 | 1005 | public function testNoProxy() 1006 | { 1007 | putenv('no_proxy='.$_SERVER['no_proxy'] = 'example.com, localhost'); 1008 | 1009 | try { 1010 | $client = $this->getHttpClient(__FUNCTION__); 1011 | $response = $client->request('GET', 'http://localhost:8057/', [ 1012 | 'proxy' => 'http://localhost:8057', 1013 | ]); 1014 | 1015 | $body = $response->toArray(); 1016 | 1017 | $this->assertSame('HTTP/1.1', $body['SERVER_PROTOCOL']); 1018 | $this->assertSame('/', $body['REQUEST_URI']); 1019 | $this->assertSame('GET', $body['REQUEST_METHOD']); 1020 | } finally { 1021 | putenv('no_proxy'); 1022 | unset($_SERVER['no_proxy']); 1023 | } 1024 | } 1025 | 1026 | /** 1027 | * @requires extension zlib 1028 | */ 1029 | #[RequiresPhpExtension('zlib')] 1030 | public function testAutoEncodingRequest() 1031 | { 1032 | $client = $this->getHttpClient(__FUNCTION__); 1033 | $response = $client->request('GET', 'http://localhost:8057'); 1034 | 1035 | $this->assertSame(200, $response->getStatusCode()); 1036 | 1037 | $headers = $response->getHeaders(); 1038 | 1039 | $this->assertSame(['Accept-Encoding'], $headers['vary']); 1040 | $this->assertStringContainsString('gzip', $headers['content-encoding'][0]); 1041 | 1042 | $body = $response->toArray(); 1043 | 1044 | $this->assertStringContainsString('gzip', $body['HTTP_ACCEPT_ENCODING']); 1045 | } 1046 | 1047 | public function testBaseUri() 1048 | { 1049 | $client = $this->getHttpClient(__FUNCTION__); 1050 | $response = $client->request('GET', '../404', [ 1051 | 'base_uri' => 'http://localhost:8057/abc/', 1052 | ]); 1053 | 1054 | $this->assertSame(404, $response->getStatusCode()); 1055 | $this->assertSame(['application/json'], $response->getHeaders(false)['content-type']); 1056 | } 1057 | 1058 | public function testQuery() 1059 | { 1060 | $client = $this->getHttpClient(__FUNCTION__); 1061 | $response = $client->request('GET', 'http://localhost:8057/?a=a', [ 1062 | 'query' => ['b' => 'b'], 1063 | ]); 1064 | 1065 | $body = $response->toArray(); 1066 | $this->assertSame('GET', $body['REQUEST_METHOD']); 1067 | $this->assertSame('/?a=a&b=b', $body['REQUEST_URI']); 1068 | } 1069 | 1070 | public function testInformationalResponse() 1071 | { 1072 | $client = $this->getHttpClient(__FUNCTION__); 1073 | $response = $client->request('GET', 'http://localhost:8057/103'); 1074 | 1075 | $this->assertSame('Here the body', $response->getContent()); 1076 | $this->assertSame(200, $response->getStatusCode()); 1077 | } 1078 | 1079 | public function testInformationalResponseStream() 1080 | { 1081 | $client = $this->getHttpClient(__FUNCTION__); 1082 | $response = $client->request('GET', 'http://localhost:8057/103'); 1083 | 1084 | $chunks = []; 1085 | foreach ($client->stream($response) as $chunk) { 1086 | $chunks[] = $chunk; 1087 | } 1088 | 1089 | $this->assertSame(103, $chunks[0]->getInformationalStatus()[0]); 1090 | $this->assertSame(['; rel=preload; as=style', '; rel=preload; as=script'], $chunks[0]->getInformationalStatus()[1]['link']); 1091 | $this->assertTrue($chunks[1]->isFirst()); 1092 | $this->assertSame('Here the body', $chunks[2]->getContent()); 1093 | $this->assertTrue($chunks[3]->isLast()); 1094 | $this->assertNull($chunks[3]->getInformationalStatus()); 1095 | 1096 | $this->assertSame(['date', 'content-length'], array_keys($response->getHeaders())); 1097 | $this->assertContains('Link: ; rel=preload; as=style', $response->getInfo('response_headers')); 1098 | } 1099 | 1100 | /** 1101 | * @requires extension zlib 1102 | */ 1103 | #[RequiresPhpExtension('zlib')] 1104 | public function testUserlandEncodingRequest() 1105 | { 1106 | $client = $this->getHttpClient(__FUNCTION__); 1107 | $response = $client->request('GET', 'http://localhost:8057', [ 1108 | 'headers' => ['Accept-Encoding' => 'gzip'], 1109 | ]); 1110 | 1111 | $headers = $response->getHeaders(); 1112 | 1113 | $this->assertSame(['Accept-Encoding'], $headers['vary']); 1114 | $this->assertStringContainsString('gzip', $headers['content-encoding'][0]); 1115 | 1116 | $body = $response->getContent(); 1117 | $this->assertSame("\x1F", $body[0]); 1118 | 1119 | $body = json_decode(gzdecode($body), true); 1120 | $this->assertSame('gzip', $body['HTTP_ACCEPT_ENCODING']); 1121 | } 1122 | 1123 | /** 1124 | * @requires extension zlib 1125 | */ 1126 | #[RequiresPhpExtension('zlib')] 1127 | public function testGzipBroken() 1128 | { 1129 | $client = $this->getHttpClient(__FUNCTION__); 1130 | $response = $client->request('GET', 'http://localhost:8057/gzip-broken'); 1131 | 1132 | $this->expectException(TransportExceptionInterface::class); 1133 | $response->getContent(); 1134 | } 1135 | 1136 | public function testMaxDuration() 1137 | { 1138 | $client = $this->getHttpClient(__FUNCTION__); 1139 | $response = $client->request('GET', 'http://localhost:8057/max-duration', [ 1140 | 'max_duration' => 0.1, 1141 | ]); 1142 | 1143 | $start = microtime(true); 1144 | 1145 | try { 1146 | $response->getContent(); 1147 | } catch (TransportExceptionInterface) { 1148 | $this->addToAssertionCount(1); 1149 | } 1150 | 1151 | $duration = microtime(true) - $start; 1152 | 1153 | $this->assertLessThan(10, $duration); 1154 | } 1155 | 1156 | public function testWithOptions() 1157 | { 1158 | $client = $this->getHttpClient(__FUNCTION__); 1159 | $client2 = $client->withOptions(['base_uri' => 'http://localhost:8057/']); 1160 | 1161 | $this->assertNotSame($client, $client2); 1162 | $this->assertSame($client::class, $client2::class); 1163 | 1164 | $response = $client2->request('GET', '/'); 1165 | $this->assertSame(200, $response->getStatusCode()); 1166 | } 1167 | 1168 | public function testBindToPort() 1169 | { 1170 | $client = $this->getHttpClient(__FUNCTION__); 1171 | $response = $client->request('GET', 'http://localhost:8057', ['bindto' => '127.0.0.1:9876']); 1172 | $response->getStatusCode(); 1173 | 1174 | $vars = $response->toArray(); 1175 | 1176 | self::assertSame('127.0.0.1', $vars['REMOTE_ADDR']); 1177 | self::assertSame('9876', $vars['REMOTE_PORT']); 1178 | } 1179 | 1180 | public function testBindToPortV6() 1181 | { 1182 | TestHttpServer::start(-8087); 1183 | 1184 | $client = $this->getHttpClient(__FUNCTION__); 1185 | $response = $client->request('GET', 'http://[::1]:8087', ['bindto' => '[::1]:9876']); 1186 | $response->getStatusCode(); 1187 | 1188 | $vars = $response->toArray(); 1189 | 1190 | self::assertSame('::1', $vars['REMOTE_ADDR']); 1191 | 1192 | if ('\\' !== \DIRECTORY_SEPARATOR) { 1193 | self::assertSame('9876', $vars['REMOTE_PORT']); 1194 | } 1195 | } 1196 | } 1197 | -------------------------------------------------------------------------------- /Test/TestHttpServer.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\HttpClient\Test; 13 | 14 | use Symfony\Component\Process\PhpExecutableFinder; 15 | use Symfony\Component\Process\Process; 16 | 17 | class TestHttpServer 18 | { 19 | private static array $process = []; 20 | 21 | /** 22 | * @param string|null $workingDirectory 23 | */ 24 | public static function start(int $port = 8057/* , ?string $workingDirectory = null */): Process 25 | { 26 | $workingDirectory = \func_get_args()[1] ?? __DIR__.'/Fixtures/web'; 27 | 28 | if (0 > $port) { 29 | $port = -$port; 30 | $ip = '[::1]'; 31 | } else { 32 | $ip = '127.0.0.1'; 33 | } 34 | 35 | if (isset(self::$process[$port])) { 36 | self::$process[$port]->stop(); 37 | } else { 38 | register_shutdown_function(static function () use ($port) { 39 | self::$process[$port]->stop(); 40 | }); 41 | } 42 | 43 | $finder = new PhpExecutableFinder(); 44 | $process = new Process(array_merge([$finder->find(false)], $finder->findArguments(), ['-dopcache.enable=0', '-dvariables_order=EGPCS', '-S', $ip.':'.$port])); 45 | $process->setWorkingDirectory($workingDirectory); 46 | $process->start(); 47 | self::$process[$port] = $process; 48 | 49 | do { 50 | usleep(50000); 51 | } while (!@fopen('http://'.$ip.':'.$port, 'r')); 52 | 53 | return $process; 54 | } 55 | 56 | public static function stop(int $port = 8057) 57 | { 58 | if (isset(self::$process[$port])) { 59 | self::$process[$port]->stop(); 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "symfony/http-client-contracts", 3 | "type": "library", 4 | "description": "Generic abstractions related to HTTP clients", 5 | "keywords": ["abstractions", "contracts", "decoupling", "interfaces", "interoperability", "standards"], 6 | "homepage": "https://symfony.com", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Nicolas Grekas", 11 | "email": "p@tchwork.com" 12 | }, 13 | { 14 | "name": "Symfony Community", 15 | "homepage": "https://symfony.com/contributors" 16 | } 17 | ], 18 | "require": { 19 | "php": ">=8.1" 20 | }, 21 | "autoload": { 22 | "psr-4": { "Symfony\\Contracts\\HttpClient\\": "" }, 23 | "exclude-from-classmap": [ 24 | "/Test/" 25 | ] 26 | }, 27 | "minimum-stability": "dev", 28 | "extra": { 29 | "branch-alias": { 30 | "dev-main": "3.6-dev" 31 | }, 32 | "thanks": { 33 | "name": "symfony/contracts", 34 | "url": "https://github.com/symfony/contracts" 35 | } 36 | } 37 | } 38 | --------------------------------------------------------------------------------