From fe9ef738096295b7f0278ead989b65bf4b2a27ae Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Sun, 10 May 2026 01:43:13 +0100 Subject: [PATCH 1/5] Add ETag-only cache mode --- CHANGELOG.md | 6 + README.md | 10 ++ src/CachePlugin.php | 62 ++++++-- tests/Cache/CachePluginTest.php | 266 ++++++++++++++++++++++++++++++++ 4 files changed, 327 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49d3a30..7d4a55c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ # Version 2 +## 2.1.0 - TBD + +### Added + +- Added an `etag_only` option to cache only ETag-backed responses and always revalidate them before serving cached bodies. + ## 2.0.2 - 2025-12-01 - Support Symfony 8. diff --git a/README.md b/README.md index f4f9f43..78d621b 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,16 @@ composer require php-http/cache-plugin Please see the [official documentation](http://docs.php-http.org/en/latest/plugins/cache.html). +To only cache ETag-backed responses and always revalidate cached bodies with `If-None-Match`, enable `etag_only`: + +``` php +$plugin = CachePlugin::clientCache($pool, $streamFactory, [ + 'etag_only' => true, +]); +``` + +When this option is enabled, responses without an `ETag` header are not cached. Cached responses are only returned after the origin replies with `304 Not Modified`. + ## Testing diff --git a/src/CachePlugin.php b/src/CachePlugin.php index 6afb03e..cc39809 100644 --- a/src/CachePlugin.php +++ b/src/CachePlugin.php @@ -54,6 +54,7 @@ final class CachePlugin implements Plugin * * bool respect_cache_headers: Whether to look at the cache directives or ignore them * int default_ttl: (seconds) If we do not respect cache headers or can't calculate a good ttl, use this value + * bool etag_only: Only cache responses with ETag headers and always revalidate them with If-None-Match * string hash_algo: The hashing algorithm to use when generating cache keys * int|null cache_lifetime: (seconds) To support serving a previous stale response when the server answers 304 * we have to store the cache for a longer time than the server originally says it is valid for. @@ -143,29 +144,35 @@ protected function doHandleRequest(RequestInterface $request, callable $next, ca if ($cacheItem->isHit()) { $data = $cacheItem->get(); if (is_array($data)) { - // The array_key_exists() is to be removed in 2.0. - if (array_key_exists('expiresAt', $data) && (null === $data['expiresAt'] || time() < $data['expiresAt'])) { - // This item is still valid according to previous cache headers - $response = $this->createResponseFromCacheItem($cacheItem); - $response = $this->handleCacheListeners($request, $response, true, $cacheItem); - - return new FulfilledPromise($response); - } - - // Add headers to ask the server if this cache is still valid - if ($modifiedSinceValue = $this->getModifiedSinceHeaderValue($cacheItem)) { - $request = $request->withHeader('If-Modified-Since', $modifiedSinceValue); - } - - if ($etag = $this->getETag($cacheItem)) { - $request = $request->withHeader('If-None-Match', $etag); + if ($this->config['etag_only']) { + if ($etag = $this->getETag($cacheItem)) { + $request = $request->withHeader('If-None-Match', $etag); + } + } else { + // The array_key_exists() is to be removed in 2.0. + if (array_key_exists('expiresAt', $data) && (null === $data['expiresAt'] || time() < $data['expiresAt'])) { + // This item is still valid according to previous cache headers + $response = $this->createResponseFromCacheItem($cacheItem); + $response = $this->handleCacheListeners($request, $response, true, $cacheItem); + + return new FulfilledPromise($response); + } + + // Add headers to ask the server if this cache is still valid + if ($modifiedSinceValue = $this->getModifiedSinceHeaderValue($cacheItem)) { + $request = $request->withHeader('If-Modified-Since', $modifiedSinceValue); + } + + if ($etag = $this->getETag($cacheItem)) { + $request = $request->withHeader('If-None-Match', $etag); + } } } } return $next($request)->then(function (ResponseInterface $response) use ($request, $cacheItem) { if (304 === $response->getStatusCode()) { - if (!$cacheItem->isHit()) { + if (!$cacheItem->isHit() || ($this->config['etag_only'] && !$this->getETag($cacheItem))) { /* * We do not have the item in cache. This plugin did not add If-Modified-Since * or If-None-Match headers. Return the response from server. @@ -239,6 +246,10 @@ private function calculateCacheItemExpiresAfter(?int $maxAge): ?int */ private function calculateResponseExpiresAt(?int $maxAge): ?int { + if ($this->config['etag_only']) { + return 0; + } + if (null === $maxAge) { return null; } @@ -257,6 +268,10 @@ protected function isCacheable(ResponseInterface $response) return false; } + if ($this->config['etag_only'] && !$this->responseHasETag($response)) { + return false; + } + $nocacheDirectives = array_intersect($this->config['respect_response_cache_directives'], $this->noCacheFlags); foreach ($nocacheDirectives as $nocacheDirective) { if ($this->getCacheControlDirective($response, $nocacheDirective)) { @@ -352,6 +367,7 @@ private function configureOptions(OptionsResolver $resolver): void $resolver->setDefaults([ 'cache_lifetime' => 86400 * 30, // 30 days 'default_ttl' => 0, + 'etag_only' => false, // Deprecated as of v1.3, to be removed in v2.0. Use respect_response_cache_directives instead 'respect_cache_headers' => null, 'hash_algo' => 'sha1', @@ -364,6 +380,7 @@ private function configureOptions(OptionsResolver $resolver): void $resolver->setAllowedTypes('cache_lifetime', ['int', 'null']); $resolver->setAllowedTypes('default_ttl', ['int', 'null']); + $resolver->setAllowedTypes('etag_only', 'bool'); $resolver->setAllowedTypes('respect_cache_headers', ['bool', 'null']); $resolver->setAllowedTypes('methods', 'array'); $resolver->setAllowedTypes('cache_key_generator', ['null', CacheKeyGenerator::class]); @@ -411,6 +428,17 @@ private function createResponseFromCacheItem(CacheItemInterface $cacheItem): Res return $response->withBody($stream); } + private function responseHasETag(ResponseInterface $response): bool + { + foreach ($response->getHeader('ETag') as $etag) { + if ('' !== trim($etag)) { + return true; + } + } + + return false; + } + /** * Get the value for the "If-Modified-Since" header. */ diff --git a/tests/Cache/CachePluginTest.php b/tests/Cache/CachePluginTest.php index 90e1d13..53dcc4a 100644 --- a/tests/Cache/CachePluginTest.php +++ b/tests/Cache/CachePluginTest.php @@ -246,6 +246,19 @@ public function invalidMethodProvider(): array ]; } + public function testEtagOnlyRequiresBoolean(): void + { + $this->expectException(InvalidOptionsException::class); + + $this->createPlugin( + $this->createMock(CacheItemPoolInterface::class), + $this->createMock(StreamFactoryInterface::class), + [ + 'etag_only' => 'yes', + ] + ); + } + public function testCalculateAgeFromResponse(): void { $httpBody = 'body'; @@ -362,6 +375,259 @@ public function testSaveEtag(): void self::assertSame($response, $result); } + public function testEtagOnlyDoesNotCacheResponseWithoutEtag(): void + { + $stream = $this->createMock(StreamInterface::class); + $stream->method('__toString')->willReturn(''); + + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $streamFactory->expects($this->never())->method('createStream'); + + $uri = $this->createMock(UriInterface::class); + $uri->method('__toString')->willReturn('https://example.com/'); + + $request = $this->createMock(RequestInterface::class); + $request->method('getBody')->willReturn($stream); + $request->method('getMethod')->willReturn('GET'); + $request->method('getUri')->willReturn($uri); + + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + $response->method('getHeader')->willReturn([]); + + $item = $this->createMock(CacheItemInterface::class); + $item->method('isHit')->willReturn(false); + $item->expects($this->never())->method('set'); + + $pool = $this->createMock(CacheItemPoolInterface::class); + $pool->expects($this->once())->method('getItem')->willReturn($item); + $pool->expects($this->never())->method('save'); + + $plugin = $this->createPlugin($pool, $streamFactory, [ + 'etag_only' => true, + ]); + + $result = $plugin->handleRequest($request, $this->createFulfilledNext($response), function () { + })->wait(); + + self::assertSame($response, $result); + } + + public function testEtagOnlyCachesResponseWithEtag(): void + { + $httpBody = 'body'; + + $stream = $this->createMock(StreamInterface::class); + $stream->method('__toString')->willReturn($httpBody); + $stream->method('isSeekable')->willReturn(true); + $stream->expects($this->once())->method('rewind'); + $stream->expects($this->once())->method('detach'); + + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $streamFactory->expects($this->once())->method('createStream')->with($httpBody)->willReturn($stream); + + $uri = $this->createMock(UriInterface::class); + $uri->method('__toString')->willReturn('https://example.com/'); + + $request = $this->createMock(RequestInterface::class); + $request->method('getBody')->willReturn($stream); + $request->method('getMethod')->willReturn('GET'); + $request->method('getUri')->willReturn($uri); + + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn($stream); + $response->method('getHeader')->willReturnCallback(function ($header) { + if ('ETag' === $header) { + return ['foo_etag']; + } + + return []; + }); + $response->expects($this->once())->method('withBody')->with($stream)->willReturnSelf(); + + $item = $this->createMock(CacheItemInterface::class); + $item->method('isHit')->willReturn(false); + $item->expects($this->once())->method('expiresAfter')->with(1060)->willReturnSelf(); + $item->expects($this->once())->method('set')->with($this->cacheItemConstraint([ + 'response' => $response, + 'body' => $httpBody, + 'expiresAt' => 0, + 'createdAt' => 0, + 'etag' => ['foo_etag'], + ]))->willReturnSelf(); + + $pool = $this->createMock(CacheItemPoolInterface::class); + $pool->expects($this->once())->method('getItem')->willReturn($item); + $pool->expects($this->once())->method('save')->with($item); + + $plugin = $this->createPlugin($pool, $streamFactory, [ + 'etag_only' => true, + ]); + + $result = $plugin->handleRequest($request, $this->createFulfilledNext($response), function () { + })->wait(); + + self::assertSame($response, $result); + } + + public function testEtagOnlyAlwaysRevalidatesCachedResponse(): void + { + $stream = $this->createMock(StreamInterface::class); + $stream->method('__toString')->willReturn(''); + + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $streamFactory->expects($this->never())->method('createStream'); + + $uri = $this->createMock(UriInterface::class); + $uri->method('__toString')->willReturn('https://example.com/'); + + $request = $this->createMock(RequestInterface::class); + $request->method('getMethod')->willReturn('GET'); + $request->method('getUri')->willReturn($uri); + $request->method('getBody')->willReturn($stream); + $request->expects($this->once())->method('withHeader')->with('If-None-Match', 'foo_etag')->willReturnSelf(); + + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + $response->method('getHeader')->willReturn([]); + + $cachedResponse = $this->createMock(ResponseInterface::class); + + $item = $this->createMock(CacheItemInterface::class); + $item->method('isHit')->willReturn(true); + $item->method('get')->willReturn([ + 'response' => $cachedResponse, + 'body' => 'cached', + 'expiresAt' => time() + 1000000, + 'createdAt' => 4711, + 'etag' => ['foo_etag'], + ]); + $item->expects($this->never())->method('set'); + + $pool = $this->createMock(CacheItemPoolInterface::class); + $pool->expects($this->once())->method('getItem')->willReturn($item); + $pool->expects($this->never())->method('save'); + + $plugin = $this->createPlugin($pool, $streamFactory, [ + 'etag_only' => true, + ]); + + $result = $plugin->handleRequest($request, $this->createFulfilledNext($response), function () { + })->wait(); + + self::assertSame($response, $result); + } + + public function testEtagOnlyServesCachedResponseAfterNotModified(): void + { + $httpBody = 'body'; + + $requestBody = $this->createMock(StreamInterface::class); + $requestBody->method('__toString')->willReturn(''); + + $cachedBody = $this->createMock(StreamInterface::class); + $cachedBody->expects($this->once())->method('rewind'); + + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $streamFactory->expects($this->once())->method('createStream')->with($httpBody)->willReturn($cachedBody); + + $uri = $this->createMock(UriInterface::class); + $uri->method('__toString')->willReturn('https://example.com/'); + + $request = $this->createMock(RequestInterface::class); + $request->method('getMethod')->willReturn('GET'); + $request->method('getUri')->willReturn($uri); + $request->method('getBody')->willReturn($requestBody); + $request->expects($this->once())->method('withHeader')->with('If-None-Match', 'foo_etag')->willReturnSelf(); + + $notModifiedResponse = $this->createMock(ResponseInterface::class); + $notModifiedResponse->method('getStatusCode')->willReturn(304); + $notModifiedResponse->method('getHeader')->willReturn([]); + + $cachedResponse = $this->createMock(ResponseInterface::class); + $cachedResponse->expects($this->once())->method('withBody')->with($cachedBody)->willReturnSelf(); + + $item = $this->createMock(CacheItemInterface::class); + $item->method('isHit')->willReturn(true); + $item->method('get')->willReturn([ + 'response' => $cachedResponse, + 'body' => $httpBody, + 'expiresAt' => 0, + 'createdAt' => 4711, + 'etag' => ['foo_etag'], + ]); + $item->expects($this->once())->method('expiresAfter')->with(1060)->willReturnSelf(); + $item->expects($this->once())->method('set')->with($this->cacheItemConstraint([ + 'response' => $cachedResponse, + 'body' => $httpBody, + 'expiresAt' => 0, + 'createdAt' => 0, + 'etag' => ['foo_etag'], + ]))->willReturnSelf(); + + $pool = $this->createMock(CacheItemPoolInterface::class); + $pool->expects($this->once())->method('getItem')->willReturn($item); + $pool->expects($this->once())->method('save')->with($item); + + $plugin = $this->createPlugin($pool, $streamFactory, [ + 'etag_only' => true, + ]); + + $result = $plugin->handleRequest($request, $this->createFulfilledNext($notModifiedResponse), function () { + })->wait(); + + self::assertSame($cachedResponse, $result); + } + + public function testEtagOnlyIgnoresCachedResponseWithoutEtag(): void + { + $stream = $this->createMock(StreamInterface::class); + $stream->method('__toString')->willReturn(''); + + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $streamFactory->expects($this->never())->method('createStream'); + + $uri = $this->createMock(UriInterface::class); + $uri->method('__toString')->willReturn('https://example.com/'); + + $request = $this->createMock(RequestInterface::class); + $request->method('getMethod')->willReturn('GET'); + $request->method('getUri')->willReturn($uri); + $request->method('getBody')->willReturn($stream); + $request->expects($this->never())->method('withHeader'); + + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + $response->method('getHeader')->willReturn([]); + + $cachedResponse = $this->createMock(ResponseInterface::class); + + $item = $this->createMock(CacheItemInterface::class); + $item->method('isHit')->willReturn(true); + $item->method('get')->willReturn([ + 'response' => $cachedResponse, + 'body' => 'cached', + 'expiresAt' => time() + 1000000, + 'createdAt' => 4711, + 'etag' => [], + ]); + $item->expects($this->never())->method('set'); + + $pool = $this->createMock(CacheItemPoolInterface::class); + $pool->expects($this->once())->method('getItem')->willReturn($item); + $pool->expects($this->never())->method('save'); + + $plugin = $this->createPlugin($pool, $streamFactory, [ + 'etag_only' => true, + ]); + + $result = $plugin->handleRequest($request, $this->createFulfilledNext($response), function () { + })->wait(); + + self::assertSame($response, $result); + } + public function testAddEtagAndModifiedSinceToRequest(): void { $httpBody = 'body'; From e0492c5c0cc3861db2659cd6ac7da87cd5e6a3c6 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Mon, 11 May 2026 12:16:43 +0100 Subject: [PATCH 2/5] Refactor ETag cache mode into separate plugin --- CHANGELOG.md | 2 +- README.md | 8 +- src/AbstractCachePlugin.php | 473 ++++++++++++++++++++++++++++ src/CachePlugin.php | 452 +------------------------- src/EtagCachePlugin.php | 75 +++++ tests/Cache/CachePluginTest.php | 266 ---------------- tests/Cache/EtagCachePluginTest.php | 314 ++++++++++++++++++ 7 files changed, 868 insertions(+), 722 deletions(-) create mode 100644 src/AbstractCachePlugin.php create mode 100644 src/EtagCachePlugin.php create mode 100644 tests/Cache/EtagCachePluginTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d4a55c..2e6f6ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ ### Added -- Added an `etag_only` option to cache only ETag-backed responses and always revalidate them before serving cached bodies. +- Added `EtagCachePlugin` to cache only ETag-backed responses and always revalidate them before serving cached bodies. ## 2.0.2 - 2025-12-01 diff --git a/README.md b/README.md index 78d621b..537660a 100644 --- a/README.md +++ b/README.md @@ -23,15 +23,13 @@ composer require php-http/cache-plugin Please see the [official documentation](http://docs.php-http.org/en/latest/plugins/cache.html). -To only cache ETag-backed responses and always revalidate cached bodies with `If-None-Match`, enable `etag_only`: +To only cache ETag-backed responses and always revalidate cached bodies with `If-None-Match`, use `EtagCachePlugin`: ``` php -$plugin = CachePlugin::clientCache($pool, $streamFactory, [ - 'etag_only' => true, -]); +$plugin = EtagCachePlugin::clientCache($pool, $streamFactory); ``` -When this option is enabled, responses without an `ETag` header are not cached. Cached responses are only returned after the origin replies with `304 Not Modified`. +Responses without an `ETag` header are not cached. Cached responses are only returned after the origin replies with `304 Not Modified`. ## Testing diff --git a/src/AbstractCachePlugin.php b/src/AbstractCachePlugin.php new file mode 100644 index 0000000..692fb1e --- /dev/null +++ b/src/AbstractCachePlugin.php @@ -0,0 +1,473 @@ +pool = $pool; + $this->streamFactory = $streamFactory; + + if (\array_key_exists('respect_cache_headers', $config) && \array_key_exists('respect_response_cache_directives', $config)) { + throw new \InvalidArgumentException('You can\'t provide config option "respect_cache_headers" and "respect_response_cache_directives". Use "respect_response_cache_directives" instead.'); + } + + $optionsResolver = new OptionsResolver(); + $this->configureOptions($optionsResolver); + $this->config = $optionsResolver->resolve($config); + + if (null === $this->config['cache_key_generator']) { + $this->config['cache_key_generator'] = new SimpleGenerator(); + } + } + + /** + * @param mixed[] $config + * + * @return mixed[] + */ + protected static function prepareClientCacheConfig(array $config): array + { + // Allow caching of private requests + if (\array_key_exists('respect_response_cache_directives', $config)) { + $config['respect_response_cache_directives'][] = 'no-cache'; + $config['respect_response_cache_directives'][] = 'max-age'; + $config['respect_response_cache_directives'] = array_unique($config['respect_response_cache_directives']); + } else { + $config['respect_response_cache_directives'] = ['no-cache', 'max-age']; + } + + return $config; + } + + /** + * {@inheritdoc} + * + * @return Promise Resolves a PSR-7 Response or fails with an Http\Client\Exception (The same as HttpAsyncClient) + */ + protected function doHandleRequest(RequestInterface $request, callable $next, callable $first) + { + $method = strtoupper($request->getMethod()); + // if the request not is cachable, move to $next + if (!in_array($method, $this->config['methods'])) { + return $next($request)->then(function (ResponseInterface $response) use ($request) { + $response = $this->handleCacheListeners($request, $response, false, null); + + return $response; + }); + } + + // If we can cache the request + $key = $this->createCacheKey($request); + $cacheItem = $this->pool->getItem($key); + + if ($cacheItem->isHit()) { + $data = $cacheItem->get(); + if (is_array($data)) { + if ($this->shouldUseCachedResponse($data)) { + // This item is still valid according to previous cache headers + $response = $this->createResponseFromCacheItem($cacheItem); + $response = $this->handleCacheListeners($request, $response, true, $cacheItem); + + return new FulfilledPromise($response); + } + + $request = $this->withCacheValidationHeaders($request, $cacheItem); + } + } + + return $next($request)->then(function (ResponseInterface $response) use ($request, $cacheItem) { + if (304 === $response->getStatusCode()) { + if (!$this->canUseCacheItemForNotModifiedResponse($cacheItem)) { + /* + * We do not have the item in cache. This plugin did not add If-Modified-Since + * or If-None-Match headers. Return the response from server. + */ + return $this->handleCacheListeners($request, $response, false, $cacheItem); + } + + // The cached response we have is still valid + $data = $cacheItem->get(); + $maxAge = $this->getMaxAge($response); + $data['expiresAt'] = $this->calculateResponseExpiresAt($maxAge); + $cacheItem->set($data)->expiresAfter($this->calculateCacheItemExpiresAfter($maxAge)); + $this->pool->save($cacheItem); + + return $this->handleCacheListeners($request, $this->createResponseFromCacheItem($cacheItem), true, $cacheItem); + } + + if ($this->isCacheable($response) && $this->isCacheableRequest($request)) { + /* The PSR-7 response body is a stream. We can't expect that the response implements Serializable and handles the body. + * Therefore we store the body separately and detach the stream to avoid attempting to serialize a resource. + .* Our implementation still makes the assumption that the response object apart from the body can be serialized and deserialized. + */ + $bodyStream = $response->getBody(); + $body = $bodyStream->__toString(); + $bodyStream->detach(); + + $maxAge = $this->getMaxAge($response); + $cacheItem + ->expiresAfter($this->calculateCacheItemExpiresAfter($maxAge)) + ->set([ + 'response' => $response, + 'body' => $body, + 'expiresAt' => $this->calculateResponseExpiresAt($maxAge), + 'createdAt' => time(), + 'etag' => $response->getHeader('ETag'), + ]); + $this->pool->save($cacheItem); + + $bodyStream = $this->streamFactory->createStream($body); + if ($bodyStream->isSeekable()) { + $bodyStream->rewind(); + } + + $response = $response->withBody($bodyStream); + } + + return $this->handleCacheListeners($request, $response, false, $cacheItem); + }); + } + + /** + * Calculate the timestamp when this cache item should be dropped from the cache. The lowest value that can be + * returned is $maxAge. + * + * @return int|null Unix system time passed to the PSR-6 cache + */ + private function calculateCacheItemExpiresAfter(?int $maxAge): ?int + { + if (null === $this->config['cache_lifetime'] && null === $maxAge) { + return null; + } + + return ($this->config['cache_lifetime'] ?: 0) + ($maxAge ?: 0); + } + + /** + * Calculate the timestamp when a response expires. After that timestamp, we need to send a + * If-Modified-Since / If-None-Match request to validate the response. + * + * @return int|null Unix system time. A null value means that the response expires when the cache item expires + */ + protected function calculateResponseExpiresAt(?int $maxAge): ?int + { + if (null === $maxAge) { + return null; + } + + return time() + $maxAge; + } + + /** + * Verify that we can cache this response. + * + * @return bool + */ + protected function isCacheable(ResponseInterface $response) + { + if (!in_array($response->getStatusCode(), [200, 203, 300, 301, 302, 404, 410])) { + return false; + } + + $nocacheDirectives = array_intersect($this->config['respect_response_cache_directives'], $this->noCacheFlags); + foreach ($nocacheDirectives as $nocacheDirective) { + if ($this->getCacheControlDirective($response, $nocacheDirective)) { + return false; + } + } + + return true; + } + + /** + * Verify that we can cache this request. + */ + private function isCacheableRequest(RequestInterface $request): bool + { + $uri = $request->getUri()->__toString(); + foreach ($this->config['blacklisted_paths'] as $regex) { + if (1 === preg_match($regex, $uri)) { + return false; + } + } + + return true; + } + + /** + * Get the value of a parameter in the cache control header. + * + * @param string $name The field of Cache-Control to fetch + * + * @return bool|string The value of the directive, true if directive without value, false if directive not present + */ + private function getCacheControlDirective(ResponseInterface $response, string $name) + { + $headers = $response->getHeader('Cache-Control'); + foreach ($headers as $header) { + if (preg_match(sprintf('|%s=?([0-9]+)?|i', $name), $header, $matches)) { + // return the value for $name if it exists + if (isset($matches[1])) { + return $matches[1]; + } + + return true; + } + } + + return false; + } + + private function createCacheKey(RequestInterface $request): string + { + $key = $this->config['cache_key_generator']->generate($request); + + return hash($this->config['hash_algo'], $key); + } + + /** + * Get a ttl in seconds. + * + * Returns null if we do not respect cache headers and got no defaultTtl. + */ + private function getMaxAge(ResponseInterface $response): ?int + { + if (!in_array('max-age', $this->config['respect_response_cache_directives'], true)) { + return $this->config['default_ttl']; + } + + // check for max age in the Cache-Control header + $maxAge = $this->getCacheControlDirective($response, 'max-age'); + if (!is_bool($maxAge)) { + $ageHeaders = $response->getHeader('Age'); + foreach ($ageHeaders as $age) { + return ((int) $maxAge) - ((int) $age); + } + + return (int) $maxAge; + } + + // check for ttl in the Expires header + $headers = $response->getHeader('Expires'); + foreach ($headers as $header) { + return (new \DateTime($header))->getTimestamp() - (new \DateTime())->getTimestamp(); + } + + return $this->config['default_ttl']; + } + + /** + * Configure an options resolver. + */ + private function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'cache_lifetime' => 86400 * 30, // 30 days + 'default_ttl' => 0, + // Deprecated as of v1.3, to be removed in v2.0. Use respect_response_cache_directives instead + 'respect_cache_headers' => null, + 'hash_algo' => 'sha1', + 'methods' => ['GET', 'HEAD'], + 'respect_response_cache_directives' => ['no-cache', 'private', 'max-age', 'no-store'], + 'cache_key_generator' => null, + 'cache_listeners' => [], + 'blacklisted_paths' => [], + ]); + + $resolver->setAllowedTypes('cache_lifetime', ['int', 'null']); + $resolver->setAllowedTypes('default_ttl', ['int', 'null']); + $resolver->setAllowedTypes('respect_cache_headers', ['bool', 'null']); + $resolver->setAllowedTypes('methods', 'array'); + $resolver->setAllowedTypes('cache_key_generator', ['null', CacheKeyGenerator::class]); + $resolver->setAllowedTypes('blacklisted_paths', 'array'); + $resolver->setAllowedValues('hash_algo', hash_algos()); + $resolver->setAllowedValues('methods', function ($value) { + /* RFC7230 sections 3.1.1 and 3.2.6 except limited to uppercase characters. */ + $matches = preg_grep('/[^A-Z0-9!#$%&\'*+\-.^_`|~]/', $value); + + return empty($matches); + }); + $resolver->setAllowedTypes('cache_listeners', ['array']); + + $resolver->setNormalizer('respect_cache_headers', function (Options $options, $value) { + if (null !== $value) { + @trigger_error('The option "respect_cache_headers" is deprecated since version 1.3 and will be removed in 2.0. Use "respect_response_cache_directives" instead.', E_USER_DEPRECATED); + } + + return null === $value ? true : $value; + }); + + $resolver->setNormalizer('respect_response_cache_directives', function (Options $options, $value) { + if (false === $options['respect_cache_headers']) { + return []; + } + + return $value; + }); + } + + private function createResponseFromCacheItem(CacheItemInterface $cacheItem): ResponseInterface + { + $data = $cacheItem->get(); + + /** @var ResponseInterface $response */ + $response = $data['response']; + $stream = $this->streamFactory->createStream($data['body']); + + try { + $stream->rewind(); + } catch (\Exception $e) { + throw new RewindStreamException('Cannot rewind stream.', 0, $e); + } + + return $response->withBody($stream); + } + + protected function responseHasETag(ResponseInterface $response): bool + { + foreach ($response->getHeader('ETag') as $etag) { + if ('' !== trim($etag)) { + return true; + } + } + + return false; + } + + /** + * Get the value for the "If-Modified-Since" header. + */ + private function getModifiedSinceHeaderValue(CacheItemInterface $cacheItem): ?string + { + $data = $cacheItem->get(); + // The isset() is to be removed in 2.0. + if (!isset($data['createdAt'])) { + return null; + } + + $modified = new \DateTime('@'.$data['createdAt']); + $modified->setTimezone(new \DateTimeZone('GMT')); + + return sprintf('%s GMT', $modified->format('l, d-M-y H:i:s')); + } + + /** + * Get the ETag from the cached response. + */ + protected function getETag(CacheItemInterface $cacheItem): ?string + { + $data = $cacheItem->get(); + // The isset() is to be removed in 2.0. + if (!isset($data['etag'])) { + return null; + } + + foreach ($data['etag'] as $etag) { + if (!empty($etag)) { + return $etag; + } + } + + return null; + } + + /** + * Call the registered cache listeners. + */ + private function handleCacheListeners(RequestInterface $request, ResponseInterface $response, bool $cacheHit, ?CacheItemInterface $cacheItem): ResponseInterface + { + foreach ($this->config['cache_listeners'] as $cacheListener) { + $response = $cacheListener->onCacheResponse($request, $response, $cacheHit, $cacheItem); + } + + return $response; + } + + /** + * @param mixed[] $data + */ + protected function shouldUseCachedResponse(array $data): bool + { + // The array_key_exists() is to be removed in 2.0. + return array_key_exists('expiresAt', $data) && (null === $data['expiresAt'] || time() < $data['expiresAt']); + } + + protected function withCacheValidationHeaders(RequestInterface $request, CacheItemInterface $cacheItem): RequestInterface + { + // Add headers to ask the server if this cache is still valid + if ($modifiedSinceValue = $this->getModifiedSinceHeaderValue($cacheItem)) { + $request = $request->withHeader('If-Modified-Since', $modifiedSinceValue); + } + + if ($etag = $this->getETag($cacheItem)) { + $request = $request->withHeader('If-None-Match', $etag); + } + + return $request; + } + + protected function canUseCacheItemForNotModifiedResponse(CacheItemInterface $cacheItem): bool + { + return $cacheItem->isHit(); + } +} diff --git a/src/CachePlugin.php b/src/CachePlugin.php index cc39809..bd36225 100644 --- a/src/CachePlugin.php +++ b/src/CachePlugin.php @@ -2,19 +2,8 @@ namespace Http\Client\Common\Plugin; -use Http\Client\Common\Plugin; -use Http\Client\Common\Plugin\Exception\RewindStreamException; -use Http\Client\Common\Plugin\Cache\Generator\CacheKeyGenerator; -use Http\Client\Common\Plugin\Cache\Generator\SimpleGenerator; -use Http\Promise\FulfilledPromise; -use Http\Promise\Promise; -use Psr\Cache\CacheItemInterface; use Psr\Cache\CacheItemPoolInterface; -use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamFactoryInterface; -use Symfony\Component\OptionsResolver\Options; -use Symfony\Component\OptionsResolver\OptionsResolver; /** * Allow for caching a response with a PSR-6 compatible caching engine. @@ -23,68 +12,8 @@ * * @author Tobias Nyholm */ -final class CachePlugin implements Plugin +final class CachePlugin extends AbstractCachePlugin { - use VersionBridgePlugin; - - /** - * @var CacheItemPoolInterface - */ - private $pool; - - /** - * @var StreamFactoryInterface - */ - private $streamFactory; - - /** - * @var mixed[] - */ - private $config; - - /** - * Cache directives indicating if a response can not be cached. - * - * @var string[] - */ - private $noCacheFlags = ['no-cache', 'private', 'no-store']; - - /** - * @param mixed[] $config - * - * bool respect_cache_headers: Whether to look at the cache directives or ignore them - * int default_ttl: (seconds) If we do not respect cache headers or can't calculate a good ttl, use this value - * bool etag_only: Only cache responses with ETag headers and always revalidate them with If-None-Match - * string hash_algo: The hashing algorithm to use when generating cache keys - * int|null cache_lifetime: (seconds) To support serving a previous stale response when the server answers 304 - * we have to store the cache for a longer time than the server originally says it is valid for. - * We store a cache item for $cache_lifetime + max age of the response. - * string[] methods: list of request methods which can be cached - * string[] blacklisted_paths: list of regex for URLs explicitly not to be cached - * string[] respect_response_cache_directives: list of cache directives this plugin will respect while caching responses - * CacheKeyGenerator cache_key_generator: an object to generate the cache key. Defaults to a new instance of SimpleGenerator - * CacheListener[] cache_listeners: an array of objects to act on the response based on the results of the cache check. - * Defaults to an empty array - * } - */ - public function __construct(CacheItemPoolInterface $pool, StreamFactoryInterface $streamFactory, array $config = []) - { - $this->pool = $pool; - $this->streamFactory = $streamFactory; - - if (\array_key_exists('respect_cache_headers', $config) && \array_key_exists('respect_response_cache_directives', $config)) { - throw new \InvalidArgumentException('You can\'t provide config option "respect_cache_headers" and "respect_response_cache_directives". Use "respect_response_cache_directives" instead.'); - } - - $optionsResolver = new OptionsResolver(); - $this->configureOptions($optionsResolver); - $this->config = $optionsResolver->resolve($config); - - if (null === $this->config['cache_key_generator']) { - $this->config['cache_key_generator'] = new SimpleGenerator(); - } - } - /** * This method will setup the cachePlugin in client cache mode. When using the client cache mode the plugin will * cache responses with `private` cache directive. @@ -95,16 +24,7 @@ public function __construct(CacheItemPoolInterface $pool, StreamFactoryInterface */ public static function clientCache(CacheItemPoolInterface $pool, StreamFactoryInterface $streamFactory, array $config = []) { - // Allow caching of private requests - if (\array_key_exists('respect_response_cache_directives', $config)) { - $config['respect_response_cache_directives'][] = 'no-cache'; - $config['respect_response_cache_directives'][] = 'max-age'; - $config['respect_response_cache_directives'] = array_unique($config['respect_response_cache_directives']); - } else { - $config['respect_response_cache_directives'] = ['no-cache', 'max-age']; - } - - return new self($pool, $streamFactory, $config); + return new self($pool, $streamFactory, self::prepareClientCacheConfig($config)); } /** @@ -119,372 +39,4 @@ public static function serverCache(CacheItemPoolInterface $pool, StreamFactoryIn { return new self($pool, $streamFactory, $config); } - - /** - * {@inheritdoc} - * - * @return Promise Resolves a PSR-7 Response or fails with an Http\Client\Exception (The same as HttpAsyncClient) - */ - protected function doHandleRequest(RequestInterface $request, callable $next, callable $first) - { - $method = strtoupper($request->getMethod()); - // if the request not is cachable, move to $next - if (!in_array($method, $this->config['methods'])) { - return $next($request)->then(function (ResponseInterface $response) use ($request) { - $response = $this->handleCacheListeners($request, $response, false, null); - - return $response; - }); - } - - // If we can cache the request - $key = $this->createCacheKey($request); - $cacheItem = $this->pool->getItem($key); - - if ($cacheItem->isHit()) { - $data = $cacheItem->get(); - if (is_array($data)) { - if ($this->config['etag_only']) { - if ($etag = $this->getETag($cacheItem)) { - $request = $request->withHeader('If-None-Match', $etag); - } - } else { - // The array_key_exists() is to be removed in 2.0. - if (array_key_exists('expiresAt', $data) && (null === $data['expiresAt'] || time() < $data['expiresAt'])) { - // This item is still valid according to previous cache headers - $response = $this->createResponseFromCacheItem($cacheItem); - $response = $this->handleCacheListeners($request, $response, true, $cacheItem); - - return new FulfilledPromise($response); - } - - // Add headers to ask the server if this cache is still valid - if ($modifiedSinceValue = $this->getModifiedSinceHeaderValue($cacheItem)) { - $request = $request->withHeader('If-Modified-Since', $modifiedSinceValue); - } - - if ($etag = $this->getETag($cacheItem)) { - $request = $request->withHeader('If-None-Match', $etag); - } - } - } - } - - return $next($request)->then(function (ResponseInterface $response) use ($request, $cacheItem) { - if (304 === $response->getStatusCode()) { - if (!$cacheItem->isHit() || ($this->config['etag_only'] && !$this->getETag($cacheItem))) { - /* - * We do not have the item in cache. This plugin did not add If-Modified-Since - * or If-None-Match headers. Return the response from server. - */ - return $this->handleCacheListeners($request, $response, false, $cacheItem); - } - - // The cached response we have is still valid - $data = $cacheItem->get(); - $maxAge = $this->getMaxAge($response); - $data['expiresAt'] = $this->calculateResponseExpiresAt($maxAge); - $cacheItem->set($data)->expiresAfter($this->calculateCacheItemExpiresAfter($maxAge)); - $this->pool->save($cacheItem); - - return $this->handleCacheListeners($request, $this->createResponseFromCacheItem($cacheItem), true, $cacheItem); - } - - if ($this->isCacheable($response) && $this->isCacheableRequest($request)) { - /* The PSR-7 response body is a stream. We can't expect that the response implements Serializable and handles the body. - * Therefore we store the body separately and detach the stream to avoid attempting to serialize a resource. - .* Our implementation still makes the assumption that the response object apart from the body can be serialized and deserialized. - */ - $bodyStream = $response->getBody(); - $body = $bodyStream->__toString(); - $bodyStream->detach(); - - $maxAge = $this->getMaxAge($response); - $cacheItem - ->expiresAfter($this->calculateCacheItemExpiresAfter($maxAge)) - ->set([ - 'response' => $response, - 'body' => $body, - 'expiresAt' => $this->calculateResponseExpiresAt($maxAge), - 'createdAt' => time(), - 'etag' => $response->getHeader('ETag'), - ]); - $this->pool->save($cacheItem); - - $bodyStream = $this->streamFactory->createStream($body); - if ($bodyStream->isSeekable()) { - $bodyStream->rewind(); - } - - $response = $response->withBody($bodyStream); - } - - return $this->handleCacheListeners($request, $response, false, $cacheItem); - }); - } - - /** - * Calculate the timestamp when this cache item should be dropped from the cache. The lowest value that can be - * returned is $maxAge. - * - * @return int|null Unix system time passed to the PSR-6 cache - */ - private function calculateCacheItemExpiresAfter(?int $maxAge): ?int - { - if (null === $this->config['cache_lifetime'] && null === $maxAge) { - return null; - } - - return ($this->config['cache_lifetime'] ?: 0) + ($maxAge ?: 0); - } - - /** - * Calculate the timestamp when a response expires. After that timestamp, we need to send a - * If-Modified-Since / If-None-Match request to validate the response. - * - * @return int|null Unix system time. A null value means that the response expires when the cache item expires - */ - private function calculateResponseExpiresAt(?int $maxAge): ?int - { - if ($this->config['etag_only']) { - return 0; - } - - if (null === $maxAge) { - return null; - } - - return time() + $maxAge; - } - - /** - * Verify that we can cache this response. - * - * @return bool - */ - protected function isCacheable(ResponseInterface $response) - { - if (!in_array($response->getStatusCode(), [200, 203, 300, 301, 302, 404, 410])) { - return false; - } - - if ($this->config['etag_only'] && !$this->responseHasETag($response)) { - return false; - } - - $nocacheDirectives = array_intersect($this->config['respect_response_cache_directives'], $this->noCacheFlags); - foreach ($nocacheDirectives as $nocacheDirective) { - if ($this->getCacheControlDirective($response, $nocacheDirective)) { - return false; - } - } - - return true; - } - - /** - * Verify that we can cache this request. - */ - private function isCacheableRequest(RequestInterface $request): bool - { - $uri = $request->getUri()->__toString(); - foreach ($this->config['blacklisted_paths'] as $regex) { - if (1 === preg_match($regex, $uri)) { - return false; - } - } - - return true; - } - - /** - * Get the value of a parameter in the cache control header. - * - * @param string $name The field of Cache-Control to fetch - * - * @return bool|string The value of the directive, true if directive without value, false if directive not present - */ - private function getCacheControlDirective(ResponseInterface $response, string $name) - { - $headers = $response->getHeader('Cache-Control'); - foreach ($headers as $header) { - if (preg_match(sprintf('|%s=?([0-9]+)?|i', $name), $header, $matches)) { - // return the value for $name if it exists - if (isset($matches[1])) { - return $matches[1]; - } - - return true; - } - } - - return false; - } - - private function createCacheKey(RequestInterface $request): string - { - $key = $this->config['cache_key_generator']->generate($request); - - return hash($this->config['hash_algo'], $key); - } - - /** - * Get a ttl in seconds. - * - * Returns null if we do not respect cache headers and got no defaultTtl. - */ - private function getMaxAge(ResponseInterface $response): ?int - { - if (!in_array('max-age', $this->config['respect_response_cache_directives'], true)) { - return $this->config['default_ttl']; - } - - // check for max age in the Cache-Control header - $maxAge = $this->getCacheControlDirective($response, 'max-age'); - if (!is_bool($maxAge)) { - $ageHeaders = $response->getHeader('Age'); - foreach ($ageHeaders as $age) { - return ((int) $maxAge) - ((int) $age); - } - - return (int) $maxAge; - } - - // check for ttl in the Expires header - $headers = $response->getHeader('Expires'); - foreach ($headers as $header) { - return (new \DateTime($header))->getTimestamp() - (new \DateTime())->getTimestamp(); - } - - return $this->config['default_ttl']; - } - - /** - * Configure an options resolver. - */ - private function configureOptions(OptionsResolver $resolver): void - { - $resolver->setDefaults([ - 'cache_lifetime' => 86400 * 30, // 30 days - 'default_ttl' => 0, - 'etag_only' => false, - // Deprecated as of v1.3, to be removed in v2.0. Use respect_response_cache_directives instead - 'respect_cache_headers' => null, - 'hash_algo' => 'sha1', - 'methods' => ['GET', 'HEAD'], - 'respect_response_cache_directives' => ['no-cache', 'private', 'max-age', 'no-store'], - 'cache_key_generator' => null, - 'cache_listeners' => [], - 'blacklisted_paths' => [], - ]); - - $resolver->setAllowedTypes('cache_lifetime', ['int', 'null']); - $resolver->setAllowedTypes('default_ttl', ['int', 'null']); - $resolver->setAllowedTypes('etag_only', 'bool'); - $resolver->setAllowedTypes('respect_cache_headers', ['bool', 'null']); - $resolver->setAllowedTypes('methods', 'array'); - $resolver->setAllowedTypes('cache_key_generator', ['null', CacheKeyGenerator::class]); - $resolver->setAllowedTypes('blacklisted_paths', 'array'); - $resolver->setAllowedValues('hash_algo', hash_algos()); - $resolver->setAllowedValues('methods', function ($value) { - /* RFC7230 sections 3.1.1 and 3.2.6 except limited to uppercase characters. */ - $matches = preg_grep('/[^A-Z0-9!#$%&\'*+\-.^_`|~]/', $value); - - return empty($matches); - }); - $resolver->setAllowedTypes('cache_listeners', ['array']); - - $resolver->setNormalizer('respect_cache_headers', function (Options $options, $value) { - if (null !== $value) { - @trigger_error('The option "respect_cache_headers" is deprecated since version 1.3 and will be removed in 2.0. Use "respect_response_cache_directives" instead.', E_USER_DEPRECATED); - } - - return null === $value ? true : $value; - }); - - $resolver->setNormalizer('respect_response_cache_directives', function (Options $options, $value) { - if (false === $options['respect_cache_headers']) { - return []; - } - - return $value; - }); - } - - private function createResponseFromCacheItem(CacheItemInterface $cacheItem): ResponseInterface - { - $data = $cacheItem->get(); - - /** @var ResponseInterface $response */ - $response = $data['response']; - $stream = $this->streamFactory->createStream($data['body']); - - try { - $stream->rewind(); - } catch (\Exception $e) { - throw new RewindStreamException('Cannot rewind stream.', 0, $e); - } - - return $response->withBody($stream); - } - - private function responseHasETag(ResponseInterface $response): bool - { - foreach ($response->getHeader('ETag') as $etag) { - if ('' !== trim($etag)) { - return true; - } - } - - return false; - } - - /** - * Get the value for the "If-Modified-Since" header. - */ - private function getModifiedSinceHeaderValue(CacheItemInterface $cacheItem): ?string - { - $data = $cacheItem->get(); - // The isset() is to be removed in 2.0. - if (!isset($data['createdAt'])) { - return null; - } - - $modified = new \DateTime('@'.$data['createdAt']); - $modified->setTimezone(new \DateTimeZone('GMT')); - - return sprintf('%s GMT', $modified->format('l, d-M-y H:i:s')); - } - - /** - * Get the ETag from the cached response. - */ - private function getETag(CacheItemInterface $cacheItem): ?string - { - $data = $cacheItem->get(); - // The isset() is to be removed in 2.0. - if (!isset($data['etag'])) { - return null; - } - - foreach ($data['etag'] as $etag) { - if (!empty($etag)) { - return $etag; - } - } - - return null; - } - - /** - * Call the registered cache listeners. - */ - private function handleCacheListeners(RequestInterface $request, ResponseInterface $response, bool $cacheHit, ?CacheItemInterface $cacheItem): ResponseInterface - { - foreach ($this->config['cache_listeners'] as $cacheListener) { - $response = $cacheListener->onCacheResponse($request, $response, $cacheHit, $cacheItem); - } - - return $response; - } } diff --git a/src/EtagCachePlugin.php b/src/EtagCachePlugin.php new file mode 100644 index 0000000..2bdcba0 --- /dev/null +++ b/src/EtagCachePlugin.php @@ -0,0 +1,75 @@ +responseHasETag($response); + } + + /** + * @param mixed[] $data + */ + protected function shouldUseCachedResponse(array $data): bool + { + return false; + } + + protected function withCacheValidationHeaders(RequestInterface $request, CacheItemInterface $cacheItem): RequestInterface + { + if ($etag = $this->getETag($cacheItem)) { + $request = $request->withHeader('If-None-Match', $etag); + } + + return $request; + } + + protected function canUseCacheItemForNotModifiedResponse(CacheItemInterface $cacheItem): bool + { + return $cacheItem->isHit() && null !== $this->getETag($cacheItem); + } +} diff --git a/tests/Cache/CachePluginTest.php b/tests/Cache/CachePluginTest.php index 53dcc4a..90e1d13 100644 --- a/tests/Cache/CachePluginTest.php +++ b/tests/Cache/CachePluginTest.php @@ -246,19 +246,6 @@ public function invalidMethodProvider(): array ]; } - public function testEtagOnlyRequiresBoolean(): void - { - $this->expectException(InvalidOptionsException::class); - - $this->createPlugin( - $this->createMock(CacheItemPoolInterface::class), - $this->createMock(StreamFactoryInterface::class), - [ - 'etag_only' => 'yes', - ] - ); - } - public function testCalculateAgeFromResponse(): void { $httpBody = 'body'; @@ -375,259 +362,6 @@ public function testSaveEtag(): void self::assertSame($response, $result); } - public function testEtagOnlyDoesNotCacheResponseWithoutEtag(): void - { - $stream = $this->createMock(StreamInterface::class); - $stream->method('__toString')->willReturn(''); - - $streamFactory = $this->createMock(StreamFactoryInterface::class); - $streamFactory->expects($this->never())->method('createStream'); - - $uri = $this->createMock(UriInterface::class); - $uri->method('__toString')->willReturn('https://example.com/'); - - $request = $this->createMock(RequestInterface::class); - $request->method('getBody')->willReturn($stream); - $request->method('getMethod')->willReturn('GET'); - $request->method('getUri')->willReturn($uri); - - $response = $this->createMock(ResponseInterface::class); - $response->method('getStatusCode')->willReturn(200); - $response->method('getHeader')->willReturn([]); - - $item = $this->createMock(CacheItemInterface::class); - $item->method('isHit')->willReturn(false); - $item->expects($this->never())->method('set'); - - $pool = $this->createMock(CacheItemPoolInterface::class); - $pool->expects($this->once())->method('getItem')->willReturn($item); - $pool->expects($this->never())->method('save'); - - $plugin = $this->createPlugin($pool, $streamFactory, [ - 'etag_only' => true, - ]); - - $result = $plugin->handleRequest($request, $this->createFulfilledNext($response), function () { - })->wait(); - - self::assertSame($response, $result); - } - - public function testEtagOnlyCachesResponseWithEtag(): void - { - $httpBody = 'body'; - - $stream = $this->createMock(StreamInterface::class); - $stream->method('__toString')->willReturn($httpBody); - $stream->method('isSeekable')->willReturn(true); - $stream->expects($this->once())->method('rewind'); - $stream->expects($this->once())->method('detach'); - - $streamFactory = $this->createMock(StreamFactoryInterface::class); - $streamFactory->expects($this->once())->method('createStream')->with($httpBody)->willReturn($stream); - - $uri = $this->createMock(UriInterface::class); - $uri->method('__toString')->willReturn('https://example.com/'); - - $request = $this->createMock(RequestInterface::class); - $request->method('getBody')->willReturn($stream); - $request->method('getMethod')->willReturn('GET'); - $request->method('getUri')->willReturn($uri); - - $response = $this->createMock(ResponseInterface::class); - $response->method('getStatusCode')->willReturn(200); - $response->method('getBody')->willReturn($stream); - $response->method('getHeader')->willReturnCallback(function ($header) { - if ('ETag' === $header) { - return ['foo_etag']; - } - - return []; - }); - $response->expects($this->once())->method('withBody')->with($stream)->willReturnSelf(); - - $item = $this->createMock(CacheItemInterface::class); - $item->method('isHit')->willReturn(false); - $item->expects($this->once())->method('expiresAfter')->with(1060)->willReturnSelf(); - $item->expects($this->once())->method('set')->with($this->cacheItemConstraint([ - 'response' => $response, - 'body' => $httpBody, - 'expiresAt' => 0, - 'createdAt' => 0, - 'etag' => ['foo_etag'], - ]))->willReturnSelf(); - - $pool = $this->createMock(CacheItemPoolInterface::class); - $pool->expects($this->once())->method('getItem')->willReturn($item); - $pool->expects($this->once())->method('save')->with($item); - - $plugin = $this->createPlugin($pool, $streamFactory, [ - 'etag_only' => true, - ]); - - $result = $plugin->handleRequest($request, $this->createFulfilledNext($response), function () { - })->wait(); - - self::assertSame($response, $result); - } - - public function testEtagOnlyAlwaysRevalidatesCachedResponse(): void - { - $stream = $this->createMock(StreamInterface::class); - $stream->method('__toString')->willReturn(''); - - $streamFactory = $this->createMock(StreamFactoryInterface::class); - $streamFactory->expects($this->never())->method('createStream'); - - $uri = $this->createMock(UriInterface::class); - $uri->method('__toString')->willReturn('https://example.com/'); - - $request = $this->createMock(RequestInterface::class); - $request->method('getMethod')->willReturn('GET'); - $request->method('getUri')->willReturn($uri); - $request->method('getBody')->willReturn($stream); - $request->expects($this->once())->method('withHeader')->with('If-None-Match', 'foo_etag')->willReturnSelf(); - - $response = $this->createMock(ResponseInterface::class); - $response->method('getStatusCode')->willReturn(200); - $response->method('getHeader')->willReturn([]); - - $cachedResponse = $this->createMock(ResponseInterface::class); - - $item = $this->createMock(CacheItemInterface::class); - $item->method('isHit')->willReturn(true); - $item->method('get')->willReturn([ - 'response' => $cachedResponse, - 'body' => 'cached', - 'expiresAt' => time() + 1000000, - 'createdAt' => 4711, - 'etag' => ['foo_etag'], - ]); - $item->expects($this->never())->method('set'); - - $pool = $this->createMock(CacheItemPoolInterface::class); - $pool->expects($this->once())->method('getItem')->willReturn($item); - $pool->expects($this->never())->method('save'); - - $plugin = $this->createPlugin($pool, $streamFactory, [ - 'etag_only' => true, - ]); - - $result = $plugin->handleRequest($request, $this->createFulfilledNext($response), function () { - })->wait(); - - self::assertSame($response, $result); - } - - public function testEtagOnlyServesCachedResponseAfterNotModified(): void - { - $httpBody = 'body'; - - $requestBody = $this->createMock(StreamInterface::class); - $requestBody->method('__toString')->willReturn(''); - - $cachedBody = $this->createMock(StreamInterface::class); - $cachedBody->expects($this->once())->method('rewind'); - - $streamFactory = $this->createMock(StreamFactoryInterface::class); - $streamFactory->expects($this->once())->method('createStream')->with($httpBody)->willReturn($cachedBody); - - $uri = $this->createMock(UriInterface::class); - $uri->method('__toString')->willReturn('https://example.com/'); - - $request = $this->createMock(RequestInterface::class); - $request->method('getMethod')->willReturn('GET'); - $request->method('getUri')->willReturn($uri); - $request->method('getBody')->willReturn($requestBody); - $request->expects($this->once())->method('withHeader')->with('If-None-Match', 'foo_etag')->willReturnSelf(); - - $notModifiedResponse = $this->createMock(ResponseInterface::class); - $notModifiedResponse->method('getStatusCode')->willReturn(304); - $notModifiedResponse->method('getHeader')->willReturn([]); - - $cachedResponse = $this->createMock(ResponseInterface::class); - $cachedResponse->expects($this->once())->method('withBody')->with($cachedBody)->willReturnSelf(); - - $item = $this->createMock(CacheItemInterface::class); - $item->method('isHit')->willReturn(true); - $item->method('get')->willReturn([ - 'response' => $cachedResponse, - 'body' => $httpBody, - 'expiresAt' => 0, - 'createdAt' => 4711, - 'etag' => ['foo_etag'], - ]); - $item->expects($this->once())->method('expiresAfter')->with(1060)->willReturnSelf(); - $item->expects($this->once())->method('set')->with($this->cacheItemConstraint([ - 'response' => $cachedResponse, - 'body' => $httpBody, - 'expiresAt' => 0, - 'createdAt' => 0, - 'etag' => ['foo_etag'], - ]))->willReturnSelf(); - - $pool = $this->createMock(CacheItemPoolInterface::class); - $pool->expects($this->once())->method('getItem')->willReturn($item); - $pool->expects($this->once())->method('save')->with($item); - - $plugin = $this->createPlugin($pool, $streamFactory, [ - 'etag_only' => true, - ]); - - $result = $plugin->handleRequest($request, $this->createFulfilledNext($notModifiedResponse), function () { - })->wait(); - - self::assertSame($cachedResponse, $result); - } - - public function testEtagOnlyIgnoresCachedResponseWithoutEtag(): void - { - $stream = $this->createMock(StreamInterface::class); - $stream->method('__toString')->willReturn(''); - - $streamFactory = $this->createMock(StreamFactoryInterface::class); - $streamFactory->expects($this->never())->method('createStream'); - - $uri = $this->createMock(UriInterface::class); - $uri->method('__toString')->willReturn('https://example.com/'); - - $request = $this->createMock(RequestInterface::class); - $request->method('getMethod')->willReturn('GET'); - $request->method('getUri')->willReturn($uri); - $request->method('getBody')->willReturn($stream); - $request->expects($this->never())->method('withHeader'); - - $response = $this->createMock(ResponseInterface::class); - $response->method('getStatusCode')->willReturn(200); - $response->method('getHeader')->willReturn([]); - - $cachedResponse = $this->createMock(ResponseInterface::class); - - $item = $this->createMock(CacheItemInterface::class); - $item->method('isHit')->willReturn(true); - $item->method('get')->willReturn([ - 'response' => $cachedResponse, - 'body' => 'cached', - 'expiresAt' => time() + 1000000, - 'createdAt' => 4711, - 'etag' => [], - ]); - $item->expects($this->never())->method('set'); - - $pool = $this->createMock(CacheItemPoolInterface::class); - $pool->expects($this->once())->method('getItem')->willReturn($item); - $pool->expects($this->never())->method('save'); - - $plugin = $this->createPlugin($pool, $streamFactory, [ - 'etag_only' => true, - ]); - - $result = $plugin->handleRequest($request, $this->createFulfilledNext($response), function () { - })->wait(); - - self::assertSame($response, $result); - } - public function testAddEtagAndModifiedSinceToRequest(): void { $httpBody = 'body'; diff --git a/tests/Cache/EtagCachePluginTest.php b/tests/Cache/EtagCachePluginTest.php new file mode 100644 index 0000000..bc09b33 --- /dev/null +++ b/tests/Cache/EtagCachePluginTest.php @@ -0,0 +1,314 @@ + 60, + 'cache_lifetime' => 1000, + ]; + + return new EtagCachePlugin($pool, $streamFactory, array_merge($defaults, $config)); + } + + private function cacheItemConstraint(array $expected): Callback + { + return $this->callback(function ($actual) use ($expected) { + if (!is_array($actual)) { + return false; + } + + foreach ($expected as $key => $value) { + if (!array_key_exists($key, $actual)) { + return false; + } + + if (in_array($key, ['expiresAt', 'createdAt'], true)) { + continue; + } + + if ($actual[$key] !== $value) { + return false; + } + } + + return true; + }); + } + + private function createFulfilledNext(ResponseInterface $response): callable + { + return function (RequestInterface $request) use ($response) { + return new FulfilledPromise($response); + }; + } + + public function testInterface(): void + { + $plugin = $this->createPlugin( + $this->createMock(CacheItemPoolInterface::class), + $this->createMock(StreamFactoryInterface::class) + ); + + self::assertInstanceOf(Plugin::class, $plugin); + } + + public function testDoesNotCacheResponseWithoutEtag(): void + { + $stream = $this->createMock(StreamInterface::class); + $stream->method('__toString')->willReturn(''); + + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $streamFactory->expects($this->never())->method('createStream'); + + $uri = $this->createMock(UriInterface::class); + $uri->method('__toString')->willReturn('https://example.com/'); + + $request = $this->createMock(RequestInterface::class); + $request->method('getBody')->willReturn($stream); + $request->method('getMethod')->willReturn('GET'); + $request->method('getUri')->willReturn($uri); + + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + $response->method('getHeader')->willReturn([]); + + $item = $this->createMock(CacheItemInterface::class); + $item->method('isHit')->willReturn(false); + $item->expects($this->never())->method('set'); + + $pool = $this->createMock(CacheItemPoolInterface::class); + $pool->expects($this->once())->method('getItem')->willReturn($item); + $pool->expects($this->never())->method('save'); + + $plugin = $this->createPlugin($pool, $streamFactory); + + $result = $plugin->handleRequest($request, $this->createFulfilledNext($response), function () { + })->wait(); + + self::assertSame($response, $result); + } + + public function testCachesResponseWithEtag(): void + { + $httpBody = 'body'; + + $stream = $this->createMock(StreamInterface::class); + $stream->method('__toString')->willReturn($httpBody); + $stream->method('isSeekable')->willReturn(true); + $stream->expects($this->once())->method('rewind'); + $stream->expects($this->once())->method('detach'); + + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $streamFactory->expects($this->once())->method('createStream')->with($httpBody)->willReturn($stream); + + $uri = $this->createMock(UriInterface::class); + $uri->method('__toString')->willReturn('https://example.com/'); + + $request = $this->createMock(RequestInterface::class); + $request->method('getBody')->willReturn($stream); + $request->method('getMethod')->willReturn('GET'); + $request->method('getUri')->willReturn($uri); + + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn($stream); + $response->method('getHeader')->willReturnCallback(function ($header) { + if ('ETag' === $header) { + return ['foo_etag']; + } + + return []; + }); + $response->expects($this->once())->method('withBody')->with($stream)->willReturnSelf(); + + $item = $this->createMock(CacheItemInterface::class); + $item->method('isHit')->willReturn(false); + $item->expects($this->once())->method('expiresAfter')->with(1060)->willReturnSelf(); + $item->expects($this->once())->method('set')->with($this->cacheItemConstraint([ + 'response' => $response, + 'body' => $httpBody, + 'expiresAt' => 0, + 'createdAt' => 0, + 'etag' => ['foo_etag'], + ]))->willReturnSelf(); + + $pool = $this->createMock(CacheItemPoolInterface::class); + $pool->expects($this->once())->method('getItem')->willReturn($item); + $pool->expects($this->once())->method('save')->with($item); + + $plugin = $this->createPlugin($pool, $streamFactory); + + $result = $plugin->handleRequest($request, $this->createFulfilledNext($response), function () { + })->wait(); + + self::assertSame($response, $result); + } + + public function testAlwaysRevalidatesCachedResponse(): void + { + $stream = $this->createMock(StreamInterface::class); + $stream->method('__toString')->willReturn(''); + + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $streamFactory->expects($this->never())->method('createStream'); + + $uri = $this->createMock(UriInterface::class); + $uri->method('__toString')->willReturn('https://example.com/'); + + $request = $this->createMock(RequestInterface::class); + $request->method('getMethod')->willReturn('GET'); + $request->method('getUri')->willReturn($uri); + $request->method('getBody')->willReturn($stream); + $request->expects($this->once())->method('withHeader')->with('If-None-Match', 'foo_etag')->willReturnSelf(); + + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + $response->method('getHeader')->willReturn([]); + + $cachedResponse = $this->createMock(ResponseInterface::class); + + $item = $this->createMock(CacheItemInterface::class); + $item->method('isHit')->willReturn(true); + $item->method('get')->willReturn([ + 'response' => $cachedResponse, + 'body' => 'cached', + 'expiresAt' => time() + 1000000, + 'createdAt' => 4711, + 'etag' => ['foo_etag'], + ]); + $item->expects($this->never())->method('set'); + + $pool = $this->createMock(CacheItemPoolInterface::class); + $pool->expects($this->once())->method('getItem')->willReturn($item); + $pool->expects($this->never())->method('save'); + + $plugin = $this->createPlugin($pool, $streamFactory); + + $result = $plugin->handleRequest($request, $this->createFulfilledNext($response), function () { + })->wait(); + + self::assertSame($response, $result); + } + + public function testServesCachedResponseAfterNotModified(): void + { + $httpBody = 'body'; + + $requestBody = $this->createMock(StreamInterface::class); + $requestBody->method('__toString')->willReturn(''); + + $cachedBody = $this->createMock(StreamInterface::class); + $cachedBody->expects($this->once())->method('rewind'); + + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $streamFactory->expects($this->once())->method('createStream')->with($httpBody)->willReturn($cachedBody); + + $uri = $this->createMock(UriInterface::class); + $uri->method('__toString')->willReturn('https://example.com/'); + + $request = $this->createMock(RequestInterface::class); + $request->method('getMethod')->willReturn('GET'); + $request->method('getUri')->willReturn($uri); + $request->method('getBody')->willReturn($requestBody); + $request->expects($this->once())->method('withHeader')->with('If-None-Match', 'foo_etag')->willReturnSelf(); + + $notModifiedResponse = $this->createMock(ResponseInterface::class); + $notModifiedResponse->method('getStatusCode')->willReturn(304); + $notModifiedResponse->method('getHeader')->willReturn([]); + + $cachedResponse = $this->createMock(ResponseInterface::class); + $cachedResponse->expects($this->once())->method('withBody')->with($cachedBody)->willReturnSelf(); + + $item = $this->createMock(CacheItemInterface::class); + $item->method('isHit')->willReturn(true); + $item->method('get')->willReturn([ + 'response' => $cachedResponse, + 'body' => $httpBody, + 'expiresAt' => 0, + 'createdAt' => 4711, + 'etag' => ['foo_etag'], + ]); + $item->expects($this->once())->method('expiresAfter')->with(1060)->willReturnSelf(); + $item->expects($this->once())->method('set')->with($this->cacheItemConstraint([ + 'response' => $cachedResponse, + 'body' => $httpBody, + 'expiresAt' => 0, + 'createdAt' => 0, + 'etag' => ['foo_etag'], + ]))->willReturnSelf(); + + $pool = $this->createMock(CacheItemPoolInterface::class); + $pool->expects($this->once())->method('getItem')->willReturn($item); + $pool->expects($this->once())->method('save')->with($item); + + $plugin = $this->createPlugin($pool, $streamFactory); + + $result = $plugin->handleRequest($request, $this->createFulfilledNext($notModifiedResponse), function () { + })->wait(); + + self::assertSame($cachedResponse, $result); + } + + public function testIgnoresCachedResponseWithoutEtag(): void + { + $stream = $this->createMock(StreamInterface::class); + $stream->method('__toString')->willReturn(''); + + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $streamFactory->expects($this->never())->method('createStream'); + + $uri = $this->createMock(UriInterface::class); + $uri->method('__toString')->willReturn('https://example.com/'); + + $request = $this->createMock(RequestInterface::class); + $request->method('getMethod')->willReturn('GET'); + $request->method('getUri')->willReturn($uri); + $request->method('getBody')->willReturn($stream); + $request->expects($this->never())->method('withHeader'); + + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + $response->method('getHeader')->willReturn([]); + + $cachedResponse = $this->createMock(ResponseInterface::class); + + $item = $this->createMock(CacheItemInterface::class); + $item->method('isHit')->willReturn(true); + $item->method('get')->willReturn([ + 'response' => $cachedResponse, + 'body' => 'cached', + 'expiresAt' => time() + 1000000, + 'createdAt' => 4711, + 'etag' => [], + ]); + $item->expects($this->never())->method('set'); + + $pool = $this->createMock(CacheItemPoolInterface::class); + $pool->expects($this->once())->method('getItem')->willReturn($item); + $pool->expects($this->never())->method('save'); + + $plugin = $this->createPlugin($pool, $streamFactory); + + $result = $plugin->handleRequest($request, $this->createFulfilledNext($response), function () { + })->wait(); + + self::assertSame($response, $result); + } +} From bfc18fe80890aabffc98290f35941f8d24d16a5c Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Mon, 11 May 2026 12:18:18 +0100 Subject: [PATCH 3/5] Fix ETag cache PHPStan return type --- src/AbstractCachePlugin.php | 2 +- src/EtagCachePlugin.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/AbstractCachePlugin.php b/src/AbstractCachePlugin.php index 692fb1e..192139d 100644 --- a/src/AbstractCachePlugin.php +++ b/src/AbstractCachePlugin.php @@ -209,7 +209,7 @@ private function calculateCacheItemExpiresAfter(?int $maxAge): ?int * * @return int|null Unix system time. A null value means that the response expires when the cache item expires */ - protected function calculateResponseExpiresAt(?int $maxAge): ?int + protected function calculateResponseExpiresAt(?int $maxAge) { if (null === $maxAge) { return null; diff --git a/src/EtagCachePlugin.php b/src/EtagCachePlugin.php index 2bdcba0..9d69318 100644 --- a/src/EtagCachePlugin.php +++ b/src/EtagCachePlugin.php @@ -41,7 +41,7 @@ public static function serverCache(CacheItemPoolInterface $pool, StreamFactoryIn return new self($pool, $streamFactory, $config); } - protected function calculateResponseExpiresAt(?int $maxAge): ?int + protected function calculateResponseExpiresAt(?int $maxAge) { return 0; } From 2f635c5d73d2fa1276db5af600741f285c3fbf48 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Mon, 11 May 2026 12:19:43 +0100 Subject: [PATCH 4/5] Clarify ETag expiry return contract --- src/EtagCachePlugin.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/EtagCachePlugin.php b/src/EtagCachePlugin.php index 9d69318..0edc96f 100644 --- a/src/EtagCachePlugin.php +++ b/src/EtagCachePlugin.php @@ -41,6 +41,9 @@ public static function serverCache(CacheItemPoolInterface $pool, StreamFactoryIn return new self($pool, $streamFactory, $config); } + /** + * @return int + */ protected function calculateResponseExpiresAt(?int $maxAge) { return 0; From a10baca693ec88b691322bd051b4c8ecae4ff350 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Mon, 11 May 2026 12:32:22 +0100 Subject: [PATCH 5/5] Make cache helper methods static --- src/AbstractCachePlugin.php | 46 ++++++++++++++++++------------------- src/EtagCachePlugin.php | 14 +++++------ 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/src/AbstractCachePlugin.php b/src/AbstractCachePlugin.php index 192139d..3192499 100644 --- a/src/AbstractCachePlugin.php +++ b/src/AbstractCachePlugin.php @@ -72,7 +72,7 @@ public function __construct(CacheItemPoolInterface $pool, StreamFactoryInterface } $optionsResolver = new OptionsResolver(); - $this->configureOptions($optionsResolver); + self::configureOptions($optionsResolver); $this->config = $optionsResolver->resolve($config); if (null === $this->config['cache_key_generator']) { @@ -123,7 +123,7 @@ protected function doHandleRequest(RequestInterface $request, callable $next, ca if ($cacheItem->isHit()) { $data = $cacheItem->get(); if (is_array($data)) { - if ($this->shouldUseCachedResponse($data)) { + if (static::shouldUseCachedResponse($data)) { // This item is still valid according to previous cache headers $response = $this->createResponseFromCacheItem($cacheItem); $response = $this->handleCacheListeners($request, $response, true, $cacheItem); @@ -131,13 +131,13 @@ protected function doHandleRequest(RequestInterface $request, callable $next, ca return new FulfilledPromise($response); } - $request = $this->withCacheValidationHeaders($request, $cacheItem); + $request = static::withCacheValidationHeaders($request, $cacheItem); } } return $next($request)->then(function (ResponseInterface $response) use ($request, $cacheItem) { if (304 === $response->getStatusCode()) { - if (!$this->canUseCacheItemForNotModifiedResponse($cacheItem)) { + if (!static::canUseCacheItemForNotModifiedResponse($cacheItem)) { /* * We do not have the item in cache. This plugin did not add If-Modified-Since * or If-None-Match headers. Return the response from server. @@ -148,7 +148,7 @@ protected function doHandleRequest(RequestInterface $request, callable $next, ca // The cached response we have is still valid $data = $cacheItem->get(); $maxAge = $this->getMaxAge($response); - $data['expiresAt'] = $this->calculateResponseExpiresAt($maxAge); + $data['expiresAt'] = static::calculateResponseExpiresAt($maxAge); $cacheItem->set($data)->expiresAfter($this->calculateCacheItemExpiresAfter($maxAge)); $this->pool->save($cacheItem); @@ -170,7 +170,7 @@ protected function doHandleRequest(RequestInterface $request, callable $next, ca ->set([ 'response' => $response, 'body' => $body, - 'expiresAt' => $this->calculateResponseExpiresAt($maxAge), + 'expiresAt' => static::calculateResponseExpiresAt($maxAge), 'createdAt' => time(), 'etag' => $response->getHeader('ETag'), ]); @@ -209,7 +209,7 @@ private function calculateCacheItemExpiresAfter(?int $maxAge): ?int * * @return int|null Unix system time. A null value means that the response expires when the cache item expires */ - protected function calculateResponseExpiresAt(?int $maxAge) + protected static function calculateResponseExpiresAt(?int $maxAge) { if (null === $maxAge) { return null; @@ -231,7 +231,7 @@ protected function isCacheable(ResponseInterface $response) $nocacheDirectives = array_intersect($this->config['respect_response_cache_directives'], $this->noCacheFlags); foreach ($nocacheDirectives as $nocacheDirective) { - if ($this->getCacheControlDirective($response, $nocacheDirective)) { + if (self::getCacheControlDirective($response, $nocacheDirective)) { return false; } } @@ -261,7 +261,7 @@ private function isCacheableRequest(RequestInterface $request): bool * * @return bool|string The value of the directive, true if directive without value, false if directive not present */ - private function getCacheControlDirective(ResponseInterface $response, string $name) + private static function getCacheControlDirective(ResponseInterface $response, string $name) { $headers = $response->getHeader('Cache-Control'); foreach ($headers as $header) { @@ -297,7 +297,7 @@ private function getMaxAge(ResponseInterface $response): ?int } // check for max age in the Cache-Control header - $maxAge = $this->getCacheControlDirective($response, 'max-age'); + $maxAge = self::getCacheControlDirective($response, 'max-age'); if (!is_bool($maxAge)) { $ageHeaders = $response->getHeader('Age'); foreach ($ageHeaders as $age) { @@ -319,7 +319,7 @@ private function getMaxAge(ResponseInterface $response): ?int /** * Configure an options resolver. */ - private function configureOptions(OptionsResolver $resolver): void + private static function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ 'cache_lifetime' => 86400 * 30, // 30 days @@ -351,7 +351,7 @@ private function configureOptions(OptionsResolver $resolver): void $resolver->setNormalizer('respect_cache_headers', function (Options $options, $value) { if (null !== $value) { - @trigger_error('The option "respect_cache_headers" is deprecated since version 1.3 and will be removed in 2.0. Use "respect_response_cache_directives" instead.', E_USER_DEPRECATED); + @trigger_error('The option "respect_cache_headers" is deprecated since version 1.3 and will be removed in 3.0. Use "respect_response_cache_directives" instead.', E_USER_DEPRECATED); } return null === $value ? true : $value; @@ -383,7 +383,7 @@ private function createResponseFromCacheItem(CacheItemInterface $cacheItem): Res return $response->withBody($stream); } - protected function responseHasETag(ResponseInterface $response): bool + protected static function responseHasETag(ResponseInterface $response): bool { foreach ($response->getHeader('ETag') as $etag) { if ('' !== trim($etag)) { @@ -397,10 +397,10 @@ protected function responseHasETag(ResponseInterface $response): bool /** * Get the value for the "If-Modified-Since" header. */ - private function getModifiedSinceHeaderValue(CacheItemInterface $cacheItem): ?string + private static function getModifiedSinceHeaderValue(CacheItemInterface $cacheItem): ?string { $data = $cacheItem->get(); - // The isset() is to be removed in 2.0. + // The isset() is to be removed in 3.0. if (!isset($data['createdAt'])) { return null; } @@ -414,10 +414,10 @@ private function getModifiedSinceHeaderValue(CacheItemInterface $cacheItem): ?st /** * Get the ETag from the cached response. */ - protected function getETag(CacheItemInterface $cacheItem): ?string + protected static function getETag(CacheItemInterface $cacheItem): ?string { $data = $cacheItem->get(); - // The isset() is to be removed in 2.0. + // The isset() is to be removed in 3.0. if (!isset($data['etag'])) { return null; } @@ -446,27 +446,27 @@ private function handleCacheListeners(RequestInterface $request, ResponseInterfa /** * @param mixed[] $data */ - protected function shouldUseCachedResponse(array $data): bool + protected static function shouldUseCachedResponse(array $data): bool { - // The array_key_exists() is to be removed in 2.0. + // The array_key_exists() is to be removed in 3.0. return array_key_exists('expiresAt', $data) && (null === $data['expiresAt'] || time() < $data['expiresAt']); } - protected function withCacheValidationHeaders(RequestInterface $request, CacheItemInterface $cacheItem): RequestInterface + protected static function withCacheValidationHeaders(RequestInterface $request, CacheItemInterface $cacheItem): RequestInterface { // Add headers to ask the server if this cache is still valid - if ($modifiedSinceValue = $this->getModifiedSinceHeaderValue($cacheItem)) { + if ($modifiedSinceValue = self::getModifiedSinceHeaderValue($cacheItem)) { $request = $request->withHeader('If-Modified-Since', $modifiedSinceValue); } - if ($etag = $this->getETag($cacheItem)) { + if ($etag = static::getETag($cacheItem)) { $request = $request->withHeader('If-None-Match', $etag); } return $request; } - protected function canUseCacheItemForNotModifiedResponse(CacheItemInterface $cacheItem): bool + protected static function canUseCacheItemForNotModifiedResponse(CacheItemInterface $cacheItem): bool { return $cacheItem->isHit(); } diff --git a/src/EtagCachePlugin.php b/src/EtagCachePlugin.php index 0edc96f..09fda1c 100644 --- a/src/EtagCachePlugin.php +++ b/src/EtagCachePlugin.php @@ -44,35 +44,35 @@ public static function serverCache(CacheItemPoolInterface $pool, StreamFactoryIn /** * @return int */ - protected function calculateResponseExpiresAt(?int $maxAge) + protected static function calculateResponseExpiresAt(?int $maxAge) { return 0; } protected function isCacheable(ResponseInterface $response) { - return parent::isCacheable($response) && $this->responseHasETag($response); + return parent::isCacheable($response) && self::responseHasETag($response); } /** * @param mixed[] $data */ - protected function shouldUseCachedResponse(array $data): bool + protected static function shouldUseCachedResponse(array $data): bool { return false; } - protected function withCacheValidationHeaders(RequestInterface $request, CacheItemInterface $cacheItem): RequestInterface + protected static function withCacheValidationHeaders(RequestInterface $request, CacheItemInterface $cacheItem): RequestInterface { - if ($etag = $this->getETag($cacheItem)) { + if ($etag = self::getETag($cacheItem)) { $request = $request->withHeader('If-None-Match', $etag); } return $request; } - protected function canUseCacheItemForNotModifiedResponse(CacheItemInterface $cacheItem): bool + protected static function canUseCacheItemForNotModifiedResponse(CacheItemInterface $cacheItem): bool { - return $cacheItem->isHit() && null !== $this->getETag($cacheItem); + return $cacheItem->isHit() && null !== self::getETag($cacheItem); } }