diff --git a/src/Symfony/Component/HttpClient/Chunk/DataChunk.php b/src/Symfony/Component/HttpClient/Chunk/DataChunk.php index 618112834d..37ca848541 100644 --- a/src/Symfony/Component/HttpClient/Chunk/DataChunk.php +++ b/src/Symfony/Component/HttpClient/Chunk/DataChunk.php @@ -20,8 +20,8 @@ use Symfony\Contracts\HttpClient\ChunkInterface; */ class DataChunk implements ChunkInterface { - private $offset; - private $content; + private $offset = 0; + private $content = ''; public function __construct(int $offset = 0, string $content = '') { @@ -53,6 +53,14 @@ class DataChunk implements ChunkInterface return false; } + /** + * {@inheritdoc} + */ + public function getInformationalStatus(): ?array + { + return null; + } + /** * {@inheritdoc} */ diff --git a/src/Symfony/Component/HttpClient/Chunk/ErrorChunk.php b/src/Symfony/Component/HttpClient/Chunk/ErrorChunk.php index d2a69bc38a..3792dccf6d 100644 --- a/src/Symfony/Component/HttpClient/Chunk/ErrorChunk.php +++ b/src/Symfony/Component/HttpClient/Chunk/ErrorChunk.php @@ -65,6 +65,15 @@ class ErrorChunk implements ChunkInterface throw new TransportException($this->errorMessage, 0, $this->error); } + /** + * {@inheritdoc} + */ + public function getInformationalStatus(): ?array + { + $this->didThrow = true; + throw new TransportException($this->errorMessage, 0, $this->error); + } + /** * {@inheritdoc} */ diff --git a/src/Symfony/Component/HttpClient/Chunk/InformationalChunk.php b/src/Symfony/Component/HttpClient/Chunk/InformationalChunk.php new file mode 100644 index 0000000000..c4452f15a0 --- /dev/null +++ b/src/Symfony/Component/HttpClient/Chunk/InformationalChunk.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient\Chunk; + +/** + * @author Nicolas Grekas
+ * + * @internal + */ +class InformationalChunk extends DataChunk +{ + private $status; + + public function __construct(int $statusCode, array $headers) + { + $this->status = [$statusCode, $headers]; + } + + /** + * {@inheritdoc} + */ + public function getInformationalStatus(): ?array + { + return $this->status; + } +} diff --git a/src/Symfony/Component/HttpClient/Response/CurlResponse.php b/src/Symfony/Component/HttpClient/Response/CurlResponse.php index b9f245a34a..0b044630f6 100644 --- a/src/Symfony/Component/HttpClient/Response/CurlResponse.php +++ b/src/Symfony/Component/HttpClient/Response/CurlResponse.php @@ -13,6 +13,7 @@ namespace Symfony\Component\HttpClient\Response; use Psr\Log\LoggerInterface; use Symfony\Component\HttpClient\Chunk\FirstChunk; +use Symfony\Component\HttpClient\Chunk\InformationalChunk; use Symfony\Component\HttpClient\Exception\TransportException; use Symfony\Component\HttpClient\Internal\CurlClientState; use Symfony\Contracts\HttpClient\ResponseInterface; @@ -311,8 +312,11 @@ final class CurlResponse implements ResponseInterface return \strlen($data); } - // End of headers: handle redirects and add to the activity list + // End of headers: handle informational responses, redirects, etc. + if (200 > $statusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE)) { + $multi->handlesActivity[$id][] = new InformationalChunk($statusCode, $headers); + return \strlen($data); } @@ -339,7 +343,7 @@ final class CurlResponse implements ResponseInterface if ($statusCode < 300 || 400 <= $statusCode || curl_getinfo($ch, CURLINFO_REDIRECT_COUNT) === $options['max_redirects']) { // Headers and redirects completed, time to get the response's body - $multi->handlesActivity[$id] = [new FirstChunk()]; + $multi->handlesActivity[$id][] = new FirstChunk(); if ('destruct' === $waitFor) { return 0; diff --git a/src/Symfony/Component/HttpClient/Response/MockResponse.php b/src/Symfony/Component/HttpClient/Response/MockResponse.php index fe94bc3436..fa8abebea4 100644 --- a/src/Symfony/Component/HttpClient/Response/MockResponse.php +++ b/src/Symfony/Component/HttpClient/Response/MockResponse.php @@ -45,7 +45,7 @@ class MockResponse implements ResponseInterface public function __construct($body = '', array $info = []) { $this->body = is_iterable($body) ? $body : (string) $body; - $this->info = $info + $this->info; + $this->info = $info + ['http_code' => 200] + $this->info; if (!isset($info['response_headers'])) { return; @@ -59,7 +59,8 @@ class MockResponse implements ResponseInterface } } - $this->info['response_headers'] = $responseHeaders; + $this->info['response_headers'] = []; + self::addResponseHeaders($responseHeaders, $this->info, $this->headers); } /** diff --git a/src/Symfony/Component/HttpClient/Response/ResponseStream.php b/src/Symfony/Component/HttpClient/Response/ResponseStream.php index cf53abcded..f86d2d4077 100644 --- a/src/Symfony/Component/HttpClient/Response/ResponseStream.php +++ b/src/Symfony/Component/HttpClient/Response/ResponseStream.php @@ -17,8 +17,6 @@ use Symfony\Contracts\HttpClient\ResponseStreamInterface; /** * @author Nicolas Grekas
- * - * @internal */ final class ResponseStream implements ResponseStreamInterface { diff --git a/src/Symfony/Component/HttpClient/Tests/MockHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/MockHttpClientTest.php index fe16de5678..8a3936b442 100644 --- a/src/Symfony/Component/HttpClient/Tests/MockHttpClientTest.php +++ b/src/Symfony/Component/HttpClient/Tests/MockHttpClientTest.php @@ -15,6 +15,8 @@ use Symfony\Component\HttpClient\Exception\TransportException; use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\NativeHttpClient; use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Component\HttpClient\Response\ResponseStream; +use Symfony\Contracts\HttpClient\ChunkInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\ResponseInterface; @@ -122,6 +124,41 @@ class MockHttpClientTest extends HttpClientTestCase $body = ['<1>', '', '<2>']; $responses[] = new MockResponse($body, ['response_headers' => $headers]); break; + + case 'testInformationalResponseStream': + $client = $this->createMock(HttpClientInterface::class); + $response = new MockResponse('Here the body', ['response_headers' => [ + 'HTTP/1.1 103 ', + 'Link: ; rel=preload; as=style', + 'HTTP/1.1 200 ', + 'Date: foo', + 'Content-Length: 13', + ]]); + $client->method('request')->willReturn($response); + $client->method('stream')->willReturn(new ResponseStream((function () use ($response) { + $chunk = $this->createMock(ChunkInterface::class); + $chunk->method('getInformationalStatus') + ->willReturn([103, ['link' => ['; rel=preload; as=style', '; rel=preload; as=script']]]); + + yield $response => $chunk; + + $chunk = $this->createMock(ChunkInterface::class); + $chunk->method('isFirst')->willReturn(true); + + yield $response => $chunk; + + $chunk = $this->createMock(ChunkInterface::class); + $chunk->method('getContent')->willReturn('Here the body'); + + yield $response => $chunk; + + $chunk = $this->createMock(ChunkInterface::class); + $chunk->method('isLast')->willReturn(true); + + yield $response => $chunk; + })())); + + return $client; } return new MockHttpClient($responses); diff --git a/src/Symfony/Component/HttpClient/Tests/NativeHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/NativeHttpClientTest.php index 2d8b7b8fad..bcfab64bdc 100644 --- a/src/Symfony/Component/HttpClient/Tests/NativeHttpClientTest.php +++ b/src/Symfony/Component/HttpClient/Tests/NativeHttpClientTest.php @@ -20,4 +20,9 @@ class NativeHttpClientTest extends HttpClientTestCase { return new NativeHttpClient(); } + + public function testInformationalResponseStream() + { + $this->markTestSkipped('NativeHttpClient doesn\'t support informational status codes.'); + } } diff --git a/src/Symfony/Component/HttpClient/composer.json b/src/Symfony/Component/HttpClient/composer.json index c32ac5771a..b3be8925e5 100644 --- a/src/Symfony/Component/HttpClient/composer.json +++ b/src/Symfony/Component/HttpClient/composer.json @@ -21,7 +21,7 @@ "require": { "php": "^7.1.3", "psr/log": "^1.0", - "symfony/http-client-contracts": "^1.1.6", + "symfony/http-client-contracts": "^1.1.7", "symfony/polyfill-php73": "^1.11" }, "require-dev": { diff --git a/src/Symfony/Contracts/HttpClient/ChunkInterface.php b/src/Symfony/Contracts/HttpClient/ChunkInterface.php index d6fd73d894..ad5efca9e9 100644 --- a/src/Symfony/Contracts/HttpClient/ChunkInterface.php +++ b/src/Symfony/Contracts/HttpClient/ChunkInterface.php @@ -47,6 +47,13 @@ interface ChunkInterface */ public function isLast(): bool; + /** + * Returns a [status code, headers] tuple when a 1xx status code was just received. + * + * @throws TransportExceptionInterface on a network error or when the idle timeout is reached + */ + public function getInformationalStatus(): ?array; + /** * Returns the content of the response chunk. * diff --git a/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php b/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php index 79ca524f2d..932614dff3 100644 --- a/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php +++ b/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php @@ -754,6 +754,27 @@ abstract class HttpClientTestCase extends TestCase $this->assertSame(200, $response->getStatusCode()); } + public function testInformationalResponseStream() + { + $client = $this->getHttpClient(__FUNCTION__); + $response = $client->request('GET', 'http://localhost:8057/103'); + + $chunks = []; + foreach ($client->stream($response) as $chunk) { + $chunks[] = $chunk; + } + + $this->assertSame(103, $chunks[0]->getInformationalStatus()[0]); + $this->assertSame(['; rel=preload; as=style', '; rel=preload; as=script'], $chunks[0]->getInformationalStatus()[1]['link']); + $this->assertTrue($chunks[1]->isFirst()); + $this->assertSame('Here the body', $chunks[2]->getContent()); + $this->assertTrue($chunks[3]->isLast()); + $this->assertNull($chunks[3]->getInformationalStatus()); + + $this->assertSame(['date', 'content-length'], array_keys($response->getHeaders())); + $this->assertContains('Link: ; rel=preload; as=style', $response->getInfo('response_headers')); + } + /** * @requires extension zlib */