diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/http_client_caching.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/http_client_caching.php
new file mode 100644
index 0000000000000..bcfdbc1dae28d
--- /dev/null
+++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/http_client_caching.php
@@ -0,0 +1,24 @@
+loadFromExtension('framework', [
+ 'annotations' => false,
+ 'http_method_override' => false,
+ 'handle_all_throwables' => true,
+ 'php_errors' => ['log' => true],
+ 'http_client' => [
+ 'default_options' => [
+ 'headers' => ['X-powered' => 'PHP'],
+ 'caching' => [
+ 'cache_pool' => 'foo',
+ 'shared' => false,
+ 'max_ttl' => 2,
+ ],
+ ],
+ 'scoped_clients' => [
+ 'bar' => [
+ 'base_uri' => 'http://example.com',
+ 'caching' => ['cache_pool' => 'baz'],
+ ],
+ ],
+ ],
+]);
diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/http_client_caching.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/http_client_caching.xml
new file mode 100644
index 0000000000000..d7a51ffc5262c
--- /dev/null
+++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/http_client_caching.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+ PHP
+
+
+
+
+
+
+
+
diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/http_client_caching.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/http_client_caching.yml
new file mode 100644
index 0000000000000..1c70128100008
--- /dev/null
+++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/http_client_caching.yml
@@ -0,0 +1,19 @@
+framework:
+ annotations: false
+ http_method_override: false
+ handle_all_throwables: true
+ php_errors:
+ log: true
+ http_client:
+ default_options:
+ headers:
+ X-powered: PHP
+ caching:
+ cache_pool: foo
+ shared: false
+ max_ttl: 2
+ scoped_clients:
+ bar:
+ base_uri: http://example.com
+ caching:
+ cache_pool: baz
diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php
index d942c122c826a..a06bb34bd4fb6 100644
--- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php
+++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php
@@ -51,6 +51,8 @@
use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerInterface;
+use Symfony\Component\HttpClient\CachingHttpClient;
+use Symfony\Component\HttpClient\Exception\ChunkCacheItemNotFoundException;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\RetryableHttpClient;
use Symfony\Component\HttpClient\ScopingHttpClient;
@@ -2073,6 +2075,39 @@ public function testHttpClientOverrideDefaultOptions()
$this->assertEquals($expected, $container->getDefinition('foo')->getArgument(2));
}
+ public function testCachingHttpClient()
+ {
+ if (!class_exists(ChunkCacheItemNotFoundException::class)) {
+ $this->expectException(LogicException::class);
+ }
+
+ $container = $this->createContainerFromFile('http_client_caching');
+
+ $this->assertTrue($container->hasDefinition('http_client.caching'));
+ $definition = $container->getDefinition('http_client.caching');
+ $this->assertSame(CachingHttpClient::class, $definition->getClass());
+ $this->assertSame('http_client', $definition->getDecoratedService()[0]);
+ $this->assertCount(5, $arguments = $definition->getArguments());
+ $this->assertInstanceOf(Reference::class, $arguments[0]);
+ $this->assertSame('http_client.caching.inner', (string) $arguments[0]);
+ $this->assertInstanceOf(Reference::class, $arguments[1]);
+ $this->assertSame('foo', (string) $arguments[1]);
+ $this->assertArrayHasKey('headers', $arguments[2]);
+ $this->assertSame(['X-powered' => 'PHP'], $arguments[2]['headers']);
+ $this->assertFalse($arguments[3]);
+ $this->assertSame(2, $arguments[4]);
+
+ $this->assertTrue($container->hasDefinition('bar.caching'));
+ $definition = $container->getDefinition('bar.caching');
+ $this->assertSame(CachingHttpClient::class, $definition->getClass());
+ $this->assertSame('bar', $definition->getDecoratedService()[0]);
+ $arguments = $definition->getArguments();
+ $this->assertInstanceOf(Reference::class, $arguments[0]);
+ $this->assertSame('bar.caching.inner', (string) $arguments[0]);
+ $this->assertInstanceOf(Reference::class, $arguments[1]);
+ $this->assertSame('baz', (string) $arguments[1]);
+ }
+
public function testHttpClientRetry()
{
$container = $this->createContainerFromFile('http_client_retry');
diff --git a/src/Symfony/Component/HttpClient/CHANGELOG.md b/src/Symfony/Component/HttpClient/CHANGELOG.md
index 40dc2ec5d5445..bc7d1dd5bb30f 100644
--- a/src/Symfony/Component/HttpClient/CHANGELOG.md
+++ b/src/Symfony/Component/HttpClient/CHANGELOG.md
@@ -6,6 +6,7 @@ CHANGELOG
* Add IPv6 support to `NativeHttpClient`
* Allow using HTTP/3 with the `CurlHttpClient`
+ * Add RFC 9111–based caching support to `CachingHttpClient`
7.2
---
diff --git a/src/Symfony/Component/HttpClient/Caching/Freshness.php b/src/Symfony/Component/HttpClient/Caching/Freshness.php
new file mode 100644
index 0000000000000..ef280807b4bab
--- /dev/null
+++ b/src/Symfony/Component/HttpClient/Caching/Freshness.php
@@ -0,0 +1,35 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpClient\Caching;
+
+/**
+ * @internal
+ */
+enum Freshness
+{
+ /**
+ * The cached response is fresh and can be used without revalidation.
+ */
+ case Fresh;
+ /**
+ * The cached response is stale and must be revalidated before use.
+ */
+ case MustRevalidate;
+ /**
+ * The cached response is stale and should not be used.
+ */
+ case Stale;
+ /**
+ * The cached response is stale but may be used as a fallback in case of errors.
+ */
+ case StaleButUsable;
+}
diff --git a/src/Symfony/Component/HttpClient/CachingHttpClient.php b/src/Symfony/Component/HttpClient/CachingHttpClient.php
index 6b14973891d5d..10c8697748411 100644
--- a/src/Symfony/Component/HttpClient/CachingHttpClient.php
+++ b/src/Symfony/Component/HttpClient/CachingHttpClient.php
@@ -11,55 +11,118 @@
namespace Symfony\Component\HttpClient;
+use Symfony\Component\HttpClient\Caching\Freshness;
+use Symfony\Component\HttpClient\Chunk\ErrorChunk;
+use Symfony\Component\HttpClient\Exception\ChunkCacheItemNotFoundException;
+use Symfony\Component\HttpClient\Response\AsyncContext;
+use Symfony\Component\HttpClient\Response\AsyncResponse;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Component\HttpClient\Response\ResponseStream;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\HttpCache\HttpCache;
use Symfony\Component\HttpKernel\HttpCache\StoreInterface;
use Symfony\Component\HttpKernel\HttpClientKernel;
+use Symfony\Contracts\Cache\ItemInterface;
+use Symfony\Contracts\Cache\TagAwareCacheInterface;
+use Symfony\Contracts\HttpClient\ChunkInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
use Symfony\Contracts\Service\ResetInterface;
/**
- * Adds caching on top of an HTTP client.
+ * Adds caching on top of an HTTP client (per RFC 9111).
*
- * 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.
+ * Known omissions / partially supported features per RFC 9111:
+ * 1. Range requests:
+ * - All range requests ("partial content") are passed through and never cached.
+ * 2. stale-while-revalidate:
+ * - There's no actual "background revalidation" for stale responses, they will
+ * always be revalidated.
+ * 3. min-fresh, max-stale, only-if-cached:
+ * - Request directives are not parsed; the client ignores them.
*
- * @author Nicolas Grekas
+ * @see https://www.rfc-editor.org/rfc/rfc9111
*/
class CachingHttpClient implements HttpClientInterface, ResetInterface
{
+ use AsyncDecoratorTrait {
+ stream as asyncStream;
+ AsyncDecoratorTrait::withOptions insteadof HttpClientTrait;
+ }
use HttpClientTrait;
- private HttpCache $cache;
+ /**
+ * The status codes that are always cacheable.
+ */
+ private const CACHEABLE_STATUS_CODES = [200, 203, 204, 300, 301, 404, 410];
+ /**
+ * The status codes that are cacheable if the response carries explicit cache directives.
+ */
+ private const CONDITIONALLY_CACHEABLE_STATUS_CODES = [302, 303, 307, 308];
+ /**
+ * The HTTP methods that are always cacheable.
+ */
+ private const CACHEABLE_METHODS = ['GET', 'HEAD'];
+ /**
+ * The HTTP methods that will trigger a cache invalidation.
+ */
+ private const UNSAFE_METHODS = ['POST', 'PUT', 'DELETE', 'PATCH'];
+ /**
+ * Headers that MUST NOT be stored as per RFC 9111 Section 3.1.
+ */
+ private const EXCLUDED_HEADERS = [
+ 'connection' => true,
+ 'proxy-authenticate' => true,
+ 'proxy-authentication-info' => true,
+ 'proxy-authorization' => true,
+ ];
+ /**
+ * Maximum heuristic freshness lifetime in seconds (24 hours).
+ */
+ private const MAX_HEURISTIC_FRESHNESS_TTL = 86400;
+
+ private TagAwareCacheInterface|HttpCache $cache;
private array $defaultOptions = self::OPTIONS_DEFAULTS;
+ /**
+ * @param bool $sharedCache Indicates whether this cache is shared or private. When true, responses
+ * may be skipped from caching in presence of certain headers
+ * (e.g. Authorization) unless explicitly marked as public.
+ * @param int|null $maxTtl The maximum time-to-live (in seconds) for cached responses.
+ * If a server-provided TTL exceeds this value, it will be capped
+ * to this maximum.
+ */
public function __construct(
private HttpClientInterface $client,
- StoreInterface $store,
+ TagAwareCacheInterface|StoreInterface $cache,
array $defaultOptions = [],
+ private readonly bool $sharedCache = true,
+ private readonly ?int $maxTtl = null,
) {
- if (!class_exists(HttpClientKernel::class)) {
- throw new \LogicException(\sprintf('Using "%s" requires the HttpKernel component, try running "composer require symfony/http-kernel".', __CLASS__));
- }
+ if ($cache instanceof StoreInterface) {
+ trigger_deprecation('symfony/http-client', '7.3', 'Passing a "%s" as constructor\'s 2nd argument of "%s" is deprecated, "%s" expected.', StoreInterface::class, __CLASS__, TagAwareCacheInterface::class);
- $kernel = new HttpClientKernel($client);
- $this->cache = new HttpCache($kernel, $store, null, $defaultOptions);
+ if (!class_exists(HttpClientKernel::class)) {
+ throw new \LogicException(\sprintf('Using "%s" requires the HttpKernel component, try running "composer require symfony/http-kernel".', __CLASS__));
+ }
+
+ $kernel = new HttpClientKernel($client);
+ $this->cache = new HttpCache($kernel, $cache, null, $defaultOptions);
- unset($defaultOptions['debug']);
- unset($defaultOptions['default_ttl']);
- unset($defaultOptions['private_headers']);
- unset($defaultOptions['skip_response_headers']);
- unset($defaultOptions['allow_reload']);
- unset($defaultOptions['allow_revalidate']);
- unset($defaultOptions['stale_while_revalidate']);
- unset($defaultOptions['stale_if_error']);
- unset($defaultOptions['trace_level']);
- unset($defaultOptions['trace_header']);
+ unset($defaultOptions['debug']);
+ unset($defaultOptions['default_ttl']);
+ unset($defaultOptions['private_headers']);
+ unset($defaultOptions['skip_response_headers']);
+ unset($defaultOptions['allow_reload']);
+ unset($defaultOptions['allow_revalidate']);
+ unset($defaultOptions['stale_while_revalidate']);
+ unset($defaultOptions['stale_if_error']);
+ unset($defaultOptions['trace_level']);
+ unset($defaultOptions['trace_header']);
+ } else {
+ $this->cache = $cache;
+ }
if ($defaultOptions) {
[, $this->defaultOptions] = self::prepareRequest(null, null, $defaultOptions, $this->defaultOptions);
@@ -67,12 +130,246 @@ public function __construct(
}
public function request(string $method, string $url, array $options = []): ResponseInterface
+ {
+ if ($this->cache instanceof HttpCache) {
+ return $this->legacyRequest($method, $url, $options);
+ }
+
+ [$fullUrl, $options] = self::prepareRequest($method, $url, $options, $this->defaultOptions);
+
+ $fullUrl = implode('', $fullUrl);
+ $fullUrlTag = self::hash($fullUrl);
+
+ if (\in_array($method, self::UNSAFE_METHODS, true)) {
+ $this->cache->invalidateTags([$fullUrlTag]);
+ }
+
+ if ('' !== $options['body'] || isset($options['normalized_headers']['range']) || !\in_array($method, self::CACHEABLE_METHODS, true)) {
+ return new AsyncResponse($this->client, $method, $url, $options);
+ }
+
+ $requestHash = self::hash($method.$fullUrl);
+ $varyKey = "vary_{$requestHash}";
+ $varyFields = $this->cache->get($varyKey, static fn (): array => []);
+
+ $metadataKey = self::getMetadataKey($requestHash, $options['normalized_headers'], $varyFields);
+ $cachedData = $this->cache->get($metadataKey, static fn (): null => null);
+
+ $freshness = null;
+ if (\is_array($cachedData)) {
+ $freshness = $this->evaluateCacheFreshness($cachedData);
+
+ if (Freshness::Fresh === $freshness) {
+ return $this->createResponseFromCache($metadataKey, $cachedData, $method, $url, $options, $fullUrlTag);
+ }
+
+ if (isset($cachedData['headers']['etag'])) {
+ $options['headers']['If-None-Match'] = implode(', ', $cachedData['headers']['etag']);
+ }
+
+ if (isset($cachedData['headers']['last-modified'][0])) {
+ $options['headers']['If-Modified-Since'] = $cachedData['headers']['last-modified'][0];
+ }
+ }
+
+ // consistent expiration time for all items
+ $expiresAt = null === $this->maxTtl ? null : \DateTimeImmutable::createFromFormat('U', time() + $this->maxTtl);
+
+ return new AsyncResponse(
+ $this->client,
+ $method,
+ $url,
+ $options,
+ function (ChunkInterface $chunk, AsyncContext $context) use (
+ $expiresAt,
+ $fullUrlTag,
+ $requestHash,
+ $varyKey,
+ &$metadataKey,
+ $cachedData,
+ $freshness,
+ $url,
+ $method,
+ $options,
+ ): \Generator {
+ static $chunkIndex = -1;
+ static $varyFields;
+
+ if (null !== $chunk->getError() || $chunk->isTimeout()) {
+ if (Freshness::StaleButUsable === $freshness) {
+ // avoid throwing exception in ErrorChunk#__destruct()
+ $chunk instanceof ErrorChunk && $chunk->didThrow(true);
+ $context->passthru();
+ $context->replaceResponse($this->createResponseFromCache($metadataKey, $cachedData, $method, $url, $options, $fullUrlTag));
+
+ return;
+ }
+
+ if (Freshness::MustRevalidate === $freshness) {
+ // avoid throwing exception in ErrorChunk#__destruct()
+ $chunk instanceof ErrorChunk && $chunk->didThrow(true);
+ $context->passthru();
+ $context->replaceResponse(self::createGatewayTimeoutResponse($method, $url, $options));
+
+ return;
+ }
+
+ yield $chunk;
+
+ return;
+ }
+
+ $headers = $context->getHeaders();
+ $cacheControl = self::parseCacheControlHeader($headers['cache-control'] ?? []);
+
+ if ($chunk->isFirst()) {
+ $statusCode = $context->getStatusCode();
+
+ if (304 === $statusCode && null !== $freshness) {
+ $maxAge = $this->determineMaxAge($headers, $cacheControl);
+
+ $this->cache->get($metadataKey, static function (ItemInterface $item) use ($headers, $maxAge, $cachedData, $expiresAt, $fullUrlTag): array {
+ $item->expiresAt($expiresAt)->tag($fullUrlTag);
+
+ $cachedData['expires_at'] = self::calculateExpiresAt($maxAge);
+ $cachedData['stored_at'] = time();
+ $cachedData['initial_age'] = (int) ($headers['age'][0] ?? 0);
+ $cachedData['headers'] = array_merge($cachedData['headers'], array_diff_key($headers, self::EXCLUDED_HEADERS));
+
+ return $cachedData;
+ }, \INF);
+
+ $context->passthru();
+ $context->replaceResponse($this->createResponseFromCache($metadataKey, $cachedData, $method, $url, $options, $fullUrlTag));
+
+ return;
+ }
+
+ if ($statusCode >= 500 && $statusCode < 600) {
+ if (Freshness::StaleButUsable === $freshness) {
+ $context->passthru();
+ $context->replaceResponse($this->createResponseFromCache($metadataKey, $cachedData, $method, $url, $options, $fullUrlTag));
+
+ return;
+ }
+
+ if (Freshness::MustRevalidate === $freshness) {
+ $context->passthru();
+ $context->replaceResponse(self::createGatewayTimeoutResponse($method, $url, $options));
+
+ return;
+ }
+ }
+
+ // recomputing vary fields in case it changed or for first request
+ $varyFields = [];
+ foreach ($headers['vary'] ?? [] as $vary) {
+ foreach (explode(',', $vary) as $field) {
+ $varyFields[] = trim($field);
+ }
+ }
+
+ if (\in_array('*', $varyFields, true)) {
+ $context->passthru();
+
+ yield $chunk;
+
+ return;
+ }
+
+ $metadataKey = self::getMetadataKey($requestHash, $options['normalized_headers'], $varyFields);
+
+ yield $chunk;
+
+ return;
+ }
+
+ if (!$this->isServerResponseCacheable($context->getStatusCode(), $options['normalized_headers'], $headers, $cacheControl)) {
+ $context->passthru();
+
+ yield $chunk;
+
+ return;
+ }
+
+ if ($chunk->isLast()) {
+ $this->cache->get($varyKey, static function (ItemInterface $item) use ($varyFields, $expiresAt, $fullUrlTag): array {
+ $item->tag($fullUrlTag)->expiresAt($expiresAt);
+
+ return $varyFields;
+ }, \INF);
+
+ $maxAge = $this->determineMaxAge($headers, $cacheControl);
+
+ $this->cache->get($metadataKey, static function (ItemInterface $item) use ($context, $headers, $maxAge, $expiresAt, $fullUrlTag, $chunkIndex): array {
+ $item->tag($fullUrlTag)->expiresAt($expiresAt);
+
+ return [
+ 'status_code' => $context->getStatusCode(),
+ 'headers' => array_diff_key($headers, self::EXCLUDED_HEADERS),
+ 'initial_age' => (int) ($headers['age'][0] ?? 0),
+ 'stored_at' => time(),
+ 'expires_at' => self::calculateExpiresAt($maxAge),
+ 'chunks_count' => $chunkIndex,
+ ];
+ }, \INF);
+
+ yield $chunk;
+
+ return;
+ }
+
+ ++$chunkIndex;
+ $chunkKey = "{$metadataKey}_chunk_{$chunkIndex}";
+ $this->cache->get($chunkKey, static function (ItemInterface $item) use ($expiresAt, $fullUrlTag, $chunk): string {
+ $item->tag($fullUrlTag)->expiresAt($expiresAt);
+
+ return $chunk->getContent();
+ }, \INF);
+
+ yield $chunk;
+ }
+ );
+ }
+
+ public function stream(ResponseInterface|iterable $responses, ?float $timeout = null): ResponseStreamInterface
+ {
+ if ($responses instanceof ResponseInterface) {
+ $responses = [$responses];
+ }
+
+ $mockResponses = [];
+ $asyncResponses = [];
+
+ foreach ($responses as $response) {
+ if ($response instanceof MockResponse) {
+ $mockResponses[] = $response;
+ } else {
+ $asyncResponses[] = $response;
+ }
+ }
+
+ if (!$mockResponses) {
+ return $this->asyncStream($asyncResponses, $timeout);
+ }
+
+ if (!$asyncResponses) {
+ return new ResponseStream(MockResponse::stream($mockResponses, $timeout));
+ }
+
+ return new ResponseStream((function () use ($mockResponses, $asyncResponses, $timeout) {
+ yield from MockResponse::stream($mockResponses, $timeout);
+ yield from $this->asyncStream($asyncResponses, $timeout);
+ })());
+ }
+
+ private function legacyRequest(string $method, string $url, array $options = []): ResponseInterface
{
[$url, $options] = $this->prepareRequest($method, $url, $options, $this->defaultOptions, true);
$url = implode('', $url);
if (!empty($options['body']) || !empty($options['extra']['no_cache']) || !\in_array($method, ['GET', 'HEAD', 'OPTIONS'])) {
- return $this->client->request($method, $url, $options);
+ return new AsyncResponse($this->client, $method, $url, $options);
}
$request = Request::create($url, $method);
@@ -106,41 +403,307 @@ public function request(string $method, string $url, array $options = []): Respo
return MockResponse::fromRequest($method, $url, $options, $response);
}
- public function stream(ResponseInterface|iterable $responses, ?float $timeout = null): ResponseStreamInterface
+ private static function hash(string $toHash): string
{
- if ($responses instanceof ResponseInterface) {
- $responses = [$responses];
- }
+ return str_replace('/', '_', base64_encode(hash('sha256', $toHash, true)));
+ }
- $mockResponses = [];
- $clientResponses = [];
+ /**
+ * Generates a unique metadata key based on the request hash and varying headers.
+ *
+ * @param string $requestHash a hash representing the request details
+ * @param array $normalizedHeaders normalized headers of the request
+ * @param string[] $varyFields headers to consider for building the variant key
+ *
+ * @return string the metadata key composed of the request hash and variant key
+ */
+ private static function getMetadataKey(string $requestHash, array $normalizedHeaders, array $varyFields): string
+ {
+ $variantKey = self::hash(self::buildVariantKey($normalizedHeaders, $varyFields));
- foreach ($responses as $response) {
- if ($response instanceof MockResponse) {
- $mockResponses[] = $response;
+ return "metadata_{$requestHash}_{$variantKey}";
+ }
+
+ /**
+ * Build a variant key for caching, given an array of normalized headers and the vary fields.
+ *
+ * The key is a pipe-separated string of "header=value" pairs, with the special case of "header=" for headers that are not present.
+ *
+ * @param array $normalizedHeaders
+ * @param string[] $varyFields
+ */
+ private static function buildVariantKey(array $normalizedHeaders, array $varyFields): string
+ {
+ $parts = [];
+ foreach ($varyFields as $field) {
+ $lower = strtolower($field);
+ if (!isset($normalizedHeaders[$lower])) {
+ $parts[] = $field.'=';
} else {
- $clientResponses[] = $response;
+ $joined = \is_array($normalizedHeaders[$lower])
+ ? implode(',', $normalizedHeaders[$lower])
+ : $normalizedHeaders[$lower];
+ $parts[] = $field.'='.rawurlencode($joined);
}
}
- if (!$mockResponses) {
- return $this->client->stream($clientResponses, $timeout);
+ return implode('|', $parts);
+ }
+
+ /**
+ * Parse the Cache-Control header and return an array of directive names as keys
+ * and their values as values, or true if the directive has no value.
+ *
+ * @param array $header the Cache-Control header as an array of strings
+ *
+ * @return array the parsed Cache-Control directives
+ */
+ private static function parseCacheControlHeader(array $header): array
+ {
+ $parsed = [];
+ foreach ($header as $line) {
+ foreach (explode(',', $line) as $directive) {
+ if (str_contains($directive, '=')) {
+ [$name, $value] = explode('=', $directive, 2);
+ $parsed[trim($name)] = trim($value);
+ } else {
+ $parsed[trim($directive)] = true;
+ }
+ }
}
- if (!$clientResponses) {
- return new ResponseStream(MockResponse::stream($mockResponses, $timeout));
+ return $parsed;
+ }
+
+ /**
+ * Evaluates the freshness of a cached response based on its headers and expiration time.
+ *
+ * This method determines the state of the cached response by analyzing the Cache-Control
+ * directives and the expiration timestamp.
+ *
+ * @param array{headers: array, expires_at: int|null} $data the cached response data, including headers and expiration time
+ */
+ private function evaluateCacheFreshness(array $data): Freshness
+ {
+ $parseCacheControlHeader = self::parseCacheControlHeader($data['headers']['cache-control'] ?? []);
+
+ if (isset($parseCacheControlHeader['no-cache'])) {
+ return Freshness::Stale;
}
- return new ResponseStream((function () use ($mockResponses, $clientResponses, $timeout) {
- yield from MockResponse::stream($mockResponses, $timeout);
- yield $this->client->stream($clientResponses, $timeout);
- })());
+ $now = time();
+ $expires = $data['expires_at'];
+
+ if (null === $expires || $now <= $expires) {
+ return Freshness::Fresh;
+ }
+
+ if (
+ isset($parseCacheControlHeader['must-revalidate'])
+ || ($this->sharedCache && isset($parseCacheControlHeader['proxy-revalidate']))
+ ) {
+ return Freshness::MustRevalidate;
+ }
+
+ if (isset($parseCacheControlHeader['stale-if-error'])) {
+ $staleWindow = (int) $parseCacheControlHeader['stale-if-error'];
+ if (($now - $expires) <= $staleWindow) {
+ return Freshness::StaleButUsable;
+ }
+ }
+
+ return Freshness::Stale;
}
- public function reset(): void
+ /**
+ * Determine the maximum age of the response.
+ *
+ * This method first checks for the presence of the s-maxage directive, and if
+ * present, returns its value minus the current age. If s-maxage is not present,
+ * it checks for the presence of the max-age directive, and if present, returns
+ * its value minus the current age. If neither directive is present, it checks
+ * the Expires header for a valid timestamp, and if present, returns the
+ * difference between the timestamp and the current time minus the current age.
+ *
+ * If none of the above directives or headers are present, the method returns
+ * null.
+ *
+ * @param array $headers an array of HTTP headers
+ * @param array $cacheControl an array of parsed Cache-Control directives
+ *
+ * @return int|null the maximum age of the response, or null if it cannot be
+ * determined
+ */
+ private function determineMaxAge(array $headers, array $cacheControl): ?int
{
- if ($this->client instanceof ResetInterface) {
- $this->client->reset();
+ $age = self::getCurrentAge($headers);
+
+ if ($this->sharedCache && isset($cacheControl['s-maxage'])) {
+ $sharedMaxAge = (int) $cacheControl['s-maxage'];
+
+ return max(0, $sharedMaxAge - $age);
+ }
+
+ if (isset($cacheControl['max-age'])) {
+ $maxAge = (int) $cacheControl['max-age'];
+
+ return max(0, $maxAge - $age);
+ }
+
+ foreach ($headers['expires'] ?? [] as $expire) {
+ $expirationTimestamp = strtotime($expire);
+ if (false !== $expirationTimestamp) {
+ $timeUntilExpiration = $expirationTimestamp - time() - $age;
+
+ return max($timeUntilExpiration, 0);
+ }
}
+
+ // Heuristic freshness fallback when no explicit directives are present
+ if (
+ !isset($cacheControl['no-cache'])
+ && !isset($cacheControl['no-store'])
+ && isset($headers['last-modified'])
+ ) {
+ foreach ($headers['last-modified'] as $lastModified) {
+ $lastModifiedTimestamp = strtotime($lastModified);
+ if (false !== $lastModifiedTimestamp) {
+ $secondsSinceLastModified = time() - $lastModifiedTimestamp;
+ if ($secondsSinceLastModified > 0) {
+ // Heuristic: 10% of time since last modified, capped at max heuristic freshness
+ $heuristicFreshnessSeconds = (int) ($secondsSinceLastModified * 0.1);
+ $cappedHeuristicFreshness = min($heuristicFreshnessSeconds, self::MAX_HEURISTIC_FRESHNESS_TTL);
+
+ return max(0, $cappedHeuristicFreshness - $age);
+ }
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Retrieves the current age of the response from the headers.
+ *
+ * @param array $headers an array of HTTP headers
+ *
+ * @return int The age of the response in seconds. Defaults to 0 if not present.
+ */
+ private static function getCurrentAge(array $headers): int
+ {
+ return (int) ($headers['age'][0] ?? 0);
+ }
+
+ /**
+ * Calculates the expiration time of the response given the maximum age.
+ *
+ * @param int|null $maxAge the maximum age of the response in seconds, or null if it cannot be determined
+ *
+ * @return int|null the expiration time of the response as a Unix timestamp, or null if the maximum age is null
+ */
+ private static function calculateExpiresAt(?int $maxAge): ?int
+ {
+ if (null === $maxAge) {
+ return null;
+ }
+
+ return time() + $maxAge;
+ }
+
+ /**
+ * Checks if the server response is cacheable according to the HTTP 1.1
+ * specification (RFC 9111).
+ *
+ * This function will return true if the server response can be cached,
+ * false otherwise.
+ *
+ * @param array $requestHeaders
+ * @param array $responseHeaders
+ * @param array $cacheControl
+ */
+ private function isServerResponseCacheable(int $statusCode, array $requestHeaders, array $responseHeaders, array $cacheControl): bool
+ {
+ // no-store => skip caching
+ if (isset($cacheControl['no-store'])) {
+ return false;
+ }
+
+ if (
+ $this->sharedCache
+ && !isset($cacheControl['public']) && !isset($cacheControl['s-maxage']) && !isset($cacheControl['must-revalidate'])
+ && isset($requestHeaders['authorization'])
+ ) {
+ return false;
+ }
+
+ if ($this->sharedCache && isset($cacheControl['private'])) {
+ return false;
+ }
+
+ // Conditionals require an explicit expiration
+ if (\in_array($statusCode, self::CONDITIONALLY_CACHEABLE_STATUS_CODES, true)) {
+ return $this->hasExplicitExpiration($responseHeaders, $cacheControl);
+ }
+
+ return \in_array($statusCode, self::CACHEABLE_STATUS_CODES, true);
+ }
+
+ /**
+ * Checks if the response has an explicit expiration.
+ *
+ * This function will return true if the response has an explicit expiration
+ * time specified in the headers or in the Cache-Control directives,
+ * false otherwise.
+ *
+ * @param array $headers
+ * @param array $cacheControl
+ */
+ private function hasExplicitExpiration(array $headers, array $cacheControl): bool
+ {
+ return isset($headers['expires'])
+ || ($this->sharedCache && isset($cacheControl['s-maxage']))
+ || isset($cacheControl['max-age']);
+ }
+
+ /**
+ * Creates a MockResponse object from cached data.
+ *
+ * This function constructs a MockResponse from the cached data, including
+ * the original request method, URL, and options, as well as the cached
+ * response headers and content. The constructed MockResponse is then
+ * returned.
+ *
+ * @param array{chunks_count: int, status_code: int, initial_age: int, headers: array, stored_at: int} $cachedData
+ */
+ private function createResponseFromCache(string $key, array $cachedData, string $method, string $url, array $options, string $fullUrlTag): MockResponse
+ {
+ return MockResponse::fromRequest(
+ $method,
+ $url,
+ $options,
+ new MockResponse(
+ (function () use ($key, $cachedData, $fullUrlTag): \Generator {
+ for ($i = 0; $i <= $cachedData['chunks_count']; ++$i) {
+ yield $this->cache->get("{$key}_chunk_{$i}", function (ItemInterface $item) use ($fullUrlTag): never {
+ $this->cache->invalidateTags([$fullUrlTag]);
+
+ throw new ChunkCacheItemNotFoundException(\sprintf('Missing cache item for chunk with key "%s". This indicates an internal cache inconsistency.', $item->getKey()));
+ }, 0);
+ }
+ })(),
+ ['http_code' => $cachedData['status_code'], 'response_headers' => ['age' => $cachedData['initial_age'] + (time() - $cachedData['stored_at'])] + $cachedData['headers']]
+ )
+ );
+ }
+
+ private static function createGatewayTimeoutResponse(string $method, string $url, array $options): MockResponse
+ {
+ return MockResponse::fromRequest(
+ $method,
+ $url,
+ $options,
+ new MockResponse('', ['http_code' => 504])
+ );
}
}
diff --git a/src/Symfony/Component/HttpClient/Exception/ChunkCacheItemNotFoundException.php b/src/Symfony/Component/HttpClient/Exception/ChunkCacheItemNotFoundException.php
new file mode 100644
index 0000000000000..46aba46dd7569
--- /dev/null
+++ b/src/Symfony/Component/HttpClient/Exception/ChunkCacheItemNotFoundException.php
@@ -0,0 +1,16 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpClient\Exception;
+
+final class ChunkCacheItemNotFoundException extends TransportException
+{
+}
diff --git a/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php
index 67e9212c957cd..252dc39a39a7a 100644
--- a/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php
+++ b/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php
@@ -12,99 +12,862 @@
namespace Symfony\Component\HttpClient\Tests;
use PHPUnit\Framework\TestCase;
+use Symfony\Bridge\PhpUnit\ClockMock;
+use Symfony\Component\Cache\Adapter\ArrayAdapter;
+use Symfony\Component\Cache\Adapter\TagAwareAdapter;
+use Symfony\Component\Cache\Adapter\TagAwareAdapterInterface;
use Symfony\Component\HttpClient\CachingHttpClient;
use Symfony\Component\HttpClient\MockHttpClient;
+use Symfony\Component\HttpClient\Response\AsyncResponse;
use Symfony\Component\HttpClient\Response\MockResponse;
-use Symfony\Component\HttpKernel\HttpCache\Store;
-use Symfony\Contracts\HttpClient\ResponseInterface;
+/**
+ * @covers \Symfony\Component\HttpClient\CachingHttpClient
+ *
+ * @group time-sensitive
+ */
class CachingHttpClientTest extends TestCase
{
- public function testRequestHeaders()
+ private TagAwareAdapterInterface $cacheAdapter;
+
+ protected function setUp(): void
{
+ parent::setUp();
+
+ $this->cacheAdapter = new TagAwareAdapter(new ArrayAdapter());
+
+ if (class_exists(ClockMock::class)) {
+ ClockMock::register(TagAwareAdapter::class);
+ }
+ }
+
+ public function testBypassCacheWhenBodyPresent()
+ {
+ // If a request has a non-empty body, caching should be bypassed.
+ $mockClient = new MockHttpClient([
+ new MockResponse('cached response', ['http_code' => 200]),
+ new MockResponse('non-cached response', ['http_code' => 200]),
+ ]);
+ $client = new CachingHttpClient($mockClient, $this->cacheAdapter);
+
+ // First request with a body; should always call underlying client.
+ $options = ['body' => 'non-empty'];
+ $client->request('GET', 'http://example.com/foo-bar', $options);
+ $response = $client->request('GET', 'http://example.com/foo-bar');
+ self::assertSame('non-cached response', $response->getContent(), 'Request with body should bypass cache.');
+ }
+
+ public function testBypassCacheWhenRangeHeaderPresent()
+ {
+ // If a "range" header is present, caching is bypassed.
+ $mockClient = new MockHttpClient([
+ new MockResponse('first response', ['http_code' => 200]),
+ new MockResponse('second response', ['http_code' => 200]),
+ ]);
+ $client = new CachingHttpClient($mockClient, $this->cacheAdapter);
+
$options = [
- 'headers' => [
- 'Application-Name' => 'test1234',
- 'Test-Name-Header' => 'test12345',
- ],
+ 'headers' => ['Range' => 'bytes=0-100'],
];
+ $client->request('GET', 'http://example.com/foo-bar', $options);
+ $response = $client->request('GET', 'http://example.com/foo-bar');
+ self::assertSame('second response', $response->getContent(), 'Presence of range header must bypass caching.');
+ }
+
+ public function testBypassCacheForNonCacheableMethod()
+ {
+ // Methods not in CACHEABLE_METHODS (e.g. POST) bypass caching.
+ $mockClient = new MockHttpClient([
+ new MockResponse('first response', ['http_code' => 200]),
+ new MockResponse('second response', ['http_code' => 200]),
+ ]);
+ $client = new CachingHttpClient($mockClient, $this->cacheAdapter);
+
+ $client->request('POST', 'http://example.com/foo-bar');
+ $response = $client->request('POST', 'http://example.com/foo-bar');
+ self::assertSame('second response', $response->getContent(), 'Non-cacheable method must bypass caching.');
+ }
+
+ public function testItServesResponseFromCache()
+ {
+ $mockClient = new MockHttpClient([
+ new MockResponse('foo', [
+ 'http_code' => 200,
+ ]),
+ new MockResponse('should not be served'),
+ ]);
+
+ $client = new CachingHttpClient(
+ $mockClient,
+ $this->cacheAdapter,
+ );
+
+ $response = $client->request('GET', 'http://example.com/foo-bar');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('foo', $response->getContent());
+
+ sleep(2);
+
+ $response = $client->request('GET', 'http://example.com/foo-bar');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('foo', $response->getContent());
+ self::assertSame('2', $response->getHeaders()['age'][0]);
+ }
+
+ public function testItSupportsVaryHeader()
+ {
+ $mockClient = new MockHttpClient([
+ new MockResponse('foo', [
+ 'http_code' => 200,
+ 'response_headers' => [
+ 'Vary' => 'Foo, Bar',
+ ],
+ ]),
+ new MockResponse('bar'),
+ ]);
+
+ $client = new CachingHttpClient(
+ $mockClient,
+ $this->cacheAdapter,
+ );
+
+ // Request with one set of headers.
+ $response = $client->request('GET', 'http://example.com/foo-bar', ['headers' => ['Foo' => 'foo', 'Bar' => 'bar']]);
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('foo', $response->getContent());
+
+ // Same headers: should return cached "foo".
+ $response = $client->request('GET', 'http://example.com/foo-bar', ['headers' => ['Foo' => 'foo', 'Bar' => 'bar']]);
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('foo', $response->getContent());
+
+ // Different header values: returns a new response.
+ $response = $client->request('GET', 'http://example.com/foo-bar', ['headers' => ['Foo' => 'bar', 'Bar' => 'foo']]);
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('bar', $response->getContent());
+ }
+
+ public function testItDoesntServeAStaleResponse()
+ {
+ $mockClient = new MockHttpClient([
+ new MockResponse('foo', [
+ 'http_code' => 200,
+ 'response_headers' => [
+ 'Cache-Control' => 'max-age=5',
+ ],
+ ]),
+ new MockResponse('bar'),
+ ]);
+
+ $client = new CachingHttpClient(
+ $mockClient,
+ $this->cacheAdapter,
+ );
+
+ // The first request returns "foo".
+ $response = $client->request('GET', 'http://example.com/foo-bar');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('foo', $response->getContent());
+
+ sleep(5);
+
+ // After 5 seconds, the cached response is still considered valid.
+ $response = $client->request('GET', 'http://example.com/foo-bar');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('foo', $response->getContent());
+
+ sleep(1);
+
+ // After an extra second the cache expires, so a new response is served.
+ $response = $client->request('GET', 'http://example.com/foo-bar');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('bar', $response->getContent());
+ }
+
+ public function testItRevalidatesAResponseWithNoCacheDirective()
+ {
+ $mockClient = new MockHttpClient([
+ new MockResponse('foo', [
+ 'http_code' => 200,
+ 'response_headers' => [
+ 'Cache-Control' => 'no-cache, max-age=5',
+ ],
+ ]),
+ new MockResponse('bar'),
+ ]);
+
+ // Use a private cache (sharedCache = false) so that revalidation is performed.
+ $client = new CachingHttpClient(
+ $mockClient,
+ $this->cacheAdapter,
+ sharedCache: false,
+ );
+
+ $response = $client->request('GET', 'http://example.com/foo-bar');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('foo', $response->getContent());
+
+ // The next request revalidates the response and should fetch "bar".
+ $response = $client->request('GET', 'http://example.com/foo-bar');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('bar', $response->getContent());
+ }
+
+ public function testItServesAStaleResponseIfError()
+ {
+ $mockClient = new MockHttpClient([
+ new MockResponse('foo', [
+ 'http_code' => 404,
+ 'response_headers' => [
+ 'Cache-Control' => 'max-age=1, stale-if-error=5',
+ ],
+ ]),
+ new MockResponse('Internal Server Error', ['http_code' => 500]),
+ ]);
+
+ $client = new CachingHttpClient(
+ $mockClient,
+ $this->cacheAdapter,
+ sharedCache: false,
+ );
+
+ $response = $client->request('GET', 'http://example.com/foo-bar');
+ self::assertSame(404, $response->getStatusCode());
+ self::assertSame('foo', $response->getContent(false));
+
+ sleep(5);
+
+ $response = $client->request('GET', 'http://example.com/foo-bar');
+ self::assertSame(404, $response->getStatusCode());
+ self::assertSame('foo', $response->getContent(false));
+ }
+
+ public function testPrivateCacheWithSharedCacheFalse()
+ {
+ $responses = [
+ new MockResponse('foo', [
+ 'http_code' => 200,
+ 'response_headers' => [
+ 'Cache-Control' => 'private, max-age=5',
+ ],
+ ]),
+ new MockResponse('should not be served'),
+ ];
+
+ $mockHttpClient = new MockHttpClient($responses);
+ $client = new CachingHttpClient(
+ $mockHttpClient,
+ $this->cacheAdapter,
+ sharedCache: false,
+ );
+
+ $response = $client->request('GET', 'http://example.com/test-private');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('foo', $response->getContent());
+
+ $response = $client->request('GET', 'http://example.com/test-private');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('foo', $response->getContent());
+ }
+
+ public function testItDoesntStoreAResponseWithNoStoreDirective()
+ {
+ $mockClient = new MockHttpClient([
+ new MockResponse('foo', [
+ 'http_code' => 200,
+ 'response_headers' => [
+ 'Cache-Control' => 'no-store',
+ ],
+ ]),
+ new MockResponse('bar'),
+ ]);
+
+ $client = new CachingHttpClient(
+ $mockClient,
+ $this->cacheAdapter,
+ );
+
+ $response = $client->request('GET', 'http://example.com/foo-bar');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('foo', $response->getContent());
+
+ $response = $client->request('GET', 'http://example.com/foo-bar');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('bar', $response->getContent());
+ }
+
+ public function testASharedCacheDoesntStoreAResponseFromRequestWithAuthorization()
+ {
+ $mockClient = new MockHttpClient([
+ new MockResponse('foo', [
+ 'http_code' => 200,
+ ]),
+ new MockResponse('bar'),
+ ]);
+
+ $client = new CachingHttpClient(
+ $mockClient,
+ $this->cacheAdapter,
+ [
+ 'headers' => [
+ 'Authorization' => 'foo',
+ ],
+ ],
+ );
+
+ $response = $client->request('GET', 'http://example.com/foo-bar');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('foo', $response->getContent());
+
+ $response = $client->request('GET', 'http://example.com/foo-bar');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('bar', $response->getContent());
+ }
+
+ public function testASharedCacheStoresAResponseWithPublicDirectiveFromRequestWithAuthorization()
+ {
+ $mockClient = new MockHttpClient([
+ new MockResponse('foo', [
+ 'http_code' => 200,
+ 'response_headers' => [
+ 'Cache-Control' => 'public',
+ ],
+ ]),
+ new MockResponse('should not be served'),
+ ]);
+
+ $client = new CachingHttpClient(
+ $mockClient,
+ $this->cacheAdapter,
+ [
+ 'headers' => [
+ 'Authorization' => 'foo',
+ ],
+ ],
+ sharedCache: true,
+ );
+
+ $response = $client->request('GET', 'http://example.com/foo-bar');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('foo', $response->getContent());
+
+ $response = $client->request('GET', 'http://example.com/foo-bar');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('foo', $response->getContent());
+ }
+
+ public function testASharedCacheStoresAResponseWithSMaxAgeDirectiveFromRequestWithAuthorization()
+ {
+ $mockClient = new MockHttpClient([
+ new MockResponse('foo', [
+ 'http_code' => 200,
+ 'response_headers' => [
+ 'Cache-Control' => 's-maxage=5',
+ ],
+ ]),
+ new MockResponse('should not be served'),
+ ]);
+
+ $client = new CachingHttpClient(
+ $mockClient,
+ $this->cacheAdapter,
+ [
+ 'headers' => [
+ 'Authorization' => 'foo',
+ ],
+ ],
+ sharedCache: true,
+ );
+
+ $response = $client->request('GET', 'http://example.com/foo-bar');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('foo', $response->getContent());
+
+ $response = $client->request('GET', 'http://example.com/foo-bar');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('foo', $response->getContent());
+ }
+
+ public function testASharedCacheDoesntStoreAResponseWithPrivateDirective()
+ {
+ $mockClient = new MockHttpClient([
+ new MockResponse('foo', [
+ 'http_code' => 200,
+ 'response_headers' => [
+ 'Cache-Control' => 'private, max-age=5',
+ ],
+ ]),
+ new MockResponse('bar'),
+ ]);
+
+ $client = new CachingHttpClient(
+ $mockClient,
+ $this->cacheAdapter,
+ sharedCache: true,
+ );
+
+ $response = $client->request('GET', 'http://example.com/foo-bar');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('foo', $response->getContent());
+
+ $response = $client->request('GET', 'http://example.com/foo-bar');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('bar', $response->getContent());
+ }
+
+ public function testAPrivateCacheStoresAResponseWithPrivateDirective()
+ {
+ $mockClient = new MockHttpClient([
+ new MockResponse('foo', [
+ 'http_code' => 200,
+ 'response_headers' => [
+ 'Cache-Control' => 'private, max-age=5',
+ ],
+ ]),
+ new MockResponse('should not be served'),
+ ]);
+
+ $client = new CachingHttpClient(
+ $mockClient,
+ $this->cacheAdapter,
+ sharedCache: false,
+ );
+
+ $response = $client->request('GET', 'http://example.com/foo-bar');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('foo', $response->getContent());
+
+ $response = $client->request('GET', 'http://example.com/foo-bar');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('foo', $response->getContent());
+ }
+
+ public function testCacheMissAfterInvalidation()
+ {
+ $mockClient = new MockHttpClient([
+ new MockResponse('foo', [
+ 'http_code' => 200,
+ 'response_headers' => [
+ 'Cache-Control' => 'max-age=300',
+ ],
+ ]),
+ new MockResponse('', ['http_code' => 204]),
+ new MockResponse('bar'),
+ ]);
+
+ $client = new CachingHttpClient(
+ $mockClient,
+ $this->cacheAdapter,
+ );
+
+ $response = $client->request('GET', 'http://example.com/foo-bar');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('foo', $response->getContent());
+
+ $client->request('DELETE', 'http://example.com/foo-bar');
+
+ $response = $client->request('GET', 'http://example.com/foo-bar');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('bar', $response->getContent());
+ }
+
+ public function testChunkErrorServesStaleResponse()
+ {
+ $mockClient = new MockHttpClient([
+ new MockResponse('foo', [
+ 'http_code' => 200,
+ 'response_headers' => [
+ 'Cache-Control' => 'max-age=1, stale-if-error=3',
+ ],
+ ]),
+ new MockResponse('', ['error' => 'Simulated']),
+ ]);
- $mockClient = new MockHttpClient();
- $store = new Store(sys_get_temp_dir().'/sf_http_cache');
- $client = new CachingHttpClient($mockClient, $store, $options);
+ $client = new CachingHttpClient(
+ $mockClient,
+ $this->cacheAdapter,
+ );
+ $response = $client->request('GET', 'http://example.com/foo-bar');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('foo', $response->getContent());
+
+ sleep(2);
+
+ $response = $client->request('GET', 'http://example.com/foo-bar');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('foo', $response->getContent());
+ }
+
+ public function testChunkErrorMustRevalidate()
+ {
+ $mockClient = new MockHttpClient([
+ new MockResponse('foo', [
+ 'http_code' => 200,
+ 'response_headers' => [
+ 'Cache-Control' => 'max-age=1, must-revalidate',
+ ],
+ ]),
+ new MockResponse('', ['error' => 'Simulated']),
+ ]);
+
+ $client = new CachingHttpClient(
+ $mockClient,
+ $this->cacheAdapter,
+ );
+
+ $response = $client->request('GET', 'http://example.com/foo-bar');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('foo', $response->getContent());
+
+ sleep(2);
+
+ $response = $client->request('GET', 'http://example.com/foo-bar');
+ self::assertSame(504, $response->getStatusCode());
+ }
+
+ public function testExceedingMaxAgeIsCappedByTtl()
+ {
+ $mockClient = new MockHttpClient([
+ new MockResponse('foo', [
+ 'http_code' => 200,
+ 'response_headers' => [
+ 'Cache-Control' => 'max-age=300',
+ ],
+ ]),
+ new MockResponse('bar', ['http_code' => 200]),
+ ]);
+
+ $client = new CachingHttpClient(
+ $mockClient,
+ $this->cacheAdapter,
+ maxTtl: 10,
+ );
+
+ $response = $client->request('GET', 'http://example.com/foo-bar');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('foo', $response->getContent());
+
+ sleep(11);
+
+ $response = $client->request('GET', 'http://example.com/foo-bar');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('bar', $response->getContent());
+ }
+
+ public function testItCanStreamAsyncResponse()
+ {
+ $mockClient = new MockHttpClient([
+ new MockResponse('foo', ['http_code' => 200]),
+ ]);
+
+ $client = new CachingHttpClient(
+ $mockClient,
+ $this->cacheAdapter,
+ );
+
+ $response = $client->request('GET', 'http://example.com/foo-bar');
+
+ self::assertInstanceOf(AsyncResponse::class, $response);
+
+ $collected = '';
+ foreach ($client->stream($response) as $chunk) {
+ $collected .= $chunk->getContent();
+ }
+
+ self::assertSame('foo', $collected);
+ }
+
+ public function testItCanStreamCachedResponse()
+ {
+ $mockClient = new MockHttpClient([
+ new MockResponse('foo', ['http_code' => 200]),
+ ]);
+
+ $client = new CachingHttpClient(
+ $mockClient,
+ $this->cacheAdapter,
+ );
+
+ $client->request('GET', 'http://example.com/foo-bar')->getContent(); // warm the cache
$response = $client->request('GET', 'http://example.com/foo-bar');
- rmdir(sys_get_temp_dir().'/sf_http_cache');
self::assertInstanceOf(MockResponse::class, $response);
- self::assertSame($response->getRequestOptions()['normalized_headers']['application-name'][0], 'Application-Name: test1234');
- self::assertSame($response->getRequestOptions()['normalized_headers']['test-name-header'][0], 'Test-Name-Header: test12345');
+
+ $collected = '';
+ foreach ($client->stream($response) as $chunk) {
+ $collected .= $chunk->getContent();
+ }
+
+ self::assertSame('foo', $collected);
+ }
+
+ public function testItCanStreamBoth()
+ {
+ $mockClient = new MockHttpClient([
+ new MockResponse('foo', ['http_code' => 200]),
+ new MockResponse('bar', ['http_code' => 200]),
+ ]);
+
+ $client = new CachingHttpClient(
+ $mockClient,
+ $this->cacheAdapter,
+ );
+
+ $client->request('GET', 'http://example.com/foo')->getContent(); // warm the cache
+ $cachedResponse = $client->request('GET', 'http://example.com/foo');
+ $asyncResponse = $client->request('GET', 'http://example.com/bar');
+
+ self::assertInstanceOf(MockResponse::class, $cachedResponse);
+ self::assertInstanceOf(AsyncResponse::class, $asyncResponse);
+
+ $collected = '';
+ foreach ($client->stream([$asyncResponse, $cachedResponse]) as $chunk) {
+ $collected .= $chunk->getContent();
+ }
+
+ self::assertSame('foobar', $collected);
+ }
+
+ public function testMultipleChunksResponse()
+ {
+ $mockClient = new MockHttpClient([
+ new MockResponse(['chunk1', 'chunk2'], ['http_code' => 200, 'response_headers' => ['Cache-Control' => 'max-age=5']]),
+ ]);
+
+ $client = new CachingHttpClient($mockClient, $this->cacheAdapter);
+
+ $response = $client->request('GET', 'http://example.com/multi-chunk');
+ $content = '';
+ foreach ($client->stream($response) as $chunk) {
+ $content .= $chunk->getContent();
+ }
+ self::assertSame('chunk1chunk2', $content);
+
+ $response = $client->request('GET', 'http://example.com/multi-chunk');
+ $content = '';
+ foreach ($client->stream($response) as $chunk) {
+ $content .= $chunk->getContent();
+ }
+ self::assertSame('chunk1chunk2', $content);
+ }
+
+ public function testConditionalCacheableStatusCodeWithoutExpiration()
+ {
+ $mockClient = new MockHttpClient([
+ new MockResponse('redirected', ['http_code' => 302]),
+ new MockResponse('new redirect', ['http_code' => 302]),
+ ]);
+
+ $client = new CachingHttpClient($mockClient, $this->cacheAdapter);
+
+ $response = $client->request('GET', 'http://example.com/redirect');
+ self::assertSame(302, $response->getStatusCode());
+ self::assertSame('redirected', $response->getContent(false));
+
+ $response = $client->request('GET', 'http://example.com/redirect');
+ self::assertSame(302, $response->getStatusCode());
+ self::assertSame('new redirect', $response->getContent(false));
+ }
+
+ public function testConditionalCacheableStatusCodeWithExpiration()
+ {
+ $mockClient = new MockHttpClient([
+ new MockResponse('redirected', [
+ 'http_code' => 302,
+ 'response_headers' => ['Cache-Control' => 'max-age=5'],
+ ]),
+ new MockResponse('should not be served'),
+ ]);
+
+ $client = new CachingHttpClient($mockClient, $this->cacheAdapter);
+
+ $response = $client->request('GET', 'http://example.com/redirect');
+ self::assertSame(302, $response->getStatusCode());
+ self::assertSame('redirected', $response->getContent(false));
+
+ $response = $client->request('GET', 'http://example.com/redirect');
+ self::assertSame(302, $response->getStatusCode());
+ self::assertSame('redirected', $response->getContent(false));
+ }
+
+ public function testETagRevalidation()
+ {
+ $mockClient = new MockHttpClient([
+ new MockResponse('foo', [
+ 'http_code' => 200,
+ 'response_headers' => ['ETag' => '"abc123"', 'Cache-Control' => 'max-age=5'],
+ ]),
+ new MockResponse('', ['http_code' => 304, 'response_headers' => ['ETag' => '"abc123"']]),
+ ]);
+
+ $client = new CachingHttpClient($mockClient, $this->cacheAdapter);
+
+ $response = $client->request('GET', 'http://example.com/etag');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('foo', $response->getContent());
+
+ sleep(6);
+
+ $response = $client->request('GET', 'http://example.com/etag');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('foo', $response->getContent());
+ }
+
+ public function testLastModifiedRevalidation()
+ {
+ $lastModified = 'Wed, 21 Oct 2015 07:28:00 GMT';
+ $mockClient = new MockHttpClient([
+ new MockResponse('foo', [
+ 'http_code' => 200,
+ 'response_headers' => ['Last-Modified' => $lastModified, 'Cache-Control' => 'max-age=5'],
+ ]),
+ new MockResponse('', ['http_code' => 304, 'response_headers' => ['Last-Modified' => $lastModified]]),
+ ]);
+
+ $client = new CachingHttpClient($mockClient, $this->cacheAdapter);
+
+ $response = $client->request('GET', 'http://example.com/last-modified');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('foo', $response->getContent());
+
+ sleep(6);
+
+ $response = $client->request('GET', 'http://example.com/last-modified');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('foo', $response->getContent());
}
- public function testDoesNotEvaluateResponseBody()
+ public function testAgeCalculation()
{
- $body = file_get_contents(__DIR__.'/Fixtures/assertion_failure.php');
- $response = $this->runRequest(new MockResponse($body, ['response_headers' => ['X-Body-Eval' => true]]));
- $headers = $response->getHeaders();
+ $mockClient = new MockHttpClient([
+ new MockResponse('foo', ['http_code' => 200, 'response_headers' => ['Cache-Control' => 'max-age=300']]),
+ ]);
+
+ $client = new CachingHttpClient($mockClient, $this->cacheAdapter);
+
+ $response = $client->request('GET', 'http://example.com/age-test');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('foo', $response->getContent());
- $this->assertSame($body, $response->getContent());
- $this->assertArrayNotHasKey('x-body-eval', $headers);
+ sleep(3);
+
+ $response = $client->request('GET', 'http://example.com/age-test');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('foo', $response->getContent());
+ self::assertSame('3', $response->getHeaders()['age'][0]);
}
- public function testDoesNotIncludeFile()
+ public function testGatewayTimeoutOnMustRevalidateFailure()
{
- $file = __DIR__.'/Fixtures/assertion_failure.php';
+ $mockClient = new MockHttpClient([
+ new MockResponse('foo', [
+ 'http_code' => 200,
+ 'response_headers' => ['Cache-Control' => 'max-age=1, must-revalidate'],
+ ]),
+ new MockResponse('server error', ['http_code' => 500]),
+ ]);
+
+ $client = new CachingHttpClient($mockClient, $this->cacheAdapter);
- $response = $this->runRequest(new MockResponse(
- 'test', ['response_headers' => [
- 'X-Body-Eval' => true,
- 'X-Body-File' => $file,
- ]]
- ));
- $headers = $response->getHeaders();
+ $response = $client->request('GET', 'http://example.com/must-revalidate');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('foo', $response->getContent());
- $this->assertSame('test', $response->getContent());
- $this->assertArrayNotHasKey('x-body-eval', $headers);
- $this->assertArrayNotHasKey('x-body-file', $headers);
+ sleep(2);
+
+ $response = $client->request('GET', 'http://example.com/must-revalidate');
+ self::assertSame(504, $response->getStatusCode());
}
- public function testDoesNotReadFile()
+ public function testVaryAsteriskPreventsCaching()
{
- $file = __DIR__.'/Fixtures/assertion_failure.php';
+ $mockClient = new MockHttpClient([
+ new MockResponse('foo', ['http_code' => 200, 'response_headers' => ['Vary' => '*']]),
+ new MockResponse('bar', ['http_code' => 200]),
+ ]);
+
+ $client = new CachingHttpClient($mockClient, $this->cacheAdapter);
- $response = $this->runRequest(new MockResponse(
- 'test', ['response_headers' => [
- 'X-Body-File' => $file,
- ]]
- ));
- $headers = $response->getHeaders();
+ $response = $client->request('GET', 'http://example.com/vary-asterisk');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('foo', $response->getContent());
- $this->assertSame('test', $response->getContent());
- $this->assertArrayNotHasKey('x-body-file', $headers);
+ $response = $client->request('GET', 'http://example.com/vary-asterisk');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('bar', $response->getContent());
}
- public function testRemovesXContentDigest()
+ public function testExcludedHeadersAreNotCached()
{
- $response = $this->runRequest(new MockResponse(
- 'test', [
+ $mockClient = new MockHttpClient([
+ new MockResponse('foo', [
+ 'http_code' => 200,
'response_headers' => [
- 'X-Content-Digest' => 'some-hash',
+ 'Cache-Control' => 'max-age=300',
+ 'Connection' => 'keep-alive',
+ 'Proxy-Authenticate' => 'Basic',
+ 'Proxy-Authentication-Info' => 'info',
+ 'Proxy-Authorization' => 'Bearer token',
+ 'Content-Type' => 'text/plain',
+ 'X-Custom-Header' => 'custom-value',
],
- ]));
- $headers = $response->getHeaders();
+ ]),
+ new MockResponse('should not be served', ['http_code' => 200]),
+ ]);
- $this->assertArrayNotHasKey('x-content-digest', $headers);
+ $client = new CachingHttpClient($mockClient, $this->cacheAdapter);
+
+ $response = $client->request('GET', 'http://example.com/header-test');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('foo', $response->getContent());
+
+ $cachedResponse = $client->request('GET', 'http://example.com/header-test');
+ self::assertSame(200, $cachedResponse->getStatusCode());
+ self::assertSame('foo', $cachedResponse->getContent());
+
+ $cachedHeaders = $cachedResponse->getHeaders();
+
+ self::assertArrayNotHasKey('connection', $cachedHeaders);
+ self::assertArrayNotHasKey('proxy-authenticate', $cachedHeaders);
+ self::assertArrayNotHasKey('proxy-authentication-info', $cachedHeaders);
+ self::assertArrayNotHasKey('proxy-authorization', $cachedHeaders);
+
+ self::assertArrayHasKey('cache-control', $cachedHeaders);
+ self::assertArrayHasKey('content-type', $cachedHeaders);
+ self::assertArrayHasKey('x-custom-header', $cachedHeaders);
}
- private function runRequest(MockResponse $mockResponse): ResponseInterface
+ public function testHeuristicFreshnessWithLastModified()
{
- $mockClient = new MockHttpClient($mockResponse);
+ $lastModified = gmdate('D, d M Y H:i:s T', time() - 3600); // 1 hour ago
+ $mockClient = new MockHttpClient([
+ new MockResponse('foo', [
+ 'http_code' => 200,
+ 'response_headers' => ['Last-Modified' => $lastModified],
+ ]),
+ new MockResponse('bar'),
+ ]);
+
+ $client = new CachingHttpClient($mockClient, $this->cacheAdapter);
+
+ // First request caches with heuristic
+ $response = $client->request('GET', 'http://example.com/heuristic');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('foo', $response->getContent());
+
+ // Heuristic: 10% of 3600s = 360s; should be fresh within this time
+ sleep(360); // 5 minutes
- $store = new Store(sys_get_temp_dir().'/sf_http_cache');
- $client = new CachingHttpClient($mockClient, $store);
+ $response = $client->request('GET', 'http://example.com/heuristic');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('foo', $response->getContent());
- $response = $client->request('GET', 'http://test');
+ // After heuristic expires
+ sleep(1); // Total 361s, past 360s heuristic
- return $response;
+ $response = $client->request('GET', 'http://example.com/heuristic');
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('bar', $response->getContent());
}
}
diff --git a/src/Symfony/Component/HttpClient/Tests/LegacyCachingHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/LegacyCachingHttpClientTest.php
new file mode 100644
index 0000000000000..fe2fae72b25ca
--- /dev/null
+++ b/src/Symfony/Component/HttpClient/Tests/LegacyCachingHttpClientTest.php
@@ -0,0 +1,126 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpClient\Tests;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait;
+use Symfony\Component\HttpClient\CachingHttpClient;
+use Symfony\Component\HttpClient\MockHttpClient;
+use Symfony\Component\HttpClient\Response\MockResponse;
+use Symfony\Component\HttpKernel\HttpCache\Store;
+use Symfony\Contracts\HttpClient\ResponseInterface;
+
+/**
+ * @group legacy
+ */
+class LegacyCachingHttpClientTest extends TestCase
+{
+ use ExpectDeprecationTrait;
+
+ public function testRequestHeaders()
+ {
+ $this->expectDeprecation('Since symfony/http-client 7.3: Passing a "Symfony\Component\HttpKernel\HttpCache\StoreInterface" as constructor\'s 2nd argument of "Symfony\Component\HttpClient\CachingHttpClient" is deprecated, "Symfony\Contracts\Cache\TagAwareCacheInterface" expected.');
+
+ $options = [
+ 'headers' => [
+ 'Application-Name' => 'test1234',
+ 'Test-Name-Header' => 'test12345',
+ ],
+ ];
+
+ $mockClient = new MockHttpClient();
+ $store = new Store(sys_get_temp_dir().'/sf_http_cache');
+ $client = new CachingHttpClient($mockClient, $store, $options);
+
+ $response = $client->request('GET', 'http://example.com/foo-bar');
+
+ rmdir(sys_get_temp_dir().'/sf_http_cache');
+ self::assertInstanceOf(MockResponse::class, $response);
+ self::assertSame($response->getRequestOptions()['normalized_headers']['application-name'][0], 'Application-Name: test1234');
+ self::assertSame($response->getRequestOptions()['normalized_headers']['test-name-header'][0], 'Test-Name-Header: test12345');
+ }
+
+ public function testDoesNotEvaluateResponseBody()
+ {
+ $this->expectDeprecation('Since symfony/http-client 7.3: Passing a "Symfony\Component\HttpKernel\HttpCache\StoreInterface" as constructor\'s 2nd argument of "Symfony\Component\HttpClient\CachingHttpClient" is deprecated, "Symfony\Contracts\Cache\TagAwareCacheInterface" expected.');
+
+ $body = file_get_contents(__DIR__.'/Fixtures/assertion_failure.php');
+ $response = $this->runRequest(new MockResponse($body, ['response_headers' => ['X-Body-Eval' => true]]));
+ $headers = $response->getHeaders();
+
+ $this->assertSame($body, $response->getContent());
+ $this->assertArrayNotHasKey('x-body-eval', $headers);
+ }
+
+ public function testDoesNotIncludeFile()
+ {
+ $this->expectDeprecation('Since symfony/http-client 7.3: Passing a "Symfony\Component\HttpKernel\HttpCache\StoreInterface" as constructor\'s 2nd argument of "Symfony\Component\HttpClient\CachingHttpClient" is deprecated, "Symfony\Contracts\Cache\TagAwareCacheInterface" expected.');
+
+ $file = __DIR__.'/Fixtures/assertion_failure.php';
+
+ $response = $this->runRequest(new MockResponse(
+ 'test', ['response_headers' => [
+ 'X-Body-Eval' => true,
+ 'X-Body-File' => $file,
+ ]]
+ ));
+ $headers = $response->getHeaders();
+
+ $this->assertSame('test', $response->getContent());
+ $this->assertArrayNotHasKey('x-body-eval', $headers);
+ $this->assertArrayNotHasKey('x-body-file', $headers);
+ }
+
+ public function testDoesNotReadFile()
+ {
+ $this->expectDeprecation('Since symfony/http-client 7.3: Passing a "Symfony\Component\HttpKernel\HttpCache\StoreInterface" as constructor\'s 2nd argument of "Symfony\Component\HttpClient\CachingHttpClient" is deprecated, "Symfony\Contracts\Cache\TagAwareCacheInterface" expected.');
+
+ $file = __DIR__.'/Fixtures/assertion_failure.php';
+
+ $response = $this->runRequest(new MockResponse(
+ 'test', ['response_headers' => [
+ 'X-Body-File' => $file,
+ ]]
+ ));
+ $headers = $response->getHeaders();
+
+ $this->assertSame('test', $response->getContent());
+ $this->assertArrayNotHasKey('x-body-file', $headers);
+ }
+
+ public function testRemovesXContentDigest()
+ {
+ $this->expectDeprecation('Since symfony/http-client 7.3: Passing a "Symfony\Component\HttpKernel\HttpCache\StoreInterface" as constructor\'s 2nd argument of "Symfony\Component\HttpClient\CachingHttpClient" is deprecated, "Symfony\Contracts\Cache\TagAwareCacheInterface" expected.');
+
+ $response = $this->runRequest(new MockResponse(
+ 'test', [
+ 'response_headers' => [
+ 'X-Content-Digest' => 'some-hash',
+ ],
+ ]));
+ $headers = $response->getHeaders();
+
+ $this->assertArrayNotHasKey('x-content-digest', $headers);
+ }
+
+ private function runRequest(MockResponse $mockResponse): ResponseInterface
+ {
+ $mockClient = new MockHttpClient($mockResponse);
+
+ $store = new Store(sys_get_temp_dir().'/sf_http_cache');
+ $client = new CachingHttpClient($mockClient, $store);
+
+ $response = $client->request('GET', 'http://test');
+
+ return $response;
+ }
+}
diff --git a/src/Symfony/Component/HttpClient/composer.json b/src/Symfony/Component/HttpClient/composer.json
index 7ca008fd01f13..e367f8c0f3875 100644
--- a/src/Symfony/Component/HttpClient/composer.json
+++ b/src/Symfony/Component/HttpClient/composer.json
@@ -37,6 +37,7 @@
"php-http/httplug": "^1.0|^2.0",
"psr/http-client": "^1.0",
"symfony/amphp-http-client-meta": "^1.0|^2.0",
+ "symfony/cache": "^6.4|^7.0",
"symfony/dependency-injection": "^6.4|^7.0",
"symfony/http-kernel": "^6.4|^7.0",
"symfony/messenger": "^6.4|^7.0",