Skip to content

Commit 9f7d31d

Browse files
feature #32565 [HttpClient] Allow enabling buffering conditionally with a Closure (rjwebdev)
This PR was merged into the 4.4 branch. Discussion ---------- [HttpClient] Allow enabling buffering conditionally with a Closure | Q | A | ------------- | --- | Branch? | 4.4 | Bug fix? | no | New feature? | yes | BC breaks? | no | Deprecations? | no | Tests pass? | yes | Fixed tickets | #31883 | License | MIT | Doc PR | symfony/symfony-docs#12043 With this PR, responses can be buffered automatically from a closure passed to the `buffer` option. ```php $resp = $client->request('GET', $url, [ 'buffer' => function (array $headers): bool { return true/false; }, ]); ``` When no option is provided, buffering is now enabled only for json, xml and text/* content types. Commits ------- f705ac9dc4 [HttpClient] Allow enabling buffering conditionally with a Closure
2 parents 3af4982 + 79a0e55 commit 9f7d31d

10 files changed

+68
-14
lines changed

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
-----

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

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();

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
}

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();

Response/CurlResponse.php

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

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

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

8181
if (null === $options) {
@@ -278,7 +278,7 @@ private static function select(CurlClientState $multi, float $timeout): int
278278
/**
279279
* Parses header lines as curl yields them to us.
280280
*/
281-
private static function parseHeaderLine($ch, string $data, array &$info, array &$headers, ?array $options, CurlClientState $multi, int $id, ?string &$location, ?callable $resolveRedirect, ?LoggerInterface $logger): int
281+
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
282282
{
283283
if (!\in_array($waitFor = @curl_getinfo($ch, CURLINFO_PRIVATE), ['headers', 'destruct'], true)) {
284284
return \strlen($data); // Ignore HTTP trailers
@@ -349,6 +349,10 @@ private static function parseHeaderLine($ch, string $data, array &$info, array &
349349
return 0;
350350
}
351351

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

Response/MockResponse.php

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

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
}

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;

Tests/HttpClientTestCase.php

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

1212
namespace Symfony\Component\HttpClient\Tests;
1313

14+
use Symfony\Component\HttpClient\Exception\TransportException;
1415
use Symfony\Contracts\HttpClient\Test\HttpClientTestCase as BaseHttpClientTestCase;
1516

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

0 commit comments

Comments
 (0)