diff --git a/UPGRADE-7.3.md b/UPGRADE-7.3.md index 0f3163740cfac..7f72ab68f694a 100644 --- a/UPGRADE-7.3.md +++ b/UPGRADE-7.3.md @@ -76,6 +76,11 @@ FrameworkBundle public function __construct(#[Autowire('@serializer.normalizer.object')] NormalizerInterface $normalizer) {} ``` +HttpClient +---------- + + * Deprecate passing an instance of `StoreInterface` as `$cache` argument to `CachingHttpClient` constructor + HttpFoundation -------------- @@ -323,4 +328,4 @@ Workflow $workflow = $this->workflows->get($event->getWorkflowName()); } } - ``` + ``` \ No newline at end of file diff --git a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md index 8e70fb98e42fe..5d6afd78ce8ba 100644 --- a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md @@ -25,6 +25,7 @@ CHANGELOG * Set `framework.rate_limiter.limiters.*.lock_factory` to `auto` by default * Deprecate `RateLimiterFactory` autowiring aliases, use `RateLimiterFactoryInterface` instead * Allow configuring compound rate limiters + * Add support for configuring the `CachingHttpClient` 7.2 --- diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index 6b168a2d4a0fd..dae038894b13a 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -2002,6 +2002,7 @@ private function addHttpClientSection(ArrayNodeDefinition $rootNode, callable $e ->defaultNull() ->info('Rate limiter name to use for throttling requests.') ->end() + ->append($this->createHttpClientCachingSection()) ->append($this->createHttpClientRetrySection()) ->end() ->end() @@ -2157,6 +2158,7 @@ private function addHttpClientSection(ArrayNodeDefinition $rootNode, callable $e ->defaultNull() ->info('Rate limiter name to use for throttling requests.') ->end() + ->append($this->createHttpClientCachingSection()) ->append($this->createHttpClientRetrySection()) ->end() ->end() @@ -2167,6 +2169,33 @@ private function addHttpClientSection(ArrayNodeDefinition $rootNode, callable $e ; } + private function createHttpClientCachingSection(): ArrayNodeDefinition + { + $root = new NodeBuilder(); + + return $root + ->arrayNode('caching') + ->info('Caching configuration.') + ->canBeEnabled() + ->addDefaultsIfNotSet() + ->children() + ->stringNode('cache_pool') + ->info('The taggable cache pool to use for storing the responses.') + ->defaultValue('cache.http_client') + ->cannotBeEmpty() + ->end() + ->booleanNode('shared') + ->info('Indicates whether the cache is shared (public) or private.') + ->defaultTrue() + ->end() + ->integerNode('max_ttl') + ->info('The maximum TTL (in seconds) allowed for cached responses. Null means no cap.') + ->defaultNull() + ->min(0) + ->end() + ->end(); + } + private function createHttpClientRetrySection(): ArrayNodeDefinition { $root = new NodeBuilder(); diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 5595e14b36329..df8879572478c 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -90,6 +90,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\Messenger\PingWebhookMessageHandler; use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\Retry\GenericRetryStrategy; @@ -2670,6 +2672,8 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder $loader->load('http_client.php'); $options = $config['default_options'] ?? []; + $cachingOptions = $options['caching'] ?? ['enabled' => false]; + unset($options['caching']); $rateLimiter = $options['rate_limiter'] ?? null; unset($options['rate_limiter']); $retryOptions = $options['retry_failed'] ?? ['enabled' => false]; @@ -2693,6 +2697,10 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder $container->removeAlias(HttpClient::class); } + if ($this->readConfigEnabled('http_client.caching', $container, $cachingOptions)) { + $this->registerCachingHttpClient($cachingOptions, $options, 'http_client', $container); + } + if (null !== $rateLimiter) { $this->registerThrottlingHttpClient($rateLimiter, 'http_client', $container); } @@ -2718,6 +2726,8 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder $scope = $scopeConfig['scope'] ?? null; unset($scopeConfig['scope']); + $cachingOptions = $scopeConfig['caching'] ?? ['enabled' => false]; + unset($scopeConfig['caching']); $rateLimiter = $scopeConfig['rate_limiter'] ?? null; unset($scopeConfig['rate_limiter']); $retryOptions = $scopeConfig['retry_failed'] ?? ['enabled' => false]; @@ -2741,6 +2751,10 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder ; } + if ($this->readConfigEnabled('http_client.scoped_clients.'.$name.'.caching', $container, $cachingOptions)) { + $this->registerCachingHttpClient($cachingOptions, $scopeConfig, $name, $container); + } + if (null !== $rateLimiter) { $this->registerThrottlingHttpClient($rateLimiter, $name, $container); } @@ -2782,6 +2796,24 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder } } + private function registerCachingHttpClient(array $options, array $defaultOptions, string $name, ContainerBuilder $container): void + { + if (!class_exists(ChunkCacheItemNotFoundException::class)) { + throw new LogicException('Caching cannot be enabled as version 7.3+ of the HttpClient component is required.'); + } + + $container + ->register($name.'.caching', CachingHttpClient::class) + ->setDecoratedService($name, null, 13) // between RetryableHttpClient (10) and ThrottlingHttpClient (15) + ->setArguments([ + new Reference($name.'.caching.inner'), + new Reference($options['cache_pool']), + $defaultOptions, + $options['shared'], + $options['max_ttl'], + ]); + } + private function registerThrottlingHttpClient(string $rateLimiter, string $name, ContainerBuilder $container): void { if (!class_exists(ThrottlingHttpClient::class)) { diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/http_client.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/http_client.php index a562c2598ce01..c963af60446b1 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/http_client.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/http_client.php @@ -15,6 +15,7 @@ use Psr\Http\Client\ClientInterface; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\StreamFactoryInterface; +use Symfony\Component\Cache\Adapter\TagAwareAdapter; use Symfony\Component\HttpClient\HttpClient; use Symfony\Component\HttpClient\HttplugClient; use Symfony\Component\HttpClient\Messenger\PingWebhookMessageHandler; @@ -25,6 +26,14 @@ return static function (ContainerConfigurator $container) { $container->services() + ->set('cache.http_client.pool') + ->parent('cache.app') + ->tag('cache.pool') + + ->set('cache.http_client', TagAwareAdapter::class) + ->args([service('cache.http_client.pool')]) + ->tag('cache.taggable', ['pool' => 'cache.http_client.pool']) + ->set('http_client.transport', HttpClientInterface::class) ->factory([HttpClient::class, 'create']) ->args([ diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd index c4ee3486dae87..8e71f7112d21d 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd @@ -712,6 +712,7 @@ + @@ -739,6 +740,7 @@ + @@ -772,6 +774,13 @@ + + + + + + + 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",