├── 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 |
--------------------------------------------------------------------------------