Skip to content

Commit 7c78618

Browse files
[HttpClient] Add $response->toStream() to cast responses to regular PHP streams
1 parent 835f6b0 commit 7c78618

File tree

8 files changed

+250
-18
lines changed

8 files changed

+250
-18
lines changed

src/Symfony/Component/HttpClient/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ CHANGELOG
66

77
* made `Psr18Client` implement relevant PSR-17 factories
88
* added `HttplugClient`
9+
* added `$response->toStream()` to cast responses to regular PHP streams
10+
* made `Psr18Client` and `HttplugClient` stream their response body
911

1012
4.3.0
1113
-----

src/Symfony/Component/HttpClient/Psr18Client.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ public function sendRequest(RequestInterface $request): ResponseInterface
9292
}
9393
}
9494

95-
return $psrResponse->withBody($this->streamFactory->createStream($response->getContent(false)));
95+
return $psrResponse->withBody($this->streamFactory->createStreamFromResource($response->toStream()));
9696
} catch (TransportExceptionInterface $e) {
9797
if ($e instanceof \InvalidArgumentException) {
9898
throw new Psr18RequestException($e, $request);

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ final class CurlResponse implements ResponseInterface
2727
use ResponseTrait;
2828

2929
private static $performing = false;
30-
private $multi;
3130
private $debugBuffer;
3231

3332
/**

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

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ class MockResponse implements ResponseInterface
2929
doDestruct as public __destruct;
3030
}
3131

32-
private $body;
32+
private $buffer;
3333
private $requestOptions = [];
3434

3535
private static $mainMulti;
@@ -44,7 +44,8 @@ class MockResponse implements ResponseInterface
4444
*/
4545
public function __construct($body = '', array $info = [])
4646
{
47-
$this->body = is_iterable($body) ? $body : (string) $body;
47+
$this->multi = self::$mainMulti ?? self::$mainMulti = new ClientState();
48+
$this->buffer = is_iterable($body) ? $body : (string) $body;
4849
$this->info = $info + $this->info;
4950

5051
if (!isset($info['response_headers'])) {
@@ -83,7 +84,8 @@ public function getInfo(string $type = null)
8384
*/
8485
protected function close(): void
8586
{
86-
$this->body = [];
87+
unset($this->multi->openHandles[$this->id], $this->multi->handlesActivity[$this->id]);
88+
$this->buffer = null;
8789
}
8890

8991
/**
@@ -100,7 +102,7 @@ public static function fromRequest(string $method, string $url, array $options,
100102
throw new TransportException($response->info['error']);
101103
}
102104

103-
if (\is_array($response->body[0] ?? null)) {
105+
if (\is_array($response->buffer[0] ?? null)) {
104106
// Consume the first chunk if it's not yielded yet
105107
self::stream([$response])->current();
106108
}
@@ -119,7 +121,7 @@ public static function fromRequest(string $method, string $url, array $options,
119121
}
120122

121123
self::writeRequest($response, $options, $mock);
122-
$response->body[] = [$options, $mock];
124+
$response->buffer[] = [$options, $mock];
123125

124126
return $response;
125127
}
@@ -133,10 +135,8 @@ protected static function schedule(self $response, array &$runningResponses): vo
133135
throw new InvalidArgumentException('MockResponse instances must be issued by MockHttpClient before processing.');
134136
}
135137

136-
$multi = self::$mainMulti ?? self::$mainMulti = new ClientState();
137-
138138
if (!isset($runningResponses[0])) {
139-
$runningResponses[0] = [$multi, []];
139+
$runningResponses[0] = [$response->multi, []];
140140
}
141141

142142
$runningResponses[0][1][$response->id] = $response;
@@ -150,14 +150,14 @@ protected static function perform(ClientState $multi, array &$responses): void
150150
foreach ($responses as $response) {
151151
$id = $response->id;
152152

153-
if (!$response->body) {
153+
if (!$response->buffer) {
154154
// Last chunk
155155
$multi->handlesActivity[$id][] = null;
156156
$multi->handlesActivity[$id][] = null !== $response->info['error'] ? new TransportException($response->info['error']) : null;
157-
} elseif (null === $chunk = array_shift($response->body)) {
157+
} elseif (null === $chunk = array_shift($response->buffer)) {
158158
// Last chunk
159159
$multi->handlesActivity[$id][] = null;
160-
$multi->handlesActivity[$id][] = array_shift($response->body);
160+
$multi->handlesActivity[$id][] = array_shift($response->buffer);
161161
} elseif (\is_array($chunk)) {
162162
// First chunk
163163
try {
@@ -260,22 +260,22 @@ private static function readResponse(self $response, array $options, ResponseInt
260260
$onProgress(0, $dlSize, $response->info);
261261

262262
// cast response body to activity list
263-
$body = $mock instanceof self ? $mock->body : $mock->getContent(false);
263+
$body = $mock instanceof self ? $mock->buffer : $mock->getContent(false);
264264

265265
if (!\is_string($body)) {
266266
foreach ($body as $chunk) {
267267
if ('' === $chunk = (string) $chunk) {
268268
// simulate a timeout
269-
$response->body[] = new ErrorChunk($offset);
269+
$response->buffer[] = new ErrorChunk($offset);
270270
} else {
271-
$response->body[] = $chunk;
271+
$response->buffer[] = $chunk;
272272
$offset += \strlen($chunk);
273273
// "notify" download progress
274274
$onProgress($offset, $dlSize, $response->info);
275275
}
276276
}
277277
} elseif ('' !== $body) {
278-
$response->body[] = $body;
278+
$response->buffer[] = $body;
279279
$offset = \strlen($body);
280280
}
281281

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ final class NativeResponse implements ResponseInterface
3333
private $remaining;
3434
private $buffer;
3535
private $inflate;
36-
private $multi;
3736
private $debugBuffer;
3837

3938
/**
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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+
/**
15+
* @author Nicolas Grekas <p@tchwork.com>
16+
*
17+
* @internal
18+
*/
19+
final class NativeStreamWrapper extends StreamWrapper
20+
{
21+
public function stream_cast(int $castAs)
22+
{
23+
if (STREAM_CAST_FOR_SELECT === $castAs) {
24+
return \Closure::bind(function () { return $this->handle ?? false; }, $this->response, $this->response)();
25+
}
26+
27+
return false;
28+
}
29+
}

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ trait ResponseTrait
5757
private $finalInfo;
5858
private $offset = 0;
5959
private $jsonData;
60+
private $multi;
6061

6162
/**
6263
* {@inheritdoc}
@@ -178,6 +179,22 @@ public function cancel(): void
178179
$this->close();
179180
}
180181

182+
/**
183+
* Casts the response to a PHP stream resource.
184+
*
185+
* @return resource|null
186+
*/
187+
public function toStream()
188+
{
189+
stream_wrapper_register('symfony', $this instanceof NativeResponse ? NativeStreamWrapper::class : StreamWrapper::class, STREAM_IS_URL);
190+
191+
try {
192+
return fopen('symfony://'.$this->getInfo('url'), 'r', false, stream_context_create(['symfony' => ['response' => $this]])) ?: null;
193+
} finally {
194+
stream_wrapper_unregister('symfony');
195+
}
196+
}
197+
181198
/**
182199
* Closes the response and all its network handles.
183200
*/
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
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\ResponseInterface;
16+
17+
/**
18+
* @author Nicolas Grekas <p@tchwork.com>
19+
*
20+
* @internal
21+
*/
22+
class StreamWrapper
23+
{
24+
/** @var resource */
25+
public $context;
26+
27+
/** @var ResponseInterface */
28+
protected $response;
29+
30+
private $content;
31+
private $offset = 0;
32+
33+
public function stream_open(string $path, string $mode, int $options): bool
34+
{
35+
if ('r' !== $mode) {
36+
if ($options & STREAM_REPORT_ERRORS) {
37+
trigger_error(sprintf('Invalid mode "%s": only "r" is supported.', $mode), E_USER_WARNING);
38+
}
39+
40+
return false;
41+
}
42+
43+
$this->response = stream_context_get_options($this->context)['symfony']['response'] ?? null;
44+
$this->context = null;
45+
46+
if ($this->response instanceof NativeResponse || $this->response instanceof CurlResponse) {
47+
$this->content = \Closure::bind(function () { return $this->content; }, $this->response, $this->response)();
48+
49+
return true;
50+
}
51+
52+
if ($options & STREAM_REPORT_ERRORS) {
53+
trigger_error('Invalid or missing "symfony.response" context option.', E_USER_WARNING);
54+
}
55+
56+
return false;
57+
}
58+
59+
public function stream_read(int $count)
60+
{
61+
$responseClass = \get_class($this->response);
62+
63+
if (null !== $this->content) {
64+
// Empty activity list
65+
foreach ($responseClass::stream([$this->response], 0) as $chunk) {
66+
$chunk->isTimeout();
67+
}
68+
69+
fseek($this->content, $this->offset);
70+
71+
if ('' !== $data = fread($this->content, $count)) {
72+
fseek($this->content, 0, SEEK_END);
73+
$this->offset += \strlen($data);
74+
75+
return $data;
76+
}
77+
}
78+
79+
foreach ($responseClass::stream([$this->response]) as $chunk) {
80+
try {
81+
if ('' !== $data = $chunk->getContent()) {
82+
$this->offset += \strlen($data);
83+
84+
return $data;
85+
}
86+
} catch (ExceptionInterface $e) {
87+
trigger_error($e->getMessage(), E_USER_WARNING);
88+
89+
return false;
90+
}
91+
}
92+
93+
return '';
94+
}
95+
96+
public function stream_tell(): int
97+
{
98+
return $this->offset;
99+
}
100+
101+
public function stream_eof(): bool
102+
{
103+
if (null !== $this->content) {
104+
fseek($this->content, $this->offset);
105+
$eof = '' === fread($this->content, 1);
106+
fseek($this->content, 0, SEEK_END);
107+
108+
if (!$eof) {
109+
return false;
110+
}
111+
}
112+
113+
$isClosed = function () {
114+
return !isset($this->multi->openHandles[$this->id]) && !isset($this->multi->handlesActivity[$this->id]) && !isset($this->buffer);
115+
};
116+
117+
return \Closure::bind($isClosed, $this->response, $this->response)();
118+
}
119+
120+
public function stream_seek(int $offset, int $whence = SEEK_SET): bool
121+
{
122+
if (null === $this->content) {
123+
return false;
124+
}
125+
126+
if (SEEK_CUR) {
127+
$offset += $this->offset;
128+
}
129+
130+
fseek($this->content, 0, SEEK_END);
131+
$size = ftell($this->content);
132+
133+
if (SEEK_END === $whence || $size < $offset) {
134+
$responseClass = \get_class($this->response);
135+
foreach ($responseClass::stream([$this->response]) as $chunk) {
136+
try {
137+
// Chunks are buffered in $this->content already
138+
$chunk->getContent();
139+
} catch (ExceptionInterface $e) {
140+
trigger_error($e->getMessage(), E_USER_WARNING);
141+
142+
return false;
143+
}
144+
}
145+
146+
fseek($this->content, 0, SEEK_END);
147+
$size = ftell($this->content);
148+
149+
if (SEEK_END === $whence) {
150+
$offset += $size;
151+
}
152+
}
153+
154+
if (0 <= $offset && $offset <= $size) {
155+
$this->offset = $offset;
156+
157+
return true;
158+
}
159+
160+
return false;
161+
}
162+
163+
public function stream_cast(int $castAs)
164+
{
165+
return false;
166+
}
167+
168+
public function stream_stat(): array
169+
{
170+
return [
171+
'dev' => 0,
172+
'ino' => 0,
173+
'mode' => 33060,
174+
'nlink' => 0,
175+
'uid' => 0,
176+
'gid' => 0,
177+
'rdev' => 0,
178+
'size' => (int) ($this->response->getHeaders(false)['content-length'][0] ?? 0),
179+
'atime' => 0,
180+
'mtime' => strtotime($this->response->getHeaders(false)['last-modified'][0] ?? '') ?: 0,
181+
'ctime' => 0,
182+
'blksize' => 0,
183+
'blocks' => 0,
184+
];
185+
}
186+
}

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