Skip to content

Commit 9f5fd61

Browse files
[HttpClient] add PluggableHttpClient to allow processing the response stream
1 parent 2ed6a0d commit 9f5fd61

12 files changed

+657
-188
lines changed

src/Symfony/Component/HttpClient/Chunk/ErrorChunk.php

+5-1
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,12 @@ public function getError(): ?string
110110
/**
111111
* @return bool Whether the wrapped error has been thrown or not
112112
*/
113-
public function didThrow(): bool
113+
public function didThrow(bool $didThrow = null): bool
114114
{
115+
if (null !== $didThrow && $this->didThrow !== $didThrow) {
116+
return !$this->didThrow = $didThrow;
117+
}
118+
115119
return $this->didThrow;
116120
}
117121

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
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;
13+
14+
use Symfony\Component\HttpClient\Response\PluggableResponse;
15+
use Symfony\Component\HttpClient\Response\ResponseStream;
16+
use Symfony\Contracts\HttpClient\HttpClientInterface;
17+
use Symfony\Contracts\HttpClient\ResponseInterface;
18+
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
19+
20+
/**
21+
* Allows processing responses while streaming them.
22+
*
23+
* @author Nicolas Grekas <p@tchwork.com>
24+
*/
25+
class PluggableHttpClient implements HttpClientInterface
26+
{
27+
private $client;
28+
private $pluggableResponseFactory;
29+
30+
public function __construct(HttpClientInterface $client, callable $pluggableResponseFactory)
31+
{
32+
$this->client = $client;
33+
$this->pluggableResponseFactory = $pluggableResponseFactory;
34+
}
35+
36+
/**
37+
* {@inheritdoc}
38+
*/
39+
public function request(string $method, string $url, array $options = []): ResponseInterface
40+
{
41+
$response = ($this->pluggableResponseFactory)($this->client, $method, $url, $options);
42+
43+
if (!$response instanceof PluggableResponse) {
44+
throw new \TypeError(sprintf('The response factory passed to "%s" must return a "%s", "%s" found.', self::class, PluggableResponse::class, get_debug_type($response)));
45+
}
46+
47+
return $response;
48+
}
49+
50+
/**
51+
* {@inheritdoc}
52+
*/
53+
public function stream($responses, float $timeout = null): ResponseStreamInterface
54+
{
55+
if ($responses instanceof PluggableResponse) {
56+
$responses = [$responses];
57+
} elseif (!is_iterable($responses)) {
58+
throw new \TypeError(sprintf('"%s()" expects parameter 1 to be an iterable of PluggableResponse objects, "%s" given.', __METHOD__, get_debug_type($responses)));
59+
}
60+
61+
return new ResponseStream(PluggableResponse::stream($responses, $timeout));
62+
}
63+
}

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

+1
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
*/
3535
final class AmpResponse implements ResponseInterface
3636
{
37+
use CommonResponseTrait;
3738
use ResponseTrait;
3839

3940
private $multi;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
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\Component\HttpClient\Exception\ClientException;
15+
use Symfony\Component\HttpClient\Exception\JsonException;
16+
use Symfony\Component\HttpClient\Exception\RedirectionException;
17+
use Symfony\Component\HttpClient\Exception\ServerException;
18+
use Symfony\Component\HttpClient\Exception\TransportException;
19+
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
20+
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
21+
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
22+
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
23+
24+
/**
25+
* Implements common logic for response classes.
26+
*
27+
* @author Nicolas Grekas <p@tchwork.com>
28+
*
29+
* @internal
30+
*/
31+
trait CommonResponseTrait
32+
{
33+
/**
34+
* @var callable|null A callback that tells whether we're waiting for response headers
35+
*/
36+
private $initializer;
37+
private $shouldBuffer;
38+
private $content;
39+
private $offset = 0;
40+
private $jsonData;
41+
42+
/**
43+
* {@inheritdoc}
44+
*/
45+
public function getContent(bool $throw = true): string
46+
{
47+
if ($this->initializer) {
48+
self::initialize($this);
49+
}
50+
51+
if ($throw) {
52+
$this->checkStatusCode();
53+
}
54+
55+
if (null === $this->content) {
56+
$content = null;
57+
58+
foreach (self::stream([$this]) as $chunk) {
59+
if (!$chunk->isLast()) {
60+
$content .= $chunk->getContent();
61+
}
62+
}
63+
64+
if (null !== $content) {
65+
return $content;
66+
}
67+
68+
if ('HEAD' === $this->getInfo('http_method') || \in_array($this->getInfo('http_code'), [204, 304], true)) {
69+
return '';
70+
}
71+
72+
throw new TransportException('Cannot get the content of the response twice: buffering is disabled.');
73+
}
74+
75+
foreach (self::stream([$this]) as $chunk) {
76+
// Chunks are buffered in $this->content already
77+
}
78+
79+
rewind($this->content);
80+
81+
return stream_get_contents($this->content);
82+
}
83+
84+
/**
85+
* {@inheritdoc}
86+
*/
87+
public function toArray(bool $throw = true): array
88+
{
89+
if ('' === $content = $this->getContent($throw)) {
90+
throw new TransportException('Response body is empty.');
91+
}
92+
93+
if (null !== $this->jsonData) {
94+
return $this->jsonData;
95+
}
96+
97+
$contentType = $this->headers['content-type'][0] ?? 'application/json';
98+
99+
if (!preg_match('/\bjson\b/i', $contentType)) {
100+
throw new JsonException(sprintf('Response content-type is "%s" while a JSON-compatible one was expected for "%s".', $contentType, $this->getInfo('url')));
101+
}
102+
103+
try {
104+
$content = json_decode($content, true, 512, JSON_BIGINT_AS_STRING | (\PHP_VERSION_ID >= 70300 ? JSON_THROW_ON_ERROR : 0));
105+
} catch (\JsonException $e) {
106+
throw new JsonException($e->getMessage().sprintf(' for "%s".', $this->getInfo('url')), $e->getCode());
107+
}
108+
109+
if (\PHP_VERSION_ID < 70300 && JSON_ERROR_NONE !== json_last_error()) {
110+
throw new JsonException(json_last_error_msg().sprintf(' for "%s".', $this->getInfo('url')), json_last_error());
111+
}
112+
113+
if (!\is_array($content)) {
114+
throw new JsonException(sprintf('JSON content was expected to decode to an array, "%s" returned for "%s".', get_debug_type($content), $this->getInfo('url')));
115+
}
116+
117+
if (null !== $this->content) {
118+
// Option "buffer" is true
119+
return $this->jsonData = $content;
120+
}
121+
122+
return $content;
123+
}
124+
125+
/**
126+
* Casts the response to a PHP stream resource.
127+
*
128+
* @return resource
129+
*
130+
* @throws TransportExceptionInterface When a network error occurs
131+
* @throws RedirectionExceptionInterface On a 3xx when $throw is true and the "max_redirects" option has been reached
132+
* @throws ClientExceptionInterface On a 4xx when $throw is true
133+
* @throws ServerExceptionInterface On a 5xx when $throw is true
134+
*/
135+
public function toStream(bool $throw = true)
136+
{
137+
if ($throw) {
138+
// Ensure headers arrived
139+
$this->getHeaders($throw);
140+
}
141+
142+
$stream = StreamWrapper::createResource($this);
143+
stream_get_meta_data($stream)['wrapper_data']
144+
->bindHandles($this->handle, $this->content);
145+
146+
return $stream;
147+
}
148+
149+
/**
150+
* {@inheritdoc}
151+
*/
152+
public function cancel(): void
153+
{
154+
$this->info['canceled'] = true;
155+
$this->info['error'] = 'Response has been canceled.';
156+
$this->close();
157+
}
158+
159+
/**
160+
* Closes the response and all its network handles.
161+
*/
162+
abstract protected function close(): void;
163+
164+
private static function initialize(self $response): void
165+
{
166+
if (null !== $response->getInfo('error')) {
167+
throw new TransportException($response->getInfo('error'));
168+
}
169+
170+
try {
171+
if (($response->initializer)($response)) {
172+
foreach (self::stream([$response]) as $chunk) {
173+
if ($chunk->isFirst()) {
174+
break;
175+
}
176+
}
177+
}
178+
} catch (\Throwable $e) {
179+
// Persist timeouts thrown during initialization
180+
$response->info['error'] = $e->getMessage();
181+
$response->close();
182+
throw $e;
183+
}
184+
185+
$response->initializer = null;
186+
}
187+
188+
private function checkStatusCode()
189+
{
190+
$code = $this->getInfo('http_code');
191+
192+
if (500 <= $code) {
193+
throw new ServerException($this);
194+
}
195+
196+
if (400 <= $code) {
197+
throw new ClientException($this);
198+
}
199+
200+
if (300 <= $code) {
201+
throw new RedirectionException($this);
202+
}
203+
}
204+
}

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

+2-1
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,10 @@
2626
*/
2727
final class CurlResponse implements ResponseInterface
2828
{
29-
use ResponseTrait {
29+
use CommonResponseTrait {
3030
getContent as private doGetContent;
3131
}
32+
use ResponseTrait;
3233

3334
private static $performing = false;
3435
private $multi;

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

+1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
*/
2626
class MockResponse implements ResponseInterface
2727
{
28+
use CommonResponseTrait;
2829
use ResponseTrait {
2930
doDestruct as public __destruct;
3031
}

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

+1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
*/
2626
final class NativeResponse implements ResponseInterface
2727
{
28+
use CommonResponseTrait;
2829
use ResponseTrait;
2930

3031
private $context;

0 commit comments

Comments
 (0)