From 867ee2a32285cf26047b7a00385c694086668e91 Mon Sep 17 00:00:00 2001 From: Lctrs Date: Wed, 22 Jan 2025 00:49:36 +0100 Subject: [PATCH 01/53] [HttpClient] Add an RFC 9111 compliant client --- .../DependencyInjection/Configuration.php | 54 ++ .../FrameworkExtension.php | 35 + .../Resources/config/schema/symfony-1.0.xsd | 31 + .../Fixtures/php/http_client_caching.php | 28 + .../Fixtures/xml/http_client_caching.xml | 27 + .../Fixtures/yml/http_client_caching.yml | 23 + .../FrameworkExtensionTestCase.php | 38 ++ .../HttpClient/Rfc9111HttpClient.php | 605 ++++++++++++++++++ .../Tests/Rfc9111HttpClientTest.php | 338 ++++++++++ 9 files changed, 1179 insertions(+) create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/http_client_caching.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/http_client_caching.xml create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/http_client_caching.yml create mode 100644 src/Symfony/Component/HttpClient/Rfc9111HttpClient.php create mode 100644 src/Symfony/Component/HttpClient/Tests/Rfc9111HttpClientTest.php diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index 6b168a2d4a0fd..2128d1241a701 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,58 @@ private function addHttpClientSection(ArrayNodeDefinition $rootNode, callable $e ; } + private function createHttpClientCachingSection(): ArrayNodeDefinition + { + $root = new NodeBuilder(); + + return $root + ->arrayNode('caching') + ->info('Caching configuration.') + ->fixXmlConfig('cacheable_status_code') + ->fixXmlConfig('conditionally_cacheable_status_code') + ->fixXmlConfig('cacheable_method') + ->fixXmlConfig('unsafe_method') + ->canBeEnabled() + ->addDefaultsIfNotSet() + ->children() + ->stringNode('cache') + ->info("The taggable cache's service ID.") + ->defaultValue('cache.app.taggable') + ->cannotBeEmpty() + ->end() + ->booleanNode('shared')->defaultTrue()->end() + ->integerNode('ttl')->defaultNull()->end() + ->arrayNode('cacheable_status_codes') + ->beforeNormalization()->castToArray()->end() + ->defaultValue([200, 203, 204, 206, 300, 301, 404, 405, 410, 414, 501]) + ->prototype('integer')->end() + ->end() + ->arrayNode('conditionally_cacheable_status_codes') + ->beforeNormalization()->castToArray()->end() + ->defaultValue([302, 303, 307, 308]) + ->prototype('integer')->end() + ->end() + ->arrayNode('cacheable_methods') + ->beforeNormalization()->castToArray()->end() + ->beforeNormalization() + ->ifArray() + ->then(fn ($v) => array_map(strtoupper(...), $v)) + ->end() + ->defaultValue(['GET', 'HEAD']) + ->stringPrototype()->end() + ->end() + ->arrayNode('unsafe_methods') + ->beforeNormalization()->castToArray()->end() + ->beforeNormalization() + ->ifArray() + ->then(fn ($v) => array_map(strtoupper(...), $v)) + ->end() + ->defaultValue(['POST', 'PUT', 'DELETE', 'PATCH']) + ->stringPrototype()->end() + ->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..a44be18ab2156 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -94,6 +94,7 @@ use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\Retry\GenericRetryStrategy; use Symfony\Component\HttpClient\RetryableHttpClient; +use Symfony\Component\HttpClient\Rfc9111HttpClient; use Symfony\Component\HttpClient\ScopingHttpClient; use Symfony\Component\HttpClient\ThrottlingHttpClient; use Symfony\Component\HttpClient\UriTemplateHttpClient; @@ -2670,6 +2671,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 +2696,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 +2725,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 +2750,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 +2795,28 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder } } + private function registerCachingHttpClient(array $options, array $defaultOptions, string $name, ContainerBuilder $container): void + { + if (!class_exists(Rfc9111HttpClient::class)) { + throw new LogicException('Caching support cannot be enabled as version 7.3+ of the HttpClient component is required.'); + } + + $container + ->register($name.'.caching', Rfc9111HttpClient::class) + ->setDecoratedService($name, null, 20) // higher priority than ThrottlingHttpClient (15) + ->setArguments([ + new Reference($name.'.caching.inner'), + new Reference($options['cache']), + $defaultOptions, + $options['shared'], + $options['ttl'], + $options['cacheable_status_codes'], + $options['conditionally_cacheable_status_codes'], + $options['cacheable_methods'], + $options['unsafe_methods'], + ]); + } + private function registerThrottlingHttpClient(string $rateLimiter, string $name, ContainerBuilder $container): void { if (!class_exists(ThrottlingHttpClient::class)) { 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..820a4cb9ee517 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,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..576b9c3ea5de6 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/http_client_caching.php @@ -0,0 +1,28 @@ +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' => 'foo', + 'shared' => false, + 'ttl' => 2, + 'cacheable_status_codes' => [200, 201], + 'conditionally_cacheable_status_codes' => [308], + 'cacheable_methods' => ['GET'], + 'unsafe_methods' => ['PUT'], + ], + ], + 'scoped_clients' => [ + 'bar' => [ + 'base_uri' => 'http://example.com', + 'caching' => ['cache' => '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..8b78b5be43279 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/http_client_caching.xml @@ -0,0 +1,27 @@ + + + + + + + + + PHP + + 200 + 201 + 308 + GET + PUT + + + + + + + + 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..b6fa1f9a19b93 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/http_client_caching.yml @@ -0,0 +1,23 @@ +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: foo + shared: false + ttl: 2 + cacheable_status_codes: [200, 201] + conditionally_cacheable_status_codes: [308] + cacheable_methods: [GET] + unsafe_methods: [PUT] + scoped_clients: + bar: + base_uri: http://example.com + caching: + cache: baz diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php index d942c122c826a..09ada5abac430 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php @@ -53,6 +53,7 @@ use Symfony\Component\HtmlSanitizer\HtmlSanitizerInterface; use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\RetryableHttpClient; +use Symfony\Component\HttpClient\Rfc9111HttpClient; use Symfony\Component\HttpClient\ScopingHttpClient; use Symfony\Component\HttpClient\ThrottlingHttpClient; use Symfony\Component\HttpFoundation\IpUtils; @@ -2073,6 +2074,43 @@ public function testHttpClientOverrideDefaultOptions() $this->assertEquals($expected, $container->getDefinition('foo')->getArgument(2)); } + public function testCachingHttpClient() + { + if (!class_exists(Rfc9111HttpClient::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(Rfc9111HttpClient::class, $definition->getClass()); + $this->assertSame('http_client', $definition->getDecoratedService()[0]); + $this->assertCount(9, $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->assertSame([200, 201], $arguments[5]); + $this->assertSame([308], $arguments[6]); + $this->assertSame(['GET'], $arguments[7]); + $this->assertSame(['PUT'], $arguments[8]); + + $this->assertTrue($container->hasDefinition('bar.caching')); + $definition = $container->getDefinition('bar.caching'); + $this->assertSame(Rfc9111HttpClient::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/Rfc9111HttpClient.php b/src/Symfony/Component/HttpClient/Rfc9111HttpClient.php new file mode 100644 index 0000000000000..6bc1dae67c7d6 --- /dev/null +++ b/src/Symfony/Component/HttpClient/Rfc9111HttpClient.php @@ -0,0 +1,605 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient; + +use Symfony\Component\Cache\Adapter\TagAwareAdapterInterface; +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\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 (per RFC 9111). + * + * 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. + * + * @see https://www.rfc-editor.org/rfc/rfc9111 + */ +class Rfc9111HttpClient implements HttpClientInterface, ResetInterface +{ + use AsyncDecoratorTrait { + stream as asyncStream; + AsyncDecoratorTrait::withOptions insteadof HttpClientTrait; + } + use HttpClientTrait; + + private array $defaultOptions = self::OPTIONS_DEFAULTS; + + public function __construct( + HttpClientInterface $client, + private readonly TagAwareAdapterInterface $cache, + array $defaultOptions = [], + private readonly bool $sharedCache = true, + private readonly ?int $ttl = null, + private readonly array $defaultCachableStatusCodes = [200, 203, 204, 206, 300, 301, 404, 405, 410, 414, 501], + private readonly array $conditionallyCachableCodes = [302, 303, 307, 308], + private readonly array $cacheableMethods = ['GET', 'HEAD'], + private readonly array $unsafeMethods = ['POST', 'PUT', 'DELETE', 'PATCH'], + ) { + $this->client = $client; + + if ($defaultOptions) { + [, $this->defaultOptions] = self::prepareRequest(null, null, $defaultOptions, $this->defaultOptions); + } + } + + public function request(string $method, string $url, array $options = []): ResponseInterface + { + [$fullUrl, $options] = self::prepareRequest($method, $url, $options, $this->defaultOptions); + + $fullUrl = implode('', $fullUrl); + $fullUrlTag = hash('xxh3', $fullUrl); + + if (\in_array($method, $this->unsafeMethods, true)) { + $this->cache->invalidateTags([$fullUrlTag]); + } + + if (!empty($options['body']) || isset($options['normalized_headers']['range']) || !\in_array($method, $this->cacheableMethods, true)) { + return $this->client->request($method, $url, $options); + } + + $requestHash = $this->getRequestHash($method, $fullUrl); + $varyKey = "vary_{$requestHash}"; + $varyItem = $this->cache->getItem($varyKey); + $varyFields = $varyItem->isHit() ? $varyItem->get() : []; + + $metadataKey = $this->getMetadataKey($requestHash, $options['normalized_headers'], $varyFields); + $metadataItem = $this->cache->getItem($metadataKey); + $cachedData = $metadataItem->isHit() ? $metadataItem->get() : null; + + $freshness = null; + if (\is_array($cachedData)) { + $freshness = $this->evaluateCachedFreshness($cachedData); + + if ('fresh' === $freshness) { + return $this->createResponseFromCache($metadataKey, $cachedData, $method, $url, $options); + } + + 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]; + } + } + + $chunkIndex = -1; + // consistent expiration time for all items + $expiresAt = null === $this->ttl + ? null + : new \DateTimeImmutable("+{$this->ttl} seconds"); + + return new AsyncResponse( + $this->client, + $method, + $url, + $options, + function (ChunkInterface $chunk, AsyncContext $context) use ( + &$chunkIndex, + $expiresAt, + $fullUrlTag, + $requestHash, + $varyItem, + &$varyFields, + &$metadataKey, + $metadataItem, + $cachedData, + $freshness, + $url, + $method, + $options, + ): \Generator { + if (null !== $chunk->getError() || $chunk->isTimeout()) { + if ('stale-but-usable' === $freshness) { + $context->passthru(); + $context->replaceResponse($this->createResponseFromCache($metadataKey, $cachedData, $method, $url, $options)); + + return; + } + + if ('must-revalidate' === $freshness) { + $context->passthru(); + $context->replaceResponse($this->createGatewayTimeoutResponse($method, $url, $options)); + + return; + } + + yield $chunk; + + return; + } + + $headers = $context->getHeaders(); + $cacheControl = $this->parseCacheControlHeader($headers['cache-control'] ?? []); + + if ($chunk->isFirst()) { + $statusCode = $context->getStatusCode(); + + if (304 === $statusCode && null !== $freshness) { + $maxAge = $this->determineMaxAge($headers, $cacheControl); + + $cachedData['expires_at'] = $this->calculateExpiresAt($maxAge); + $cachedData['stored_at'] = time(); + $cachedData['initial_age'] = (int) ($headers['age'][0] ?? 0); + $cachedData['headers'] = array_merge($cachedData['headers'], $headers); + + $metadataItem->set($cachedData)->expiresAt($expiresAt); + $this->cache->save($metadataItem); + + $context->passthru(); + $context->replaceResponse($this->createResponseFromCache($metadataKey, $cachedData, $method, $url, $options)); + + return; + } + + if ($statusCode >= 500 && $statusCode < 600) { + if ('stale-but-usable' === $freshness) { + $context->passthru(); + $context->replaceResponse($this->createResponseFromCache($metadataKey, $cachedData, $method, $url, $options)); + + return; + } + + if ('must-revalidate' === $freshness) { + $context->passthru(); + $context->replaceResponse($this->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); + } + } + + $metadataKey = $this->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->saveDeferred($varyItem->set($varyFields)->tag($fullUrlTag)->expiresAt($expiresAt)); + + $maxAge = $this->determineMaxAge($headers, $cacheControl); + + $this->cache->saveDeferred( + $this->cache->getItem($metadataKey) + ->tag($fullUrlTag) + ->set([ + 'status_code' => $context->getStatusCode(), + 'headers' => $headers, + 'initial_age' => (int) ($headers['age'][0] ?? 0), + 'stored_at' => time(), + 'expires_at' => $this->calculateExpiresAt($maxAge), + 'chunks_count' => $chunkIndex, + ]) + ->expiresAt($expiresAt) + ); + + $this->cache->commit(); + + yield $chunk; + + return; + } + + ++$chunkIndex; + $chunkKey = "{$metadataKey}_chunk_{$chunkIndex}"; + $chunkItem = $this->cache->getItem($chunkKey) + ->tag($fullUrlTag) + ->set($chunk->getContent()) + ->expiresAfter($this->ttl); + + $this->cache->save($chunkItem); + + yield $chunk; + } + ); + } + + public function stream(ResponseInterface|iterable $responses, ?float $timeout = null): ResponseStreamInterface + { + if ($responses instanceof ResponseInterface) { + $responses = [$responses]; + } + + $mockResponses = []; + $asyncResponses = []; + $clientResponses = []; + + foreach ($responses as $response) { + if ($response instanceof MockResponse) { + $mockResponses[] = $response; + } elseif ($response instanceof AsyncResponse) { + $asyncResponses[] = $response; + } else { + $clientResponses[] = $response; + } + } + + if (!$mockResponses && !$clientResponses) { + return $this->asyncStream($asyncResponses, $timeout); + } + + if (!$asyncResponses && !$clientResponses) { + return new ResponseStream(MockResponse::stream($mockResponses, $timeout)); + } + + if (!$asyncResponses && !$mockResponses) { + return $this->client->stream($clientResponses, $timeout); + } + + return new ResponseStream((function () use ($mockResponses, $asyncResponses, $clientResponses, $timeout) { + yield from MockResponse::stream($mockResponses, $timeout); + yield $this->client->stream($clientResponses, $timeout); + yield $this->asyncStream($asyncResponses, $timeout); + })()); + } + + /** + * Returns a hash representing the request details. + * + * @param string $method the request method + * @param string $url the request URL + * + * @return string the request hash + */ + private function getRequestHash(string $method, string $url): string + { + return hash('xxh3', $method.$url); + } + + /** + * 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 array $varyFields headers to consider for building the variant key + * + * @return string the metadata key composed of the request hash and variant key + */ + private function getMetadataKey(string $requestHash, array $normalizedHeaders, array $varyFields): string + { + $variantKey = hash( + 'xxh3', + [] === $varyFields + ? 'NO-VARY' + : $this->buildVariantKey($normalizedHeaders, $varyFields) + ); + + 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 normalized headers + * @param array $varyFields vary fields + * + * @return string the variant key + */ + private function buildVariantKey(array $normalizedHeaders, array $varyFields): string + { + $parts = []; + foreach ($varyFields as $field) { + $lower = strtolower($field); + if (!isset($normalizedHeaders[$lower])) { + $parts[] = $field.'='; + } else { + $joined = \is_array($normalizedHeaders[$lower]) + ? implode(',', $normalizedHeaders[$lower]) + : (string) $normalizedHeaders[$lower]; + $parts[] = $field.'='.$joined; + } + } + + 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 list $header the Cache-Control header as an array of strings + * + * @return array the parsed Cache-Control directives + */ + private 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; + } + } + } + + 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. It returns one of the following states: + * - 'fresh': if the cached response is still valid or has no expiration. + * - 'must-revalidate': if the response must be revalidated before use. + * - 'stale-but-usable': if the response is stale but can be used in case of errors. + * - 'stale': if the cached response is no longer valid or usable. + * + * @param array $data the cached response data, including headers and expiration time + * + * @return string the freshness status of the cached response + */ + private function evaluateCachedFreshness(array $data): string + { + $parseCacheControlHeader = $this->parseCacheControlHeader($data['headers']['cache-control'] ?? []); + + if (isset($parseCacheControlHeader['no-cache'])) { + return 'stale'; + } + + $now = time(); + $expires = $data['expires_at']; + + if (null === $expires || $now <= $expires) { + return 'fresh'; + } + + if ( + isset($parseCacheControlHeader['must-revalidate']) + || ($this->sharedCache && isset($parseCacheControlHeader['proxy-revalidate'])) + ) { + return 'must-revalidate'; + } + + if (isset($parseCacheControlHeader['stale-if-error'])) { + $staleWindow = (int) $parseCacheControlHeader['stale-if-error']; + if (($now - $expires) <= $staleWindow) { + return 'stale-but-usable'; + } + } + + return 'stale'; + } + + /** + * 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 + { + $age = $this->getCurrentAge($headers); + + if ($this->sharedCache && isset($cacheControl['s-maxage'])) { + $val = (int) $cacheControl['s-maxage']; + + return max(0, $val - $age); + } + + if (isset($cacheControl['max-age'])) { + $val = (int) $cacheControl['max-age']; + + return max(0, $val - $age); + } + + foreach ($headers['expires'] ?? [] as $expire) { + $ts = strtotime($expire); + if (false !== $ts) { + $diff = $ts - time() - $age; + + return max($diff, 0); + } + } + + 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 function getCurrentAge(array $headers): int + { + return (int) ($headers['age'][0] ?? 0); + } + + /** + * Calculates the expiration time of the cache. + * + * @param int|null $maxAge the maximum age of the cache as specified in the Cache-Control header + * + * @return int|null the timestamp when the cache is set to expire, or null if the cache should not expire + */ + private function calculateExpiresAt(?int $maxAge): ?int + { + if (null === $maxAge && null === $this->ttl) { + return null; + } + + return time() + ($maxAge ?? $this->ttl); + } + + /** + * 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 int $statusCode the HTTP status code of the response + * @param array $requestHeaders the HTTP request headers + * @param array $responseHeaders the HTTP response headers + * @param array $cacheControl an array of parsed Cache-Control directives + * + * @return bool true if the response is cacheable, false otherwise + */ + 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'], $cacheControl['s-maxage'], $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, $this->conditionallyCachableCodes, true)) { + return $this->hasExplicitExpiration($responseHeaders, $cacheControl); + } + + return \in_array($statusCode, $this->defaultCachableStatusCodes, 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 the HTTP response headers + * @param array $cacheControl an array of parsed Cache-Control directives + * + * @return bool true if the response has an explicit expiration, false otherwise + */ + 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 string $key the cache key for the response + * @param array $cachedData the cached data for the response + * @param string $method the original request method + * @param string $url the original request URL + * @param array $options the original request options + * + * @return MockResponse the constructed MockResponse object + */ + private function createResponseFromCache(string $key, array $cachedData, string $method, string $url, array $options): MockResponse + { + return MockResponse::fromRequest( + $method, + $url, + $options, + new MockResponse( + (function () use ($key, $cachedData): \Generator { + for ($i = 0; $i <= $cachedData['chunks_count']; ++$i) { + yield $this->cache->getItem("{$key}_chunk_{$i}")->get(); + } + })(), + ['http_code' => $cachedData['status_code'], 'response_headers' => ['age' => $cachedData['initial_age'] + (time() - $cachedData['stored_at'])] + $cachedData['headers']] + ) + ); + } + + private 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/Tests/Rfc9111HttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/Rfc9111HttpClientTest.php new file mode 100644 index 0000000000000..82c3b4de42faa --- /dev/null +++ b/src/Symfony/Component/HttpClient/Tests/Rfc9111HttpClientTest.php @@ -0,0 +1,338 @@ + + * + * 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\Component\Cache\Adapter\FilesystemTagAwareAdapter; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Component\HttpClient\Rfc9111HttpClient; + +/** + * @covers \Symfony\Component\HttpClient\Rfc9111HttpClient + * + * @group time-sensitive + */ +class Rfc9111HttpClientTest extends TestCase +{ + private FilesystemTagAwareAdapter $cacheAdapter; + + protected function setUp(): void + { + parent::setUp(); + + $this->cacheAdapter = new FilesystemTagAwareAdapter('', 0, sys_get_temp_dir().'/rfc9111_client_test'); + $this->cacheAdapter->clear(); + } + + public function testItServesResponseFromCache(): void + { + $mockClient = new MockHttpClient([ + new MockResponse('foo', [ + 'http_code' => 200, + 'response_headers' => [ + 'Cache-Control' => 'max-age=300', + ], + ]), + new MockResponse('should not be served'), + ]); + + $client = new Rfc9111HttpClient( + $mockClient, + $this->cacheAdapter, + defaultCachableStatusCodes: [200], + cacheableMethods: ['GET'], + ); + + $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 testItDoesntServeAStaleResponse(): void + { + $mockClient = new MockHttpClient([ + new MockResponse('foo', [ + 'http_code' => 200, + 'response_headers' => [ + 'Cache-Control' => 'max-age=5', + ], + ]), + new MockResponse('bar'), + ]); + + $client = new Rfc9111HttpClient( + $mockClient, + $this->cacheAdapter, + defaultCachableStatusCodes: [200], + cacheableMethods: ['GET'], + ); + + $response = $client->request('GET', 'http://example.com/foo-bar'); + self::assertSame(200, $response->getStatusCode()); + self::assertSame('foo', $response->getContent()); + + sleep(5); + + $response = $client->request('GET', 'http://example.com/foo-bar'); + self::assertSame(200, $response->getStatusCode()); + self::assertSame('foo', $response->getContent()); + + sleep(1); + + $response = $client->request('GET', 'http://example.com/foo-bar'); + self::assertSame(200, $response->getStatusCode()); + self::assertSame('bar', $response->getContent()); + } + + public function testItRevalidatesAResponseWithNoCacheDirective(): void + { + $mockClient = new MockHttpClient([ + new MockResponse('foo', [ + 'http_code' => 200, + 'response_headers' => [ + 'Cache-Control' => 'no-cache, max-age=5', + ], + ]), + new MockResponse('bar'), + ]); + + $client = new Rfc9111HttpClient( + $mockClient, + $this->cacheAdapter, + sharedCache: false, + defaultCachableStatusCodes: [200], + cacheableMethods: ['GET'], + ); + + $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 testItServesAStaleResponseIfError(): void + { + + $mockClient = new MockHttpClient([ + new MockResponse('foo', [ + 'http_code' => 200, + 'response_headers' => [ + 'Cache-Control' => 'max-age=1, stale-if-error=5', + ], + ]), + new MockResponse('Internal Server Error', ['http_code' => 500]), + ]); + + $client = new Rfc9111HttpClient( + $mockClient, + $this->cacheAdapter, + sharedCache: false, + defaultCachableStatusCodes: [200], + cacheableMethods: ['GET'], + ); + + $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 testItDoesntCacheNotConfiguredStatusCode(): void + { + $mockClient = new MockHttpClient([ + new MockResponse('foo', [ + 'http_code' => 200, + 'response_headers' => [ + 'Cache-Control' => 'max-age=300', + ], + ]), + new MockResponse('bar'), + ]); + + $client = new Rfc9111HttpClient( + $mockClient, + $this->cacheAdapter, + defaultCachableStatusCodes: [201], + cacheableMethods: ['GET'], + ); + + $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 testItDoesntCacheNotConfiguredHttpMethods(): void + { + $mockClient = new MockHttpClient([ + new MockResponse('foo', [ + 'http_code' => 200, + 'response_headers' => [ + 'Cache-Control' => 'max-age=300', + ], + ]), + new MockResponse('bar'), + ]); + + $client = new Rfc9111HttpClient( + $mockClient, + $this->cacheAdapter, + defaultCachableStatusCodes: [200], + cacheableMethods: ['OPTIONS'], + ); + + $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 testItDoesntStoreAResponseWithNoStoreDirective(): void + { + $mockClient = new MockHttpClient([ + new MockResponse('foo', [ + 'http_code' => 200, + 'response_headers' => [ + 'Cache-Control' => 'no-store', + ], + ]), + new MockResponse('bar'), + ]); + + $client = new Rfc9111HttpClient( + $mockClient, + $this->cacheAdapter, + defaultCachableStatusCodes: [200], + cacheableMethods: ['GET'], + ); + + $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 testASharedCacheDoesntStoreAResponseWithPrivateDirective(): void + { + $mockClient = new MockHttpClient([ + new MockResponse('foo', [ + 'http_code' => 200, + 'response_headers' => [ + 'Cache-Control' => 'private, max-age=5', + ], + ]), + new MockResponse('bar'), + ]); + + $client = new Rfc9111HttpClient( + $mockClient, + $this->cacheAdapter, + sharedCache: true, + defaultCachableStatusCodes: [200], + cacheableMethods: ['GET'], + ); + + $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(): void + { + $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 Rfc9111HttpClient( + $mockClient, + $this->cacheAdapter, + sharedCache: false, + defaultCachableStatusCodes: [200], + cacheableMethods: ['GET'], + ); + + $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 testUnsafeMethodsInvalidateCache(): void + { + $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 Rfc9111HttpClient( + $mockClient, + $this->cacheAdapter, + defaultCachableStatusCodes: [200], + cacheableMethods: ['GET'], + unsafeMethods: ['DELETE'], + ); + + $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()); + } +} From d8b4992a611a354fac3485dfc8b39fa4a170d169 Mon Sep 17 00:00:00 2001 From: Lctrs Date: Wed, 22 Jan 2025 13:53:21 +0100 Subject: [PATCH 02/53] rename and doc --- .../HttpClient/Rfc9111HttpClient.php | 19 +++++++++++++---- .../Tests/Rfc9111HttpClientTest.php | 21 +++++++++---------- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/Symfony/Component/HttpClient/Rfc9111HttpClient.php b/src/Symfony/Component/HttpClient/Rfc9111HttpClient.php index 6bc1dae67c7d6..7fc4a851af8cb 100644 --- a/src/Symfony/Component/HttpClient/Rfc9111HttpClient.php +++ b/src/Symfony/Component/HttpClient/Rfc9111HttpClient.php @@ -46,14 +46,25 @@ class Rfc9111HttpClient implements HttpClientInterface, ResetInterface private array $defaultOptions = self::OPTIONS_DEFAULTS; + /** + * @param HttpClientInterface $client The decorated client + * @param TagAwareAdapterInterface $cache The cache to store responses + * @param array $defaultOptions The default options for the client + * @param bool $sharedCache Whether to share the cache between all instances of this client + * @param int|null $ttl The default TTL (in seconds) for the cache + * @param array $cachableStatusCodes The status codes that are always cacheable + * @param array $conditionallyCachableStatusCodes The status codes that are cacheable if the response has a Cache-Control header or ETag + * @param array $cacheableMethods The HTTP methods that are cacheable + * @param array $unsafeMethods The HTTP methods that will trigger a cache invalidation + */ public function __construct( HttpClientInterface $client, private readonly TagAwareAdapterInterface $cache, array $defaultOptions = [], private readonly bool $sharedCache = true, private readonly ?int $ttl = null, - private readonly array $defaultCachableStatusCodes = [200, 203, 204, 206, 300, 301, 404, 405, 410, 414, 501], - private readonly array $conditionallyCachableCodes = [302, 303, 307, 308], + private readonly array $cachableStatusCodes = [200, 203, 204, 206, 300, 301, 404, 405, 410, 414, 501], + private readonly array $conditionallyCachableStatusCodes = [302, 303, 307, 308], private readonly array $cacheableMethods = ['GET', 'HEAD'], private readonly array $unsafeMethods = ['POST', 'PUT', 'DELETE', 'PATCH'], ) { @@ -534,11 +545,11 @@ private function isServerResponseCacheable(int $statusCode, array $requestHeader } // Conditionals require an explicit expiration - if (\in_array($statusCode, $this->conditionallyCachableCodes, true)) { + if (\in_array($statusCode, $this->conditionallyCachableStatusCodes, true)) { return $this->hasExplicitExpiration($responseHeaders, $cacheControl); } - return \in_array($statusCode, $this->defaultCachableStatusCodes, true); + return \in_array($statusCode, $this->cachableStatusCodes, true); } /** diff --git a/src/Symfony/Component/HttpClient/Tests/Rfc9111HttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/Rfc9111HttpClientTest.php index 82c3b4de42faa..22591dfdc2782 100644 --- a/src/Symfony/Component/HttpClient/Tests/Rfc9111HttpClientTest.php +++ b/src/Symfony/Component/HttpClient/Tests/Rfc9111HttpClientTest.php @@ -49,7 +49,7 @@ public function testItServesResponseFromCache(): void $client = new Rfc9111HttpClient( $mockClient, $this->cacheAdapter, - defaultCachableStatusCodes: [200], + cachableStatusCodes: [200], cacheableMethods: ['GET'], ); @@ -80,7 +80,7 @@ public function testItDoesntServeAStaleResponse(): void $client = new Rfc9111HttpClient( $mockClient, $this->cacheAdapter, - defaultCachableStatusCodes: [200], + cachableStatusCodes: [200], cacheableMethods: ['GET'], ); @@ -117,7 +117,7 @@ public function testItRevalidatesAResponseWithNoCacheDirective(): void $mockClient, $this->cacheAdapter, sharedCache: false, - defaultCachableStatusCodes: [200], + cachableStatusCodes: [200], cacheableMethods: ['GET'], ); @@ -132,7 +132,6 @@ public function testItRevalidatesAResponseWithNoCacheDirective(): void public function testItServesAStaleResponseIfError(): void { - $mockClient = new MockHttpClient([ new MockResponse('foo', [ 'http_code' => 200, @@ -147,7 +146,7 @@ public function testItServesAStaleResponseIfError(): void $mockClient, $this->cacheAdapter, sharedCache: false, - defaultCachableStatusCodes: [200], + cachableStatusCodes: [200], cacheableMethods: ['GET'], ); @@ -177,7 +176,7 @@ public function testItDoesntCacheNotConfiguredStatusCode(): void $client = new Rfc9111HttpClient( $mockClient, $this->cacheAdapter, - defaultCachableStatusCodes: [201], + cachableStatusCodes: [201], cacheableMethods: ['GET'], ); @@ -205,7 +204,7 @@ public function testItDoesntCacheNotConfiguredHttpMethods(): void $client = new Rfc9111HttpClient( $mockClient, $this->cacheAdapter, - defaultCachableStatusCodes: [200], + cachableStatusCodes: [200], cacheableMethods: ['OPTIONS'], ); @@ -233,7 +232,7 @@ public function testItDoesntStoreAResponseWithNoStoreDirective(): void $client = new Rfc9111HttpClient( $mockClient, $this->cacheAdapter, - defaultCachableStatusCodes: [200], + cachableStatusCodes: [200], cacheableMethods: ['GET'], ); @@ -262,7 +261,7 @@ public function testASharedCacheDoesntStoreAResponseWithPrivateDirective(): void $mockClient, $this->cacheAdapter, sharedCache: true, - defaultCachableStatusCodes: [200], + cachableStatusCodes: [200], cacheableMethods: ['GET'], ); @@ -291,7 +290,7 @@ public function testAPrivateCacheStoresAResponseWithPrivateDirective(): void $mockClient, $this->cacheAdapter, sharedCache: false, - defaultCachableStatusCodes: [200], + cachableStatusCodes: [200], cacheableMethods: ['GET'], ); @@ -320,7 +319,7 @@ public function testUnsafeMethodsInvalidateCache(): void $client = new Rfc9111HttpClient( $mockClient, $this->cacheAdapter, - defaultCachableStatusCodes: [200], + cachableStatusCodes: [200], cacheableMethods: ['GET'], unsafeMethods: ['DELETE'], ); From 492b4ab62962bf0181e7e1c5b7631bbdc8719a86 Mon Sep 17 00:00:00 2001 From: Lctrs Date: Wed, 22 Jan 2025 17:33:07 +0100 Subject: [PATCH 03/53] remove configurable status codes and methods + doc constructor --- .../DependencyInjection/Configuration.php | 28 ------- .../FrameworkExtension.php | 4 - .../Resources/config/schema/symfony-1.0.xsd | 22 ------ .../Fixtures/php/http_client_caching.php | 4 - .../Fixtures/xml/http_client_caching.xml | 12 +-- .../Fixtures/yml/http_client_caching.yml | 4 - .../FrameworkExtensionTestCase.php | 6 +- .../HttpClient/Rfc9111HttpClient.php | 46 +++++++----- .../Tests/Rfc9111HttpClientTest.php | 73 ------------------- 9 files changed, 33 insertions(+), 166 deletions(-) diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index 2128d1241a701..43675ae952503 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -2190,34 +2190,6 @@ private function createHttpClientCachingSection(): ArrayNodeDefinition ->end() ->booleanNode('shared')->defaultTrue()->end() ->integerNode('ttl')->defaultNull()->end() - ->arrayNode('cacheable_status_codes') - ->beforeNormalization()->castToArray()->end() - ->defaultValue([200, 203, 204, 206, 300, 301, 404, 405, 410, 414, 501]) - ->prototype('integer')->end() - ->end() - ->arrayNode('conditionally_cacheable_status_codes') - ->beforeNormalization()->castToArray()->end() - ->defaultValue([302, 303, 307, 308]) - ->prototype('integer')->end() - ->end() - ->arrayNode('cacheable_methods') - ->beforeNormalization()->castToArray()->end() - ->beforeNormalization() - ->ifArray() - ->then(fn ($v) => array_map(strtoupper(...), $v)) - ->end() - ->defaultValue(['GET', 'HEAD']) - ->stringPrototype()->end() - ->end() - ->arrayNode('unsafe_methods') - ->beforeNormalization()->castToArray()->end() - ->beforeNormalization() - ->ifArray() - ->then(fn ($v) => array_map(strtoupper(...), $v)) - ->end() - ->defaultValue(['POST', 'PUT', 'DELETE', 'PATCH']) - ->stringPrototype()->end() - ->end() ->end(); } diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index a44be18ab2156..2be273373a631 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -2810,10 +2810,6 @@ private function registerCachingHttpClient(array $options, array $defaultOptions $defaultOptions, $options['shared'], $options['ttl'], - $options['cacheable_status_codes'], - $options['conditionally_cacheable_status_codes'], - $options['cacheable_methods'], - $options['unsafe_methods'], ]); } 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 820a4cb9ee517..82863c7ae74bd 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 @@ -775,28 +775,6 @@ - - - - - - - - - - - - - - - - - - - - - - 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 index 576b9c3ea5de6..32873e2cec2d2 100644 --- 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 @@ -12,10 +12,6 @@ 'cache' => 'foo', 'shared' => false, 'ttl' => 2, - 'cacheable_status_codes' => [200, 201], - 'conditionally_cacheable_status_codes' => [308], - 'cacheable_methods' => ['GET'], - 'unsafe_methods' => ['PUT'], ], ], 'scoped_clients' => [ 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 index 8b78b5be43279..89f8f2a8c9f11 100644 --- 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 @@ -11,16 +11,10 @@ PHP - - 200 - 201 - 308 - GET - PUT - + - - + + 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 index b6fa1f9a19b93..1ccffb6d548bd 100644 --- 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 @@ -12,10 +12,6 @@ framework: cache: foo shared: false ttl: 2 - cacheable_status_codes: [200, 201] - conditionally_cacheable_status_codes: [308] - cacheable_methods: [GET] - unsafe_methods: [PUT] scoped_clients: bar: base_uri: http://example.com diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php index 09ada5abac430..be1f4ca384fe3 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php @@ -2086,7 +2086,7 @@ public function testCachingHttpClient() $definition = $container->getDefinition('http_client.caching'); $this->assertSame(Rfc9111HttpClient::class, $definition->getClass()); $this->assertSame('http_client', $definition->getDecoratedService()[0]); - $this->assertCount(9, $arguments = $definition->getArguments()); + $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]); @@ -2095,10 +2095,6 @@ public function testCachingHttpClient() $this->assertSame(['X-powered' => 'PHP'], $arguments[2]['headers']); $this->assertFalse($arguments[3]); $this->assertSame(2, $arguments[4]); - $this->assertSame([200, 201], $arguments[5]); - $this->assertSame([308], $arguments[6]); - $this->assertSame(['GET'], $arguments[7]); - $this->assertSame(['PUT'], $arguments[8]); $this->assertTrue($container->hasDefinition('bar.caching')); $definition = $container->getDefinition('bar.caching'); diff --git a/src/Symfony/Component/HttpClient/Rfc9111HttpClient.php b/src/Symfony/Component/HttpClient/Rfc9111HttpClient.php index 7fc4a851af8cb..fcbf61f52221e 100644 --- a/src/Symfony/Component/HttpClient/Rfc9111HttpClient.php +++ b/src/Symfony/Component/HttpClient/Rfc9111HttpClient.php @@ -44,18 +44,34 @@ class Rfc9111HttpClient implements HttpClientInterface, ResetInterface } use HttpClientTrait; + /** + * The status codes that are always cacheable. + */ + private const CACHEABLE_STATUS_CODES = [200, 203, 204, 206, 300, 301, 404, 405, 410, 414, 501]; + /** + * The status codes that are cacheable if the response carry 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']; + private array $defaultOptions = self::OPTIONS_DEFAULTS; /** - * @param HttpClientInterface $client The decorated client - * @param TagAwareAdapterInterface $cache The cache to store responses - * @param array $defaultOptions The default options for the client - * @param bool $sharedCache Whether to share the cache between all instances of this client - * @param int|null $ttl The default TTL (in seconds) for the cache - * @param array $cachableStatusCodes The status codes that are always cacheable - * @param array $conditionallyCachableStatusCodes The status codes that are cacheable if the response has a Cache-Control header or ETag - * @param array $cacheableMethods The HTTP methods that are cacheable - * @param array $unsafeMethods The HTTP methods that will trigger a cache invalidation + * @param HttpClientInterface $client The decorated client + * @param TagAwareAdapterInterface $cache The cache to store responses + * @param array $defaultOptions The default options for the client + * @param bool $sharedCache Indicates whether this cache is shared or private. + * If true, directives such as "private" will prevent + * storing responses, and "Authorization" headers will + * bypass caching unless overridden by "public". + * @param int|null $ttl The TTL (in seconds) for the cache */ public function __construct( HttpClientInterface $client, @@ -63,10 +79,6 @@ public function __construct( array $defaultOptions = [], private readonly bool $sharedCache = true, private readonly ?int $ttl = null, - private readonly array $cachableStatusCodes = [200, 203, 204, 206, 300, 301, 404, 405, 410, 414, 501], - private readonly array $conditionallyCachableStatusCodes = [302, 303, 307, 308], - private readonly array $cacheableMethods = ['GET', 'HEAD'], - private readonly array $unsafeMethods = ['POST', 'PUT', 'DELETE', 'PATCH'], ) { $this->client = $client; @@ -82,11 +94,11 @@ public function request(string $method, string $url, array $options = []): Respo $fullUrl = implode('', $fullUrl); $fullUrlTag = hash('xxh3', $fullUrl); - if (\in_array($method, $this->unsafeMethods, true)) { + if (\in_array($method, self::UNSAFE_METHODS, true)) { $this->cache->invalidateTags([$fullUrlTag]); } - if (!empty($options['body']) || isset($options['normalized_headers']['range']) || !\in_array($method, $this->cacheableMethods, true)) { + if (!empty($options['body']) || isset($options['normalized_headers']['range']) || !\in_array($method, self::CACHEABLE_METHODS, true)) { return $this->client->request($method, $url, $options); } @@ -545,11 +557,11 @@ private function isServerResponseCacheable(int $statusCode, array $requestHeader } // Conditionals require an explicit expiration - if (\in_array($statusCode, $this->conditionallyCachableStatusCodes, true)) { + if (\in_array($statusCode, self::CONDITIONALLY_CACHEABLE_STATUS_CODES, true)) { return $this->hasExplicitExpiration($responseHeaders, $cacheControl); } - return \in_array($statusCode, $this->cachableStatusCodes, true); + return \in_array($statusCode, self::CACHEABLE_STATUS_CODES, true); } /** diff --git a/src/Symfony/Component/HttpClient/Tests/Rfc9111HttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/Rfc9111HttpClientTest.php index 22591dfdc2782..e14d8ad5e8bc2 100644 --- a/src/Symfony/Component/HttpClient/Tests/Rfc9111HttpClientTest.php +++ b/src/Symfony/Component/HttpClient/Tests/Rfc9111HttpClientTest.php @@ -49,8 +49,6 @@ public function testItServesResponseFromCache(): void $client = new Rfc9111HttpClient( $mockClient, $this->cacheAdapter, - cachableStatusCodes: [200], - cacheableMethods: ['GET'], ); $response = $client->request('GET', 'http://example.com/foo-bar'); @@ -80,8 +78,6 @@ public function testItDoesntServeAStaleResponse(): void $client = new Rfc9111HttpClient( $mockClient, $this->cacheAdapter, - cachableStatusCodes: [200], - cacheableMethods: ['GET'], ); $response = $client->request('GET', 'http://example.com/foo-bar'); @@ -117,8 +113,6 @@ public function testItRevalidatesAResponseWithNoCacheDirective(): void $mockClient, $this->cacheAdapter, sharedCache: false, - cachableStatusCodes: [200], - cacheableMethods: ['GET'], ); $response = $client->request('GET', 'http://example.com/foo-bar'); @@ -146,8 +140,6 @@ public function testItServesAStaleResponseIfError(): void $mockClient, $this->cacheAdapter, sharedCache: false, - cachableStatusCodes: [200], - cacheableMethods: ['GET'], ); $response = $client->request('GET', 'http://example.com/foo-bar'); @@ -161,62 +153,6 @@ public function testItServesAStaleResponseIfError(): void self::assertSame('foo', $response->getContent()); } - public function testItDoesntCacheNotConfiguredStatusCode(): void - { - $mockClient = new MockHttpClient([ - new MockResponse('foo', [ - 'http_code' => 200, - 'response_headers' => [ - 'Cache-Control' => 'max-age=300', - ], - ]), - new MockResponse('bar'), - ]); - - $client = new Rfc9111HttpClient( - $mockClient, - $this->cacheAdapter, - cachableStatusCodes: [201], - cacheableMethods: ['GET'], - ); - - $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 testItDoesntCacheNotConfiguredHttpMethods(): void - { - $mockClient = new MockHttpClient([ - new MockResponse('foo', [ - 'http_code' => 200, - 'response_headers' => [ - 'Cache-Control' => 'max-age=300', - ], - ]), - new MockResponse('bar'), - ]); - - $client = new Rfc9111HttpClient( - $mockClient, - $this->cacheAdapter, - cachableStatusCodes: [200], - cacheableMethods: ['OPTIONS'], - ); - - $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 testItDoesntStoreAResponseWithNoStoreDirective(): void { $mockClient = new MockHttpClient([ @@ -232,8 +168,6 @@ public function testItDoesntStoreAResponseWithNoStoreDirective(): void $client = new Rfc9111HttpClient( $mockClient, $this->cacheAdapter, - cachableStatusCodes: [200], - cacheableMethods: ['GET'], ); $response = $client->request('GET', 'http://example.com/foo-bar'); @@ -261,8 +195,6 @@ public function testASharedCacheDoesntStoreAResponseWithPrivateDirective(): void $mockClient, $this->cacheAdapter, sharedCache: true, - cachableStatusCodes: [200], - cacheableMethods: ['GET'], ); $response = $client->request('GET', 'http://example.com/foo-bar'); @@ -290,8 +222,6 @@ public function testAPrivateCacheStoresAResponseWithPrivateDirective(): void $mockClient, $this->cacheAdapter, sharedCache: false, - cachableStatusCodes: [200], - cacheableMethods: ['GET'], ); $response = $client->request('GET', 'http://example.com/foo-bar'); @@ -319,9 +249,6 @@ public function testUnsafeMethodsInvalidateCache(): void $client = new Rfc9111HttpClient( $mockClient, $this->cacheAdapter, - cachableStatusCodes: [200], - cacheableMethods: ['GET'], - unsafeMethods: ['DELETE'], ); $response = $client->request('GET', 'http://example.com/foo-bar'); From 4dc015710460ebb9fbb3102c7bb78772326d40ec Mon Sep 17 00:00:00 2001 From: Lctrs Date: Wed, 22 Jan 2025 17:37:40 +0100 Subject: [PATCH 04/53] phpdoc --- .../HttpClient/Rfc9111HttpClient.php | 37 ++++++++----------- 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/src/Symfony/Component/HttpClient/Rfc9111HttpClient.php b/src/Symfony/Component/HttpClient/Rfc9111HttpClient.php index fcbf61f52221e..456de3d3fa9c4 100644 --- a/src/Symfony/Component/HttpClient/Rfc9111HttpClient.php +++ b/src/Symfony/Component/HttpClient/Rfc9111HttpClient.php @@ -317,11 +317,6 @@ public function stream(ResponseInterface|iterable $responses, ?float $timeout = /** * Returns a hash representing the request details. - * - * @param string $method the request method - * @param string $url the request URL - * - * @return string the request hash */ private function getRequestHash(string $method, string $url): string { @@ -331,9 +326,9 @@ private function getRequestHash(string $method, string $url): string /** * 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 array $varyFields headers to consider for building the variant key + * @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 */ @@ -354,10 +349,8 @@ private function getMetadataKey(string $requestHash, array $normalizedHeaders, a * * 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 normalized headers - * @param array $varyFields vary fields - * - * @return string the variant key + * @param array $normalizedHeaders + * @param string[] $varyFields */ private function buildVariantKey(array $normalizedHeaders, array $varyFields): string { @@ -381,7 +374,7 @@ private function buildVariantKey(array $normalizedHeaders, array $varyFields): s * 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 list $header the Cache-Control header as an array of strings + * @param string[] $header the Cache-Control header as an array of strings * * @return array the parsed Cache-Control directives */ @@ -461,8 +454,8 @@ private function evaluateCachedFreshness(array $data): string * 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 + * @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 @@ -498,7 +491,7 @@ private function determineMaxAge(array $headers, array $cacheControl): ?int /** * Retrieves the current age of the response from the headers. * - * @param array $headers an array of HTTP headers + * @param array $headers an array of HTTP headers * * @return int The age of the response in seconds. Defaults to 0 if not present. */ @@ -530,10 +523,10 @@ private function calculateExpiresAt(?int $maxAge): ?int * This function will return true if the server response can be cached, * false otherwise. * - * @param int $statusCode the HTTP status code of the response - * @param array $requestHeaders the HTTP request headers - * @param array $responseHeaders the HTTP response headers - * @param array $cacheControl an array of parsed Cache-Control directives + * @param int $statusCode the HTTP status code of the response + * @param array $requestHeaders the HTTP request headers + * @param array $responseHeaders the HTTP response headers + * @param array $cacheControl an array of parsed Cache-Control directives * * @return bool true if the response is cacheable, false otherwise */ @@ -571,8 +564,8 @@ private function isServerResponseCacheable(int $statusCode, array $requestHeader * time specified in the headers or in the Cache-Control directives, * false otherwise. * - * @param array $headers the HTTP response headers - * @param array $cacheControl an array of parsed Cache-Control directives + * @param array $headers the HTTP response headers + * @param array $cacheControl an array of parsed Cache-Control directives * * @return bool true if the response has an explicit expiration, false otherwise */ From 5462d6098f9ab3c2a4b2dd04c3477d690dc47936 Mon Sep 17 00:00:00 2001 From: Lctrs Date: Wed, 22 Jan 2025 18:02:52 +0100 Subject: [PATCH 05/53] into cachinghttpclient with bc layer --- .../FrameworkExtension.php | 8 +- .../FrameworkExtensionTestCase.php | 10 +- .../HttpClient/CachingHttpClient.php | 607 +++++++++++++++-- .../HttpClient/Rfc9111HttpClient.php | 621 ------------------ .../Tests/CachingHttpClientTest.php | 274 ++++++-- .../Tests/LegacyCachingHttpClientTest.php | 110 ++++ .../Tests/Rfc9111HttpClientTest.php | 264 -------- 7 files changed, 898 insertions(+), 996 deletions(-) delete mode 100644 src/Symfony/Component/HttpClient/Rfc9111HttpClient.php create mode 100644 src/Symfony/Component/HttpClient/Tests/LegacyCachingHttpClientTest.php delete mode 100644 src/Symfony/Component/HttpClient/Tests/Rfc9111HttpClientTest.php diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 2be273373a631..0a63fc3e39939 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -90,11 +90,11 @@ use Symfony\Component\HtmlSanitizer\HtmlSanitizer; use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig; use Symfony\Component\HtmlSanitizer\HtmlSanitizerInterface; +use Symfony\Component\HttpClient\CachingHttpClient; use Symfony\Component\HttpClient\Messenger\PingWebhookMessageHandler; use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\Retry\GenericRetryStrategy; use Symfony\Component\HttpClient\RetryableHttpClient; -use Symfony\Component\HttpClient\Rfc9111HttpClient; use Symfony\Component\HttpClient\ScopingHttpClient; use Symfony\Component\HttpClient\ThrottlingHttpClient; use Symfony\Component\HttpClient\UriTemplateHttpClient; @@ -2797,12 +2797,8 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder private function registerCachingHttpClient(array $options, array $defaultOptions, string $name, ContainerBuilder $container): void { - if (!class_exists(Rfc9111HttpClient::class)) { - throw new LogicException('Caching support cannot be enabled as version 7.3+ of the HttpClient component is required.'); - } - $container - ->register($name.'.caching', Rfc9111HttpClient::class) + ->register($name.'.caching', CachingHttpClient::class) ->setDecoratedService($name, null, 20) // higher priority than ThrottlingHttpClient (15) ->setArguments([ new Reference($name.'.caching.inner'), diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php index be1f4ca384fe3..8fc2a3549b72d 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php @@ -51,9 +51,9 @@ use Symfony\Component\HtmlSanitizer\HtmlSanitizer; use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig; use Symfony\Component\HtmlSanitizer\HtmlSanitizerInterface; +use Symfony\Component\HttpClient\CachingHttpClient; use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\RetryableHttpClient; -use Symfony\Component\HttpClient\Rfc9111HttpClient; use Symfony\Component\HttpClient\ScopingHttpClient; use Symfony\Component\HttpClient\ThrottlingHttpClient; use Symfony\Component\HttpFoundation\IpUtils; @@ -2076,15 +2076,11 @@ public function testHttpClientOverrideDefaultOptions() public function testCachingHttpClient() { - if (!class_exists(Rfc9111HttpClient::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(Rfc9111HttpClient::class, $definition->getClass()); + $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]); @@ -2098,7 +2094,7 @@ public function testCachingHttpClient() $this->assertTrue($container->hasDefinition('bar.caching')); $definition = $container->getDefinition('bar.caching'); - $this->assertSame(Rfc9111HttpClient::class, $definition->getClass()); + $this->assertSame(CachingHttpClient::class, $definition->getClass()); $this->assertSame('bar', $definition->getDecoratedService()[0]); $arguments = $definition->getArguments(); $this->assertInstanceOf(Reference::class, $arguments[0]); diff --git a/src/Symfony/Component/HttpClient/CachingHttpClient.php b/src/Symfony/Component/HttpClient/CachingHttpClient.php index 6b14973891d5d..d8c7bb56458e9 100644 --- a/src/Symfony/Component/HttpClient/CachingHttpClient.php +++ b/src/Symfony/Component/HttpClient/CachingHttpClient.php @@ -11,12 +11,16 @@ namespace Symfony\Component\HttpClient; +use Symfony\Component\Cache\Adapter\TagAwareAdapterInterface; +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\HttpClient\ChunkInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\ResponseInterface; use Symfony\Contracts\HttpClient\ResponseStreamInterface; @@ -33,33 +37,62 @@ */ 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, 206, 300, 301, 404, 405, 410, 414, 501]; + /** + * The status codes that are cacheable if the response carry 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']; + + private TagAwareAdapterInterface|HttpCache $cache; private array $defaultOptions = self::OPTIONS_DEFAULTS; public function __construct( private HttpClientInterface $client, - StoreInterface $store, + TagAwareAdapterInterface|StoreInterface $store, array $defaultOptions = [], + private readonly bool $sharedCache = true, + private readonly ?int $ttl = 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 ($store 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__, TagAwareAdapterInterface::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, $store, 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 = $store; + } if ($defaultOptions) { [, $this->defaultOptions] = self::prepareRequest(null, null, $defaultOptions, $this->defaultOptions); @@ -67,6 +100,238 @@ 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 = hash('xxh3', $fullUrl); + + if (\in_array($method, self::UNSAFE_METHODS, true)) { + $this->cache->invalidateTags([$fullUrlTag]); + } + + if (!empty($options['body']) || isset($options['normalized_headers']['range']) || !\in_array($method, self::CACHEABLE_METHODS, true)) { + return $this->client->request($method, $url, $options); + } + + $requestHash = $this->getRequestHash($method, $fullUrl); + $varyKey = "vary_{$requestHash}"; + $varyItem = $this->cache->getItem($varyKey); + $varyFields = $varyItem->isHit() ? $varyItem->get() : []; + + $metadataKey = $this->getMetadataKey($requestHash, $options['normalized_headers'], $varyFields); + $metadataItem = $this->cache->getItem($metadataKey); + $cachedData = $metadataItem->isHit() ? $metadataItem->get() : null; + + $freshness = null; + if (\is_array($cachedData)) { + $freshness = $this->evaluateCachedFreshness($cachedData); + + if ('fresh' === $freshness) { + return $this->createResponseFromCache($metadataKey, $cachedData, $method, $url, $options); + } + + 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]; + } + } + + $chunkIndex = -1; + // consistent expiration time for all items + $expiresAt = null === $this->ttl + ? null + : new \DateTimeImmutable("+{$this->ttl} seconds"); + + return new AsyncResponse( + $this->client, + $method, + $url, + $options, + function (ChunkInterface $chunk, AsyncContext $context) use ( + &$chunkIndex, + $expiresAt, + $fullUrlTag, + $requestHash, + $varyItem, + &$varyFields, + &$metadataKey, + $metadataItem, + $cachedData, + $freshness, + $url, + $method, + $options, + ): \Generator { + if (null !== $chunk->getError() || $chunk->isTimeout()) { + if ('stale-but-usable' === $freshness) { + $context->passthru(); + $context->replaceResponse($this->createResponseFromCache($metadataKey, $cachedData, $method, $url, $options)); + + return; + } + + if ('must-revalidate' === $freshness) { + $context->passthru(); + $context->replaceResponse($this->createGatewayTimeoutResponse($method, $url, $options)); + + return; + } + + yield $chunk; + + return; + } + + $headers = $context->getHeaders(); + $cacheControl = $this->parseCacheControlHeader($headers['cache-control'] ?? []); + + if ($chunk->isFirst()) { + $statusCode = $context->getStatusCode(); + + if (304 === $statusCode && null !== $freshness) { + $maxAge = $this->determineMaxAge($headers, $cacheControl); + + $cachedData['expires_at'] = $this->calculateExpiresAt($maxAge); + $cachedData['stored_at'] = time(); + $cachedData['initial_age'] = (int) ($headers['age'][0] ?? 0); + $cachedData['headers'] = array_merge($cachedData['headers'], $headers); + + $metadataItem->set($cachedData)->expiresAt($expiresAt); + $this->cache->save($metadataItem); + + $context->passthru(); + $context->replaceResponse($this->createResponseFromCache($metadataKey, $cachedData, $method, $url, $options)); + + return; + } + + if ($statusCode >= 500 && $statusCode < 600) { + if ('stale-but-usable' === $freshness) { + $context->passthru(); + $context->replaceResponse($this->createResponseFromCache($metadataKey, $cachedData, $method, $url, $options)); + + return; + } + + if ('must-revalidate' === $freshness) { + $context->passthru(); + $context->replaceResponse($this->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); + } + } + + $metadataKey = $this->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->saveDeferred($varyItem->set($varyFields)->tag($fullUrlTag)->expiresAt($expiresAt)); + + $maxAge = $this->determineMaxAge($headers, $cacheControl); + + $this->cache->saveDeferred( + $this->cache->getItem($metadataKey) + ->tag($fullUrlTag) + ->set([ + 'status_code' => $context->getStatusCode(), + 'headers' => $headers, + 'initial_age' => (int) ($headers['age'][0] ?? 0), + 'stored_at' => time(), + 'expires_at' => $this->calculateExpiresAt($maxAge), + 'chunks_count' => $chunkIndex, + ]) + ->expiresAt($expiresAt) + ); + + $this->cache->commit(); + + yield $chunk; + + return; + } + + ++$chunkIndex; + $chunkKey = "{$metadataKey}_chunk_{$chunkIndex}"; + $chunkItem = $this->cache->getItem($chunkKey) + ->tag($fullUrlTag) + ->set($chunk->getContent()) + ->expiresAfter($this->ttl); + + $this->cache->save($chunkItem); + + yield $chunk; + } + ); + } + + public function stream(ResponseInterface|iterable $responses, ?float $timeout = null): ResponseStreamInterface + { + if ($responses instanceof ResponseInterface) { + $responses = [$responses]; + } + + $mockResponses = []; + $asyncResponses = []; + $clientResponses = []; + + foreach ($responses as $response) { + if ($response instanceof MockResponse) { + $mockResponses[] = $response; + } elseif ($response instanceof AsyncResponse) { + $asyncResponses[] = $response; + } else { + $clientResponses[] = $response; + } + } + + if (!$mockResponses && !$clientResponses) { + return $this->asyncStream($asyncResponses, $timeout); + } + + if (!$asyncResponses && !$clientResponses) { + return new ResponseStream(MockResponse::stream($mockResponses, $timeout)); + } + + if (!$asyncResponses && !$mockResponses) { + return $this->client->stream($clientResponses, $timeout); + } + + return new ResponseStream((function () use ($mockResponses, $asyncResponses, $clientResponses, $timeout) { + yield from MockResponse::stream($mockResponses, $timeout); + yield $this->client->stream($clientResponses, $timeout); + yield $this->asyncStream($asyncResponses, $timeout); + })()); + } + + public function legacyRequest(string $method, string $url, array $options = []): ResponseInterface { [$url, $options] = $this->prepareRequest($method, $url, $options, $this->defaultOptions, true); $url = implode('', $url); @@ -106,41 +371,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 + /** + * Returns a hash representing the request details. + */ + private function getRequestHash(string $method, string $url): string { - if ($responses instanceof ResponseInterface) { - $responses = [$responses]; - } + return hash('xxh3', $method.$url); + } - $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 function getMetadataKey(string $requestHash, array $normalizedHeaders, array $varyFields): string + { + $variantKey = hash( + 'xxh3', + [] === $varyFields + ? 'NO-VARY' + : $this->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 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]) + : (string) $normalizedHeaders[$lower]; + $parts[] = $field.'='.$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 string[] $header the Cache-Control header as an array of strings + * + * @return array the parsed Cache-Control directives + */ + private 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. It returns one of the following states: + * - 'fresh': if the cached response is still valid or has no expiration. + * - 'must-revalidate': if the response must be revalidated before use. + * - 'stale-but-usable': if the response is stale but can be used in case of errors. + * - 'stale': if the cached response is no longer valid or usable. + * + * @param array $data the cached response data, including headers and expiration time + * + * @return string the freshness status of the cached response + */ + private function evaluateCachedFreshness(array $data): string + { + $parseCacheControlHeader = $this->parseCacheControlHeader($data['headers']['cache-control'] ?? []); + + if (isset($parseCacheControlHeader['no-cache'])) { + return '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 'fresh'; + } + + if ( + isset($parseCacheControlHeader['must-revalidate']) + || ($this->sharedCache && isset($parseCacheControlHeader['proxy-revalidate'])) + ) { + return 'must-revalidate'; + } + + if (isset($parseCacheControlHeader['stale-if-error'])) { + $staleWindow = (int) $parseCacheControlHeader['stale-if-error']; + if (($now - $expires) <= $staleWindow) { + return 'stale-but-usable'; + } + } + + return 'stale'; + } + + /** + * 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 + { + $age = $this->getCurrentAge($headers); + + if ($this->sharedCache && isset($cacheControl['s-maxage'])) { + $val = (int) $cacheControl['s-maxage']; + + return max(0, $val - $age); + } + + if (isset($cacheControl['max-age'])) { + $val = (int) $cacheControl['max-age']; + + return max(0, $val - $age); + } + + foreach ($headers['expires'] ?? [] as $expire) { + $ts = strtotime($expire); + if (false !== $ts) { + $diff = $ts - time() - $age; + + return max($diff, 0); + } + } + + 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 function getCurrentAge(array $headers): int + { + return (int) ($headers['age'][0] ?? 0); } - public function reset(): void + /** + * Calculates the expiration time of the cache. + * + * @param int|null $maxAge the maximum age of the cache as specified in the Cache-Control header + * + * @return int|null the timestamp when the cache is set to expire, or null if the cache should not expire + */ + private function calculateExpiresAt(?int $maxAge): ?int { - if ($this->client instanceof ResetInterface) { - $this->client->reset(); + if (null === $maxAge && null === $this->ttl) { + return null; } + + return time() + ($maxAge ?? $this->ttl); + } + + /** + * 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 int $statusCode the HTTP status code of the response + * @param array $requestHeaders the HTTP request headers + * @param array $responseHeaders the HTTP response headers + * @param array $cacheControl an array of parsed Cache-Control directives + * + * @return bool true if the response is cacheable, false otherwise + */ + 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'], $cacheControl['s-maxage'], $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 the HTTP response headers + * @param array $cacheControl an array of parsed Cache-Control directives + * + * @return bool true if the response has an explicit expiration, false otherwise + */ + 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 string $key the cache key for the response + * @param array $cachedData the cached data for the response + * @param string $method the original request method + * @param string $url the original request URL + * @param array $options the original request options + * + * @return MockResponse the constructed MockResponse object + */ + private function createResponseFromCache(string $key, array $cachedData, string $method, string $url, array $options): MockResponse + { + return MockResponse::fromRequest( + $method, + $url, + $options, + new MockResponse( + (function () use ($key, $cachedData): \Generator { + for ($i = 0; $i <= $cachedData['chunks_count']; ++$i) { + yield $this->cache->getItem("{$key}_chunk_{$i}")->get(); + } + })(), + ['http_code' => $cachedData['status_code'], 'response_headers' => ['age' => $cachedData['initial_age'] + (time() - $cachedData['stored_at'])] + $cachedData['headers']] + ) + ); + } + + private 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/Rfc9111HttpClient.php b/src/Symfony/Component/HttpClient/Rfc9111HttpClient.php deleted file mode 100644 index 456de3d3fa9c4..0000000000000 --- a/src/Symfony/Component/HttpClient/Rfc9111HttpClient.php +++ /dev/null @@ -1,621 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\HttpClient; - -use Symfony\Component\Cache\Adapter\TagAwareAdapterInterface; -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\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 (per RFC 9111). - * - * 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. - * - * @see https://www.rfc-editor.org/rfc/rfc9111 - */ -class Rfc9111HttpClient implements HttpClientInterface, ResetInterface -{ - use AsyncDecoratorTrait { - stream as asyncStream; - AsyncDecoratorTrait::withOptions insteadof HttpClientTrait; - } - use HttpClientTrait; - - /** - * The status codes that are always cacheable. - */ - private const CACHEABLE_STATUS_CODES = [200, 203, 204, 206, 300, 301, 404, 405, 410, 414, 501]; - /** - * The status codes that are cacheable if the response carry 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']; - - private array $defaultOptions = self::OPTIONS_DEFAULTS; - - /** - * @param HttpClientInterface $client The decorated client - * @param TagAwareAdapterInterface $cache The cache to store responses - * @param array $defaultOptions The default options for the client - * @param bool $sharedCache Indicates whether this cache is shared or private. - * If true, directives such as "private" will prevent - * storing responses, and "Authorization" headers will - * bypass caching unless overridden by "public". - * @param int|null $ttl The TTL (in seconds) for the cache - */ - public function __construct( - HttpClientInterface $client, - private readonly TagAwareAdapterInterface $cache, - array $defaultOptions = [], - private readonly bool $sharedCache = true, - private readonly ?int $ttl = null, - ) { - $this->client = $client; - - if ($defaultOptions) { - [, $this->defaultOptions] = self::prepareRequest(null, null, $defaultOptions, $this->defaultOptions); - } - } - - public function request(string $method, string $url, array $options = []): ResponseInterface - { - [$fullUrl, $options] = self::prepareRequest($method, $url, $options, $this->defaultOptions); - - $fullUrl = implode('', $fullUrl); - $fullUrlTag = hash('xxh3', $fullUrl); - - if (\in_array($method, self::UNSAFE_METHODS, true)) { - $this->cache->invalidateTags([$fullUrlTag]); - } - - if (!empty($options['body']) || isset($options['normalized_headers']['range']) || !\in_array($method, self::CACHEABLE_METHODS, true)) { - return $this->client->request($method, $url, $options); - } - - $requestHash = $this->getRequestHash($method, $fullUrl); - $varyKey = "vary_{$requestHash}"; - $varyItem = $this->cache->getItem($varyKey); - $varyFields = $varyItem->isHit() ? $varyItem->get() : []; - - $metadataKey = $this->getMetadataKey($requestHash, $options['normalized_headers'], $varyFields); - $metadataItem = $this->cache->getItem($metadataKey); - $cachedData = $metadataItem->isHit() ? $metadataItem->get() : null; - - $freshness = null; - if (\is_array($cachedData)) { - $freshness = $this->evaluateCachedFreshness($cachedData); - - if ('fresh' === $freshness) { - return $this->createResponseFromCache($metadataKey, $cachedData, $method, $url, $options); - } - - 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]; - } - } - - $chunkIndex = -1; - // consistent expiration time for all items - $expiresAt = null === $this->ttl - ? null - : new \DateTimeImmutable("+{$this->ttl} seconds"); - - return new AsyncResponse( - $this->client, - $method, - $url, - $options, - function (ChunkInterface $chunk, AsyncContext $context) use ( - &$chunkIndex, - $expiresAt, - $fullUrlTag, - $requestHash, - $varyItem, - &$varyFields, - &$metadataKey, - $metadataItem, - $cachedData, - $freshness, - $url, - $method, - $options, - ): \Generator { - if (null !== $chunk->getError() || $chunk->isTimeout()) { - if ('stale-but-usable' === $freshness) { - $context->passthru(); - $context->replaceResponse($this->createResponseFromCache($metadataKey, $cachedData, $method, $url, $options)); - - return; - } - - if ('must-revalidate' === $freshness) { - $context->passthru(); - $context->replaceResponse($this->createGatewayTimeoutResponse($method, $url, $options)); - - return; - } - - yield $chunk; - - return; - } - - $headers = $context->getHeaders(); - $cacheControl = $this->parseCacheControlHeader($headers['cache-control'] ?? []); - - if ($chunk->isFirst()) { - $statusCode = $context->getStatusCode(); - - if (304 === $statusCode && null !== $freshness) { - $maxAge = $this->determineMaxAge($headers, $cacheControl); - - $cachedData['expires_at'] = $this->calculateExpiresAt($maxAge); - $cachedData['stored_at'] = time(); - $cachedData['initial_age'] = (int) ($headers['age'][0] ?? 0); - $cachedData['headers'] = array_merge($cachedData['headers'], $headers); - - $metadataItem->set($cachedData)->expiresAt($expiresAt); - $this->cache->save($metadataItem); - - $context->passthru(); - $context->replaceResponse($this->createResponseFromCache($metadataKey, $cachedData, $method, $url, $options)); - - return; - } - - if ($statusCode >= 500 && $statusCode < 600) { - if ('stale-but-usable' === $freshness) { - $context->passthru(); - $context->replaceResponse($this->createResponseFromCache($metadataKey, $cachedData, $method, $url, $options)); - - return; - } - - if ('must-revalidate' === $freshness) { - $context->passthru(); - $context->replaceResponse($this->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); - } - } - - $metadataKey = $this->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->saveDeferred($varyItem->set($varyFields)->tag($fullUrlTag)->expiresAt($expiresAt)); - - $maxAge = $this->determineMaxAge($headers, $cacheControl); - - $this->cache->saveDeferred( - $this->cache->getItem($metadataKey) - ->tag($fullUrlTag) - ->set([ - 'status_code' => $context->getStatusCode(), - 'headers' => $headers, - 'initial_age' => (int) ($headers['age'][0] ?? 0), - 'stored_at' => time(), - 'expires_at' => $this->calculateExpiresAt($maxAge), - 'chunks_count' => $chunkIndex, - ]) - ->expiresAt($expiresAt) - ); - - $this->cache->commit(); - - yield $chunk; - - return; - } - - ++$chunkIndex; - $chunkKey = "{$metadataKey}_chunk_{$chunkIndex}"; - $chunkItem = $this->cache->getItem($chunkKey) - ->tag($fullUrlTag) - ->set($chunk->getContent()) - ->expiresAfter($this->ttl); - - $this->cache->save($chunkItem); - - yield $chunk; - } - ); - } - - public function stream(ResponseInterface|iterable $responses, ?float $timeout = null): ResponseStreamInterface - { - if ($responses instanceof ResponseInterface) { - $responses = [$responses]; - } - - $mockResponses = []; - $asyncResponses = []; - $clientResponses = []; - - foreach ($responses as $response) { - if ($response instanceof MockResponse) { - $mockResponses[] = $response; - } elseif ($response instanceof AsyncResponse) { - $asyncResponses[] = $response; - } else { - $clientResponses[] = $response; - } - } - - if (!$mockResponses && !$clientResponses) { - return $this->asyncStream($asyncResponses, $timeout); - } - - if (!$asyncResponses && !$clientResponses) { - return new ResponseStream(MockResponse::stream($mockResponses, $timeout)); - } - - if (!$asyncResponses && !$mockResponses) { - return $this->client->stream($clientResponses, $timeout); - } - - return new ResponseStream((function () use ($mockResponses, $asyncResponses, $clientResponses, $timeout) { - yield from MockResponse::stream($mockResponses, $timeout); - yield $this->client->stream($clientResponses, $timeout); - yield $this->asyncStream($asyncResponses, $timeout); - })()); - } - - /** - * Returns a hash representing the request details. - */ - private function getRequestHash(string $method, string $url): string - { - return hash('xxh3', $method.$url); - } - - /** - * 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 function getMetadataKey(string $requestHash, array $normalizedHeaders, array $varyFields): string - { - $variantKey = hash( - 'xxh3', - [] === $varyFields - ? 'NO-VARY' - : $this->buildVariantKey($normalizedHeaders, $varyFields) - ); - - 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 function buildVariantKey(array $normalizedHeaders, array $varyFields): string - { - $parts = []; - foreach ($varyFields as $field) { - $lower = strtolower($field); - if (!isset($normalizedHeaders[$lower])) { - $parts[] = $field.'='; - } else { - $joined = \is_array($normalizedHeaders[$lower]) - ? implode(',', $normalizedHeaders[$lower]) - : (string) $normalizedHeaders[$lower]; - $parts[] = $field.'='.$joined; - } - } - - 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 string[] $header the Cache-Control header as an array of strings - * - * @return array the parsed Cache-Control directives - */ - private 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; - } - } - } - - 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. It returns one of the following states: - * - 'fresh': if the cached response is still valid or has no expiration. - * - 'must-revalidate': if the response must be revalidated before use. - * - 'stale-but-usable': if the response is stale but can be used in case of errors. - * - 'stale': if the cached response is no longer valid or usable. - * - * @param array $data the cached response data, including headers and expiration time - * - * @return string the freshness status of the cached response - */ - private function evaluateCachedFreshness(array $data): string - { - $parseCacheControlHeader = $this->parseCacheControlHeader($data['headers']['cache-control'] ?? []); - - if (isset($parseCacheControlHeader['no-cache'])) { - return 'stale'; - } - - $now = time(); - $expires = $data['expires_at']; - - if (null === $expires || $now <= $expires) { - return 'fresh'; - } - - if ( - isset($parseCacheControlHeader['must-revalidate']) - || ($this->sharedCache && isset($parseCacheControlHeader['proxy-revalidate'])) - ) { - return 'must-revalidate'; - } - - if (isset($parseCacheControlHeader['stale-if-error'])) { - $staleWindow = (int) $parseCacheControlHeader['stale-if-error']; - if (($now - $expires) <= $staleWindow) { - return 'stale-but-usable'; - } - } - - return 'stale'; - } - - /** - * 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 - { - $age = $this->getCurrentAge($headers); - - if ($this->sharedCache && isset($cacheControl['s-maxage'])) { - $val = (int) $cacheControl['s-maxage']; - - return max(0, $val - $age); - } - - if (isset($cacheControl['max-age'])) { - $val = (int) $cacheControl['max-age']; - - return max(0, $val - $age); - } - - foreach ($headers['expires'] ?? [] as $expire) { - $ts = strtotime($expire); - if (false !== $ts) { - $diff = $ts - time() - $age; - - return max($diff, 0); - } - } - - 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 function getCurrentAge(array $headers): int - { - return (int) ($headers['age'][0] ?? 0); - } - - /** - * Calculates the expiration time of the cache. - * - * @param int|null $maxAge the maximum age of the cache as specified in the Cache-Control header - * - * @return int|null the timestamp when the cache is set to expire, or null if the cache should not expire - */ - private function calculateExpiresAt(?int $maxAge): ?int - { - if (null === $maxAge && null === $this->ttl) { - return null; - } - - return time() + ($maxAge ?? $this->ttl); - } - - /** - * 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 int $statusCode the HTTP status code of the response - * @param array $requestHeaders the HTTP request headers - * @param array $responseHeaders the HTTP response headers - * @param array $cacheControl an array of parsed Cache-Control directives - * - * @return bool true if the response is cacheable, false otherwise - */ - 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'], $cacheControl['s-maxage'], $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 the HTTP response headers - * @param array $cacheControl an array of parsed Cache-Control directives - * - * @return bool true if the response has an explicit expiration, false otherwise - */ - 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 string $key the cache key for the response - * @param array $cachedData the cached data for the response - * @param string $method the original request method - * @param string $url the original request URL - * @param array $options the original request options - * - * @return MockResponse the constructed MockResponse object - */ - private function createResponseFromCache(string $key, array $cachedData, string $method, string $url, array $options): MockResponse - { - return MockResponse::fromRequest( - $method, - $url, - $options, - new MockResponse( - (function () use ($key, $cachedData): \Generator { - for ($i = 0; $i <= $cachedData['chunks_count']; ++$i) { - yield $this->cache->getItem("{$key}_chunk_{$i}")->get(); - } - })(), - ['http_code' => $cachedData['status_code'], 'response_headers' => ['age' => $cachedData['initial_age'] + (time() - $cachedData['stored_at'])] + $cachedData['headers']] - ) - ); - } - - private 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/Tests/CachingHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php index 67e9212c957cd..9e3f648b7aae8 100644 --- a/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php +++ b/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php @@ -12,99 +12,253 @@ namespace Symfony\Component\HttpClient\Tests; use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpClient\CachingHttpClient; +use Symfony\Component\Cache\Adapter\FilesystemTagAwareAdapter; use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\Response\MockResponse; -use Symfony\Component\HttpKernel\HttpCache\Store; -use Symfony\Contracts\HttpClient\ResponseInterface; +use Symfony\Component\HttpClient\CachingHttpClient; +/** + * @covers \Symfony\Component\HttpClient\CachingHttpClient + * + * @group time-sensitive + */ class CachingHttpClientTest extends TestCase { - public function testRequestHeaders() + private FilesystemTagAwareAdapter $cacheAdapter; + + protected function setUp(): void { - $options = [ - 'headers' => [ - 'Application-Name' => 'test1234', - 'Test-Name-Header' => 'test12345', - ], - ]; + parent::setUp(); - $mockClient = new MockHttpClient(); - $store = new Store(sys_get_temp_dir().'/sf_http_cache'); - $client = new CachingHttpClient($mockClient, $store, $options); + $this->cacheAdapter = new FilesystemTagAwareAdapter('', 0, sys_get_temp_dir().'/rfc9111_client_test'); + $this->cacheAdapter->clear(); + } + + public function testItServesResponseFromCache() + { + $mockClient = new MockHttpClient([ + new MockResponse('foo', [ + 'http_code' => 200, + 'response_headers' => [ + 'Cache-Control' => 'max-age=300', + ], + ]), + 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()); - 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'); + 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 testDoesNotEvaluateResponseBody() + public function testItDoesntServeAStaleResponse() { - $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=5', + ], + ]), + new MockResponse('bar'), + ]); - $this->assertSame($body, $response->getContent()); - $this->assertArrayNotHasKey('x-body-eval', $headers); + $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(5); + + $response = $client->request('GET', 'http://example.com/foo-bar'); + self::assertSame(200, $response->getStatusCode()); + self::assertSame('foo', $response->getContent()); + + sleep(1); + + $response = $client->request('GET', 'http://example.com/foo-bar'); + self::assertSame(200, $response->getStatusCode()); + self::assertSame('bar', $response->getContent()); } - public function testDoesNotIncludeFile() + public function testItRevalidatesAResponseWithNoCacheDirective() { - $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); + $mockClient = new MockHttpClient([ + new MockResponse('foo', [ + 'http_code' => 200, + 'response_headers' => [ + 'Cache-Control' => 'no-cache, max-age=5', + ], + ]), + new MockResponse('bar'), + ]); + + $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('bar', $response->getContent()); } - public function testDoesNotReadFile() + public function testItServesAStaleResponseIfError() { - $file = __DIR__.'/Fixtures/assertion_failure.php'; + $mockClient = new MockHttpClient([ + new MockResponse('foo', [ + 'http_code' => 200, + 'response_headers' => [ + 'Cache-Control' => 'max-age=1, stale-if-error=5', + ], + ]), + new MockResponse('Internal Server Error', ['http_code' => 500]), + ]); - $response = $this->runRequest(new MockResponse( - 'test', ['response_headers' => [ - 'X-Body-File' => $file, - ]] - )); - $headers = $response->getHeaders(); + $client = new CachingHttpClient( + $mockClient, + $this->cacheAdapter, + sharedCache: false, + ); - $this->assertSame('test', $response->getContent()); - $this->assertArrayNotHasKey('x-body-file', $headers); + $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 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 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 testRemovesXContentDigest() + public function testAPrivateCacheStoresAResponseWithPrivateDirective() { - $response = $this->runRequest(new MockResponse( - 'test', [ + $mockClient = new MockHttpClient([ + new MockResponse('foo', [ + 'http_code' => 200, 'response_headers' => [ - 'X-Content-Digest' => 'some-hash', + 'Cache-Control' => 'private, max-age=5', ], - ])); - $headers = $response->getHeaders(); + ]), + new MockResponse('should not be served'), + ]); + + $client = new CachingHttpClient( + $mockClient, + $this->cacheAdapter, + sharedCache: false, + ); - $this->assertArrayNotHasKey('x-content-digest', $headers); + $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()); } - private function runRequest(MockResponse $mockResponse): ResponseInterface + public function testUnsafeMethodsInvalidateCache() { - $mockClient = new MockHttpClient($mockResponse); + $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, + ); - $store = new Store(sys_get_temp_dir().'/sf_http_cache'); - $client = new CachingHttpClient($mockClient, $store); + $response = $client->request('GET', 'http://example.com/foo-bar'); + self::assertSame(200, $response->getStatusCode()); + self::assertSame('foo', $response->getContent()); - $response = $client->request('GET', 'http://test'); + $client->request('DELETE', 'http://example.com/foo-bar'); - return $response; + $response = $client->request('GET', 'http://example.com/foo-bar'); + 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..9633d192d5dd2 --- /dev/null +++ b/src/Symfony/Component/HttpClient/Tests/LegacyCachingHttpClientTest.php @@ -0,0 +1,110 @@ + + * + * 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\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; + +class LegacyCachingHttpClientTest extends TestCase +{ + public function testRequestHeaders() + { + $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() + { + $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() + { + $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() + { + $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() + { + $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/Tests/Rfc9111HttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/Rfc9111HttpClientTest.php deleted file mode 100644 index e14d8ad5e8bc2..0000000000000 --- a/src/Symfony/Component/HttpClient/Tests/Rfc9111HttpClientTest.php +++ /dev/null @@ -1,264 +0,0 @@ - - * - * 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\Component\Cache\Adapter\FilesystemTagAwareAdapter; -use Symfony\Component\HttpClient\MockHttpClient; -use Symfony\Component\HttpClient\Response\MockResponse; -use Symfony\Component\HttpClient\Rfc9111HttpClient; - -/** - * @covers \Symfony\Component\HttpClient\Rfc9111HttpClient - * - * @group time-sensitive - */ -class Rfc9111HttpClientTest extends TestCase -{ - private FilesystemTagAwareAdapter $cacheAdapter; - - protected function setUp(): void - { - parent::setUp(); - - $this->cacheAdapter = new FilesystemTagAwareAdapter('', 0, sys_get_temp_dir().'/rfc9111_client_test'); - $this->cacheAdapter->clear(); - } - - public function testItServesResponseFromCache(): void - { - $mockClient = new MockHttpClient([ - new MockResponse('foo', [ - 'http_code' => 200, - 'response_headers' => [ - 'Cache-Control' => 'max-age=300', - ], - ]), - new MockResponse('should not be served'), - ]); - - $client = new Rfc9111HttpClient( - $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 testItDoesntServeAStaleResponse(): void - { - $mockClient = new MockHttpClient([ - new MockResponse('foo', [ - 'http_code' => 200, - 'response_headers' => [ - 'Cache-Control' => 'max-age=5', - ], - ]), - new MockResponse('bar'), - ]); - - $client = new Rfc9111HttpClient( - $mockClient, - $this->cacheAdapter, - ); - - $response = $client->request('GET', 'http://example.com/foo-bar'); - self::assertSame(200, $response->getStatusCode()); - self::assertSame('foo', $response->getContent()); - - sleep(5); - - $response = $client->request('GET', 'http://example.com/foo-bar'); - self::assertSame(200, $response->getStatusCode()); - self::assertSame('foo', $response->getContent()); - - sleep(1); - - $response = $client->request('GET', 'http://example.com/foo-bar'); - self::assertSame(200, $response->getStatusCode()); - self::assertSame('bar', $response->getContent()); - } - - public function testItRevalidatesAResponseWithNoCacheDirective(): void - { - $mockClient = new MockHttpClient([ - new MockResponse('foo', [ - 'http_code' => 200, - 'response_headers' => [ - 'Cache-Control' => 'no-cache, max-age=5', - ], - ]), - new MockResponse('bar'), - ]); - - $client = new Rfc9111HttpClient( - $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('bar', $response->getContent()); - } - - public function testItServesAStaleResponseIfError(): void - { - $mockClient = new MockHttpClient([ - new MockResponse('foo', [ - 'http_code' => 200, - 'response_headers' => [ - 'Cache-Control' => 'max-age=1, stale-if-error=5', - ], - ]), - new MockResponse('Internal Server Error', ['http_code' => 500]), - ]); - - $client = new Rfc9111HttpClient( - $mockClient, - $this->cacheAdapter, - sharedCache: false, - ); - - $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 testItDoesntStoreAResponseWithNoStoreDirective(): void - { - $mockClient = new MockHttpClient([ - new MockResponse('foo', [ - 'http_code' => 200, - 'response_headers' => [ - 'Cache-Control' => 'no-store', - ], - ]), - new MockResponse('bar'), - ]); - - $client = new Rfc9111HttpClient( - $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 testASharedCacheDoesntStoreAResponseWithPrivateDirective(): void - { - $mockClient = new MockHttpClient([ - new MockResponse('foo', [ - 'http_code' => 200, - 'response_headers' => [ - 'Cache-Control' => 'private, max-age=5', - ], - ]), - new MockResponse('bar'), - ]); - - $client = new Rfc9111HttpClient( - $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(): void - { - $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 Rfc9111HttpClient( - $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 testUnsafeMethodsInvalidateCache(): void - { - $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 Rfc9111HttpClient( - $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()); - } -} From ea6004fdcbb12c4843d8ec28ba8ff8a7b3b01331 Mon Sep 17 00:00:00 2001 From: Lctrs Date: Wed, 22 Jan 2025 18:04:49 +0100 Subject: [PATCH 06/53] cs --- .../Component/HttpClient/Tests/CachingHttpClientTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php index 9e3f648b7aae8..b6699fc73f9ab 100644 --- a/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php +++ b/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php @@ -13,9 +13,9 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Cache\Adapter\FilesystemTagAwareAdapter; +use Symfony\Component\HttpClient\CachingHttpClient; use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\Response\MockResponse; -use Symfony\Component\HttpClient\CachingHttpClient; /** * @covers \Symfony\Component\HttpClient\CachingHttpClient From 6a2ee7374674195c33cfa2a33bc8d3068a754e48 Mon Sep 17 00:00:00 2001 From: Lctrs Date: Wed, 22 Jan 2025 18:14:57 +0100 Subject: [PATCH 07/53] encode vary field value to avoid collision --- src/Symfony/Component/HttpClient/CachingHttpClient.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Component/HttpClient/CachingHttpClient.php b/src/Symfony/Component/HttpClient/CachingHttpClient.php index d8c7bb56458e9..572cd4df74682 100644 --- a/src/Symfony/Component/HttpClient/CachingHttpClient.php +++ b/src/Symfony/Component/HttpClient/CachingHttpClient.php @@ -419,7 +419,7 @@ private function buildVariantKey(array $normalizedHeaders, array $varyFields): s $joined = \is_array($normalizedHeaders[$lower]) ? implode(',', $normalizedHeaders[$lower]) : (string) $normalizedHeaders[$lower]; - $parts[] = $field.'='.$joined; + $parts[] = $field.'='.rawurlencode($joined); } } From dc8069f6fcad760e93cfd738575dcf9d5ebc3306 Mon Sep 17 00:00:00 2001 From: Lctrs Date: Wed, 22 Jan 2025 18:16:49 +0100 Subject: [PATCH 08/53] require-dev symfony/cache --- src/Symfony/Component/HttpClient/composer.json | 1 + 1 file changed, 1 insertion(+) 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", From c06169dd8462e4c60775af3d385302f786f37c17 Mon Sep 17 00:00:00 2001 From: Lctrs Date: Wed, 22 Jan 2025 20:28:49 +0100 Subject: [PATCH 09/53] fixing legacy tests --- .../Tests/LegacyCachingHttpClientTest.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/Symfony/Component/HttpClient/Tests/LegacyCachingHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/LegacyCachingHttpClientTest.php index 9633d192d5dd2..4185c0d97dc1f 100644 --- a/src/Symfony/Component/HttpClient/Tests/LegacyCachingHttpClientTest.php +++ b/src/Symfony/Component/HttpClient/Tests/LegacyCachingHttpClientTest.php @@ -18,8 +18,14 @@ use Symfony\Component\HttpKernel\HttpCache\Store; use Symfony\Contracts\HttpClient\ResponseInterface; +/** + * @group legacy + */ class LegacyCachingHttpClientTest extends TestCase { + /** + * @expectedDeprecation 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\Component\Cache\Adapter\TagAwareAdapterInterface" expected. + */ public function testRequestHeaders() { $options = [ @@ -41,6 +47,9 @@ public function testRequestHeaders() self::assertSame($response->getRequestOptions()['normalized_headers']['test-name-header'][0], 'Test-Name-Header: test12345'); } + /** + * @expectedDeprecation 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\Component\Cache\Adapter\TagAwareAdapterInterface" expected. + */ public function testDoesNotEvaluateResponseBody() { $body = file_get_contents(__DIR__.'/Fixtures/assertion_failure.php'); @@ -51,6 +60,9 @@ public function testDoesNotEvaluateResponseBody() $this->assertArrayNotHasKey('x-body-eval', $headers); } + /** + * @expectedDeprecation 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\Component\Cache\Adapter\TagAwareAdapterInterface" expected. + */ public function testDoesNotIncludeFile() { $file = __DIR__.'/Fixtures/assertion_failure.php'; @@ -68,6 +80,9 @@ public function testDoesNotIncludeFile() $this->assertArrayNotHasKey('x-body-file', $headers); } + /** + * @expectedDeprecation 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\Component\Cache\Adapter\TagAwareAdapterInterface" expected. + */ public function testDoesNotReadFile() { $file = __DIR__.'/Fixtures/assertion_failure.php'; @@ -83,6 +98,9 @@ public function testDoesNotReadFile() $this->assertArrayNotHasKey('x-body-file', $headers); } + /** + * @expectedDeprecation 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\Component\Cache\Adapter\TagAwareAdapterInterface" expected. + */ public function testRemovesXContentDigest() { $response = $this->runRequest(new MockResponse( From 5339c8fb4223cf48d3141dbb4429928c67fae43f Mon Sep 17 00:00:00 2001 From: Lctrs Date: Tue, 18 Feb 2025 21:02:10 +0100 Subject: [PATCH 10/53] fix legacy tests 2 --- .../Tests/LegacyCachingHttpClientTest.php | 28 +++++++++---------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/src/Symfony/Component/HttpClient/Tests/LegacyCachingHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/LegacyCachingHttpClientTest.php index 4185c0d97dc1f..4a305a3435c63 100644 --- a/src/Symfony/Component/HttpClient/Tests/LegacyCachingHttpClientTest.php +++ b/src/Symfony/Component/HttpClient/Tests/LegacyCachingHttpClientTest.php @@ -12,6 +12,7 @@ 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; @@ -23,11 +24,12 @@ */ class LegacyCachingHttpClientTest extends TestCase { - /** - * @expectedDeprecation 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\Component\Cache\Adapter\TagAwareAdapterInterface" expected. - */ + 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\Component\Cache\Adapter\TagAwareAdapterInterface" expected.'); + $options = [ 'headers' => [ 'Application-Name' => 'test1234', @@ -47,11 +49,10 @@ public function testRequestHeaders() self::assertSame($response->getRequestOptions()['normalized_headers']['test-name-header'][0], 'Test-Name-Header: test12345'); } - /** - * @expectedDeprecation 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\Component\Cache\Adapter\TagAwareAdapterInterface" expected. - */ 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\Component\Cache\Adapter\TagAwareAdapterInterface" 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(); @@ -60,11 +61,10 @@ public function testDoesNotEvaluateResponseBody() $this->assertArrayNotHasKey('x-body-eval', $headers); } - /** - * @expectedDeprecation 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\Component\Cache\Adapter\TagAwareAdapterInterface" expected. - */ 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\Component\Cache\Adapter\TagAwareAdapterInterface" expected.'); + $file = __DIR__.'/Fixtures/assertion_failure.php'; $response = $this->runRequest(new MockResponse( @@ -80,11 +80,10 @@ public function testDoesNotIncludeFile() $this->assertArrayNotHasKey('x-body-file', $headers); } - /** - * @expectedDeprecation 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\Component\Cache\Adapter\TagAwareAdapterInterface" expected. - */ 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\Component\Cache\Adapter\TagAwareAdapterInterface" expected.'); + $file = __DIR__.'/Fixtures/assertion_failure.php'; $response = $this->runRequest(new MockResponse( @@ -98,11 +97,10 @@ public function testDoesNotReadFile() $this->assertArrayNotHasKey('x-body-file', $headers); } - /** - * @expectedDeprecation 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\Component\Cache\Adapter\TagAwareAdapterInterface" expected. - */ 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\Component\Cache\Adapter\TagAwareAdapterInterface" expected.'); + $response = $this->runRequest(new MockResponse( 'test', [ 'response_headers' => [ From 033f9d85ec6af04f53219751ad5db5d56eecf232 Mon Sep 17 00:00:00 2001 From: Lctrs Date: Tue, 18 Feb 2025 21:20:02 +0100 Subject: [PATCH 11/53] throw exception when chunk cache item not found --- .../DependencyInjection/FrameworkExtension.php | 5 +++++ src/Symfony/Component/HttpClient/CachingHttpClient.php | 9 ++++++++- .../Exception/ChunkCacheItemNotFoundException.php | 9 +++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 src/Symfony/Component/HttpClient/Exception/ChunkCacheItemNotFoundException.php diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 0a63fc3e39939..014b7edf8f383 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -91,6 +91,7 @@ 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; @@ -2797,6 +2798,10 @@ 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, 20) // higher priority than ThrottlingHttpClient (15) diff --git a/src/Symfony/Component/HttpClient/CachingHttpClient.php b/src/Symfony/Component/HttpClient/CachingHttpClient.php index 572cd4df74682..fcf11c5087cb8 100644 --- a/src/Symfony/Component/HttpClient/CachingHttpClient.php +++ b/src/Symfony/Component/HttpClient/CachingHttpClient.php @@ -12,6 +12,7 @@ namespace Symfony\Component\HttpClient; use Symfony\Component\Cache\Adapter\TagAwareAdapterInterface; +use Symfony\Component\HttpClient\Exception\ChunkCacheItemNotFoundException; use Symfony\Component\HttpClient\Response\AsyncContext; use Symfony\Component\HttpClient\Response\AsyncResponse; use Symfony\Component\HttpClient\Response\MockResponse; @@ -657,7 +658,13 @@ private function createResponseFromCache(string $key, array $cachedData, string new MockResponse( (function () use ($key, $cachedData): \Generator { for ($i = 0; $i <= $cachedData['chunks_count']; ++$i) { - yield $this->cache->getItem("{$key}_chunk_{$i}")->get(); + $chunkItem = $this->cache->getItem("{$key}_chunk_{$i}"); + + if (!$chunkItem->isHit()) { + throw new ChunkCacheItemNotFoundException(\sprintf('Missing cache item for chunk with key "%s". This indicates an internal cache inconsistency.', $chunkItem->getKey())); + } + + yield $chunkItem->get(); } })(), ['http_code' => $cachedData['status_code'], 'response_headers' => ['age' => $cachedData['initial_age'] + (time() - $cachedData['stored_at'])] + $cachedData['headers']] diff --git a/src/Symfony/Component/HttpClient/Exception/ChunkCacheItemNotFoundException.php b/src/Symfony/Component/HttpClient/Exception/ChunkCacheItemNotFoundException.php new file mode 100644 index 0000000000000..f0fc19e9d9cf9 --- /dev/null +++ b/src/Symfony/Component/HttpClient/Exception/ChunkCacheItemNotFoundException.php @@ -0,0 +1,9 @@ + Date: Tue, 18 Feb 2025 21:21:04 +0100 Subject: [PATCH 12/53] fix check if response is cacheable --- src/Symfony/Component/HttpClient/CachingHttpClient.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Component/HttpClient/CachingHttpClient.php b/src/Symfony/Component/HttpClient/CachingHttpClient.php index fcf11c5087cb8..3fad68dbbc00d 100644 --- a/src/Symfony/Component/HttpClient/CachingHttpClient.php +++ b/src/Symfony/Component/HttpClient/CachingHttpClient.php @@ -596,7 +596,7 @@ private function isServerResponseCacheable(int $statusCode, array $requestHeader if ( $this->sharedCache - && !isset($cacheControl['public'], $cacheControl['s-maxage'], $cacheControl['must-revalidate']) + && !isset($cacheControl['public']) && !isset($cacheControl['s-maxage']) && !isset($cacheControl['must-revalidate']) && isset($requestHeaders['authorization']) ) { return false; From c5b040467e00ab2e05559196fd88b015900e7d8b Mon Sep 17 00:00:00 2001 From: Lctrs Date: Tue, 18 Feb 2025 21:59:15 +0100 Subject: [PATCH 13/53] more fixes and tests --- .../HttpClient/CachingHttpClient.php | 9 +- .../Tests/CachingHttpClientTest.php | 281 +++++++++++++++++- .../Component/HttpClient/composer.json | 1 + 3 files changed, 280 insertions(+), 11 deletions(-) diff --git a/src/Symfony/Component/HttpClient/CachingHttpClient.php b/src/Symfony/Component/HttpClient/CachingHttpClient.php index 3fad68dbbc00d..0c82976c15c7b 100644 --- a/src/Symfony/Component/HttpClient/CachingHttpClient.php +++ b/src/Symfony/Component/HttpClient/CachingHttpClient.php @@ -12,6 +12,7 @@ namespace Symfony\Component\HttpClient; use Symfony\Component\Cache\Adapter\TagAwareAdapterInterface; +use Symfony\Component\HttpClient\Chunk\ErrorChunk; use Symfony\Component\HttpClient\Exception\ChunkCacheItemNotFoundException; use Symfony\Component\HttpClient\Response\AsyncContext; use Symfony\Component\HttpClient\Response\AsyncResponse; @@ -149,7 +150,7 @@ public function request(string $method, string $url, array $options = []): Respo // consistent expiration time for all items $expiresAt = null === $this->ttl ? null - : new \DateTimeImmutable("+{$this->ttl} seconds"); + : \DateTimeImmutable::createFromFormat('U', time() + $this->ttl); return new AsyncResponse( $this->client, @@ -173,6 +174,8 @@ function (ChunkInterface $chunk, AsyncContext $context) use ( ): \Generator { if (null !== $chunk->getError() || $chunk->isTimeout()) { if ('stale-but-usable' === $freshness) { + // avoid throwing exception in ErrorChunk#__destruct() + $chunk instanceof ErrorChunk && $chunk->didThrow(true); $context->passthru(); $context->replaceResponse($this->createResponseFromCache($metadataKey, $cachedData, $method, $url, $options)); @@ -180,6 +183,8 @@ function (ChunkInterface $chunk, AsyncContext $context) use ( } if ('must-revalidate' === $freshness) { + // avoid throwing exception in ErrorChunk#__destruct() + $chunk instanceof ErrorChunk && $chunk->didThrow(true); $context->passthru(); $context->replaceResponse($this->createGatewayTimeoutResponse($method, $url, $options)); @@ -419,7 +424,7 @@ private function buildVariantKey(array $normalizedHeaders, array $varyFields): s } else { $joined = \is_array($normalizedHeaders[$lower]) ? implode(',', $normalizedHeaders[$lower]) - : (string) $normalizedHeaders[$lower]; + : $normalizedHeaders[$lower]; $parts[] = $field.'='.rawurlencode($joined); } } diff --git a/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php index b6699fc73f9ab..a5e5d6e98ec92 100644 --- a/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php +++ b/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php @@ -13,6 +13,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Cache\Adapter\FilesystemTagAwareAdapter; +use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\HttpClient\CachingHttpClient; use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\Response\MockResponse; @@ -30,8 +31,59 @@ protected function setUp(): void { parent::setUp(); - $this->cacheAdapter = new FilesystemTagAwareAdapter('', 0, sys_get_temp_dir().'/rfc9111_client_test'); - $this->cacheAdapter->clear(); + $this->cacheAdapter = new FilesystemTagAwareAdapter('', 0, __DIR__.\DIRECTORY_SEPARATOR.'caching-http-client'); + } + + protected function tearDown(): void + { + (new Filesystem())->remove(__DIR__.\DIRECTORY_SEPARATOR.'caching-http-client'); + } + + public function testBypassCacheWhenBodyPresent(): void + { + // 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(): void + { + // 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' => ['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(): void + { + // 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() @@ -39,9 +91,6 @@ public function testItServesResponseFromCache() $mockClient = new MockHttpClient([ new MockResponse('foo', [ 'http_code' => 200, - 'response_headers' => [ - 'Cache-Control' => 'max-age=300', - ], ]), new MockResponse('should not be served'), ]); @@ -63,6 +112,39 @@ public function testItServesResponseFromCache() 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([ @@ -80,18 +162,21 @@ public function testItDoesntServeAStaleResponse() $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()); @@ -109,6 +194,7 @@ public function testItRevalidatesAResponseWithNoCacheDirective() new MockResponse('bar'), ]); + // Use a private cache (sharedCache = false) so that revalidation is performed. $client = new CachingHttpClient( $mockClient, $this->cacheAdapter, @@ -119,6 +205,7 @@ public function testItRevalidatesAResponseWithNoCacheDirective() 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()); @@ -128,7 +215,7 @@ public function testItServesAStaleResponseIfError() { $mockClient = new MockHttpClient([ new MockResponse('foo', [ - 'http_code' => 200, + 'http_code' => 501, 'response_headers' => [ 'Cache-Control' => 'max-age=1, stale-if-error=5', ], @@ -143,12 +230,40 @@ public function testItServesAStaleResponseIfError() ); $response = $client->request('GET', 'http://example.com/foo-bar'); - self::assertSame(200, $response->getStatusCode()); - self::assertSame('foo', $response->getContent()); + self::assertSame(501, $response->getStatusCode()); + self::assertSame('foo', $response->getContent(false)); - sleep(2); + sleep(5); $response = $client->request('GET', 'http://example.com/foo-bar'); + self::assertSame(501, $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()); } @@ -179,6 +294,98 @@ public function testItDoesntStoreAResponseWithNoStoreDirective() 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([ @@ -261,4 +468,60 @@ public function testUnsafeMethodsInvalidateCache() 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']), + ]); + + $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' => 'must-revalidate', + ], + ]), + new MockResponse('', ['error' => 'Simulated']), + ]); + + $client = new CachingHttpClient( + $mockClient, + $this->cacheAdapter, + ttl: 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(504, $response->getStatusCode()); + } } diff --git a/src/Symfony/Component/HttpClient/composer.json b/src/Symfony/Component/HttpClient/composer.json index e367f8c0f3875..5ab4b2e0aa25e 100644 --- a/src/Symfony/Component/HttpClient/composer.json +++ b/src/Symfony/Component/HttpClient/composer.json @@ -39,6 +39,7 @@ "symfony/amphp-http-client-meta": "^1.0|^2.0", "symfony/cache": "^6.4|^7.0", "symfony/dependency-injection": "^6.4|^7.0", + "symfony/filesystem": "^6.4|^7.0", "symfony/http-kernel": "^6.4|^7.0", "symfony/messenger": "^6.4|^7.0", "symfony/process": "^6.4|^7.0", From 71f2e3f9edfd115eece4619da31a41bbfdf52bb6 Mon Sep 17 00:00:00 2001 From: Lctrs Date: Tue, 18 Feb 2025 22:01:54 +0100 Subject: [PATCH 14/53] cs fixes --- .../Exception/ChunkCacheItemNotFoundException.php | 9 +++++++++ .../Component/HttpClient/Tests/CachingHttpClientTest.php | 6 +++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/Symfony/Component/HttpClient/Exception/ChunkCacheItemNotFoundException.php b/src/Symfony/Component/HttpClient/Exception/ChunkCacheItemNotFoundException.php index f0fc19e9d9cf9..f99caf5eeb08c 100644 --- a/src/Symfony/Component/HttpClient/Exception/ChunkCacheItemNotFoundException.php +++ b/src/Symfony/Component/HttpClient/Exception/ChunkCacheItemNotFoundException.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\Component\HttpClient\Exception; use Symfony\Contracts\HttpClient\Exception\ExceptionInterface; diff --git a/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php index a5e5d6e98ec92..72bf27d07d1b1 100644 --- a/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php +++ b/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php @@ -39,7 +39,7 @@ protected function tearDown(): void (new Filesystem())->remove(__DIR__.\DIRECTORY_SEPARATOR.'caching-http-client'); } - public function testBypassCacheWhenBodyPresent(): void + public function testBypassCacheWhenBodyPresent() { // If a request has a non-empty body, caching should be bypassed. $mockClient = new MockHttpClient([ @@ -55,7 +55,7 @@ public function testBypassCacheWhenBodyPresent(): void self::assertSame('non-cached response', $response->getContent(), 'Request with body should bypass cache.'); } - public function testBypassCacheWhenRangeHeaderPresent(): void + public function testBypassCacheWhenRangeHeaderPresent() { // If a "range" header is present, caching is bypassed. $mockClient = new MockHttpClient([ @@ -72,7 +72,7 @@ public function testBypassCacheWhenRangeHeaderPresent(): void self::assertSame('second response', $response->getContent(), 'Presence of range header must bypass caching.'); } - public function testBypassCacheForNonCacheableMethod(): void + public function testBypassCacheForNonCacheableMethod() { // Methods not in CACHEABLE_METHODS (e.g. POST) bypass caching. $mockClient = new MockHttpClient([ From 54e9fb2e6fc94da9c8e5f254bbe96dae8b9d8c5d Mon Sep 17 00:00:00 2001 From: Lctrs Date: Tue, 18 Feb 2025 22:47:42 +0100 Subject: [PATCH 15/53] replace ttl by maxTtl (should be clearer) + add missing docs + fix tests --- .../DependencyInjection/Configuration.php | 14 ++++--- .../FrameworkExtension.php | 2 +- .../Resources/config/schema/symfony-1.0.xsd | 2 +- .../Fixtures/php/http_client_caching.php | 2 +- .../Fixtures/xml/http_client_caching.xml | 2 +- .../Fixtures/yml/http_client_caching.yml | 2 +- .../HttpClient/CachingHttpClient.php | 41 ++++++++++++------- .../Tests/CachingHttpClientTest.php | 34 +++++++++++++-- 8 files changed, 71 insertions(+), 28 deletions(-) diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index 43675ae952503..e4cad14e12661 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -2176,10 +2176,6 @@ private function createHttpClientCachingSection(): ArrayNodeDefinition return $root ->arrayNode('caching') ->info('Caching configuration.') - ->fixXmlConfig('cacheable_status_code') - ->fixXmlConfig('conditionally_cacheable_status_code') - ->fixXmlConfig('cacheable_method') - ->fixXmlConfig('unsafe_method') ->canBeEnabled() ->addDefaultsIfNotSet() ->children() @@ -2188,8 +2184,14 @@ private function createHttpClientCachingSection(): ArrayNodeDefinition ->defaultValue('cache.app.taggable') ->cannotBeEmpty() ->end() - ->booleanNode('shared')->defaultTrue()->end() - ->integerNode('ttl')->defaultNull()->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() + ->end() ->end(); } diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 014b7edf8f383..e183eb69d7a2c 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -2810,7 +2810,7 @@ private function registerCachingHttpClient(array $options, array $defaultOptions new Reference($options['cache']), $defaultOptions, $options['shared'], - $options['ttl'], + $options['max_ttl'], ]); } 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 82863c7ae74bd..5b8ca6b7d1e9d 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 @@ -778,7 +778,7 @@ - + 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 index 32873e2cec2d2..8bf855aa100da 100644 --- 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 @@ -11,7 +11,7 @@ 'caching' => [ 'cache' => 'foo', 'shared' => false, - 'ttl' => 2, + 'max_ttl' => 2, ], ], 'scoped_clients' => [ 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 index 89f8f2a8c9f11..d697a2a084486 100644 --- 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 @@ -11,7 +11,7 @@ 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 index 1ccffb6d548bd..9cc8446365d24 100644 --- 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 @@ -11,7 +11,7 @@ framework: caching: cache: foo shared: false - ttl: 2 + max_ttl: 2 scoped_clients: bar: base_uri: http://example.com diff --git a/src/Symfony/Component/HttpClient/CachingHttpClient.php b/src/Symfony/Component/HttpClient/CachingHttpClient.php index 0c82976c15c7b..e942abcca26a3 100644 --- a/src/Symfony/Component/HttpClient/CachingHttpClient.php +++ b/src/Symfony/Component/HttpClient/CachingHttpClient.php @@ -29,13 +29,18 @@ 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 { @@ -65,12 +70,20 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface private TagAwareAdapterInterface|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, TagAwareAdapterInterface|StoreInterface $store, array $defaultOptions = [], private readonly bool $sharedCache = true, - private readonly ?int $ttl = null, + private readonly ?int $maxTtl = null, ) { if ($store 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__, TagAwareAdapterInterface::class); @@ -148,9 +161,9 @@ public function request(string $method, string $url, array $options = []): Respo $chunkIndex = -1; // consistent expiration time for all items - $expiresAt = null === $this->ttl + $expiresAt = null === $this->maxTtl ? null - : \DateTimeImmutable::createFromFormat('U', time() + $this->ttl); + : \DateTimeImmutable::createFromFormat('U', time() + $this->maxTtl); return new AsyncResponse( $this->client, @@ -289,7 +302,7 @@ function (ChunkInterface $chunk, AsyncContext $context) use ( $chunkItem = $this->cache->getItem($chunkKey) ->tag($fullUrlTag) ->set($chunk->getContent()) - ->expiresAfter($this->ttl); + ->expiresAt($expiresAt); $this->cache->save($chunkItem); @@ -563,19 +576,19 @@ private function getCurrentAge(array $headers): int } /** - * Calculates the expiration time of the cache. + * Calculates the expiration time of the response given the maximum age. * - * @param int|null $maxAge the maximum age of the cache as specified in the Cache-Control header + * @param int|null $maxAge the maximum age of the response in seconds, or null if it cannot be determined * - * @return int|null the timestamp when the cache is set to expire, or null if the cache should not expire + * @return int|null the expiration time of the response as a Unix timestamp, or null if the maximum age is null */ private function calculateExpiresAt(?int $maxAge): ?int { - if (null === $maxAge && null === $this->ttl) { + if (null === $maxAge) { return null; } - return time() + ($maxAge ?? $this->ttl); + return time() + $maxAge; } /** diff --git a/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php index 72bf27d07d1b1..46128d7ee529e 100644 --- a/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php +++ b/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php @@ -503,7 +503,7 @@ public function testChunkErrorMustRevalidate() new MockResponse('foo', [ 'http_code' => 200, 'response_headers' => [ - 'Cache-Control' => 'must-revalidate', + 'Cache-Control' => 'max-age=1, must-revalidate', ], ]), new MockResponse('', ['error' => 'Simulated']), @@ -512,16 +512,44 @@ public function testChunkErrorMustRevalidate() $client = new CachingHttpClient( $mockClient, $this->cacheAdapter, - ttl: 10, ); $response = $client->request('GET', 'http://example.com/foo-bar'); self::assertSame(200, $response->getStatusCode()); self::assertSame('foo', $response->getContent()); - sleep(11); + 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()); + } } From 4079e2ee0b8b7f73f4b3482136616dbd46934c86 Mon Sep 17 00:00:00 2001 From: Lctrs Date: Tue, 18 Feb 2025 23:03:39 +0100 Subject: [PATCH 16/53] fix lowest tests --- .../Tests/DependencyInjection/FrameworkExtensionTestCase.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php index 8fc2a3549b72d..a06bb34bd4fb6 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php @@ -52,6 +52,7 @@ 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; @@ -2076,6 +2077,10 @@ public function testHttpClientOverrideDefaultOptions() 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')); From 22d5691149ed1a72dc3f8102de1d71b909a4b03d Mon Sep 17 00:00:00 2001 From: Lctrs Date: Tue, 18 Feb 2025 23:24:16 +0100 Subject: [PATCH 17/53] add changelogs --- UPGRADE-7.3.md | 5 +++++ src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md | 1 + src/Symfony/Component/HttpClient/CHANGELOG.md | 1 + 3 files changed, 7 insertions(+) diff --git a/UPGRADE-7.3.md b/UPGRADE-7.3.md index 0f3163740cfac..3dc4b7f66beac 100644 --- a/UPGRADE-7.3.md +++ b/UPGRADE-7.3.md @@ -324,3 +324,8 @@ Workflow } } ``` + +HttpClient +---------- + +* Deprecate passing an instance of `StoreInterface` as `$store` argument to `CachingHttpClient` constructor 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/Component/HttpClient/CHANGELOG.md b/src/Symfony/Component/HttpClient/CHANGELOG.md index 40dc2ec5d5445..b1cc7db37b785 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` + * Added RFC 9111–based caching support to `CachingHttpClient` (though some features remain partially supported) 7.2 --- From fb273c9995a1f136b1d2c7987289404d71ecebc5 Mon Sep 17 00:00:00 2001 From: Lctrs Date: Wed, 19 Feb 2025 21:25:26 +0100 Subject: [PATCH 18/53] own cache pool --- .../FrameworkBundle/DependencyInjection/Configuration.php | 6 +++--- .../DependencyInjection/FrameworkExtension.php | 2 +- .../Bundle/FrameworkBundle/Resources/config/http_client.php | 4 ++++ .../FrameworkBundle/Resources/config/schema/symfony-1.0.xsd | 2 +- .../Fixtures/php/http_client_caching.php | 4 ++-- .../Fixtures/xml/http_client_caching.xml | 4 ++-- .../Fixtures/yml/http_client_caching.yml | 4 ++-- 7 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index e4cad14e12661..4106fe62c017d 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -2179,9 +2179,9 @@ private function createHttpClientCachingSection(): ArrayNodeDefinition ->canBeEnabled() ->addDefaultsIfNotSet() ->children() - ->stringNode('cache') - ->info("The taggable cache's service ID.") - ->defaultValue('cache.app.taggable') + ->stringNode('cache_pool') + ->info("The taggable cache pool to use for storing the responses.") + ->defaultValue('cache.http_client') ->cannotBeEmpty() ->end() ->booleanNode('shared') diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index e183eb69d7a2c..c02ffcf0c4e93 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -2807,7 +2807,7 @@ private function registerCachingHttpClient(array $options, array $defaultOptions ->setDecoratedService($name, null, 20) // higher priority than ThrottlingHttpClient (15) ->setArguments([ new Reference($name.'.caching.inner'), - new Reference($options['cache']), + new Reference($options['cache_pool']), $defaultOptions, $options['shared'], $options['max_ttl'], diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/http_client.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/http_client.php index a562c2598ce01..1a4d4b499b8a1 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/http_client.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/http_client.php @@ -25,6 +25,10 @@ return static function (ContainerConfigurator $container) { $container->services() + ->set('cache.http_client') + ->parent('cache.app.taggable') + ->tag('cache.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 5b8ca6b7d1e9d..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 @@ -776,7 +776,7 @@ - + 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 index 8bf855aa100da..bcfdbc1dae28d 100644 --- 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 @@ -9,7 +9,7 @@ 'default_options' => [ 'headers' => ['X-powered' => 'PHP'], 'caching' => [ - 'cache' => 'foo', + 'cache_pool' => 'foo', 'shared' => false, 'max_ttl' => 2, ], @@ -17,7 +17,7 @@ 'scoped_clients' => [ 'bar' => [ 'base_uri' => 'http://example.com', - 'caching' => ['cache' => 'baz'], + '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 index d697a2a084486..d7a51ffc5262c 100644 --- 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 @@ -11,10 +11,10 @@ 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 index 9cc8446365d24..1c70128100008 100644 --- 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 @@ -9,11 +9,11 @@ framework: headers: X-powered: PHP caching: - cache: foo + cache_pool: foo shared: false max_ttl: 2 scoped_clients: bar: base_uri: http://example.com caching: - cache: baz + cache_pool: baz From 061118ea8cd6f717ce94eba209274943ce3a58c7 Mon Sep 17 00:00:00 2001 From: Lctrs Date: Wed, 19 Feb 2025 21:26:19 +0100 Subject: [PATCH 19/53] added -> add --- src/Symfony/Component/HttpClient/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Component/HttpClient/CHANGELOG.md b/src/Symfony/Component/HttpClient/CHANGELOG.md index b1cc7db37b785..d64e386e79cc8 100644 --- a/src/Symfony/Component/HttpClient/CHANGELOG.md +++ b/src/Symfony/Component/HttpClient/CHANGELOG.md @@ -6,7 +6,7 @@ CHANGELOG * Add IPv6 support to `NativeHttpClient` * Allow using HTTP/3 with the `CurlHttpClient` - * Added RFC 9111–based caching support to `CachingHttpClient` (though some features remain partially supported) + * Add RFC 9111–based caching support to `CachingHttpClient` (though some features remain partially supported) 7.2 --- From 9c7fe49514501507c738eddac7b280ff2896a2ee Mon Sep 17 00:00:00 2001 From: Lctrs Date: Wed, 19 Feb 2025 21:27:42 +0100 Subject: [PATCH 20/53] $store -> $cache --- UPGRADE-7.3.md | 2 +- src/Symfony/Component/HttpClient/CachingHttpClient.php | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/UPGRADE-7.3.md b/UPGRADE-7.3.md index 3dc4b7f66beac..5799e0d34153e 100644 --- a/UPGRADE-7.3.md +++ b/UPGRADE-7.3.md @@ -328,4 +328,4 @@ Workflow HttpClient ---------- -* Deprecate passing an instance of `StoreInterface` as `$store` argument to `CachingHttpClient` constructor +* Deprecate passing an instance of `StoreInterface` as `$cache` argument to `CachingHttpClient` constructor diff --git a/src/Symfony/Component/HttpClient/CachingHttpClient.php b/src/Symfony/Component/HttpClient/CachingHttpClient.php index e942abcca26a3..64b2ea4c92a5a 100644 --- a/src/Symfony/Component/HttpClient/CachingHttpClient.php +++ b/src/Symfony/Component/HttpClient/CachingHttpClient.php @@ -80,12 +80,12 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface */ public function __construct( private HttpClientInterface $client, - TagAwareAdapterInterface|StoreInterface $store, + TagAwareAdapterInterface|StoreInterface $cache, array $defaultOptions = [], private readonly bool $sharedCache = true, private readonly ?int $maxTtl = null, ) { - if ($store instanceof StoreInterface) { + 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__, TagAwareAdapterInterface::class); if (!class_exists(HttpClientKernel::class)) { @@ -93,7 +93,7 @@ public function __construct( } $kernel = new HttpClientKernel($client); - $this->cache = new HttpCache($kernel, $store, null, $defaultOptions); + $this->cache = new HttpCache($kernel, $cache, null, $defaultOptions); unset($defaultOptions['debug']); unset($defaultOptions['default_ttl']); @@ -106,7 +106,7 @@ public function __construct( unset($defaultOptions['trace_level']); unset($defaultOptions['trace_header']); } else { - $this->cache = $store; + $this->cache = $cache; } if ($defaultOptions) { From 59cbd4003b49fa80947f8d431015029d443590cc Mon Sep 17 00:00:00 2001 From: Lctrs Date: Wed, 19 Feb 2025 21:40:21 +0100 Subject: [PATCH 21/53] private legacy --- src/Symfony/Component/HttpClient/CachingHttpClient.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Component/HttpClient/CachingHttpClient.php b/src/Symfony/Component/HttpClient/CachingHttpClient.php index 64b2ea4c92a5a..f5ad2bed8eed7 100644 --- a/src/Symfony/Component/HttpClient/CachingHttpClient.php +++ b/src/Symfony/Component/HttpClient/CachingHttpClient.php @@ -350,7 +350,7 @@ public function stream(ResponseInterface|iterable $responses, ?float $timeout = })()); } - public function legacyRequest(string $method, string $url, array $options = []): ResponseInterface + private function legacyRequest(string $method, string $url, array $options = []): ResponseInterface { [$url, $options] = $this->prepareRequest($method, $url, $options, $this->defaultOptions, true); $url = implode('', $url); From 288a2a26022d59bd14ba86d887b8f79ec8575a1c Mon Sep 17 00:00:00 2001 From: Lctrs Date: Thu, 20 Feb 2025 00:08:26 +0100 Subject: [PATCH 22/53] cleanup phpdocs --- .../HttpClient/CachingHttpClient.php | 29 +++++-------------- 1 file changed, 7 insertions(+), 22 deletions(-) diff --git a/src/Symfony/Component/HttpClient/CachingHttpClient.php b/src/Symfony/Component/HttpClient/CachingHttpClient.php index f5ad2bed8eed7..fad585b418f7b 100644 --- a/src/Symfony/Component/HttpClient/CachingHttpClient.php +++ b/src/Symfony/Component/HttpClient/CachingHttpClient.php @@ -449,7 +449,7 @@ private function buildVariantKey(array $normalizedHeaders, array $varyFields): s * 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 string[] $header the Cache-Control header as an array of strings + * @param array $header the Cache-Control header as an array of strings * * @return array the parsed Cache-Control directives */ @@ -474,15 +474,9 @@ private function parseCacheControlHeader(array $header): array * 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. It returns one of the following states: - * - 'fresh': if the cached response is still valid or has no expiration. - * - 'must-revalidate': if the response must be revalidated before use. - * - 'stale-but-usable': if the response is stale but can be used in case of errors. - * - 'stale': if the cached response is no longer valid or usable. + * directives and the expiration timestamp. * - * @param array $data the cached response data, including headers and expiration time - * - * @return string the freshness status of the cached response + * @param array{headers: array, expires_at: int|null} $data the cached response data, including headers and expiration time */ private function evaluateCachedFreshness(array $data): string { @@ -598,12 +592,9 @@ private function calculateExpiresAt(?int $maxAge): ?int * This function will return true if the server response can be cached, * false otherwise. * - * @param int $statusCode the HTTP status code of the response - * @param array $requestHeaders the HTTP request headers - * @param array $responseHeaders the HTTP response headers - * @param array $cacheControl an array of parsed Cache-Control directives - * - * @return bool true if the response is cacheable, false otherwise + * @param array $requestHeaders + * @param array $responseHeaders + * @param array $cacheControl */ private function isServerResponseCacheable(int $statusCode, array $requestHeaders, array $responseHeaders, array $cacheControl): bool { @@ -659,13 +650,7 @@ private function hasExplicitExpiration(array $headers, array $cacheControl): boo * response headers and content. The constructed MockResponse is then * returned. * - * @param string $key the cache key for the response - * @param array $cachedData the cached data for the response - * @param string $method the original request method - * @param string $url the original request URL - * @param array $options the original request options - * - * @return MockResponse the constructed MockResponse object + * @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): MockResponse { From 04439cdf5671634b6bf9d2ca27ddc2a7c9e51d27 Mon Sep 17 00:00:00 2001 From: Lctrs Date: Thu, 20 Feb 2025 00:08:49 +0100 Subject: [PATCH 23/53] freshness enum --- .../HttpClient/Caching/Freshness.php | 35 +++++++++++++++++++ .../HttpClient/CachingHttpClient.php | 23 ++++++------ 2 files changed, 47 insertions(+), 11 deletions(-) create mode 100644 src/Symfony/Component/HttpClient/Caching/Freshness.php 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 fad585b418f7b..ab3e50021b422 100644 --- a/src/Symfony/Component/HttpClient/CachingHttpClient.php +++ b/src/Symfony/Component/HttpClient/CachingHttpClient.php @@ -12,6 +12,7 @@ namespace Symfony\Component\HttpClient; use Symfony\Component\Cache\Adapter\TagAwareAdapterInterface; +use Symfony\Component\HttpClient\Caching\Freshness; use Symfony\Component\HttpClient\Chunk\ErrorChunk; use Symfony\Component\HttpClient\Exception\ChunkCacheItemNotFoundException; use Symfony\Component\HttpClient\Response\AsyncContext; @@ -146,7 +147,7 @@ public function request(string $method, string $url, array $options = []): Respo if (\is_array($cachedData)) { $freshness = $this->evaluateCachedFreshness($cachedData); - if ('fresh' === $freshness) { + if (Freshness::Fresh === $freshness) { return $this->createResponseFromCache($metadataKey, $cachedData, $method, $url, $options); } @@ -186,7 +187,7 @@ function (ChunkInterface $chunk, AsyncContext $context) use ( $options, ): \Generator { if (null !== $chunk->getError() || $chunk->isTimeout()) { - if ('stale-but-usable' === $freshness) { + if (Freshness::StaleButUsable === $freshness) { // avoid throwing exception in ErrorChunk#__destruct() $chunk instanceof ErrorChunk && $chunk->didThrow(true); $context->passthru(); @@ -195,7 +196,7 @@ function (ChunkInterface $chunk, AsyncContext $context) use ( return; } - if ('must-revalidate' === $freshness) { + if (Freshness::MustRevalidate === $freshness) { // avoid throwing exception in ErrorChunk#__destruct() $chunk instanceof ErrorChunk && $chunk->didThrow(true); $context->passthru(); @@ -233,14 +234,14 @@ function (ChunkInterface $chunk, AsyncContext $context) use ( } if ($statusCode >= 500 && $statusCode < 600) { - if ('stale-but-usable' === $freshness) { + if (Freshness::StaleButUsable === $freshness) { $context->passthru(); $context->replaceResponse($this->createResponseFromCache($metadataKey, $cachedData, $method, $url, $options)); return; } - if ('must-revalidate' === $freshness) { + if (Freshness::MustRevalidate === $freshness) { $context->passthru(); $context->replaceResponse($this->createGatewayTimeoutResponse($method, $url, $options)); @@ -478,36 +479,36 @@ private function parseCacheControlHeader(array $header): array * * @param array{headers: array, expires_at: int|null} $data the cached response data, including headers and expiration time */ - private function evaluateCachedFreshness(array $data): string + private function evaluateCachedFreshness(array $data): Freshness { $parseCacheControlHeader = $this->parseCacheControlHeader($data['headers']['cache-control'] ?? []); if (isset($parseCacheControlHeader['no-cache'])) { - return 'stale'; + return Freshness::Stale; } $now = time(); $expires = $data['expires_at']; if (null === $expires || $now <= $expires) { - return 'fresh'; + return Freshness::Fresh; } if ( isset($parseCacheControlHeader['must-revalidate']) || ($this->sharedCache && isset($parseCacheControlHeader['proxy-revalidate'])) ) { - return 'must-revalidate'; + return Freshness::MustRevalidate; } if (isset($parseCacheControlHeader['stale-if-error'])) { $staleWindow = (int) $parseCacheControlHeader['stale-if-error']; if (($now - $expires) <= $staleWindow) { - return 'stale-but-usable'; + return Freshness::StaleButUsable; } } - return 'stale'; + return Freshness::Stale; } /** From aeddd216fee56f5810e4a2eeff914ef51e82d730 Mon Sep 17 00:00:00 2001 From: Lctrs Date: Thu, 20 Feb 2025 00:40:47 +0100 Subject: [PATCH 24/53] fix stream issues --- .../Component/HttpClient/CachingHttpClient.php | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/src/Symfony/Component/HttpClient/CachingHttpClient.php b/src/Symfony/Component/HttpClient/CachingHttpClient.php index ab3e50021b422..64fb0da9ed2c4 100644 --- a/src/Symfony/Component/HttpClient/CachingHttpClient.php +++ b/src/Symfony/Component/HttpClient/CachingHttpClient.php @@ -131,7 +131,7 @@ public function request(string $method, string $url, array $options = []): Respo } if (!empty($options['body']) || isset($options['normalized_headers']['range']) || !\in_array($method, self::CACHEABLE_METHODS, true)) { - return $this->client->request($method, $url, $options); + return new AsyncResponse($this->client, $method, $url, $options); } $requestHash = $this->getRequestHash($method, $fullUrl); @@ -320,33 +320,25 @@ public function stream(ResponseInterface|iterable $responses, ?float $timeout = $mockResponses = []; $asyncResponses = []; - $clientResponses = []; foreach ($responses as $response) { if ($response instanceof MockResponse) { $mockResponses[] = $response; - } elseif ($response instanceof AsyncResponse) { - $asyncResponses[] = $response; } else { - $clientResponses[] = $response; + $asyncResponses[] = $response; } } - if (!$mockResponses && !$clientResponses) { + if (!$mockResponses) { return $this->asyncStream($asyncResponses, $timeout); } - if (!$asyncResponses && !$clientResponses) { + if (!$asyncResponses) { return new ResponseStream(MockResponse::stream($mockResponses, $timeout)); } - if (!$asyncResponses && !$mockResponses) { - return $this->client->stream($clientResponses, $timeout); - } - - return new ResponseStream((function () use ($mockResponses, $asyncResponses, $clientResponses, $timeout) { + return new ResponseStream((function () use ($mockResponses, $asyncResponses, $timeout) { yield from MockResponse::stream($mockResponses, $timeout); - yield $this->client->stream($clientResponses, $timeout); yield $this->asyncStream($asyncResponses, $timeout); })()); } From ea96bf20030553856cab61ee9ceca4e236f09812 Mon Sep 17 00:00:00 2001 From: Lctrs Date: Thu, 20 Feb 2025 00:46:48 +0100 Subject: [PATCH 25/53] more phpdoc fix --- src/Symfony/Component/HttpClient/CachingHttpClient.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Symfony/Component/HttpClient/CachingHttpClient.php b/src/Symfony/Component/HttpClient/CachingHttpClient.php index 64fb0da9ed2c4..07c8e9c3b4bda 100644 --- a/src/Symfony/Component/HttpClient/CachingHttpClient.php +++ b/src/Symfony/Component/HttpClient/CachingHttpClient.php @@ -623,10 +623,8 @@ private function isServerResponseCacheable(int $statusCode, array $requestHeader * time specified in the headers or in the Cache-Control directives, * false otherwise. * - * @param array $headers the HTTP response headers - * @param array $cacheControl an array of parsed Cache-Control directives - * - * @return bool true if the response has an explicit expiration, false otherwise + * @param array $headers + * @param array $cacheControl */ private function hasExplicitExpiration(array $headers, array $cacheControl): bool { From a2969ba4695d9a1973dbae9c76da88452f1bc4ab Mon Sep 17 00:00:00 2001 From: Lctrs Date: Thu, 20 Feb 2025 00:48:57 +0100 Subject: [PATCH 26/53] cs fix --- .../FrameworkBundle/DependencyInjection/Configuration.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index 4106fe62c017d..4af03adb1940a 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -2180,7 +2180,7 @@ private function createHttpClientCachingSection(): ArrayNodeDefinition ->addDefaultsIfNotSet() ->children() ->stringNode('cache_pool') - ->info("The taggable cache pool to use for storing the responses.") + ->info('The taggable cache pool to use for storing the responses.') ->defaultValue('cache.http_client') ->cannotBeEmpty() ->end() From a52a36be5fbd52a1cb69978f2ad95b2cd369f146 Mon Sep 17 00:00:00 2001 From: Lctrs Date: Thu, 20 Feb 2025 01:19:42 +0100 Subject: [PATCH 27/53] fix cache definition --- .../FrameworkBundle/Resources/config/http_client.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/http_client.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/http_client.php index 1a4d4b499b8a1..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,10 +26,14 @@ return static function (ContainerConfigurator $container) { $container->services() - ->set('cache.http_client') - ->parent('cache.app.taggable') + ->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([ From 47d542257c7ee38b0c60c9b297ee91731cf9c47a Mon Sep 17 00:00:00 2001 From: Lctrs Date: Thu, 20 Feb 2025 01:47:43 +0100 Subject: [PATCH 28/53] put caching client between retry and throttling and invalidate cache when inconsistent --- .../DependencyInjection/FrameworkExtension.php | 2 +- .../Component/HttpClient/CachingHttpClient.php | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index c02ffcf0c4e93..df8879572478c 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -2804,7 +2804,7 @@ private function registerCachingHttpClient(array $options, array $defaultOptions $container ->register($name.'.caching', CachingHttpClient::class) - ->setDecoratedService($name, null, 20) // higher priority than ThrottlingHttpClient (15) + ->setDecoratedService($name, null, 13) // between RetryableHttpClient (10) and ThrottlingHttpClient (15) ->setArguments([ new Reference($name.'.caching.inner'), new Reference($options['cache_pool']), diff --git a/src/Symfony/Component/HttpClient/CachingHttpClient.php b/src/Symfony/Component/HttpClient/CachingHttpClient.php index 07c8e9c3b4bda..0a8b30243b8b1 100644 --- a/src/Symfony/Component/HttpClient/CachingHttpClient.php +++ b/src/Symfony/Component/HttpClient/CachingHttpClient.php @@ -148,7 +148,7 @@ public function request(string $method, string $url, array $options = []): Respo $freshness = $this->evaluateCachedFreshness($cachedData); if (Freshness::Fresh === $freshness) { - return $this->createResponseFromCache($metadataKey, $cachedData, $method, $url, $options); + return $this->createResponseFromCache($metadataKey, $cachedData, $method, $url, $options, $fullUrlTag); } if (isset($cachedData['headers']['etag'])) { @@ -191,7 +191,7 @@ function (ChunkInterface $chunk, AsyncContext $context) use ( // avoid throwing exception in ErrorChunk#__destruct() $chunk instanceof ErrorChunk && $chunk->didThrow(true); $context->passthru(); - $context->replaceResponse($this->createResponseFromCache($metadataKey, $cachedData, $method, $url, $options)); + $context->replaceResponse($this->createResponseFromCache($metadataKey, $cachedData, $method, $url, $options, $fullUrlTag)); return; } @@ -228,7 +228,7 @@ function (ChunkInterface $chunk, AsyncContext $context) use ( $this->cache->save($metadataItem); $context->passthru(); - $context->replaceResponse($this->createResponseFromCache($metadataKey, $cachedData, $method, $url, $options)); + $context->replaceResponse($this->createResponseFromCache($metadataKey, $cachedData, $method, $url, $options, $fullUrlTag)); return; } @@ -236,7 +236,7 @@ function (ChunkInterface $chunk, AsyncContext $context) use ( if ($statusCode >= 500 && $statusCode < 600) { if (Freshness::StaleButUsable === $freshness) { $context->passthru(); - $context->replaceResponse($this->createResponseFromCache($metadataKey, $cachedData, $method, $url, $options)); + $context->replaceResponse($this->createResponseFromCache($metadataKey, $cachedData, $method, $url, $options, $fullUrlTag)); return; } @@ -643,18 +643,20 @@ private function hasExplicitExpiration(array $headers, array $cacheControl): boo * * @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): MockResponse + 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): \Generator { + (function () use ($key, $cachedData, $fullUrlTag): \Generator { for ($i = 0; $i <= $cachedData['chunks_count']; ++$i) { $chunkItem = $this->cache->getItem("{$key}_chunk_{$i}"); if (!$chunkItem->isHit()) { + $this->cache->invalidateTags([$fullUrlTag]); + throw new ChunkCacheItemNotFoundException(\sprintf('Missing cache item for chunk with key "%s". This indicates an internal cache inconsistency.', $chunkItem->getKey())); } From 03b782a4716c039bd9aaf793a7e77c3a1e653fd2 Mon Sep 17 00:00:00 2001 From: Lctrs Date: Thu, 20 Feb 2025 17:14:30 +0100 Subject: [PATCH 29/53] also clock mock symfony cache namespace --- .../Component/HttpClient/Tests/CachingHttpClientTest.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php index 46128d7ee529e..9184d5b2f2d4f 100644 --- a/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php +++ b/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php @@ -12,7 +12,9 @@ namespace Symfony\Component\HttpClient\Tests; use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ClockMock; use Symfony\Component\Cache\Adapter\FilesystemTagAwareAdapter; +use Symfony\Component\Cache\Traits\FilesystemTrait; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\HttpClient\CachingHttpClient; use Symfony\Component\HttpClient\MockHttpClient; @@ -32,6 +34,10 @@ protected function setUp(): void parent::setUp(); $this->cacheAdapter = new FilesystemTagAwareAdapter('', 0, __DIR__.\DIRECTORY_SEPARATOR.'caching-http-client'); + + if (class_exists(ClockMock::class)) { + ClockMock::register(FilesystemTrait::class); + } } protected function tearDown(): void From c949f8a18dd4b62eba0e675f6ac6dcd04e9de3c5 Mon Sep 17 00:00:00 2001 From: Lctrs Date: Thu, 20 Feb 2025 17:18:52 +0100 Subject: [PATCH 30/53] bcb: also return async response --- src/Symfony/Component/HttpClient/CachingHttpClient.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Component/HttpClient/CachingHttpClient.php b/src/Symfony/Component/HttpClient/CachingHttpClient.php index 0a8b30243b8b1..1f8488d701102 100644 --- a/src/Symfony/Component/HttpClient/CachingHttpClient.php +++ b/src/Symfony/Component/HttpClient/CachingHttpClient.php @@ -349,7 +349,7 @@ private function legacyRequest(string $method, string $url, array $options = []) $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); From 7f888aca59a54357cfe4bc8aa5df5ad9f2d7de3e Mon Sep 17 00:00:00 2001 From: Lctrs Date: Fri, 21 Feb 2025 00:37:03 +0100 Subject: [PATCH 31/53] fix stream and add tests --- .../HttpClient/CachingHttpClient.php | 2 +- .../Tests/CachingHttpClientTest.php | 75 +++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/src/Symfony/Component/HttpClient/CachingHttpClient.php b/src/Symfony/Component/HttpClient/CachingHttpClient.php index 1f8488d701102..20e0dcdb96e57 100644 --- a/src/Symfony/Component/HttpClient/CachingHttpClient.php +++ b/src/Symfony/Component/HttpClient/CachingHttpClient.php @@ -339,7 +339,7 @@ public function stream(ResponseInterface|iterable $responses, ?float $timeout = return new ResponseStream((function () use ($mockResponses, $asyncResponses, $timeout) { yield from MockResponse::stream($mockResponses, $timeout); - yield $this->asyncStream($asyncResponses, $timeout); + yield from $this->asyncStream($asyncResponses, $timeout); })()); } diff --git a/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php index 9184d5b2f2d4f..f7c7fecb18792 100644 --- a/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php +++ b/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php @@ -18,6 +18,7 @@ use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\HttpClient\CachingHttpClient; use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\AsyncResponse; use Symfony\Component\HttpClient\Response\MockResponse; /** @@ -558,4 +559,78 @@ public function testExceedingMaxAgeIsCappedByTtl() 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'); + + self::assertInstanceOf(MockResponse::class, $response); + + $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); + } } From e6b3756cbdcc1f919941120f14f9aec05c6885fc Mon Sep 17 00:00:00 2001 From: Lctrs Date: Fri, 21 Feb 2025 09:38:03 +0100 Subject: [PATCH 32/53] extend TransportException --- .../HttpClient/Exception/ChunkCacheItemNotFoundException.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Symfony/Component/HttpClient/Exception/ChunkCacheItemNotFoundException.php b/src/Symfony/Component/HttpClient/Exception/ChunkCacheItemNotFoundException.php index f99caf5eeb08c..46aba46dd7569 100644 --- a/src/Symfony/Component/HttpClient/Exception/ChunkCacheItemNotFoundException.php +++ b/src/Symfony/Component/HttpClient/Exception/ChunkCacheItemNotFoundException.php @@ -11,8 +11,6 @@ namespace Symfony\Component\HttpClient\Exception; -use Symfony\Contracts\HttpClient\Exception\ExceptionInterface; - -final class ChunkCacheItemNotFoundException extends \RuntimeException implements ExceptionInterface +final class ChunkCacheItemNotFoundException extends TransportException { } From e5260d30cede6da1bff4184e7169cbd10363276b Mon Sep 17 00:00:00 2001 From: Lctrs Date: Fri, 21 Feb 2025 14:25:53 +0100 Subject: [PATCH 33/53] tests: in memory cache adapter --- .../HttpClient/Tests/CachingHttpClientTest.php | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php index f7c7fecb18792..981be337e3313 100644 --- a/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php +++ b/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php @@ -13,9 +13,9 @@ use PHPUnit\Framework\TestCase; use Symfony\Bridge\PhpUnit\ClockMock; -use Symfony\Component\Cache\Adapter\FilesystemTagAwareAdapter; -use Symfony\Component\Cache\Traits\FilesystemTrait; -use Symfony\Component\Filesystem\Filesystem; +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; @@ -28,24 +28,19 @@ */ class CachingHttpClientTest extends TestCase { - private FilesystemTagAwareAdapter $cacheAdapter; + private TagAwareAdapterInterface $cacheAdapter; protected function setUp(): void { parent::setUp(); - $this->cacheAdapter = new FilesystemTagAwareAdapter('', 0, __DIR__.\DIRECTORY_SEPARATOR.'caching-http-client'); + $this->cacheAdapter = new TagAwareAdapter(new ArrayAdapter()); if (class_exists(ClockMock::class)) { - ClockMock::register(FilesystemTrait::class); + ClockMock::register(TagAwareAdapter::class); } } - protected function tearDown(): void - { - (new Filesystem())->remove(__DIR__.\DIRECTORY_SEPARATOR.'caching-http-client'); - } - public function testBypassCacheWhenBodyPresent() { // If a request has a non-empty body, caching should be bypassed. From 454339a0cc88da1f299a448e97249a8d5bebb930 Mon Sep 17 00:00:00 2001 From: Lctrs Date: Fri, 28 Feb 2025 13:53:44 +0100 Subject: [PATCH 34/53] order UPGRADE-7.3.md --- UPGRADE-7.3.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/UPGRADE-7.3.md b/UPGRADE-7.3.md index 5799e0d34153e..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,9 +328,4 @@ Workflow $workflow = $this->workflows->get($event->getWorkflowName()); } } - ``` - -HttpClient ----------- - -* Deprecate passing an instance of `StoreInterface` as `$cache` argument to `CachingHttpClient` constructor + ``` \ No newline at end of file From df72186ff54f9fa046ae7311e4b790de9e86a608 Mon Sep 17 00:00:00 2001 From: Lctrs Date: Fri, 28 Feb 2025 13:54:53 +0100 Subject: [PATCH 35/53] ensure positive integer for max_ttl option --- .../Bundle/FrameworkBundle/DependencyInjection/Configuration.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index 4af03adb1940a..dae038894b13a 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -2191,6 +2191,7 @@ private function createHttpClientCachingSection(): ArrayNodeDefinition ->integerNode('max_ttl') ->info('The maximum TTL (in seconds) allowed for cached responses. Null means no cap.') ->defaultNull() + ->min(0) ->end() ->end(); } From 1ab157d9c9220b5db4080853059932b36e648de4 Mon Sep 17 00:00:00 2001 From: Lctrs Date: Fri, 28 Feb 2025 13:55:05 +0100 Subject: [PATCH 36/53] reword CHANGELOG.md --- src/Symfony/Component/HttpClient/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Component/HttpClient/CHANGELOG.md b/src/Symfony/Component/HttpClient/CHANGELOG.md index d64e386e79cc8..bc7d1dd5bb30f 100644 --- a/src/Symfony/Component/HttpClient/CHANGELOG.md +++ b/src/Symfony/Component/HttpClient/CHANGELOG.md @@ -6,7 +6,7 @@ CHANGELOG * Add IPv6 support to `NativeHttpClient` * Allow using HTTP/3 with the `CurlHttpClient` - * Add RFC 9111–based caching support to `CachingHttpClient` (though some features remain partially supported) + * Add RFC 9111–based caching support to `CachingHttpClient` 7.2 --- From 6d4f0d6af91e27b3ee6a409d89e62a9ccff6435d Mon Sep 17 00:00:00 2001 From: Lctrs Date: Fri, 28 Feb 2025 13:55:50 +0100 Subject: [PATCH 37/53] remove empty() usage --- src/Symfony/Component/HttpClient/CachingHttpClient.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Component/HttpClient/CachingHttpClient.php b/src/Symfony/Component/HttpClient/CachingHttpClient.php index 20e0dcdb96e57..07fda41988ef6 100644 --- a/src/Symfony/Component/HttpClient/CachingHttpClient.php +++ b/src/Symfony/Component/HttpClient/CachingHttpClient.php @@ -130,7 +130,7 @@ public function request(string $method, string $url, array $options = []): Respo $this->cache->invalidateTags([$fullUrlTag]); } - if (!empty($options['body']) || isset($options['normalized_headers']['range']) || !\in_array($method, self::CACHEABLE_METHODS, true)) { + if ($options['body'] !== '' || isset($options['normalized_headers']['range']) || !\in_array($method, self::CACHEABLE_METHODS, true)) { return new AsyncResponse($this->client, $method, $url, $options); } From 1b0a30a20d9f70dc23b49d5e42b7c7c6abfe635d Mon Sep 17 00:00:00 2001 From: Lctrs Date: Fri, 28 Feb 2025 13:56:11 +0100 Subject: [PATCH 38/53] evaluateCachedFreshness -> evaluateCacheFreshness --- src/Symfony/Component/HttpClient/CachingHttpClient.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Symfony/Component/HttpClient/CachingHttpClient.php b/src/Symfony/Component/HttpClient/CachingHttpClient.php index 07fda41988ef6..6f88102437806 100644 --- a/src/Symfony/Component/HttpClient/CachingHttpClient.php +++ b/src/Symfony/Component/HttpClient/CachingHttpClient.php @@ -145,7 +145,7 @@ public function request(string $method, string $url, array $options = []): Respo $freshness = null; if (\is_array($cachedData)) { - $freshness = $this->evaluateCachedFreshness($cachedData); + $freshness = $this->evaluateCacheFreshness($cachedData); if (Freshness::Fresh === $freshness) { return $this->createResponseFromCache($metadataKey, $cachedData, $method, $url, $options, $fullUrlTag); @@ -471,7 +471,7 @@ private function parseCacheControlHeader(array $header): array * * @param array{headers: array, expires_at: int|null} $data the cached response data, including headers and expiration time */ - private function evaluateCachedFreshness(array $data): Freshness + private function evaluateCacheFreshness(array $data): Freshness { $parseCacheControlHeader = $this->parseCacheControlHeader($data['headers']['cache-control'] ?? []); From 73311bc28ce9c347adaafc085f5872cd7177bcfd Mon Sep 17 00:00:00 2001 From: Lctrs Date: Fri, 28 Feb 2025 15:50:02 +0100 Subject: [PATCH 39/53] remove dev deps on symfony/filesystem --- src/Symfony/Component/HttpClient/composer.json | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Symfony/Component/HttpClient/composer.json b/src/Symfony/Component/HttpClient/composer.json index 5ab4b2e0aa25e..e367f8c0f3875 100644 --- a/src/Symfony/Component/HttpClient/composer.json +++ b/src/Symfony/Component/HttpClient/composer.json @@ -39,7 +39,6 @@ "symfony/amphp-http-client-meta": "^1.0|^2.0", "symfony/cache": "^6.4|^7.0", "symfony/dependency-injection": "^6.4|^7.0", - "symfony/filesystem": "^6.4|^7.0", "symfony/http-kernel": "^6.4|^7.0", "symfony/messenger": "^6.4|^7.0", "symfony/process": "^6.4|^7.0", From 6b1dfda9cccb46058158b0f8bf72356d303d68d1 Mon Sep 17 00:00:00 2001 From: Lctrs Date: Wed, 12 Mar 2025 21:00:38 +0100 Subject: [PATCH 40/53] add more tests --- .../Tests/CachingHttpClientTest.php | 153 +++++++++++++++++- 1 file changed, 152 insertions(+), 1 deletion(-) diff --git a/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php index 981be337e3313..39ca0f2dbe48a 100644 --- a/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php +++ b/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php @@ -442,7 +442,7 @@ public function testAPrivateCacheStoresAResponseWithPrivateDirective() self::assertSame('foo', $response->getContent()); } - public function testUnsafeMethodsInvalidateCache() + public function testCacheMissAfterInvalidation() { $mockClient = new MockHttpClient([ new MockResponse('foo', [ @@ -628,4 +628,155 @@ public function testItCanStreamBoth() 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 testAgeCalculation() + { + $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()); + + 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 testGatewayTimeoutOnMustRevalidateFailure() + { + $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 = $client->request('GET', 'http://example.com/must-revalidate'); + self::assertSame(200, $response->getStatusCode()); + self::assertSame('foo', $response->getContent()); + + sleep(2); + + $response = $client->request('GET', 'http://example.com/must-revalidate'); + self::assertSame(504, $response->getStatusCode()); + } } From 463b71b798d2c23dacb5d8470a8b8c4d8bec41b2 Mon Sep 17 00:00:00 2001 From: Lctrs Date: Wed, 12 Mar 2025 21:01:02 +0100 Subject: [PATCH 41/53] vary asterisk prevents caching --- .../Component/HttpClient/CachingHttpClient.php | 8 ++++++++ .../HttpClient/Tests/CachingHttpClientTest.php | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/Symfony/Component/HttpClient/CachingHttpClient.php b/src/Symfony/Component/HttpClient/CachingHttpClient.php index 6f88102437806..4812cd1a58751 100644 --- a/src/Symfony/Component/HttpClient/CachingHttpClient.php +++ b/src/Symfony/Component/HttpClient/CachingHttpClient.php @@ -257,6 +257,14 @@ function (ChunkInterface $chunk, AsyncContext $context) use ( } } + if (in_array('*', $varyFields, true)) { + $context->passthru(); + + yield $chunk; + + return; + } + $metadataKey = $this->getMetadataKey($requestHash, $options['normalized_headers'], $varyFields); yield $chunk; diff --git a/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php index 39ca0f2dbe48a..f5721cf7d9f25 100644 --- a/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php +++ b/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php @@ -779,4 +779,22 @@ public function testGatewayTimeoutOnMustRevalidateFailure() $response = $client->request('GET', 'http://example.com/must-revalidate'); self::assertSame(504, $response->getStatusCode()); } + + public function testVaryAsteriskPreventsCaching() + { + $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 = $client->request('GET', 'http://example.com/vary-asterisk'); + self::assertSame(200, $response->getStatusCode()); + self::assertSame('foo', $response->getContent()); + + $response = $client->request('GET', 'http://example.com/vary-asterisk'); + self::assertSame(200, $response->getStatusCode()); + self::assertSame('bar', $response->getContent()); + } } From 0ebc780c73d193aca0ad91452e61cc32420057c9 Mon Sep 17 00:00:00 2001 From: Lctrs Date: Wed, 12 Mar 2025 21:22:38 +0100 Subject: [PATCH 42/53] exclude non cacheable headers from cache --- .../HttpClient/CachingHttpClient.php | 13 +++++- .../Tests/CachingHttpClientTest.php | 40 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/Symfony/Component/HttpClient/CachingHttpClient.php b/src/Symfony/Component/HttpClient/CachingHttpClient.php index 4812cd1a58751..4a6346470bce4 100644 --- a/src/Symfony/Component/HttpClient/CachingHttpClient.php +++ b/src/Symfony/Component/HttpClient/CachingHttpClient.php @@ -67,6 +67,15 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface * 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, + ]; private TagAwareAdapterInterface|HttpCache $cache; private array $defaultOptions = self::OPTIONS_DEFAULTS; @@ -222,7 +231,7 @@ function (ChunkInterface $chunk, AsyncContext $context) use ( $cachedData['expires_at'] = $this->calculateExpiresAt($maxAge); $cachedData['stored_at'] = time(); $cachedData['initial_age'] = (int) ($headers['age'][0] ?? 0); - $cachedData['headers'] = array_merge($cachedData['headers'], $headers); + $cachedData['headers'] = array_merge($cachedData['headers'], array_diff_key($headers, self::EXCLUDED_HEADERS)); $metadataItem->set($cachedData)->expiresAt($expiresAt); $this->cache->save($metadataItem); @@ -290,7 +299,7 @@ function (ChunkInterface $chunk, AsyncContext $context) use ( ->tag($fullUrlTag) ->set([ 'status_code' => $context->getStatusCode(), - 'headers' => $headers, + 'headers' => array_diff_key($headers, self::EXCLUDED_HEADERS), 'initial_age' => (int) ($headers['age'][0] ?? 0), 'stored_at' => time(), 'expires_at' => $this->calculateExpiresAt($maxAge), diff --git a/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php index f5721cf7d9f25..1f7c158a36e3c 100644 --- a/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php +++ b/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php @@ -797,4 +797,44 @@ public function testVaryAsteriskPreventsCaching() self::assertSame(200, $response->getStatusCode()); self::assertSame('bar', $response->getContent()); } + + public function testExcludedHeadersAreNotCached() + { + $mockClient = new MockHttpClient([ + new MockResponse('foo', [ + 'http_code' => 200, + 'response_headers' => [ + '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', + ], + ]), + new MockResponse('should not be served', ['http_code' => 200]), + ]); + + $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); + } } From d1886eb0f686dae00d61c377b3ef10ce8b02b90f Mon Sep 17 00:00:00 2001 From: Lctrs Date: Wed, 12 Mar 2025 21:33:30 +0100 Subject: [PATCH 43/53] remove wrongly cacheable status codes --- src/Symfony/Component/HttpClient/CachingHttpClient.php | 2 +- .../Component/HttpClient/Tests/CachingHttpClientTest.php | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Symfony/Component/HttpClient/CachingHttpClient.php b/src/Symfony/Component/HttpClient/CachingHttpClient.php index 4a6346470bce4..a88be088cb4d5 100644 --- a/src/Symfony/Component/HttpClient/CachingHttpClient.php +++ b/src/Symfony/Component/HttpClient/CachingHttpClient.php @@ -54,7 +54,7 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface /** * The status codes that are always cacheable. */ - private const CACHEABLE_STATUS_CODES = [200, 203, 204, 206, 300, 301, 404, 405, 410, 414, 501]; + private const CACHEABLE_STATUS_CODES = [200, 203, 204, 300, 301, 404, 410]; /** * The status codes that are cacheable if the response carry explicit cache directives. */ diff --git a/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php index 1f7c158a36e3c..dc31a27f2587a 100644 --- a/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php +++ b/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php @@ -217,7 +217,7 @@ public function testItServesAStaleResponseIfError() { $mockClient = new MockHttpClient([ new MockResponse('foo', [ - 'http_code' => 501, + 'http_code' => 404, 'response_headers' => [ 'Cache-Control' => 'max-age=1, stale-if-error=5', ], @@ -232,13 +232,13 @@ public function testItServesAStaleResponseIfError() ); $response = $client->request('GET', 'http://example.com/foo-bar'); - self::assertSame(501, $response->getStatusCode()); + self::assertSame(404, $response->getStatusCode()); self::assertSame('foo', $response->getContent(false)); sleep(5); $response = $client->request('GET', 'http://example.com/foo-bar'); - self::assertSame(501, $response->getStatusCode()); + self::assertSame(404, $response->getStatusCode()); self::assertSame('foo', $response->getContent(false)); } From e15142ce543b535bf257b9dd96080807d601a151 Mon Sep 17 00:00:00 2001 From: Lctrs Date: Wed, 12 Mar 2025 21:51:25 +0100 Subject: [PATCH 44/53] implement heuristic caching --- .../HttpClient/CachingHttpClient.php | 41 +++++++++++++++---- .../Tests/CachingHttpClientTest.php | 33 +++++++++++++++ 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/src/Symfony/Component/HttpClient/CachingHttpClient.php b/src/Symfony/Component/HttpClient/CachingHttpClient.php index a88be088cb4d5..eff3eca18abb6 100644 --- a/src/Symfony/Component/HttpClient/CachingHttpClient.php +++ b/src/Symfony/Component/HttpClient/CachingHttpClient.php @@ -76,6 +76,10 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface 'proxy-authentication-info' => true, 'proxy-authorization' => true, ]; + /** + * Maximum heuristic freshness lifetime in seconds (24 hours). + */ + private const MAX_HEURISTIC_FRESHNESS_TTL = 86400; private TagAwareAdapterInterface|HttpCache $cache; private array $defaultOptions = self::OPTIONS_DEFAULTS; @@ -544,23 +548,44 @@ private function determineMaxAge(array $headers, array $cacheControl): ?int $age = $this->getCurrentAge($headers); if ($this->sharedCache && isset($cacheControl['s-maxage'])) { - $val = (int) $cacheControl['s-maxage']; + $sharedMaxAge = (int) $cacheControl['s-maxage']; - return max(0, $val - $age); + return max(0, $sharedMaxAge - $age); } if (isset($cacheControl['max-age'])) { - $val = (int) $cacheControl['max-age']; + $maxAge = (int) $cacheControl['max-age']; - return max(0, $val - $age); + return max(0, $maxAge - $age); } foreach ($headers['expires'] ?? [] as $expire) { - $ts = strtotime($expire); - if (false !== $ts) { - $diff = $ts - time() - $age; + $expirationTimestamp = strtotime($expire); + if (false !== $expirationTimestamp) { + $timeUntilExpiration = $expirationTimestamp - time() - $age; + + return max($timeUntilExpiration, 0); + } + } - return max($diff, 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); + } + } } } diff --git a/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php index dc31a27f2587a..252dc39a39a7a 100644 --- a/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php +++ b/src/Symfony/Component/HttpClient/Tests/CachingHttpClientTest.php @@ -837,4 +837,37 @@ public function testExcludedHeadersAreNotCached() self::assertArrayHasKey('content-type', $cachedHeaders); self::assertArrayHasKey('x-custom-header', $cachedHeaders); } + + public function testHeuristicFreshnessWithLastModified() + { + $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 + + $response = $client->request('GET', 'http://example.com/heuristic'); + self::assertSame(200, $response->getStatusCode()); + self::assertSame('foo', $response->getContent()); + + // After heuristic expires + sleep(1); // Total 361s, past 360s heuristic + + $response = $client->request('GET', 'http://example.com/heuristic'); + self::assertSame(200, $response->getStatusCode()); + self::assertSame('bar', $response->getContent()); + } } From bb9b0ba231a1c6e36cc43865b7cf8f2ebd378e16 Mon Sep 17 00:00:00 2001 From: Lctrs Date: Mon, 31 Mar 2025 20:47:10 +0200 Subject: [PATCH 45/53] Update src/Symfony/Component/HttpClient/CachingHttpClient.php Co-authored-by: Nicolas Grekas --- src/Symfony/Component/HttpClient/CachingHttpClient.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Component/HttpClient/CachingHttpClient.php b/src/Symfony/Component/HttpClient/CachingHttpClient.php index eff3eca18abb6..a30f8f379e808 100644 --- a/src/Symfony/Component/HttpClient/CachingHttpClient.php +++ b/src/Symfony/Component/HttpClient/CachingHttpClient.php @@ -56,7 +56,7 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface */ private const CACHEABLE_STATUS_CODES = [200, 203, 204, 300, 301, 404, 410]; /** - * The status codes that are cacheable if the response carry explicit cache directives. + * The status codes that are cacheable if the response carries explicit cache directives. */ private const CONDITIONALLY_CACHEABLE_STATUS_CODES = [302, 303, 307, 308]; /** From 342fedc25eda519040512b134bbd55274b41c136 Mon Sep 17 00:00:00 2001 From: Lctrs Date: Mon, 31 Mar 2025 20:47:23 +0200 Subject: [PATCH 46/53] Update src/Symfony/Component/HttpClient/CachingHttpClient.php Co-authored-by: Nicolas Grekas --- src/Symfony/Component/HttpClient/CachingHttpClient.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Component/HttpClient/CachingHttpClient.php b/src/Symfony/Component/HttpClient/CachingHttpClient.php index a30f8f379e808..bc9f0b10b8894 100644 --- a/src/Symfony/Component/HttpClient/CachingHttpClient.php +++ b/src/Symfony/Component/HttpClient/CachingHttpClient.php @@ -143,7 +143,7 @@ public function request(string $method, string $url, array $options = []): Respo $this->cache->invalidateTags([$fullUrlTag]); } - if ($options['body'] !== '' || isset($options['normalized_headers']['range']) || !\in_array($method, self::CACHEABLE_METHODS, true)) { + if ('' !== $options['body'] || isset($options['normalized_headers']['range']) || !\in_array($method, self::CACHEABLE_METHODS, true)) { return new AsyncResponse($this->client, $method, $url, $options); } From e16d16f1f686affcab0c4d807e164b195c27884e Mon Sep 17 00:00:00 2001 From: Lctrs Date: Mon, 31 Mar 2025 20:47:39 +0200 Subject: [PATCH 47/53] Update src/Symfony/Component/HttpClient/CachingHttpClient.php Co-authored-by: Nicolas Grekas --- src/Symfony/Component/HttpClient/CachingHttpClient.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Symfony/Component/HttpClient/CachingHttpClient.php b/src/Symfony/Component/HttpClient/CachingHttpClient.php index bc9f0b10b8894..00d44fbd5e522 100644 --- a/src/Symfony/Component/HttpClient/CachingHttpClient.php +++ b/src/Symfony/Component/HttpClient/CachingHttpClient.php @@ -175,9 +175,7 @@ public function request(string $method, string $url, array $options = []): Respo $chunkIndex = -1; // consistent expiration time for all items - $expiresAt = null === $this->maxTtl - ? null - : \DateTimeImmutable::createFromFormat('U', time() + $this->maxTtl); + $expiresAt = null === $this->maxTtl ? null : \DateTimeImmutable::createFromFormat('U', time() + $this->maxTtl); return new AsyncResponse( $this->client, From bf285c0f15a8554248ab5de7e0a9031bc976b336 Mon Sep 17 00:00:00 2001 From: Lctrs Date: Mon, 31 Mar 2025 20:50:07 +0200 Subject: [PATCH 48/53] Update src/Symfony/Component/HttpClient/CachingHttpClient.php Co-authored-by: Nicolas Grekas --- src/Symfony/Component/HttpClient/CachingHttpClient.php | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/Symfony/Component/HttpClient/CachingHttpClient.php b/src/Symfony/Component/HttpClient/CachingHttpClient.php index 00d44fbd5e522..3053715d4b5b4 100644 --- a/src/Symfony/Component/HttpClient/CachingHttpClient.php +++ b/src/Symfony/Component/HttpClient/CachingHttpClient.php @@ -421,12 +421,7 @@ private function getRequestHash(string $method, string $url): string */ private function getMetadataKey(string $requestHash, array $normalizedHeaders, array $varyFields): string { - $variantKey = hash( - 'xxh3', - [] === $varyFields - ? 'NO-VARY' - : $this->buildVariantKey($normalizedHeaders, $varyFields) - ); + $variantKey = hash('xxh3', $this->buildVariantKey($normalizedHeaders, $varyFields)); return "metadata_{$requestHash}_{$variantKey}"; } From 8c9871dccacfeb5da6d1720e69026498bb45bb7a Mon Sep 17 00:00:00 2001 From: Lctrs Date: Mon, 31 Mar 2025 21:38:14 +0200 Subject: [PATCH 49/53] switch to a sha256 based hash --- .../HttpClient/CachingHttpClient.php | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/src/Symfony/Component/HttpClient/CachingHttpClient.php b/src/Symfony/Component/HttpClient/CachingHttpClient.php index 3053715d4b5b4..b4832fbb37a13 100644 --- a/src/Symfony/Component/HttpClient/CachingHttpClient.php +++ b/src/Symfony/Component/HttpClient/CachingHttpClient.php @@ -137,7 +137,7 @@ public function request(string $method, string $url, array $options = []): Respo [$fullUrl, $options] = self::prepareRequest($method, $url, $options, $this->defaultOptions); $fullUrl = implode('', $fullUrl); - $fullUrlTag = hash('xxh3', $fullUrl); + $fullUrlTag = self::hash($fullUrl); if (\in_array($method, self::UNSAFE_METHODS, true)) { $this->cache->invalidateTags([$fullUrlTag]); @@ -147,12 +147,12 @@ public function request(string $method, string $url, array $options = []): Respo return new AsyncResponse($this->client, $method, $url, $options); } - $requestHash = $this->getRequestHash($method, $fullUrl); + $requestHash = self::hash($method.$fullUrl); $varyKey = "vary_{$requestHash}"; $varyItem = $this->cache->getItem($varyKey); $varyFields = $varyItem->isHit() ? $varyItem->get() : []; - $metadataKey = $this->getMetadataKey($requestHash, $options['normalized_headers'], $varyFields); + $metadataKey = self::getMetadataKey($requestHash, $options['normalized_headers'], $varyFields); $metadataItem = $this->cache->getItem($metadataKey); $cachedData = $metadataItem->isHit() ? $metadataItem->get() : null; @@ -276,7 +276,7 @@ function (ChunkInterface $chunk, AsyncContext $context) use ( return; } - $metadataKey = $this->getMetadataKey($requestHash, $options['normalized_headers'], $varyFields); + $metadataKey = self::getMetadataKey($requestHash, $options['normalized_headers'], $varyFields); yield $chunk; @@ -402,12 +402,9 @@ private function legacyRequest(string $method, string $url, array $options = []) return MockResponse::fromRequest($method, $url, $options, $response); } - /** - * Returns a hash representing the request details. - */ - private function getRequestHash(string $method, string $url): string + private static function hash(string $toHash): string { - return hash('xxh3', $method.$url); + return str_replace('/', '_', base64_encode(hash('sha256', $toHash, true))); } /** @@ -419,9 +416,9 @@ private function getRequestHash(string $method, string $url): string * * @return string the metadata key composed of the request hash and variant key */ - private function getMetadataKey(string $requestHash, array $normalizedHeaders, array $varyFields): string + private static function getMetadataKey(string $requestHash, array $normalizedHeaders, array $varyFields): string { - $variantKey = hash('xxh3', $this->buildVariantKey($normalizedHeaders, $varyFields)); + $variantKey = self::hash(self::buildVariantKey($normalizedHeaders, $varyFields)); return "metadata_{$requestHash}_{$variantKey}"; } @@ -434,7 +431,7 @@ private function getMetadataKey(string $requestHash, array $normalizedHeaders, a * @param array $normalizedHeaders * @param string[] $varyFields */ - private function buildVariantKey(array $normalizedHeaders, array $varyFields): string + private static function buildVariantKey(array $normalizedHeaders, array $varyFields): string { $parts = []; foreach ($varyFields as $field) { From 54b9fdeeaaa1f7fe496aa247b9a047360f12654f Mon Sep 17 00:00:00 2001 From: Lctrs Date: Mon, 31 Mar 2025 21:38:57 +0200 Subject: [PATCH 50/53] turn some vars by ref to static ones --- src/Symfony/Component/HttpClient/CachingHttpClient.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Symfony/Component/HttpClient/CachingHttpClient.php b/src/Symfony/Component/HttpClient/CachingHttpClient.php index b4832fbb37a13..bd3628d5bed6c 100644 --- a/src/Symfony/Component/HttpClient/CachingHttpClient.php +++ b/src/Symfony/Component/HttpClient/CachingHttpClient.php @@ -173,7 +173,6 @@ public function request(string $method, string $url, array $options = []): Respo } } - $chunkIndex = -1; // consistent expiration time for all items $expiresAt = null === $this->maxTtl ? null : \DateTimeImmutable::createFromFormat('U', time() + $this->maxTtl); @@ -183,12 +182,10 @@ public function request(string $method, string $url, array $options = []): Respo $url, $options, function (ChunkInterface $chunk, AsyncContext $context) use ( - &$chunkIndex, $expiresAt, $fullUrlTag, $requestHash, $varyItem, - &$varyFields, &$metadataKey, $metadataItem, $cachedData, @@ -197,6 +194,9 @@ function (ChunkInterface $chunk, AsyncContext $context) use ( $method, $options, ): \Generator { + static $chunkIndex = -1; + static $varyFields; + if (null !== $chunk->getError() || $chunk->isTimeout()) { if (Freshness::StaleButUsable === $freshness) { // avoid throwing exception in ErrorChunk#__destruct() From 4c1a097040634ea94290b8d5d2049d505b12f76b Mon Sep 17 00:00:00 2001 From: Lctrs Date: Mon, 31 Mar 2025 21:39:03 +0200 Subject: [PATCH 51/53] cs --- src/Symfony/Component/HttpClient/CachingHttpClient.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Component/HttpClient/CachingHttpClient.php b/src/Symfony/Component/HttpClient/CachingHttpClient.php index bd3628d5bed6c..7f0b1ac057e9f 100644 --- a/src/Symfony/Component/HttpClient/CachingHttpClient.php +++ b/src/Symfony/Component/HttpClient/CachingHttpClient.php @@ -268,7 +268,7 @@ function (ChunkInterface $chunk, AsyncContext $context) use ( } } - if (in_array('*', $varyFields, true)) { + if (\in_array('*', $varyFields, true)) { $context->passthru(); yield $chunk; From df3f803d8e12decd9127bf87ca8d112cf4b10c85 Mon Sep 17 00:00:00 2001 From: Lctrs Date: Mon, 31 Mar 2025 21:44:24 +0200 Subject: [PATCH 52/53] more static methods --- .../HttpClient/CachingHttpClient.php | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/Symfony/Component/HttpClient/CachingHttpClient.php b/src/Symfony/Component/HttpClient/CachingHttpClient.php index 7f0b1ac057e9f..c58a6e52189ee 100644 --- a/src/Symfony/Component/HttpClient/CachingHttpClient.php +++ b/src/Symfony/Component/HttpClient/CachingHttpClient.php @@ -211,7 +211,7 @@ function (ChunkInterface $chunk, AsyncContext $context) use ( // avoid throwing exception in ErrorChunk#__destruct() $chunk instanceof ErrorChunk && $chunk->didThrow(true); $context->passthru(); - $context->replaceResponse($this->createGatewayTimeoutResponse($method, $url, $options)); + $context->replaceResponse(self::createGatewayTimeoutResponse($method, $url, $options)); return; } @@ -222,7 +222,7 @@ function (ChunkInterface $chunk, AsyncContext $context) use ( } $headers = $context->getHeaders(); - $cacheControl = $this->parseCacheControlHeader($headers['cache-control'] ?? []); + $cacheControl = self::parseCacheControlHeader($headers['cache-control'] ?? []); if ($chunk->isFirst()) { $statusCode = $context->getStatusCode(); @@ -230,7 +230,7 @@ function (ChunkInterface $chunk, AsyncContext $context) use ( if (304 === $statusCode && null !== $freshness) { $maxAge = $this->determineMaxAge($headers, $cacheControl); - $cachedData['expires_at'] = $this->calculateExpiresAt($maxAge); + $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)); @@ -254,7 +254,7 @@ function (ChunkInterface $chunk, AsyncContext $context) use ( if (Freshness::MustRevalidate === $freshness) { $context->passthru(); - $context->replaceResponse($this->createGatewayTimeoutResponse($method, $url, $options)); + $context->replaceResponse(self::createGatewayTimeoutResponse($method, $url, $options)); return; } @@ -304,7 +304,7 @@ function (ChunkInterface $chunk, AsyncContext $context) use ( 'headers' => array_diff_key($headers, self::EXCLUDED_HEADERS), 'initial_age' => (int) ($headers['age'][0] ?? 0), 'stored_at' => time(), - 'expires_at' => $this->calculateExpiresAt($maxAge), + 'expires_at' => self::calculateExpiresAt($maxAge), 'chunks_count' => $chunkIndex, ]) ->expiresAt($expiresAt) @@ -457,7 +457,7 @@ private static function buildVariantKey(array $normalizedHeaders, array $varyFie * * @return array the parsed Cache-Control directives */ - private function parseCacheControlHeader(array $header): array + private static function parseCacheControlHeader(array $header): array { $parsed = []; foreach ($header as $line) { @@ -484,7 +484,7 @@ private function parseCacheControlHeader(array $header): array */ private function evaluateCacheFreshness(array $data): Freshness { - $parseCacheControlHeader = $this->parseCacheControlHeader($data['headers']['cache-control'] ?? []); + $parseCacheControlHeader = self::parseCacheControlHeader($data['headers']['cache-control'] ?? []); if (isset($parseCacheControlHeader['no-cache'])) { return Freshness::Stale; @@ -535,7 +535,7 @@ private function evaluateCacheFreshness(array $data): Freshness */ private function determineMaxAge(array $headers, array $cacheControl): ?int { - $age = $this->getCurrentAge($headers); + $age = self::getCurrentAge($headers); if ($this->sharedCache && isset($cacheControl['s-maxage'])) { $sharedMaxAge = (int) $cacheControl['s-maxage']; @@ -589,7 +589,7 @@ private function determineMaxAge(array $headers, array $cacheControl): ?int * * @return int The age of the response in seconds. Defaults to 0 if not present. */ - private function getCurrentAge(array $headers): int + private static function getCurrentAge(array $headers): int { return (int) ($headers['age'][0] ?? 0); } @@ -601,7 +601,7 @@ private function getCurrentAge(array $headers): int * * @return int|null the expiration time of the response as a Unix timestamp, or null if the maximum age is null */ - private function calculateExpiresAt(?int $maxAge): ?int + private static function calculateExpiresAt(?int $maxAge): ?int { if (null === $maxAge) { return null; @@ -700,7 +700,7 @@ private function createResponseFromCache(string $key, array $cachedData, string ); } - private function createGatewayTimeoutResponse(string $method, string $url, array $options): MockResponse + private static function createGatewayTimeoutResponse(string $method, string $url, array $options): MockResponse { return MockResponse::fromRequest( $method, From 7c66ffd82f2fc9dedd07fd4ebe629ed6a34bbec3 Mon Sep 17 00:00:00 2001 From: Lctrs Date: Wed, 2 Apr 2025 21:40:41 +0200 Subject: [PATCH 53/53] switch to TagAwareCacheInterface --- .../HttpClient/CachingHttpClient.php | 85 +++++++++---------- .../Tests/LegacyCachingHttpClientTest.php | 10 +-- 2 files changed, 46 insertions(+), 49 deletions(-) diff --git a/src/Symfony/Component/HttpClient/CachingHttpClient.php b/src/Symfony/Component/HttpClient/CachingHttpClient.php index c58a6e52189ee..10c8697748411 100644 --- a/src/Symfony/Component/HttpClient/CachingHttpClient.php +++ b/src/Symfony/Component/HttpClient/CachingHttpClient.php @@ -11,7 +11,6 @@ namespace Symfony\Component\HttpClient; -use Symfony\Component\Cache\Adapter\TagAwareAdapterInterface; use Symfony\Component\HttpClient\Caching\Freshness; use Symfony\Component\HttpClient\Chunk\ErrorChunk; use Symfony\Component\HttpClient\Exception\ChunkCacheItemNotFoundException; @@ -23,6 +22,8 @@ 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; @@ -81,7 +82,7 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface */ private const MAX_HEURISTIC_FRESHNESS_TTL = 86400; - private TagAwareAdapterInterface|HttpCache $cache; + private TagAwareCacheInterface|HttpCache $cache; private array $defaultOptions = self::OPTIONS_DEFAULTS; /** @@ -94,13 +95,13 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface */ public function __construct( private HttpClientInterface $client, - TagAwareAdapterInterface|StoreInterface $cache, + TagAwareCacheInterface|StoreInterface $cache, array $defaultOptions = [], private readonly bool $sharedCache = true, private readonly ?int $maxTtl = null, ) { 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__, TagAwareAdapterInterface::class); + 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); if (!class_exists(HttpClientKernel::class)) { throw new \LogicException(\sprintf('Using "%s" requires the HttpKernel component, try running "composer require symfony/http-kernel".', __CLASS__)); @@ -149,12 +150,10 @@ public function request(string $method, string $url, array $options = []): Respo $requestHash = self::hash($method.$fullUrl); $varyKey = "vary_{$requestHash}"; - $varyItem = $this->cache->getItem($varyKey); - $varyFields = $varyItem->isHit() ? $varyItem->get() : []; + $varyFields = $this->cache->get($varyKey, static fn (): array => []); $metadataKey = self::getMetadataKey($requestHash, $options['normalized_headers'], $varyFields); - $metadataItem = $this->cache->getItem($metadataKey); - $cachedData = $metadataItem->isHit() ? $metadataItem->get() : null; + $cachedData = $this->cache->get($metadataKey, static fn (): null => null); $freshness = null; if (\is_array($cachedData)) { @@ -185,9 +184,8 @@ function (ChunkInterface $chunk, AsyncContext $context) use ( $expiresAt, $fullUrlTag, $requestHash, - $varyItem, + $varyKey, &$metadataKey, - $metadataItem, $cachedData, $freshness, $url, @@ -230,13 +228,16 @@ function (ChunkInterface $chunk, AsyncContext $context) use ( if (304 === $statusCode && null !== $freshness) { $maxAge = $this->determineMaxAge($headers, $cacheControl); - $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)); + $this->cache->get($metadataKey, static function (ItemInterface $item) use ($headers, $maxAge, $cachedData, $expiresAt, $fullUrlTag): array { + $item->expiresAt($expiresAt)->tag($fullUrlTag); - $metadataItem->set($cachedData)->expiresAt($expiresAt); - $this->cache->save($metadataItem); + $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)); @@ -292,25 +293,26 @@ function (ChunkInterface $chunk, AsyncContext $context) use ( } if ($chunk->isLast()) { - $this->cache->saveDeferred($varyItem->set($varyFields)->tag($fullUrlTag)->expiresAt($expiresAt)); + $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->saveDeferred( - $this->cache->getItem($metadataKey) - ->tag($fullUrlTag) - ->set([ - '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, - ]) - ->expiresAt($expiresAt) - ); - - $this->cache->commit(); + $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; @@ -319,12 +321,11 @@ function (ChunkInterface $chunk, AsyncContext $context) use ( ++$chunkIndex; $chunkKey = "{$metadataKey}_chunk_{$chunkIndex}"; - $chunkItem = $this->cache->getItem($chunkKey) - ->tag($fullUrlTag) - ->set($chunk->getContent()) - ->expiresAt($expiresAt); + $this->cache->get($chunkKey, static function (ItemInterface $item) use ($expiresAt, $fullUrlTag, $chunk): string { + $item->tag($fullUrlTag)->expiresAt($expiresAt); - $this->cache->save($chunkItem); + return $chunk->getContent(); + }, \INF); yield $chunk; } @@ -684,15 +685,11 @@ private function createResponseFromCache(string $key, array $cachedData, string new MockResponse( (function () use ($key, $cachedData, $fullUrlTag): \Generator { for ($i = 0; $i <= $cachedData['chunks_count']; ++$i) { - $chunkItem = $this->cache->getItem("{$key}_chunk_{$i}"); - - if (!$chunkItem->isHit()) { + 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.', $chunkItem->getKey())); - } - - yield $chunkItem->get(); + 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']] diff --git a/src/Symfony/Component/HttpClient/Tests/LegacyCachingHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/LegacyCachingHttpClientTest.php index 4a305a3435c63..fe2fae72b25ca 100644 --- a/src/Symfony/Component/HttpClient/Tests/LegacyCachingHttpClientTest.php +++ b/src/Symfony/Component/HttpClient/Tests/LegacyCachingHttpClientTest.php @@ -28,7 +28,7 @@ class LegacyCachingHttpClientTest extends TestCase 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\Component\Cache\Adapter\TagAwareAdapterInterface" expected.'); + $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' => [ @@ -51,7 +51,7 @@ public function testRequestHeaders() 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\Component\Cache\Adapter\TagAwareAdapterInterface" expected.'); + $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]])); @@ -63,7 +63,7 @@ public function testDoesNotEvaluateResponseBody() 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\Component\Cache\Adapter\TagAwareAdapterInterface" expected.'); + $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'; @@ -82,7 +82,7 @@ public function testDoesNotIncludeFile() 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\Component\Cache\Adapter\TagAwareAdapterInterface" expected.'); + $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'; @@ -99,7 +99,7 @@ public function testDoesNotReadFile() 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\Component\Cache\Adapter\TagAwareAdapterInterface" expected.'); + $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', [