From a59e0af24a60ff9e7d9f8de8e353368821e2c2be Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Sun, 30 Jun 2019 01:27:41 +0200 Subject: [PATCH] [HttpClient] Add $response->toStream() to cast responses to regular PHP streams --- src/Symfony/Component/HttpClient/CHANGELOG.md | 4 +- .../Component/HttpClient/Psr18Client.php | 6 +- .../HttpClient/Response/ResponseTrait.php | 13 + .../HttpClient/Response/StreamWrapper.php | 231 ++++++++++++++++++ .../HttpClient/Tests/CurlHttpClientTest.php | 1 - .../HttpClient/Tests/HttpClientTestCase.php | 35 +++ .../HttpClient/Tests/MockHttpClientTest.php | 16 +- .../HttpClient/Tests/NativeHttpClientTest.php | 1 - 8 files changed, 295 insertions(+), 12 deletions(-) create mode 100644 src/Symfony/Component/HttpClient/Response/StreamWrapper.php create mode 100644 src/Symfony/Component/HttpClient/Tests/HttpClientTestCase.php 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 { pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy