[HttpClient] Add $response->toStream() to cast responses to regular PHP streams
This commit is contained in:
parent
b9b03fe1d3
commit
a59e0af24a
@ -4,9 +4,11 @@ CHANGELOG
|
|||||||
4.4.0
|
4.4.0
|
||||||
-----
|
-----
|
||||||
|
|
||||||
* made `Psr18Client` implement relevant PSR-17 factories
|
* added `StreamWrapper`
|
||||||
* added `HttplugClient`
|
* added `HttplugClient`
|
||||||
* added support for NTLM authentication
|
* 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
|
4.3.0
|
||||||
-----
|
-----
|
||||||
|
@ -25,6 +25,8 @@ use Psr\Http\Message\StreamFactoryInterface;
|
|||||||
use Psr\Http\Message\StreamInterface;
|
use Psr\Http\Message\StreamInterface;
|
||||||
use Psr\Http\Message\UriFactoryInterface;
|
use Psr\Http\Message\UriFactoryInterface;
|
||||||
use Psr\Http\Message\UriInterface;
|
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\Exception\TransportExceptionInterface;
|
||||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
|
|
||||||
@ -90,7 +92,9 @@ final class Psr18Client implements ClientInterface, RequestFactoryInterface, Str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
} catch (TransportExceptionInterface $e) {
|
||||||
if ($e instanceof \InvalidArgumentException) {
|
if ($e instanceof \InvalidArgumentException) {
|
||||||
throw new Psr18RequestException($e, $request);
|
throw new Psr18RequestException($e, $request);
|
||||||
|
@ -178,6 +178,19 @@ trait ResponseTrait
|
|||||||
$this->close();
|
$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.
|
* Closes the response and all its network handles.
|
||||||
*/
|
*/
|
||||||
|
231
src/Symfony/Component/HttpClient/Response/StreamWrapper.php
Normal file
231
src/Symfony/Component/HttpClient/Response/StreamWrapper.php
Normal file
@ -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
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
@ -14,7 +14,6 @@ namespace Symfony\Component\HttpClient\Tests;
|
|||||||
use Psr\Log\AbstractLogger;
|
use Psr\Log\AbstractLogger;
|
||||||
use Symfony\Component\HttpClient\CurlHttpClient;
|
use Symfony\Component\HttpClient\CurlHttpClient;
|
||||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
use Symfony\Contracts\HttpClient\Test\HttpClientTestCase;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @requires extension curl
|
* @requires extension curl
|
||||||
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
@ -17,7 +17,6 @@ use Symfony\Component\HttpClient\NativeHttpClient;
|
|||||||
use Symfony\Component\HttpClient\Response\MockResponse;
|
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
use Symfony\Contracts\HttpClient\ResponseInterface;
|
use Symfony\Contracts\HttpClient\ResponseInterface;
|
||||||
use Symfony\Contracts\HttpClient\Test\HttpClientTestCase;
|
|
||||||
|
|
||||||
class MockHttpClientTest extends HttpClientTestCase
|
class MockHttpClientTest extends HttpClientTestCase
|
||||||
{
|
{
|
||||||
@ -31,13 +30,13 @@ class MockHttpClientTest extends HttpClientTestCase
|
|||||||
];
|
];
|
||||||
|
|
||||||
$body = '{
|
$body = '{
|
||||||
"SERVER_PROTOCOL": "HTTP/1.1",
|
"SERVER_PROTOCOL": "HTTP/1.1",
|
||||||
"SERVER_NAME": "127.0.0.1",
|
"SERVER_NAME": "127.0.0.1",
|
||||||
"REQUEST_URI": "/",
|
"REQUEST_URI": "/",
|
||||||
"REQUEST_METHOD": "GET",
|
"REQUEST_METHOD": "GET",
|
||||||
"HTTP_FOO": "baR",
|
"HTTP_FOO": "baR",
|
||||||
"HTTP_HOST": "localhost:8057"
|
"HTTP_HOST": "localhost:8057"
|
||||||
}';
|
}';
|
||||||
|
|
||||||
$client = new NativeHttpClient();
|
$client = new NativeHttpClient();
|
||||||
|
|
||||||
@ -97,6 +96,7 @@ class MockHttpClientTest extends HttpClientTestCase
|
|||||||
$responses[] = $mock;
|
$responses[] = $mock;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'testToStream':
|
||||||
case 'testBadRequestBody':
|
case 'testBadRequestBody':
|
||||||
case 'testOnProgressCancel':
|
case 'testOnProgressCancel':
|
||||||
case 'testOnProgressError':
|
case 'testOnProgressError':
|
||||||
|
@ -13,7 +13,6 @@ namespace Symfony\Component\HttpClient\Tests;
|
|||||||
|
|
||||||
use Symfony\Component\HttpClient\NativeHttpClient;
|
use Symfony\Component\HttpClient\NativeHttpClient;
|
||||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
use Symfony\Contracts\HttpClient\Test\HttpClientTestCase;
|
|
||||||
|
|
||||||
class NativeHttpClientTest extends HttpClientTestCase
|
class NativeHttpClientTest extends HttpClientTestCase
|
||||||
{
|
{
|
||||||
|
Reference in New Issue
Block a user