diff --git a/src/Symfony/Component/HttpClient/CHANGELOG.md b/src/Symfony/Component/HttpClient/CHANGELOG.md index 9fcfa7ee9a..973bb6107b 100644 --- a/src/Symfony/Component/HttpClient/CHANGELOG.md +++ b/src/Symfony/Component/HttpClient/CHANGELOG.md @@ -11,6 +11,7 @@ CHANGELOG * added `$response->toStream()` to cast responses to regular PHP streams * made `Psr18Client` implement relevant PSR-17 factories and have streaming responses * added `TraceableHttpClient`, `HttpClientDataCollector` and `HttpClientPass` to integrate with the web profiler + * allow enabling buffering conditionally with a Closure 4.3.0 ----- diff --git a/src/Symfony/Component/HttpClient/CachingHttpClient.php b/src/Symfony/Component/HttpClient/CachingHttpClient.php index 65426ef647..3d7a1232f6 100644 --- a/src/Symfony/Component/HttpClient/CachingHttpClient.php +++ b/src/Symfony/Component/HttpClient/CachingHttpClient.php @@ -68,9 +68,8 @@ class CachingHttpClient implements HttpClientInterface { [$url, $options] = $this->prepareRequest($method, $url, $options, $this->defaultOptions, true); $url = implode('', $url); - $options['extra']['no_cache'] = $options['extra']['no_cache'] ?? !$options['buffer']; - if (!empty($options['body']) || $options['extra']['no_cache'] || !\in_array($method, ['GET', 'HEAD', 'OPTIONS'])) { + if (!empty($options['body']) || !empty($options['extra']['no_cache']) || !\in_array($method, ['GET', 'HEAD', 'OPTIONS'])) { return $this->client->request($method, $url, $options); } diff --git a/src/Symfony/Component/HttpClient/CurlHttpClient.php b/src/Symfony/Component/HttpClient/CurlHttpClient.php index 9ecd62b086..bdd1992cf3 100644 --- a/src/Symfony/Component/HttpClient/CurlHttpClient.php +++ b/src/Symfony/Component/HttpClient/CurlHttpClient.php @@ -37,7 +37,9 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface use HttpClientTrait; use LoggerAwareTrait; - private $defaultOptions = self::OPTIONS_DEFAULTS + [ + private $defaultOptions = [ + 'buffer' => null, // bool|\Closure - a boolean or a closure telling if the response should be buffered based on its headers + ] + self::OPTIONS_DEFAULTS + [ 'auth_ntlm' => null, // array|string - an array containing the username as first value, and optionally the // password as the second one; or string like username:password - enabling NTLM auth ]; @@ -62,8 +64,10 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface throw new \LogicException('You cannot use the "Symfony\Component\HttpClient\CurlHttpClient" as the "curl" extension is not installed.'); } + $this->defaultOptions['buffer'] = \Closure::fromCallable([__CLASS__, 'shouldBuffer']); + if ($defaultOptions) { - [, $this->defaultOptions] = self::prepareRequest(null, null, $defaultOptions, self::OPTIONS_DEFAULTS); + [, $this->defaultOptions] = self::prepareRequest(null, null, $defaultOptions, $this->defaultOptions); } $this->multi = $multi = new CurlClientState(); diff --git a/src/Symfony/Component/HttpClient/HttpClientTrait.php b/src/Symfony/Component/HttpClient/HttpClientTrait.php index c5c1cdb25f..b42d2369c3 100644 --- a/src/Symfony/Component/HttpClient/HttpClientTrait.php +++ b/src/Symfony/Component/HttpClient/HttpClientTrait.php @@ -503,4 +503,15 @@ trait HttpClientTrait return implode('&', $replace ? array_replace($query, $queryArray) : ($query + $queryArray)); } + + private static function shouldBuffer(array $headers): bool + { + $contentType = $headers['content-type'][0] ?? null; + + if (false !== $i = strpos($contentType, ';')) { + $contentType = substr($contentType, 0, $i); + } + + return $contentType && preg_match('#^(?:text/|application/(?:.+\+)?(?:json|xml)$)#i', $contentType); + } } diff --git a/src/Symfony/Component/HttpClient/NativeHttpClient.php b/src/Symfony/Component/HttpClient/NativeHttpClient.php index f39d72c450..35a26d9e6c 100644 --- a/src/Symfony/Component/HttpClient/NativeHttpClient.php +++ b/src/Symfony/Component/HttpClient/NativeHttpClient.php @@ -35,7 +35,9 @@ final class NativeHttpClient implements HttpClientInterface, LoggerAwareInterfac use HttpClientTrait; use LoggerAwareTrait; - private $defaultOptions = self::OPTIONS_DEFAULTS; + private $defaultOptions = [ + 'buffer' => null, // bool|\Closure - a boolean or a closure telling if the response should be buffered based on its headers + ] + self::OPTIONS_DEFAULTS; /** @var NativeClientState */ private $multi; @@ -48,8 +50,10 @@ final class NativeHttpClient implements HttpClientInterface, LoggerAwareInterfac */ public function __construct(array $defaultOptions = [], int $maxHostConnections = 6) { + $this->defaultOptions['buffer'] = \Closure::fromCallable([__CLASS__, 'shouldBuffer']); + if ($defaultOptions) { - [, $this->defaultOptions] = self::prepareRequest(null, null, $defaultOptions, self::OPTIONS_DEFAULTS); + [, $this->defaultOptions] = self::prepareRequest(null, null, $defaultOptions, $this->defaultOptions); } $this->multi = new NativeClientState(); diff --git a/src/Symfony/Component/HttpClient/Response/CurlResponse.php b/src/Symfony/Component/HttpClient/Response/CurlResponse.php index a064361763..35aec9b09e 100644 --- a/src/Symfony/Component/HttpClient/Response/CurlResponse.php +++ b/src/Symfony/Component/HttpClient/Response/CurlResponse.php @@ -63,18 +63,18 @@ final class CurlResponse implements ResponseInterface } if (null === $content = &$this->content) { - $content = ($options['buffer'] ?? true) ? fopen('php://temp', 'w+') : null; + $content = true === $options['buffer'] ? fopen('php://temp', 'w+') : null; } else { // Move the pushed response to the activity list if (ftell($content)) { rewind($content); $multi->handlesActivity[$id][] = stream_get_contents($content); } - $content = ($options['buffer'] ?? true) ? $content : null; + $content = true === $options['buffer'] ? $content : null; } - curl_setopt($ch, CURLOPT_HEADERFUNCTION, static function ($ch, string $data) use (&$info, &$headers, $options, $multi, $id, &$location, $resolveRedirect, $logger): int { - return self::parseHeaderLine($ch, $data, $info, $headers, $options, $multi, $id, $location, $resolveRedirect, $logger); + curl_setopt($ch, CURLOPT_HEADERFUNCTION, static function ($ch, string $data) use (&$info, &$headers, $options, $multi, $id, &$location, $resolveRedirect, $logger, &$content): int { + return self::parseHeaderLine($ch, $data, $info, $headers, $options, $multi, $id, $location, $resolveRedirect, $logger, $content); }); if (null === $options) { @@ -280,7 +280,7 @@ final class CurlResponse implements ResponseInterface /** * Parses header lines as curl yields them to us. */ - private static function parseHeaderLine($ch, string $data, array &$info, array &$headers, ?array $options, CurlClientState $multi, int $id, ?string &$location, ?callable $resolveRedirect, ?LoggerInterface $logger): int + 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 { if (!\in_array($waitFor = @curl_getinfo($ch, CURLINFO_PRIVATE), ['headers', 'destruct'], true)) { return \strlen($data); // Ignore HTTP trailers @@ -348,6 +348,10 @@ final class CurlResponse implements ResponseInterface return 0; } + if ($options['buffer'] instanceof \Closure && !$content && $options['buffer']($headers)) { + $content = fopen('php://temp', 'w+'); + } + curl_setopt($ch, CURLOPT_PRIVATE, 'content'); } elseif (null !== $info['redirect_url'] && $logger) { $logger->info(sprintf('Redirecting: "%s %s"', $info['http_code'], $info['redirect_url'])); diff --git a/src/Symfony/Component/HttpClient/Response/MockResponse.php b/src/Symfony/Component/HttpClient/Response/MockResponse.php index fe94bc3436..d5599f7765 100644 --- a/src/Symfony/Component/HttpClient/Response/MockResponse.php +++ b/src/Symfony/Component/HttpClient/Response/MockResponse.php @@ -103,7 +103,12 @@ class MockResponse implements ResponseInterface $response = new self([]); $response->requestOptions = $options; $response->id = ++self::$idSequence; - $response->content = ($options['buffer'] ?? true) ? fopen('php://temp', 'w+') : null; + + if (($options['buffer'] ?? null) instanceof \Closure) { + $response->content = $options['buffer']($mock->getHeaders(false)) ? fopen('php://temp', 'w+') : null; + } else { + $response->content = true === ($options['buffer'] ?? true) ? fopen('php://temp', 'w+') : null; + } $response->initializer = static function (self $response) { if (null !== $response->info['error']) { throw new TransportException($response->info['error']); diff --git a/src/Symfony/Component/HttpClient/Response/NativeResponse.php b/src/Symfony/Component/HttpClient/Response/NativeResponse.php index 7aa2d8022d..e0fb09327b 100644 --- a/src/Symfony/Component/HttpClient/Response/NativeResponse.php +++ b/src/Symfony/Component/HttpClient/Response/NativeResponse.php @@ -35,6 +35,7 @@ final class NativeResponse implements ResponseInterface private $inflate; private $multi; private $debugBuffer; + private $shouldBuffer; /** * @internal @@ -50,7 +51,8 @@ final class NativeResponse implements ResponseInterface $this->info = &$info; $this->resolveRedirect = $resolveRedirect; $this->onProgress = $onProgress; - $this->content = $options['buffer'] ? fopen('php://temp', 'w+') : null; + $this->content = true === $options['buffer'] ? fopen('php://temp', 'w+') : null; + $this->shouldBuffer = $options['buffer'] instanceof \Closure ? $options['buffer'] : null; // Temporary resources to dechunk/inflate the response stream $this->buffer = fopen('php://temp', 'w+'); @@ -92,6 +94,8 @@ final class NativeResponse implements ResponseInterface public function __destruct() { + $this->shouldBuffer = null; + try { $this->doDestruct(); } finally { @@ -152,6 +156,10 @@ final class NativeResponse implements ResponseInterface stream_set_blocking($h, false); $this->context = $this->resolveRedirect = null; + if (null !== $this->shouldBuffer && null === $this->content && ($this->shouldBuffer)($this->headers)) { + $this->content = fopen('php://temp', 'w+'); + } + if (isset($context['ssl']['peer_certificate_chain'])) { $this->info['peer_certificate_chain'] = $context['ssl']['peer_certificate_chain']; } diff --git a/src/Symfony/Component/HttpClient/Response/ResponseTrait.php b/src/Symfony/Component/HttpClient/Response/ResponseTrait.php index 4be2706003..c51b3d3c05 100644 --- a/src/Symfony/Component/HttpClient/Response/ResponseTrait.php +++ b/src/Symfony/Component/HttpClient/Response/ResponseTrait.php @@ -117,7 +117,7 @@ trait ResponseTrait } if (null === $content) { - throw new TransportException('Cannot get the content of the response twice: the request was issued with option "buffer" set to false.'); + throw new TransportException('Cannot get the content of the response twice: buffering is disabled.'); } return $content; diff --git a/src/Symfony/Component/HttpClient/Tests/HttpClientTestCase.php b/src/Symfony/Component/HttpClient/Tests/HttpClientTestCase.php index 1e7fe180a3..fbe68d8c80 100644 --- a/src/Symfony/Component/HttpClient/Tests/HttpClientTestCase.php +++ b/src/Symfony/Component/HttpClient/Tests/HttpClientTestCase.php @@ -11,6 +11,7 @@ namespace Symfony\Component\HttpClient\Tests; +use Symfony\Component\HttpClient\Exception\TransportException; use Symfony\Contracts\HttpClient\Test\HttpClientTestCase as BaseHttpClientTestCase; abstract class HttpClientTestCase extends BaseHttpClientTestCase @@ -37,4 +38,21 @@ abstract class HttpClientTestCase extends BaseHttpClientTestCase $this->assertSame('', fread($stream, 1)); $this->assertTrue(feof($stream)); } + + public function testConditionalBuffering() + { + $client = $this->getHttpClient(__FUNCTION__); + $response = $client->request('GET', 'http://localhost:8057'); + $firstContent = $response->getContent(); + $secondContent = $response->getContent(); + + $this->assertSame($firstContent, $secondContent); + + $response = $client->request('GET', 'http://localhost:8057', ['buffer' => function () { return false; }]); + $response->getContent(); + + $this->expectException(TransportException::class); + $this->expectExceptionMessage('Cannot get the content of the response twice: buffering is disabled.'); + $response->getContent(); + } }