diff --git a/src/Symfony/Component/HttpClient/CHANGELOG.md b/src/Symfony/Component/HttpClient/CHANGELOG.md index 5348259f63f14..c9cd7a60f0128 100644 --- a/src/Symfony/Component/HttpClient/CHANGELOG.md +++ b/src/Symfony/Component/HttpClient/CHANGELOG.md @@ -4,9 +4,11 @@ CHANGELOG 4.4.0 ----- - * made `Psr18Client` implement relevant PSR-17 factories + * added `StreamWrapper` * added `HttplugClient` * added support for NTLM authentication + * added `$response->toStream()` to cast responses to regular PHP streams + * made `Psr18Client` implement relevant PSR-17 factories and have streaming responses 4.3.0 ----- diff --git a/src/Symfony/Component/HttpClient/Psr18Client.php b/src/Symfony/Component/HttpClient/Psr18Client.php index dee930fbae40f..9d3628afb0bbf 100644 --- a/src/Symfony/Component/HttpClient/Psr18Client.php +++ b/src/Symfony/Component/HttpClient/Psr18Client.php @@ -25,6 +25,8 @@ use Psr\Http\Message\StreamInterface; use Psr\Http\Message\UriFactoryInterface; use Psr\Http\Message\UriInterface; +use Symfony\Component\HttpClient\Response\ResponseTrait; +use Symfony\Component\HttpClient\Response\StreamWrapper; use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -90,7 +92,9 @@ public function sendRequest(RequestInterface $request): ResponseInterface } } - return $psrResponse->withBody($this->streamFactory->createStream($response->getContent(false))); + $body = isset(class_uses($response)[ResponseTrait::class]) ? $response->toStream() : StreamWrapper::createResource($response, $this->client); + + return $psrResponse->withBody($this->streamFactory->createStreamFromResource($body)); } catch (TransportExceptionInterface $e) { if ($e instanceof \InvalidArgumentException) { throw new Psr18RequestException($e, $request); diff --git a/src/Symfony/Component/HttpClient/Response/ResponseTrait.php b/src/Symfony/Component/HttpClient/Response/ResponseTrait.php index cd444439926a7..10c2d505c3c99 100644 --- a/src/Symfony/Component/HttpClient/Response/ResponseTrait.php +++ b/src/Symfony/Component/HttpClient/Response/ResponseTrait.php @@ -178,6 +178,19 @@ public function cancel(): void $this->close(); } + /** + * Casts the response to a PHP stream resource. + * + * @return resource|null + */ + public function toStream() + { + // Ensure headers arrived + $this->getStatusCode(); + + return StreamWrapper::createResource($this, null, $this->content, $this->handle && 'stream' === get_resource_type($this->handle) ? $this->handle : null); + } + /** * Closes the response and all its network handles. */ diff --git a/src/Symfony/Component/HttpClient/Response/StreamWrapper.php b/src/Symfony/Component/HttpClient/Response/StreamWrapper.php new file mode 100644 index 0000000000000..0c9a95a9511dc --- /dev/null +++ b/src/Symfony/Component/HttpClient/Response/StreamWrapper.php @@ -0,0 +1,231 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient\Response; + +use Symfony\Contracts\HttpClient\Exception\ExceptionInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * Allows turning ResponseInterface instances to PHP streams. + * + * @author Nicolas Grekas + */ +class StreamWrapper +{ + /** @var resource */ + public $context; + + /** @var HttpClientInterface */ + private $client; + + /** @var ResponseInterface */ + private $response; + + /** @var resource|null */ + private $content; + + /** @var resource|null */ + private $handle; + + private $eof = false; + private $offset = 0; + + /** + * Creates a PHP stream resource from a ResponseInterface. + * + * @param resource|null $contentBuffer The seekable resource where the response body is buffered + * @param resource|null $selectHandle The resource handle that should be monitored when + * stream_select() is used on the created stream + * + * @return resource + */ + public static function createResource(ResponseInterface $response, HttpClientInterface $client = null, $contentBuffer = null, $selectHandle = null) + { + if (null === $client && !method_exists($response, 'stream')) { + throw new \InvalidArgumentException(sprintf('Providing a client to "%s()" is required when the response doesn\'t have any "stream()" method.', __CLASS__)); + } + + if (false === stream_wrapper_register('symfony', __CLASS__, STREAM_IS_URL)) { + throw new \RuntimeException(error_get_last()['message'] ?? 'Registering the "symfony" stream wrapper failed.'); + } + + try { + $context = [ + 'client' => $client ?? $response, + 'response' => $response, + 'content' => $contentBuffer, + 'handle' => $selectHandle, + ]; + + return fopen('symfony://'.$response->getInfo('url'), 'r', false, stream_context_create(['symfony' => $context])) ?: null; + } finally { + stream_wrapper_unregister('symfony'); + } + } + + public function stream_open(string $path, string $mode, int $options): bool + { + if ('r' !== $mode) { + if ($options & STREAM_REPORT_ERRORS) { + trigger_error(sprintf('Invalid mode "%s": only "r" is supported.', $mode), E_USER_WARNING); + } + + return false; + } + + $context = stream_context_get_options($this->context)['symfony'] ?? null; + $this->client = $context['client'] ?? null; + $this->response = $context['response'] ?? null; + $this->content = $context['content'] ?? null; + $this->handle = $context['handle'] ?? null; + $this->context = null; + + if (null !== $this->client && null !== $this->response) { + return true; + } + + if ($options & STREAM_REPORT_ERRORS) { + trigger_error('Missing options "client" or "response" in "symfony" stream context.', E_USER_WARNING); + } + + return false; + } + + public function stream_read(int $count) + { + if (null !== $this->content) { + // Empty the internal activity list + foreach ($this->client->stream([$this->response], 0) as $chunk) { + try { + $chunk->isTimeout(); + } catch (ExceptionInterface $e) { + trigger_error($e->getMessage(), E_USER_WARNING); + + return false; + } + } + + if (0 !== fseek($this->content, $this->offset)) { + return false; + } + + if ('' !== $data = fread($this->content, $count)) { + fseek($this->content, 0, SEEK_END); + $this->offset += \strlen($data); + + return $data; + } + } + + foreach ($this->client->stream([$this->response]) as $chunk) { + try { + $this->eof = true; + $this->eof = !$chunk->isTimeout(); + $this->eof = $chunk->isLast(); + + if ('' !== $data = $chunk->getContent()) { + $this->offset += \strlen($data); + + return $data; + } + } catch (ExceptionInterface $e) { + trigger_error($e->getMessage(), E_USER_WARNING); + + return false; + } + } + + return ''; + } + + public function stream_tell(): int + { + return $this->offset; + } + + public function stream_eof(): bool + { + return $this->eof; + } + + public function stream_seek(int $offset, int $whence = SEEK_SET): bool + { + if (null === $this->content || 0 !== fseek($this->content, 0, SEEK_END)) { + return false; + } + + $size = ftell($this->content); + + if (SEEK_CUR === $whence) { + $offset += $this->offset; + } + + if (SEEK_END === $whence || $size < $offset) { + foreach ($this->client->stream([$this->response]) as $chunk) { + try { + // Chunks are buffered in $this->content already + $size += \strlen($chunk->getContent()); + + if (SEEK_END !== $whence && $offset <= $size) { + break; + } + } catch (ExceptionInterface $e) { + trigger_error($e->getMessage(), E_USER_WARNING); + + return false; + } + } + + if (SEEK_END === $whence) { + $offset += $size; + } + } + + if (0 <= $offset && $offset <= $size) { + $this->eof = false; + $this->offset = $offset; + + return true; + } + + return false; + } + + public function stream_cast(int $castAs) + { + if (STREAM_CAST_FOR_SELECT === $castAs) { + return $this->handle ?? false; + } + + return false; + } + + public function stream_stat(): array + { + return [ + 'dev' => 0, + 'ino' => 0, + 'mode' => 33060, + 'nlink' => 0, + 'uid' => 0, + 'gid' => 0, + 'rdev' => 0, + 'size' => (int) ($this->response->getHeaders(false)['content-length'][0] ?? 0), + 'atime' => 0, + 'mtime' => strtotime($this->response->getHeaders(false)['last-modified'][0] ?? '') ?: 0, + 'ctime' => 0, + 'blksize' => 0, + 'blocks' => 0, + ]; + } +} diff --git a/src/Symfony/Component/HttpClient/Tests/CurlHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/CurlHttpClientTest.php index 630c37b06322f..2c27bb7b3d6eb 100644 --- a/src/Symfony/Component/HttpClient/Tests/CurlHttpClientTest.php +++ b/src/Symfony/Component/HttpClient/Tests/CurlHttpClientTest.php @@ -14,7 +14,6 @@ use Psr\Log\AbstractLogger; use Symfony\Component\HttpClient\CurlHttpClient; use Symfony\Contracts\HttpClient\HttpClientInterface; -use Symfony\Contracts\HttpClient\Test\HttpClientTestCase; /** * @requires extension curl diff --git a/src/Symfony/Component/HttpClient/Tests/HttpClientTestCase.php b/src/Symfony/Component/HttpClient/Tests/HttpClientTestCase.php new file mode 100644 index 0000000000000..0f5b1677574ec --- /dev/null +++ b/src/Symfony/Component/HttpClient/Tests/HttpClientTestCase.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\Tests; + +use Symfony\Contracts\HttpClient\Test\HttpClientTestCase as BaseHttpClientTestCase; + +abstract class HttpClientTestCase extends BaseHttpClientTestCase +{ + public function testToStream() + { + $client = $this->getHttpClient(__FUNCTION__); + + $response = $client->request('GET', 'http://localhost:8057'); + + $stream = $response->toStream(); + + $this->assertSame("{\n \"SER", fread($stream, 10)); + $this->assertSame('VER_PROTOCOL', fread($stream, 12)); + $this->assertFalse(feof($stream)); + $this->assertTrue(rewind($stream)); + + $this->assertInternalType('array', json_decode(fread($stream, 1024), true)); + $this->assertSame('', fread($stream, 1)); + $this->assertTrue(feof($stream)); + } +} diff --git a/src/Symfony/Component/HttpClient/Tests/MockHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/MockHttpClientTest.php index 710d86a258da0..943fb089a2b84 100644 --- a/src/Symfony/Component/HttpClient/Tests/MockHttpClientTest.php +++ b/src/Symfony/Component/HttpClient/Tests/MockHttpClientTest.php @@ -17,7 +17,6 @@ use Symfony\Component\HttpClient\Response\MockResponse; use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\ResponseInterface; -use Symfony\Contracts\HttpClient\Test\HttpClientTestCase; class MockHttpClientTest extends HttpClientTestCase { @@ -31,13 +30,13 @@ protected function getHttpClient(string $testCase): HttpClientInterface ]; $body = '{ - "SERVER_PROTOCOL": "HTTP/1.1", - "SERVER_NAME": "127.0.0.1", - "REQUEST_URI": "/", - "REQUEST_METHOD": "GET", - "HTTP_FOO": "baR", - "HTTP_HOST": "localhost:8057" - }'; + "SERVER_PROTOCOL": "HTTP/1.1", + "SERVER_NAME": "127.0.0.1", + "REQUEST_URI": "/", + "REQUEST_METHOD": "GET", + "HTTP_FOO": "baR", + "HTTP_HOST": "localhost:8057" +}'; $client = new NativeHttpClient(); @@ -97,6 +96,7 @@ protected function getHttpClient(string $testCase): HttpClientInterface $responses[] = $mock; break; + case 'testToStream': case 'testBadRequestBody': case 'testOnProgressCancel': case 'testOnProgressError': diff --git a/src/Symfony/Component/HttpClient/Tests/NativeHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/NativeHttpClientTest.php index 783167791dd60..2d8b7b8fad912 100644 --- a/src/Symfony/Component/HttpClient/Tests/NativeHttpClientTest.php +++ b/src/Symfony/Component/HttpClient/Tests/NativeHttpClientTest.php @@ -13,7 +13,6 @@ use Symfony\Component\HttpClient\NativeHttpClient; use Symfony\Contracts\HttpClient\HttpClientInterface; -use Symfony\Contracts\HttpClient\Test\HttpClientTestCase; class NativeHttpClientTest extends HttpClientTestCase {