Skip to content

Commit a59e0af

Browse files
[HttpClient] Add $response->toStream() to cast responses to regular PHP streams
1 parent b9b03fe commit a59e0af

File tree

8 files changed

+295
-12
lines changed

8 files changed

+295
-12
lines changed

src/Symfony/Component/HttpClient/CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ CHANGELOG
44
4.4.0
55
-----
66

7-
* made `Psr18Client` implement relevant PSR-17 factories
7+
* added `StreamWrapper`
88
* added `HttplugClient`
99
* added support for NTLM authentication
10+
* added `$response->toStream()` to cast responses to regular PHP streams
11+
* made `Psr18Client` implement relevant PSR-17 factories and have streaming responses
1012

1113
4.3.0
1214
-----

src/Symfony/Component/HttpClient/Psr18Client.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
use Psr\Http\Message\StreamInterface;
2626
use Psr\Http\Message\UriFactoryInterface;
2727
use Psr\Http\Message\UriInterface;
28+
use Symfony\Component\HttpClient\Response\ResponseTrait;
29+
use Symfony\Component\HttpClient\Response\StreamWrapper;
2830
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
2931
use Symfony\Contracts\HttpClient\HttpClientInterface;
3032

@@ -90,7 +92,9 @@ public function sendRequest(RequestInterface $request): ResponseInterface
9092
}
9193
}
9294

93-
return $psrResponse->withBody($this->streamFactory->createStream($response->getContent(false)));
95+
$body = isset(class_uses($response)[ResponseTrait::class]) ? $response->toStream() : StreamWrapper::createResource($response, $this->client);
96+
97+
return $psrResponse->withBody($this->streamFactory->createStreamFromResource($body));
9498
} catch (TransportExceptionInterface $e) {
9599
if ($e instanceof \InvalidArgumentException) {
96100
throw new Psr18RequestException($e, $request);

src/Symfony/Component/HttpClient/Response/ResponseTrait.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,19 @@ public function cancel(): void
178178
$this->close();
179179
}
180180

181+
/**
182+
* Casts the response to a PHP stream resource.
183+
*
184+
* @return resource|null
185+
*/
186+
public function toStream()
187+
{
188+
// Ensure headers arrived
189+
$this->getStatusCode();
190+
191+
return StreamWrapper::createResource($this, null, $this->content, $this->handle && 'stream' === get_resource_type($this->handle) ? $this->handle : null);
192+
}
193+
181194
/**
182195
* Closes the response and all its network handles.
183196
*/
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\HttpClient\Response;
13+
14+
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
15+
use Symfony\Contracts\HttpClient\HttpClientInterface;
16+
use Symfony\Contracts\HttpClient\ResponseInterface;
17+
18+
/**
19+
* Allows turning ResponseInterface instances to PHP streams.
20+
*
21+
* @author Nicolas Grekas <p@tchwork.com>
22+
*/
23+
class StreamWrapper
24+
{
25+
/** @var resource */
26+
public $context;
27+
28+
/** @var HttpClientInterface */
29+
private $client;
30+
31+
/** @var ResponseInterface */
32+
private $response;
33+
34+
/** @var resource|null */
35+
private $content;
36+
37+
/** @var resource|null */
38+
private $handle;
39+
40+
private $eof = false;
41+
private $offset = 0;
42+
43+
/**
44+
* Creates a PHP stream resource from a ResponseInterface.
45+
*
46+
* @param resource|null $contentBuffer The seekable resource where the response body is buffered
47+
* @param resource|null $selectHandle The resource handle that should be monitored when
48+
* stream_select() is used on the created stream
49+
*
50+
* @return resource
51+
*/
52+
public static function createResource(ResponseInterface $response, HttpClientInterface $client = null, $contentBuffer = null, $selectHandle = null)
53+
{
54+
if (null === $client && !method_exists($response, 'stream')) {
55+
throw new \InvalidArgumentException(sprintf('Providing a client to "%s()" is required when the response doesn\'t have any "stream()" method.', __CLASS__));
56+
}
57+
58+
if (false === stream_wrapper_register('symfony', __CLASS__, STREAM_IS_URL)) {
59+
throw new \RuntimeException(error_get_last()['message'] ?? 'Registering the "symfony" stream wrapper failed.');
60+
}
61+
62+
try {
63+
$context = [
64+
'client' => $client ?? $response,
65+
'response' => $response,
66+
'content' => $contentBuffer,
67+
'handle' => $selectHandle,
68+
];
69+
70+
return fopen('symfony://'.$response->getInfo('url'), 'r', false, stream_context_create(['symfony' => $context])) ?: null;
71+
} finally {
72+
stream_wrapper_unregister('symfony');
73+
}
74+
}
75+
76+
public function stream_open(string $path, string $mode, int $options): bool
77+
{
78+
if ('r' !== $mode) {
79+
if ($options & STREAM_REPORT_ERRORS) {
80+
trigger_error(sprintf('Invalid mode "%s": only "r" is supported.', $mode), E_USER_WARNING);
81+
}
82+
83+
return false;
84+
}
85+
86+
$context = stream_context_get_options($this->context)['symfony'] ?? null;
87+
$this->client = $context['client'] ?? null;
88+
$this->response = $context['response'] ?? null;
89+
$this->content = $context['content'] ?? null;
90+
$this->handle = $context['handle'] ?? null;
91+
$this->context = null;
92+
93+
if (null !== $this->client && null !== $this->response) {
94+
return true;
95+
}
96+
97+
if ($options & STREAM_REPORT_ERRORS) {
98+
trigger_error('Missing options "client" or "response" in "symfony" stream context.', E_USER_WARNING);
99+
}
100+
101+
return false;
102+
}
103+
104+
public function stream_read(int $count)
105+
{
106+
if (null !== $this->content) {
107+
// Empty the internal activity list
108+
foreach ($this->client->stream([$this->response], 0) as $chunk) {
109+
try {
110+
$chunk->isTimeout();
111+
} catch (ExceptionInterface $e) {
112+
trigger_error($e->getMessage(), E_USER_WARNING);
113+
114+
return false;
115+
}
116+
}
117+
118+
if (0 !== fseek($this->content, $this->offset)) {
119+
return false;
120+
}
121+
122+
if ('' !== $data = fread($this->content, $count)) {
123+
fseek($this->content, 0, SEEK_END);
124+
$this->offset += \strlen($data);
125+
126+
return $data;
127+
}
128+
}
129+
130+
foreach ($this->client->stream([$this->response]) as $chunk) {
131+
try {
132+
$this->eof = true;
133+
$this->eof = !$chunk->isTimeout();
134+
$this->eof = $chunk->isLast();
135+
136+
if ('' !== $data = $chunk->getContent()) {
137+
$this->offset += \strlen($data);
138+
139+
return $data;
140+
}
141+
} catch (ExceptionInterface $e) {
142+
trigger_error($e->getMessage(), E_USER_WARNING);
143+
144+
return false;
145+
}
146+
}
147+
148+
return '';
149+
}
150+
151+
public function stream_tell(): int
152+
{
153+
return $this->offset;
154+
}
155+
156+
public function stream_eof(): bool
157+
{
158+
return $this->eof;
159+
}
160+
161+
public function stream_seek(int $offset, int $whence = SEEK_SET): bool
162+
{
163+
if (null === $this->content || 0 !== fseek($this->content, 0, SEEK_END)) {
164+
return false;
165+
}
166+
167+
$size = ftell($this->content);
168+
169+
if (SEEK_CUR === $whence) {
170+
$offset += $this->offset;
171+
}
172+
173+
if (SEEK_END === $whence || $size < $offset) {
174+
foreach ($this->client->stream([$this->response]) as $chunk) {
175+
try {
176+
// Chunks are buffered in $this->content already
177+
$size += \strlen($chunk->getContent());
178+
179+
if (SEEK_END !== $whence && $offset <= $size) {
180+
break;
181+
}
182+
} catch (ExceptionInterface $e) {
183+
trigger_error($e->getMessage(), E_USER_WARNING);
184+
185+
return false;
186+
}
187+
}
188+
189+
if (SEEK_END === $whence) {
190+
$offset += $size;
191+
}
192+
}
193+
194+
if (0 <= $offset && $offset <= $size) {
195+
$this->eof = false;
196+
$this->offset = $offset;
197+
198+
return true;
199+
}
200+
201+
return false;
202+
}
203+
204+
public function stream_cast(int $castAs)
205+
{
206+
if (STREAM_CAST_FOR_SELECT === $castAs) {
207+
return $this->handle ?? false;
208+
}
209+
210+
return false;
211+
}
212+
213+
public function stream_stat(): array
214+
{
215+
return [
216+
'dev' => 0,
217+
'ino' => 0,
218+
'mode' => 33060,
219+
'nlink' => 0,
220+
'uid' => 0,
221+
'gid' => 0,
222+
'rdev' => 0,
223+
'size' => (int) ($this->response->getHeaders(false)['content-length'][0] ?? 0),
224+
'atime' => 0,
225+
'mtime' => strtotime($this->response->getHeaders(false)['last-modified'][0] ?? '') ?: 0,
226+
'ctime' => 0,
227+
'blksize' => 0,
228+
'blocks' => 0,
229+
];
230+
}
231+
}

src/Symfony/Component/HttpClient/Tests/CurlHttpClientTest.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
use Psr\Log\AbstractLogger;
1515
use Symfony\Component\HttpClient\CurlHttpClient;
1616
use Symfony\Contracts\HttpClient\HttpClientInterface;
17-
use Symfony\Contracts\HttpClient\Test\HttpClientTestCase;
1817

1918
/**
2019
* @requires extension curl
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\HttpClient\Tests;
13+
14+
use Symfony\Contracts\HttpClient\Test\HttpClientTestCase as BaseHttpClientTestCase;
15+
16+
abstract class HttpClientTestCase extends BaseHttpClientTestCase
17+
{
18+
public function testToStream()
19+
{
20+
$client = $this->getHttpClient(__FUNCTION__);
21+
22+
$response = $client->request('GET', 'http://localhost:8057');
23+
24+
$stream = $response->toStream();
25+
26+
$this->assertSame("{\n \"SER", fread($stream, 10));
27+
$this->assertSame('VER_PROTOCOL', fread($stream, 12));
28+
$this->assertFalse(feof($stream));
29+
$this->assertTrue(rewind($stream));
30+
31+
$this->assertInternalType('array', json_decode(fread($stream, 1024), true));
32+
$this->assertSame('', fread($stream, 1));
33+
$this->assertTrue(feof($stream));
34+
}
35+
}

src/Symfony/Component/HttpClient/Tests/MockHttpClientTest.php

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
use Symfony\Component\HttpClient\Response\MockResponse;
1818
use Symfony\Contracts\HttpClient\HttpClientInterface;
1919
use Symfony\Contracts\HttpClient\ResponseInterface;
20-
use Symfony\Contracts\HttpClient\Test\HttpClientTestCase;
2120

2221
class MockHttpClientTest extends HttpClientTestCase
2322
{
@@ -31,13 +30,13 @@ protected function getHttpClient(string $testCase): HttpClientInterface
3130
];
3231

3332
$body = '{
34-
"SERVER_PROTOCOL": "HTTP/1.1",
35-
"SERVER_NAME": "127.0.0.1",
36-
"REQUEST_URI": "/",
37-
"REQUEST_METHOD": "GET",
38-
"HTTP_FOO": "baR",
39-
"HTTP_HOST": "localhost:8057"
40-
}';
33+
"SERVER_PROTOCOL": "HTTP/1.1",
34+
"SERVER_NAME": "127.0.0.1",
35+
"REQUEST_URI": "/",
36+
"REQUEST_METHOD": "GET",
37+
"HTTP_FOO": "baR",
38+
"HTTP_HOST": "localhost:8057"
39+
}';
4140

4241
$client = new NativeHttpClient();
4342

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

99+
case 'testToStream':
100100
case 'testBadRequestBody':
101101
case 'testOnProgressCancel':
102102
case 'testOnProgressError':

src/Symfony/Component/HttpClient/Tests/NativeHttpClientTest.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313

1414
use Symfony\Component\HttpClient\NativeHttpClient;
1515
use Symfony\Contracts\HttpClient\HttpClientInterface;
16-
use Symfony\Contracts\HttpClient\Test\HttpClientTestCase;
1716

1817
class NativeHttpClientTest extends HttpClientTestCase
1918
{

0 commit comments

Comments
 (0)
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