Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 9c413a3

Browse files
feature #59576 [HttpClient] Make CachingHttpClient compatible with RFC 9111 (Lctrs)
This PR was squashed before being merged into the 7.4 branch. Discussion ---------- [HttpClient] Make `CachingHttpClient` compatible with RFC 9111 | Q | A | ------------- | --- | Branch? | 7.3 | Bug fix? | no | New feature? | yes | Deprecations? | yes | Issues | Fix #36858 Fix #49937 Fix #41843 Fix #61588 | License | MIT This PR brings [RFC9111](https://www.rfc-editor.org/rfc/rfc9111.html) support to `CachingHttpClient` by leveraging `symfony/cache`. It provides caching for `GET` and `HEAD` requests, and cache invalidation for `POST`, `PUT`, `DELETE`, `PATCH`. The implementation is **asynchronous**, so the response **must be consumed** (e.g., via getContent() or streaming) for caching to occur. ### Basic usage ```php // Create the base HTTP client $baseClient = HttpClient::create(); // Create a cache backend (e.g., Filesystem cache) $cache = new FilesystemAdapter(); // Instantiate the CachingHttpClient $client = new CachingHttpClient( client: $baseClient, cache: $cache, defaultOptions: [], // Optional default options sharedCache: true, // Use a shared cache (default) maxTtl: 86400 // Maximum cache TTL in seconds (optional) ); // Make a request $response = $client->request('GET', 'https://api.example.com/data'); // Get the response content $content = $response->getContent(); ``` ### Constructor parameters - `$client`: The underlying HTTP client. - `$cache`: A cache backend implementing `TagAwareCacheInterface`. - `$defaultOptions`: An optional array of default request options. - `$sharedCache`: `true` to indicate the cache is [shared](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Caching#shared_cache), `false` for [private](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Caching#private_caches). Defaults to true. - `$maxTtl`: Optional maximum time-to-live (in seconds) for cached responses. If set, server-provided TTLs are capped to this value. ### Omissions from RFC9111 - The following cache-control directives are not supported and thus ignored : `stale-while-revalidate`, `min-fresh`, `max-stale`, and `only-if-cached`. In the case of `stale-while-revalidate`, a stale response will always be revalidated. - Range requests and partial content (206 status, Range/Content-Range headers) are not supported; requests with Range headers bypass caching. ### Integration with FrameworkBundle ```yaml framework: http_client: caching: cache_pool: my_taggable_pool shared: true max_ttl: 86400 scoped_clients: caching_client: base_uri: 'https://symfony.com' caching: cache_pool: my_taggable_pool ``` It is also **highly recommended** to configure a retry strategy to automatically retry the request in case of cache inconsistency. Commits ------- a62b547 [HttpClient] Make `CachingHttpClient` compatible with RFC 9111
2 parents 37029cc + a62b547 commit 9c413a3

File tree

17 files changed

+1914
-99
lines changed

17 files changed

+1914
-99
lines changed

UPGRADE-7.4.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ HttpClient
5757
----------
5858

5959
* Deprecate using amphp/http-client < 5
60+
* Deprecate passing an instance of `StoreInterface` as `$cache` argument to `CachingHttpClient` constructor
6061

6162
HttpFoundation
6263
--------------

src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ CHANGELOG
1212
* Add `framework.type_info.aliases` option
1313
* Add `KernelBrowser::getSession()`
1414
* Add support for configuring workflow places with glob patterns matching consts/backed enums
15+
* Add support for configuring the `CachingHttpClient`
1516

1617
7.3
1718
---

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1995,6 +1995,7 @@ private function addHttpClientSection(ArrayNodeDefinition $rootNode, callable $e
19951995
->defaultNull()
19961996
->info('Rate limiter name to use for throttling requests.')
19971997
->end()
1998+
->append($this->createHttpClientCachingSection())
19981999
->append($this->createHttpClientRetrySection())
19992000
->end()
20002001
->end()
@@ -2140,6 +2141,7 @@ private function addHttpClientSection(ArrayNodeDefinition $rootNode, callable $e
21402141
->defaultNull()
21412142
->info('Rate limiter name to use for throttling requests.')
21422143
->end()
2144+
->append($this->createHttpClientCachingSection())
21432145
->append($this->createHttpClientRetrySection())
21442146
->end()
21452147
->end()
@@ -2150,6 +2152,33 @@ private function addHttpClientSection(ArrayNodeDefinition $rootNode, callable $e
21502152
;
21512153
}
21522154

2155+
private function createHttpClientCachingSection(): ArrayNodeDefinition
2156+
{
2157+
$root = new NodeBuilder();
2158+
2159+
return $root
2160+
->arrayNode('caching')
2161+
->info('Caching configuration.')
2162+
->canBeEnabled()
2163+
->addDefaultsIfNotSet()
2164+
->children()
2165+
->stringNode('cache_pool')
2166+
->info('The taggable cache pool to use for storing the responses.')
2167+
->defaultValue('cache.http_client')
2168+
->cannotBeEmpty()
2169+
->end()
2170+
->booleanNode('shared')
2171+
->info('Indicates whether the cache is shared (public) or private.')
2172+
->defaultTrue()
2173+
->end()
2174+
->integerNode('max_ttl')
2175+
->info('The maximum TTL (in seconds) allowed for cached responses. Null means no cap.')
2176+
->defaultNull()
2177+
->min(0)
2178+
->end()
2179+
->end();
2180+
}
2181+
21532182
private function createHttpClientRetrySection(): ArrayNodeDefinition
21542183
{
21552184
$root = new NodeBuilder();

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@
9393
use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
9494
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
9595
use Symfony\Component\HtmlSanitizer\HtmlSanitizerInterface;
96+
use Symfony\Component\HttpClient\CachingHttpClient;
97+
use Symfony\Component\HttpClient\Exception\ChunkCacheItemNotFoundException;
9698
use Symfony\Component\HttpClient\Messenger\PingWebhookMessageHandler;
9799
use Symfony\Component\HttpClient\MockHttpClient;
98100
use Symfony\Component\HttpClient\Retry\GenericRetryStrategy;
@@ -2770,6 +2772,8 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder
27702772
$loader->load('http_client.php');
27712773

27722774
$options = $config['default_options'] ?? [];
2775+
$cachingOptions = $options['caching'] ?? ['enabled' => false];
2776+
unset($options['caching']);
27732777
$rateLimiter = $options['rate_limiter'] ?? null;
27742778
unset($options['rate_limiter']);
27752779
$retryOptions = $options['retry_failed'] ?? ['enabled' => false];
@@ -2793,6 +2797,10 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder
27932797
$container->removeAlias(HttpClient::class);
27942798
}
27952799

2800+
if ($this->readConfigEnabled('http_client.caching', $container, $cachingOptions)) {
2801+
$this->registerCachingHttpClient($cachingOptions, $options, 'http_client', $container);
2802+
}
2803+
27962804
if (null !== $rateLimiter) {
27972805
$this->registerThrottlingHttpClient($rateLimiter, 'http_client', $container);
27982806
}
@@ -2818,6 +2826,8 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder
28182826

28192827
$scope = $scopeConfig['scope'] ?? null;
28202828
unset($scopeConfig['scope']);
2829+
$cachingOptions = $scopeConfig['caching'] ?? ['enabled' => false];
2830+
unset($scopeConfig['caching']);
28212831
$rateLimiter = $scopeConfig['rate_limiter'] ?? null;
28222832
unset($scopeConfig['rate_limiter']);
28232833
$retryOptions = $scopeConfig['retry_failed'] ?? ['enabled' => false];
@@ -2841,6 +2851,10 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder
28412851
;
28422852
}
28432853

2854+
if ($this->readConfigEnabled('http_client.scoped_clients.'.$name.'.caching', $container, $cachingOptions)) {
2855+
$this->registerCachingHttpClient($cachingOptions, $scopeConfig, $name, $container);
2856+
}
2857+
28442858
if (null !== $rateLimiter) {
28452859
$this->registerThrottlingHttpClient($rateLimiter, $name, $container);
28462860
}
@@ -2882,6 +2896,24 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder
28822896
}
28832897
}
28842898

2899+
private function registerCachingHttpClient(array $options, array $defaultOptions, string $name, ContainerBuilder $container): void
2900+
{
2901+
if (!class_exists(ChunkCacheItemNotFoundException::class)) {
2902+
throw new LogicException('Caching cannot be enabled as version 7.3+ of the HttpClient component is required.');
2903+
}
2904+
2905+
$container
2906+
->register($name.'.caching', CachingHttpClient::class)
2907+
->setDecoratedService($name, null, 13) // between RetryableHttpClient (10) and ThrottlingHttpClient (15)
2908+
->setArguments([
2909+
new Reference($name.'.caching.inner'),
2910+
new Reference($options['cache_pool']),
2911+
$defaultOptions,
2912+
$options['shared'],
2913+
$options['max_ttl'],
2914+
]);
2915+
}
2916+
28852917
private function registerThrottlingHttpClient(string $rateLimiter, string $name, ContainerBuilder $container): void
28862918
{
28872919
if (!class_exists(ThrottlingHttpClient::class)) {

src/Symfony/Bundle/FrameworkBundle/Resources/config/http_client.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use Psr\Http\Client\ClientInterface;
1616
use Psr\Http\Message\ResponseFactoryInterface;
1717
use Psr\Http\Message\StreamFactoryInterface;
18+
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
1819
use Symfony\Component\HttpClient\HttpClient;
1920
use Symfony\Component\HttpClient\HttplugClient;
2021
use Symfony\Component\HttpClient\Messenger\PingWebhookMessageHandler;
@@ -25,6 +26,14 @@
2526

2627
return static function (ContainerConfigurator $container) {
2728
$container->services()
29+
->set('cache.http_client.pool')
30+
->parent('cache.app')
31+
->tag('cache.pool')
32+
33+
->set('cache.http_client', TagAwareAdapter::class)
34+
->args([service('cache.http_client.pool')])
35+
->tag('cache.taggable', ['pool' => 'cache.http_client.pool'])
36+
2837
->set('http_client.transport', HttpClientInterface::class)
2938
->factory([HttpClient::class, 'create'])
3039
->args([

src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -730,6 +730,7 @@
730730
<xsd:element name="header" type="http_header" minOccurs="0" maxOccurs="unbounded" />
731731
<xsd:element name="var" type="http_var" minOccurs="0" maxOccurs="unbounded" />
732732
<xsd:element name="peer-fingerprint" type="fingerprint" minOccurs="0" maxOccurs="unbounded" />
733+
<xsd:element name="caching" type="http_client_caching" minOccurs="0" maxOccurs="1" />
733734
<xsd:element name="retry-failed" type="http_client_retry_failed" minOccurs="0" maxOccurs="1" />
734735
<xsd:element name="extra" type="xsd:anyType" minOccurs="0" maxOccurs="unbounded" />
735736
</xsd:choice>
@@ -757,6 +758,7 @@
757758
<xsd:element name="resolve" type="http_resolve" minOccurs="0" maxOccurs="unbounded" />
758759
<xsd:element name="header" type="http_header" minOccurs="0" maxOccurs="unbounded" />
759760
<xsd:element name="peer-fingerprint" type="fingerprint" minOccurs="0" maxOccurs="unbounded" />
761+
<xsd:element name="caching" type="http_client_caching" minOccurs="0" maxOccurs="1" />
760762
<xsd:element name="retry-failed" type="http_client_retry_failed" minOccurs="0" maxOccurs="1" />
761763
<xsd:element name="extra" type="xsd:anyType" minOccurs="0" maxOccurs="unbounded" />
762764
</xsd:choice>
@@ -790,6 +792,13 @@
790792
</xsd:choice>
791793
</xsd:complexType>
792794

795+
<xsd:complexType name="http_client_caching">
796+
<xsd:attribute name="enabled" type="xsd:boolean" />
797+
<xsd:attribute name="cache-pool" type="xsd:string" />
798+
<xsd:attribute name="shared" type="xsd:boolean" />
799+
<xsd:attribute name="max-ttl" type="xsd:unsignedInt" />
800+
</xsd:complexType>
801+
793802
<xsd:complexType name="http_client_retry_failed">
794803
<xsd:sequence>
795804
<xsd:element name="http-code" type="http_client_retry_code" minOccurs="0" maxOccurs="unbounded" />
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
$container->loadFromExtension('framework', [
4+
'annotations' => false,
5+
'http_method_override' => false,
6+
'handle_all_throwables' => true,
7+
'php_errors' => ['log' => true],
8+
'http_client' => [
9+
'default_options' => [
10+
'headers' => ['X-powered' => 'PHP'],
11+
'caching' => [
12+
'cache_pool' => 'foo',
13+
'shared' => false,
14+
'max_ttl' => 2,
15+
],
16+
],
17+
'scoped_clients' => [
18+
'bar' => [
19+
'base_uri' => 'http://example.com',
20+
'caching' => ['cache_pool' => 'baz'],
21+
],
22+
],
23+
],
24+
]);
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?xml version="1.0" encoding="utf-8" ?>
2+
<container xmlns="http://symfony.com/schema/dic/services"
3+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4+
xmlns:framework="http://symfony.com/schema/dic/symfony"
5+
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd
6+
http://symfony.com/schema/dic/symfony http://symfony.com/schema/dic/symfony/symfony-1.0.xsd">
7+
8+
<framework:config http-method-override="false" handle-all-throwables="true">
9+
<framework:annotations enabled="false" />
10+
<framework:php-errors log="true" />
11+
<framework:http-client>
12+
<framework:default-options>
13+
<framework:header name="X-powered">PHP</framework:header>
14+
<framework:caching enabled="true" cache-pool="foo" shared="false" max-ttl="2"/>
15+
</framework:default-options>
16+
<framework:scoped-client name="bar" base-uri="http://example.com">
17+
<framework:caching cache-pool="baz"/>
18+
</framework:scoped-client>
19+
</framework:http-client>
20+
</framework:config>
21+
</container>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
framework:
2+
annotations: false
3+
http_method_override: false
4+
handle_all_throwables: true
5+
php_errors:
6+
log: true
7+
http_client:
8+
default_options:
9+
headers:
10+
X-powered: PHP
11+
caching:
12+
cache_pool: foo
13+
shared: false
14+
max_ttl: 2
15+
scoped_clients:
16+
bar:
17+
base_uri: http://example.com
18+
caching:
19+
cache_pool: baz

src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@
5454
use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
5555
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
5656
use Symfony\Component\HtmlSanitizer\HtmlSanitizerInterface;
57+
use Symfony\Component\HttpClient\CachingHttpClient;
58+
use Symfony\Component\HttpClient\Exception\ChunkCacheItemNotFoundException;
5759
use Symfony\Component\HttpClient\MockHttpClient;
5860
use Symfony\Component\HttpClient\RetryableHttpClient;
5961
use Symfony\Component\HttpClient\ScopingHttpClient;
@@ -2185,6 +2187,39 @@ public function testHttpClientOverrideDefaultOptions()
21852187
$this->assertEquals($expected, $container->getDefinition('foo')->getArgument(2));
21862188
}
21872189

2190+
public function testCachingHttpClient()
2191+
{
2192+
if (!class_exists(ChunkCacheItemNotFoundException::class)) {
2193+
$this->expectException(LogicException::class);
2194+
}
2195+
2196+
$container = $this->createContainerFromFile('http_client_caching');
2197+
2198+
$this->assertTrue($container->hasDefinition('http_client.caching'));
2199+
$definition = $container->getDefinition('http_client.caching');
2200+
$this->assertSame(CachingHttpClient::class, $definition->getClass());
2201+
$this->assertSame('http_client', $definition->getDecoratedService()[0]);
2202+
$this->assertCount(5, $arguments = $definition->getArguments());
2203+
$this->assertInstanceOf(Reference::class, $arguments[0]);
2204+
$this->assertSame('http_client.caching.inner', (string) $arguments[0]);
2205+
$this->assertInstanceOf(Reference::class, $arguments[1]);
2206+
$this->assertSame('foo', (string) $arguments[1]);
2207+
$this->assertArrayHasKey('headers', $arguments[2]);
2208+
$this->assertSame(['X-powered' => 'PHP'], $arguments[2]['headers']);
2209+
$this->assertFalse($arguments[3]);
2210+
$this->assertSame(2, $arguments[4]);
2211+
2212+
$this->assertTrue($container->hasDefinition('bar.caching'));
2213+
$definition = $container->getDefinition('bar.caching');
2214+
$this->assertSame(CachingHttpClient::class, $definition->getClass());
2215+
$this->assertSame('bar', $definition->getDecoratedService()[0]);
2216+
$arguments = $definition->getArguments();
2217+
$this->assertInstanceOf(Reference::class, $arguments[0]);
2218+
$this->assertSame('bar.caching.inner', (string) $arguments[0]);
2219+
$this->assertInstanceOf(Reference::class, $arguments[1]);
2220+
$this->assertSame('baz', (string) $arguments[1]);
2221+
}
2222+
21882223
public function testHttpClientRetry()
21892224
{
21902225
$container = $this->createContainerFromFile('http_client_retry');

0 commit comments

Comments
 (0)