Skip to content

[HttpClient] Add $response->toStream() to cast responses to regular PHP streams #32290

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 8, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/Symfony/Component/HttpClient/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----
Expand Down
6 changes: 5 additions & 1 deletion src/Symfony/Component/HttpClient/Psr18Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
Expand Down
13 changes: 13 additions & 0 deletions src/Symfony/Component/HttpClient/Response/ResponseTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
231 changes: 231 additions & 0 deletions src/Symfony/Component/HttpClient/Response/StreamWrapper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
*/
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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this purposeful that the methods are stream_open and not streamOpen . I see this is mixed now . Accidental ?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My bad, I see where this is ending up. https://www.php.net/manual/en/stream.streamwrapper.example-1.php . Sorry about the comment.

{
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,
];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions src/Symfony/Component/HttpClient/Tests/HttpClientTestCase.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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));
}
}
16 changes: 8 additions & 8 deletions src/Symfony/Component/HttpClient/Tests/MockHttpClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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();

Expand Down Expand Up @@ -97,6 +96,7 @@ protected function getHttpClient(string $testCase): HttpClientInterface
$responses[] = $mock;
break;

case 'testToStream':
case 'testBadRequestBody':
case 'testOnProgressCancel':
case 'testOnProgressError':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@

use Symfony\Component\HttpClient\NativeHttpClient;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\Test\HttpClientTestCase;

class NativeHttpClientTest extends HttpClientTestCase
{
Expand Down