Skip to content

Commit db9b15a

Browse files
rjwebdevnicolas-grekas
authored andcommitted
[HttpClient] Allow enabling buffering conditionally with a Closure
1 parent 9ea05b1 commit db9b15a

File tree

10 files changed

+69
-14
lines changed

10 files changed

+69
-14
lines changed

src/Symfony/Component/HttpClient/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ CHANGELOG
1111
* added `$response->toStream()` to cast responses to regular PHP streams
1212
* made `Psr18Client` implement relevant PSR-17 factories and have streaming responses
1313
* added `TraceableHttpClient`, `HttpClientDataCollector` and `HttpClientPass` to integrate with the web profiler
14+
* allow enabling buffering conditionally with a Closure
1415

1516
4.3.0
1617
-----

src/Symfony/Component/HttpClient/CachingHttpClient.php

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,8 @@ public function request(string $method, string $url, array $options = []): Respo
6868
{
6969
[$url, $options] = $this->prepareRequest($method, $url, $options, $this->defaultOptions, true);
7070
$url = implode('', $url);
71-
$options['extra']['no_cache'] = $options['extra']['no_cache'] ?? !$options['buffer'];
7271

73-
if (!empty($options['body']) || $options['extra']['no_cache'] || !\in_array($method, ['GET', 'HEAD', 'OPTIONS'])) {
72+
if (!empty($options['body']) || !empty($options['extra']['no_cache']) || !\in_array($method, ['GET', 'HEAD', 'OPTIONS'])) {
7473
return $this->client->request($method, $url, $options);
7574
}
7675

src/Symfony/Component/HttpClient/CurlHttpClient.php

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface
3737
use HttpClientTrait;
3838
use LoggerAwareTrait;
3939

40-
private $defaultOptions = self::OPTIONS_DEFAULTS + [
40+
private $defaultOptions = [
41+
'buffer' => null, // bool|\Closure - a boolean or a closure telling if the response should be buffered based on its headers
42+
] + self::OPTIONS_DEFAULTS + [
4143
'auth_ntlm' => null, // array|string - an array containing the username as first value, and optionally the
4244
// password as the second one; or string like username:password - enabling NTLM auth
4345
];
@@ -62,8 +64,10 @@ public function __construct(array $defaultOptions = [], int $maxHostConnections
6264
throw new \LogicException('You cannot use the "Symfony\Component\HttpClient\CurlHttpClient" as the "curl" extension is not installed.');
6365
}
6466

67+
$this->defaultOptions['buffer'] = \Closure::fromCallable([__CLASS__, 'shouldBuffer']);
68+
6569
if ($defaultOptions) {
66-
[, $this->defaultOptions] = self::prepareRequest(null, null, $defaultOptions, self::OPTIONS_DEFAULTS);
70+
[, $this->defaultOptions] = self::prepareRequest(null, null, $defaultOptions, $this->defaultOptions);
6771
}
6872

6973
$this->multi = $multi = new CurlClientState();

src/Symfony/Component/HttpClient/HttpClientTrait.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,4 +503,15 @@ private static function mergeQueryString(?string $queryString, array $queryArray
503503

504504
return implode('&', $replace ? array_replace($query, $queryArray) : ($query + $queryArray));
505505
}
506+
507+
private static function shouldBuffer(array $headers): bool
508+
{
509+
$contentType = $headers['content-type'][0] ?? null;
510+
511+
if (false !== $i = strpos($contentType, ';')) {
512+
$contentType = substr($contentType, 0, $i);
513+
}
514+
515+
return $contentType && preg_match('#^(?:text/|application/(?:.+\+)?(?:json|xml)$)#i', $contentType);
516+
}
506517
}

src/Symfony/Component/HttpClient/NativeHttpClient.php

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ final class NativeHttpClient implements HttpClientInterface, LoggerAwareInterfac
3535
use HttpClientTrait;
3636
use LoggerAwareTrait;
3737

38-
private $defaultOptions = self::OPTIONS_DEFAULTS;
38+
private $defaultOptions = [
39+
'buffer' => null, // bool|\Closure - a boolean or a closure telling if the response should be buffered based on its headers
40+
] + self::OPTIONS_DEFAULTS;
3941

4042
/** @var NativeClientState */
4143
private $multi;
@@ -48,8 +50,10 @@ final class NativeHttpClient implements HttpClientInterface, LoggerAwareInterfac
4850
*/
4951
public function __construct(array $defaultOptions = [], int $maxHostConnections = 6)
5052
{
53+
$this->defaultOptions['buffer'] = \Closure::fromCallable([__CLASS__, 'shouldBuffer']);
54+
5155
if ($defaultOptions) {
52-
[, $this->defaultOptions] = self::prepareRequest(null, null, $defaultOptions, self::OPTIONS_DEFAULTS);
56+
[, $this->defaultOptions] = self::prepareRequest(null, null, $defaultOptions, $this->defaultOptions);
5357
}
5458

5559
$this->multi = new NativeClientState();

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

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,18 +63,18 @@ public function __construct(CurlClientState $multi, $ch, array $options = null,
6363
}
6464

6565
if (null === $content = &$this->content) {
66-
$content = ($options['buffer'] ?? true) ? fopen('php://temp', 'w+') : null;
66+
$content = true === $options['buffer'] ? fopen('php://temp', 'w+') : null;
6767
} else {
6868
// Move the pushed response to the activity list
6969
if (ftell($content)) {
7070
rewind($content);
7171
$multi->handlesActivity[$id][] = stream_get_contents($content);
7272
}
73-
$content = ($options['buffer'] ?? true) ? $content : null;
73+
$content = true === $options['buffer'] ? $content : null;
7474
}
7575

76-
curl_setopt($ch, CURLOPT_HEADERFUNCTION, static function ($ch, string $data) use (&$info, &$headers, $options, $multi, $id, &$location, $resolveRedirect, $logger): int {
77-
return self::parseHeaderLine($ch, $data, $info, $headers, $options, $multi, $id, $location, $resolveRedirect, $logger);
76+
curl_setopt($ch, CURLOPT_HEADERFUNCTION, static function ($ch, string $data) use (&$info, &$headers, $options, $multi, $id, &$location, $resolveRedirect, $logger, &$content): int {
77+
return self::parseHeaderLine($ch, $data, $info, $headers, $options, $multi, $id, $location, $resolveRedirect, $logger, $content);
7878
});
7979

8080
if (null === $options) {
@@ -280,7 +280,7 @@ private static function select(CurlClientState $multi, float $timeout): int
280280
/**
281281
* Parses header lines as curl yields them to us.
282282
*/
283-
private static function parseHeaderLine($ch, string $data, array &$info, array &$headers, ?array $options, CurlClientState $multi, int $id, ?string &$location, ?callable $resolveRedirect, ?LoggerInterface $logger): int
283+
private static function parseHeaderLine($ch, string $data, array &$info, array &$headers, ?array $options, CurlClientState $multi, int $id, ?string &$location, ?callable $resolveRedirect, ?LoggerInterface $logger, &$content = null): int
284284
{
285285
if (!\in_array($waitFor = @curl_getinfo($ch, CURLINFO_PRIVATE), ['headers', 'destruct'], true)) {
286286
return \strlen($data); // Ignore HTTP trailers
@@ -348,6 +348,10 @@ private static function parseHeaderLine($ch, string $data, array &$info, array &
348348
return 0;
349349
}
350350

351+
if ($options['buffer'] instanceof \Closure && !$content && $options['buffer']($headers)) {
352+
$content = fopen('php://temp', 'w+');
353+
}
354+
351355
curl_setopt($ch, CURLOPT_PRIVATE, 'content');
352356
} elseif (null !== $info['redirect_url'] && $logger) {
353357
$logger->info(sprintf('Redirecting: "%s %s"', $info['http_code'], $info['redirect_url']));

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,12 @@ public static function fromRequest(string $method, string $url, array $options,
103103
$response = new self([]);
104104
$response->requestOptions = $options;
105105
$response->id = ++self::$idSequence;
106-
$response->content = ($options['buffer'] ?? true) ? fopen('php://temp', 'w+') : null;
106+
107+
if (($options['buffer'] ?? null) instanceof \Closure) {
108+
$response->content = $options['buffer']($mock->getHeaders(false)) ? fopen('php://temp', 'w+') : null;
109+
} else {
110+
$response->content = true === ($options['buffer'] ?? true) ? fopen('php://temp', 'w+') : null;
111+
}
107112
$response->initializer = static function (self $response) {
108113
if (null !== $response->info['error']) {
109114
throw new TransportException($response->info['error']);

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ final class NativeResponse implements ResponseInterface
3535
private $inflate;
3636
private $multi;
3737
private $debugBuffer;
38+
private $shouldBuffer;
3839

3940
/**
4041
* @internal
@@ -50,7 +51,8 @@ public function __construct(NativeClientState $multi, $context, string $url, $op
5051
$this->info = &$info;
5152
$this->resolveRedirect = $resolveRedirect;
5253
$this->onProgress = $onProgress;
53-
$this->content = $options['buffer'] ? fopen('php://temp', 'w+') : null;
54+
$this->content = true === $options['buffer'] ? fopen('php://temp', 'w+') : null;
55+
$this->shouldBuffer = $options['buffer'] instanceof \Closure ? $options['buffer'] : null;
5456

5557
// Temporary resources to dechunk/inflate the response stream
5658
$this->buffer = fopen('php://temp', 'w+');
@@ -92,6 +94,8 @@ public function getInfo(string $type = null)
9294

9395
public function __destruct()
9496
{
97+
$this->shouldBuffer = null;
98+
9599
try {
96100
$this->doDestruct();
97101
} finally {
@@ -152,6 +156,10 @@ private function open(): void
152156
stream_set_blocking($h, false);
153157
$this->context = $this->resolveRedirect = null;
154158

159+
if (null !== $this->shouldBuffer && null === $this->content && ($this->shouldBuffer)($this->headers)) {
160+
$this->content = fopen('php://temp', 'w+');
161+
}
162+
155163
if (isset($context['ssl']['peer_certificate_chain'])) {
156164
$this->info['peer_certificate_chain'] = $context['ssl']['peer_certificate_chain'];
157165
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ public function getContent(bool $throw = true): string
117117
}
118118

119119
if (null === $content) {
120-
throw new TransportException('Cannot get the content of the response twice: the request was issued with option "buffer" set to false.');
120+
throw new TransportException('Cannot get the content of the response twice: buffering is disabled.');
121121
}
122122

123123
return $content;

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
namespace Symfony\Component\HttpClient\Tests;
1313

14+
use Symfony\Component\HttpClient\Exception\InvalidArgumentException;
15+
use Symfony\Component\HttpClient\Exception\TransportException;
1416
use Symfony\Contracts\HttpClient\Test\HttpClientTestCase as BaseHttpClientTestCase;
1517

1618
abstract class HttpClientTestCase extends BaseHttpClientTestCase
@@ -37,4 +39,21 @@ public function testToStream()
3739
$this->assertSame('', fread($stream, 1));
3840
$this->assertTrue(feof($stream));
3941
}
42+
43+
public function testConditionalBuffering()
44+
{
45+
$client = $this->getHttpClient(__FUNCTION__);
46+
$response = $client->request('GET', 'http://localhost:8057');
47+
$firstContent = $response->getContent();
48+
$secondContent = $response->getContent();
49+
50+
$this->assertSame($firstContent, $secondContent);
51+
52+
$response = $client->request('GET', 'http://localhost:8057', ['buffer' => function () { return false; }]);
53+
$response->getContent();
54+
55+
$this->expectException(TransportException::class);
56+
$this->expectExceptionMessage('Cannot get the content of the response twice: buffering is disabled.');
57+
$response->getContent();
58+
}
4059
}

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