diff --git a/src/Symfony/Component/HttpClient/CachingHttpClient.php b/src/Symfony/Component/HttpClient/CachingHttpClient.php new file mode 100644 index 000000000000..30f370078f26 --- /dev/null +++ b/src/Symfony/Component/HttpClient/CachingHttpClient.php @@ -0,0 +1,142 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient; + +use Psr\Log\LoggerInterface; +use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Component\HttpClient\Response\ResponseStream; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\HttpCache\HttpCache; +use Symfony\Component\HttpKernel\HttpCache\StoreInterface; +use Symfony\Component\HttpKernel\HttpClientKernel; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; +use Symfony\Contracts\HttpClient\ResponseStreamInterface; + +/** + * Adds caching on top of an HTTP client. + * + * The implementation buffers responses in memory and doesn't stream directly from the network. + * You can disable/enable this layer by setting option "no_cache" under "extra" to true/false. + * By default, caching is enabled unless the "buffer" option is set to false. + * + * @author Nicolas Grekas + */ +class CachingHttpClient implements HttpClientInterface +{ + use HttpClientTrait; + + private $client; + private $cache; + private $defaultOptions = self::OPTIONS_DEFAULTS; + + public function __construct(HttpClientInterface $client, StoreInterface $store, array $defaultOptions = [], LoggerInterface $logger = null) + { + if (!class_exists(HttpClientKernel::class)) { + throw new \LogicException(sprintf('Using "%s" requires that the HttpKernel component version 4.3 or higher is installed, try running "composer require symfony/http-kernel:^4.3".', __CLASS__)); + } + + $this->client = $client; + $kernel = new HttpClientKernel($client, $logger); + $this->cache = new HttpCache($kernel, $store, null, $defaultOptions); + + unset($defaultOptions['debug']); + unset($defaultOptions['default_ttl']); + unset($defaultOptions['private_headers']); + unset($defaultOptions['allow_reload']); + unset($defaultOptions['allow_revalidate']); + unset($defaultOptions['stale_while_revalidate']); + unset($defaultOptions['stale_if_error']); + + if ($defaultOptions) { + [, $this->defaultOptions] = self::prepareRequest(null, null, $defaultOptions, $this->defaultOptions); + } + } + + /** + * {@inheritdoc} + */ + public function request(string $method, string $url, array $options = []): ResponseInterface + { + [$url, $options] = $this->prepareRequest($method, $url, $options, $this->defaultOptions, true); + $url = implode('', $url); + $options['extra']['no_cache'] = $options['extra']['no_cache'] ?? !$options['buffer']; + + if ($options['extra']['no_cache'] || !empty($options['body']) || !\in_array($method, ['GET', 'HEAD', 'OPTIONS'])) { + return $this->client->request($method, $url, $options); + } + + $request = Request::create($url, $method); + $request->attributes->set('http_client_options', $options); + + foreach ($options['headers'] as $name => $values) { + if ('cookie' !== $name) { + $request->headers->set($name, $values); + continue; + } + + foreach ($values as $cookies) { + foreach (explode('; ', $cookies) as $cookie) { + if ('' !== $cookie) { + $cookie = explode('=', $cookie, 2); + $request->cookies->set($cookie[0], $cookie[1] ?? null); + } + } + } + } + + $response = $this->cache->handle($request); + $response = new MockResponse($response->getContent(), [ + 'http_code' => $response->getStatusCode(), + 'raw_headers' => $response->headers->allPreserveCase(), + ]); + + return MockResponse::fromRequest($method, $url, $options, $response); + } + + /** + * {@inheritdoc} + */ + public function stream($responses, float $timeout = null): ResponseStreamInterface + { + if ($responses instanceof ResponseInterface) { + $responses = [$responses]; + } elseif (!\is_iterable($responses)) { + throw new \TypeError(sprintf('%s() expects parameter 1 to be an iterable of ResponseInterface objects, %s given.', __METHOD__, \is_object($responses) ? \get_class($responses) : \gettype($responses))); + } + + $mockResponses = []; + $clientResponses = []; + + foreach ($responses as $response) { + if ($response instanceof MockResponse) { + $mockResponses[] = $response; + } else { + $clientResponses[] = $response; + } + } + + if (!$mockResponses) { + return $this->client->stream($clientResponses, $timeout); + } + + if (!$clientResponses) { + return new ResponseStream(MockResponse::stream($mockResponses, $timeout)); + } + + return new ResponseStream((function () use ($mockResponses, $clientResponses, $timeout) { + yield from MockResponse::stream($mockResponses, $timeout); + yield $this->client->stream($clientResponses, $timeout); + })()); + } +} diff --git a/src/Symfony/Component/HttpClient/HttpClientTrait.php b/src/Symfony/Component/HttpClient/HttpClientTrait.php index 04178ae65624..9a45d420b98e 100644 --- a/src/Symfony/Component/HttpClient/HttpClientTrait.php +++ b/src/Symfony/Component/HttpClient/HttpClientTrait.php @@ -147,6 +147,10 @@ private static function mergeDefaultOptions(array $options, array $defaultOption $options[$k] = $options[$k] ?? $v; } + if (isset($defaultOptions['extra'])) { + $options['extra'] += $defaultOptions['extra']; + } + if ($defaultOptions['resolve'] ?? false) { $options['resolve'] += array_change_key_case($defaultOptions['resolve']); } diff --git a/src/Symfony/Component/HttpClient/HttpOptions.php b/src/Symfony/Component/HttpClient/HttpOptions.php index 2a29fc66fbeb..85b55f0d0852 100644 --- a/src/Symfony/Component/HttpClient/HttpOptions.php +++ b/src/Symfony/Component/HttpClient/HttpOptions.php @@ -309,4 +309,14 @@ public function capturePeerCertChain(bool $capture) return $this; } + + /** + * @return $this + */ + public function setExtra(string $name, $value) + { + $this->options['extra'][$name] = $value; + + return $this; + } } diff --git a/src/Symfony/Component/HttpClient/composer.json b/src/Symfony/Component/HttpClient/composer.json index 979385626d2c..803b424d3d33 100644 --- a/src/Symfony/Component/HttpClient/composer.json +++ b/src/Symfony/Component/HttpClient/composer.json @@ -26,7 +26,8 @@ "require-dev": { "nyholm/psr7": "^1.0", "psr/http-client": "^1.0", - "symfony/process": "~4.2" + "symfony/http-kernel": "^4.3", + "symfony/process": "^4.2" }, "autoload": { "psr-4": { "Symfony\\Component\\HttpClient\\": "" }, diff --git a/src/Symfony/Component/HttpKernel/RealHttpKernel.php b/src/Symfony/Component/HttpKernel/HttpClientKernel.php similarity index 98% rename from src/Symfony/Component/HttpKernel/RealHttpKernel.php rename to src/Symfony/Component/HttpKernel/HttpClientKernel.php index d78fda1f8dac..29a6a97cefe2 100644 --- a/src/Symfony/Component/HttpKernel/RealHttpKernel.php +++ b/src/Symfony/Component/HttpKernel/HttpClientKernel.php @@ -27,7 +27,7 @@ * * @author Fabien Potencier */ -final class RealHttpKernel implements HttpKernelInterface +final class HttpClientKernel implements HttpKernelInterface { private $client; private $logger; diff --git a/src/Symfony/Contracts/HttpClient/HttpClientInterface.php b/src/Symfony/Contracts/HttpClient/HttpClientInterface.php index 6abc459fee78..194a8fb8afee 100644 --- a/src/Symfony/Contracts/HttpClient/HttpClientInterface.php +++ b/src/Symfony/Contracts/HttpClient/HttpClientInterface.php @@ -64,6 +64,7 @@ interface HttpClientInterface 'ciphers' => null, 'peer_fingerprint' => null, 'capture_peer_cert_chain' => false, + 'extra' => [], // array - additional options that can be ignored if unsupported, unlike regular options ]; /**