Repository: php-http/cache-plugin Branch: 2.x Commit: c7fd57abaf6d Files: 30 Total size: 66.7 KB Directory structure: gitextract_nm_wkhzu/ ├── .editorconfig ├── .gitattributes ├── .github/ │ ├── CONTRIBUTING.md │ ├── ISSUE_TEMPLATE.md │ ├── PULL_REQUEST_TEMPLATE.md │ └── workflows/ │ ├── .editorconfig │ ├── static.yml │ └── tests.yml ├── .gitignore ├── .php_cs ├── .scrutinizer.yml ├── .styleci.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json ├── phpspec.ci.yml ├── phpspec.yml.dist ├── phpstan.neon.dist ├── phpunit.xml.dist ├── src/ │ ├── Cache/ │ │ ├── Generator/ │ │ │ ├── CacheKeyGenerator.php │ │ │ ├── HeaderCacheKeyGenerator.php │ │ │ └── SimpleGenerator.php │ │ └── Listener/ │ │ ├── AddHeaderCacheListener.php │ │ └── CacheListener.php │ ├── CachePlugin.php │ └── Exception/ │ └── RewindStreamException.php └── tests/ └── Cache/ ├── CachePluginTest.php └── Generator/ ├── HeaderCacheKeyGeneratorTest.php └── SimpleGeneratorTest.php ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ root = true [*] charset = utf-8 end_of_line = lf indent_size = 4 indent_style = space insert_final_newline = true trim_trailing_whitespace = true ================================================ FILE: .gitattributes ================================================ .editorconfig export-ignore .gitattributes export-ignore /.github/ export-ignore .gitignore export-ignore /.php_cs export-ignore /.scrutinizer.yml export-ignore /.styleci.yml export-ignore /behat.yml.dist export-ignore /features/ export-ignore /phpspec.ci.yml export-ignore /phpspec.yml.dist export-ignore /spec/ export-ignore ================================================ FILE: .github/CONTRIBUTING.md ================================================ # Contributing Please see our [contributing guide](http://docs.php-http.org/en/latest/development/contributing.html). ================================================ FILE: .github/ISSUE_TEMPLATE.md ================================================ | Q | A | ------------ | --- | Bug? | no|yes | New Feature? | no|yes | Version | Specific version or SHA of a commit #### Actual Behavior What is the actual behavior? #### Expected Behavior What is the behavior you expect? #### Steps to Reproduce What are the steps to reproduce this bug? Please add code examples, screenshots or links to GitHub repositories that reproduce the problem. #### Possible Solutions If you have already ideas how to solve the issue, add them here. (remove this section if not needed) ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ | Q | A | --------------- | --- | Bug fix? | no|yes | New feature? | no|yes | BC breaks? | no|yes | Deprecations? | no|yes | Related tickets | fixes #X, partially #Y, mentioned in #Z | Documentation | if this is a new feature, link to pull request in https://github.com/php-http/documentation that adds relevant documentation | License | MIT #### What's in this PR? Explain what the changes in this PR do. #### Why? Which problem does the PR fix? (remove this section if you linked an issue above) #### Example Usage ``` php // If you added new features, show examples of how to use them here // (remove this section if not a new feature) $foo = new Foo(); // Now we can do $foo->doSomething(); ``` #### Checklist - [ ] Updated CHANGELOG.md to describe BC breaks / deprecations | new feature | bugfix - [ ] Documentation pull request created (if not simply a bugfix) #### To Do - [ ] If the PR is not complete but you want to discuss the approach, list what remains to be done here ================================================ FILE: .github/workflows/.editorconfig ================================================ [*.yml] indent_size = 2 ================================================ FILE: .github/workflows/static.yml ================================================ name: Static analysis on: push: branches: - '*.x' pull_request: jobs: phpstan: name: PHPStan runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: PHPStan uses: docker://oskarstark/phpstan-ga with: args: analyze --no-progress ================================================ FILE: .github/workflows/tests.yml ================================================ name: tests on: push: branches: - '*.x' pull_request: jobs: latest: name: PHP ${{ matrix.php }} Latest runs-on: ubuntu-latest strategy: matrix: php: ['7.1', '7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} tools: composer:v2 coverage: none - name: Install PHP dependencies run: composer update --prefer-dist --no-interaction --no-progress - name: Execute tests run: composer test lowest: name: PHP ${{ matrix.php }} Lowest runs-on: ubuntu-latest strategy: matrix: php: ['7.1', '7.4', '8.0'] steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} tools: composer:v2 coverage: none - name: Install dependencies run: | composer require "sebastian/comparator:^3.0.2" --no-interaction --no-update composer update --prefer-dist --prefer-stable --prefer-lowest --no-interaction --no-progress - name: Execute tests run: composer test coverage: name: Code Coverage runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: 7.4 tools: composer:v2 coverage: xdebug - name: Install dependencies run: | composer require "friends-of-phpspec/phpspec-code-coverage" --no-interaction --no-update composer update --prefer-dist --no-interaction --no-progress - name: Execute tests run: composer test-ci - name: Upload coverage run: | wget https://scrutinizer-ci.com/ocular.phar php ocular.phar code-coverage:upload --format=php-clover build/coverage.xml ================================================ FILE: .gitignore ================================================ /behat.yml /build/ /composer.lock /phpspec.yml /phpunit.xml /vendor/ ================================================ FILE: .php_cs ================================================ false` in combination with `default_ttl=>null`. - We allow `cache_lifetime` to be set to `null`. ## 1.1.0 - 2016-08-04 ### Added - Support for cache validation with ETag and Last-Modified headers. (Enabled automatically when the server sends the relevant headers.) - `hash_algo` config option used for cache key generation (defaults to **sha1**). ### Changed - Default hash algo used for cache generation (from **md5** to **sha1**). ### Fixed - Cast max age header to integer in order to get valid expiration value. ## 1.0.0 - 2016-05-05 - Initial release ================================================ FILE: LICENSE ================================================ Copyright (c) 2015-2016 PHP HTTP Team Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Cache Plugin [![Latest Version](https://img.shields.io/github/release/php-http/cache-plugin.svg?style=flat-square)](https://github.com/php-http/cache-plugin/releases) [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE) [![Build Status](https://github.com/php-http/cache-plugin/actions/workflows/tests.yml/badge.svg)](https://github.com/php-http/cache-plugin/actions/workflows/tests.yml) [![Code Coverage](https://img.shields.io/scrutinizer/coverage/g/php-http/cache-plugin.svg?style=flat-square)](https://scrutinizer-ci.com/g/php-http/cache-plugin) [![Quality Score](https://img.shields.io/scrutinizer/g/php-http/cache-plugin.svg?style=flat-square)](https://scrutinizer-ci.com/g/php-http/cache-plugin) [![Total Downloads](https://img.shields.io/packagist/dt/php-http/cache-plugin.svg?style=flat-square)](https://packagist.org/packages/php-http/cache-plugin) **PSR-6 Cache plugin for HTTPlug.** ## Install Via Composer ``` bash composer require php-http/cache-plugin ``` ## Documentation Please see the [official documentation](http://docs.php-http.org/en/latest/plugins/cache.html). ## Testing ``` bash composer test ``` ## Contributing Please see our [contributing guide](http://docs.php-http.org/en/latest/development/contributing.html). ## Security If you discover any security related issues, please contact us at [security@php-http.org](mailto:security@php-http.org). ## License The MIT License (MIT). Please see [License File](LICENSE) for more information. ================================================ FILE: composer.json ================================================ { "name": "php-http/cache-plugin", "description": "PSR-6 Cache plugin for HTTPlug", "license": "MIT", "keywords": ["cache", "http", "httplug", "plugin"], "homepage": "http://httplug.io", "authors": [ { "name": "Márk Sági-Kazár", "email": "mark.sagikazar@gmail.com" } ], "require": { "php": "^7.1 || ^8.0", "psr/cache": "^1.0 || ^2.0 || ^3.0", "php-http/client-common": "^1.9 || ^2.0", "psr/http-factory-implementation": "^1.0", "symfony/options-resolver": "^2.6 || ^3.0 || ^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0" }, "require-dev": { "nyholm/psr7": "^1.6.1", "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6" }, "autoload": { "psr-4": { "Http\\Client\\Common\\Plugin\\": "src/" } }, "autoload-dev": { "psr-4": { "Http\\Client\\Common\\Plugin\\Tests\\": "tests/" } }, "scripts": { "test": "vendor/bin/phpunit", "test-ci": "vendor/bin/phpunit" } } ================================================ FILE: phpspec.ci.yml ================================================ suites: cache_plugin_suite: namespace: Http\Client\Common\Plugin psr4_prefix: Http\Client\Common\Plugin formatter.name: pretty extensions: FriendsOfPhpSpec\PhpSpec\CodeCoverage\CodeCoverageExtension: ~ code_coverage: format: clover output: build/coverage.xml ================================================ FILE: phpspec.yml.dist ================================================ suites: cache_plugin_suite: namespace: Http\Client\Common\Plugin psr4_prefix: Http\Client\Common\Plugin formatter.name: pretty ================================================ FILE: phpstan.neon.dist ================================================ parameters: level: 8 paths: - src treatPhpDocTypesAsCertain: false ================================================ FILE: phpunit.xml.dist ================================================ tests ================================================ FILE: src/Cache/Generator/CacheKeyGenerator.php ================================================ */ interface CacheKeyGenerator { /** * Generate a cache key from a Request. * * @return string */ public function generate(RequestInterface $request); } ================================================ FILE: src/Cache/Generator/HeaderCacheKeyGenerator.php ================================================ */ class HeaderCacheKeyGenerator implements CacheKeyGenerator { /** * The header names we should take into account when creating the cache key. * * @var string[] */ private $headerNames; /** * @param string[] $headerNames */ public function __construct(array $headerNames) { $this->headerNames = $headerNames; } public function generate(RequestInterface $request) { $concatenatedHeaders = []; foreach ($this->headerNames as $headerName) { $concatenatedHeaders[] = sprintf(' %s:"%s"', $headerName, $request->getHeaderLine($headerName)); } return $request->getMethod().' '.$request->getUri().implode('', $concatenatedHeaders).' '.$request->getBody(); } } ================================================ FILE: src/Cache/Generator/SimpleGenerator.php ================================================ */ class SimpleGenerator implements CacheKeyGenerator { public function generate(RequestInterface $request) { $body = (string) $request->getBody(); if (!empty($body)) { $body = ' '.$body; } return $request->getMethod().' '.$request->getUri().$body; } } ================================================ FILE: src/Cache/Listener/AddHeaderCacheListener.php ================================================ */ class AddHeaderCacheListener implements CacheListener { /** @var string */ private $headerName; /** * @param string $headerName */ public function __construct($headerName = 'X-Cache') { $this->headerName = $headerName; } /** * Called before the cache plugin returns the response, with information on whether that response came from cache. * * @param bool $fromCache Whether the `$response` was from the cache or not. * Note that checking `$cacheItem->isHit()` is not sufficent to determine this. * @param CacheItemInterface|null $cacheItem * * @return ResponseInterface */ public function onCacheResponse(RequestInterface $request, ResponseInterface $response, $fromCache, $cacheItem) { return $response->withHeader($this->headerName, $fromCache ? 'HIT' : 'MISS'); } } ================================================ FILE: src/Cache/Listener/CacheListener.php ================================================ */ interface CacheListener { /** * Called before the cache plugin returns the response, with information on whether that response came from cache. * * @param bool $fromCache Whether the `$response` was from the cache or not. * Note that checking `$cacheItem->isHit()` is not sufficent to determine this. * @param CacheItemInterface|null $cacheItem * * @return ResponseInterface */ public function onCacheResponse(RequestInterface $request, ResponseInterface $response, $fromCache, $cacheItem); } ================================================ FILE: src/CachePlugin.php ================================================ */ final class CachePlugin implements Plugin { 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 * 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. * * @param mixed[] $config For all possible config options see the constructor docs * * @return CachePlugin */ 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); } /** * This method will setup the cachePlugin in server cache mode. This is the default caching behavior it refuses to * cache responses with the `private`or `no-cache` directives. * * @param mixed[] $config For all possible config options see the constructor docs * * @return CachePlugin */ public static function serverCache(CacheItemPoolInterface $pool, StreamFactoryInterface $streamFactory, array $config = []) { 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)) { // 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()) { /* * 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 (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); } /** * 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; } } ================================================ FILE: src/Exception/RewindStreamException.php ================================================ */ class RewindStreamException extends \RuntimeException implements Exception { } ================================================ FILE: tests/Cache/CachePluginTest.php ================================================ 60, 'cache_lifetime' => 1000, ]; return new CachePlugin($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 testCacheResponses(): 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('getMethod')->willReturn('GET'); $request->method('getUri')->willReturn($uri); $request->method('getBody')->willReturn($stream); $response = $this->createMock(ResponseInterface::class); $response->method('getStatusCode')->willReturn(200); $response->method('getBody')->willReturn($stream); $response->method('getHeader')->willReturn([]); $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' => [], ]))->willReturnSelf(); $pool = $this->createMock(CacheItemPoolInterface::class); $pool->expects($this->once())->method('getItem')->with($this->anything())->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 testDoNotStoreFailedResponses(): void { $item = $this->createMock(CacheItemInterface::class); $item->method('isHit')->willReturn(false); $pool = $this->createMock(CacheItemPoolInterface::class); $pool->expects($this->once())->method('getItem')->with($this->anything())->willReturn($item); $pool->expects($this->never())->method('save'); $uri = $this->createMock(UriInterface::class); $uri->method('__toString')->willReturn('https://example.com/'); $requestBody = $this->createMock(StreamInterface::class); $requestBody->method('__toString')->willReturn('body'); $request = $this->createMock(RequestInterface::class); $request->method('getMethod')->willReturn('GET'); $request->method('getUri')->willReturn($uri); $request->method('getBody')->willReturn($requestBody); $response = $this->createMock(ResponseInterface::class); $response->method('getStatusCode')->willReturn(400); $plugin = $this->createPlugin($pool, $this->createMock(StreamFactoryInterface::class)); $result = $plugin->handleRequest($request, $this->createFulfilledNext($response), function () { })->wait(); self::assertSame($response, $result); } public function testDoNotStorePostRequestsByDefault(): void { $pool = $this->createMock(CacheItemPoolInterface::class); $pool->expects($this->never())->method('getItem'); $request = $this->createMock(RequestInterface::class); $request->method('getMethod')->willReturn('POST'); $response = $this->createMock(ResponseInterface::class); $plugin = $this->createPlugin($pool, $this->createMock(StreamFactoryInterface::class)); $result = $plugin->handleRequest($request, $this->createFulfilledNext($response), function () { })->wait(); self::assertSame($response, $result); } public function testStorePostRequestsWhenAllowed(): void { $httpBody = 'hello=world'; $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('getMethod')->willReturn('POST'); $request->method('getUri')->willReturn($uri); $request->method('getBody')->willReturn($stream); $response = $this->createMock(ResponseInterface::class); $response->method('getStatusCode')->willReturn(200); $response->method('getBody')->willReturn($stream); $response->method('getHeader')->willReturn([]); $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' => [], ]))->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, [ 'methods' => ['GET', 'HEAD', 'POST'], ]); $result = $plugin->handleRequest($request, $this->createFulfilledNext($response), function () { })->wait(); self::assertSame($response, $result); } /** * @dataProvider invalidMethodProvider */ public function testDoNotAllowInvalidRequestMethods(array $methods): void { $this->expectException(InvalidOptionsException::class); $this->createPlugin( $this->createMock(CacheItemPoolInterface::class), $this->createMock(StreamFactoryInterface::class), [ 'methods' => $methods, ] ); } public function invalidMethodProvider(): array { return [ [['GET', 'HEAD', 'POST ']], [['GET', 'HEAD"', 'POST']], [['GET', 'head', 'POST']], ]; } public function testCalculateAgeFromResponse(): 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('getMethod')->willReturn('GET'); $request->method('getUri')->willReturn($uri); $request->method('getBody')->willReturn($stream); $response = $this->createMock(ResponseInterface::class); $response->method('getStatusCode')->willReturn(200); $response->method('getBody')->willReturn($stream); $response->method('getHeader')->willReturnCallback(function ($header) { if ('Cache-Control' === $header) { return ['max-age=40']; } if ('Age' === $header) { return ['15']; } 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('set')->with($this->cacheItemConstraint([ 'response' => $response, 'body' => $httpBody, 'expiresAt' => 0, 'createdAt' => 0, 'etag' => [], ]))->willReturnSelf(); $item->expects($this->once())->method('expiresAfter')->with(1025)->willReturnSelf(); $pool = $this->createMock(CacheItemPoolInterface::class); $pool->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 testSaveEtag(): 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 testAddEtagAndModifiedSinceToRequest(): void { $httpBody = 'body'; $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->exactly(2)) ->method('withHeader') ->withConsecutive( ['If-Modified-Since', 'Thursday, 01-Jan-70 01:18:31 GMT'], ['If-None-Match', 'foo_etag'] ) ->willReturnSelf(); $response = $this->createMock(ResponseInterface::class); $response->method('getStatusCode')->willReturn(304); $item = $this->createMock(CacheItemInterface::class); $item->expects($this->exactly(2))->method('isHit')->willReturnOnConsecutiveCalls(true, false); $item->method('get')->willReturn([ 'response' => $response, 'body' => $httpBody, 'expiresAt' => 0, 'createdAt' => 4711, 'etag' => ['foo_etag'], ]); $pool = $this->createMock(CacheItemPoolInterface::class); $pool->expects($this->once())->method('getItem')->willReturn($item); $plugin = $this->createPlugin($pool, $streamFactory); $result = $plugin->handleRequest($request, $this->createFulfilledNext($response), function () { })->wait(); self::assertSame($response, $result); } public function testServeCachedResponse(): void { $httpBody = 'body'; $stream = $this->createMock(StreamInterface::class); $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/'); $requestBody = $this->createMock(StreamInterface::class); $requestBody->method('__toString')->willReturn(''); $request = $this->createMock(RequestInterface::class); $request->method('getMethod')->willReturn('GET'); $request->method('getUri')->willReturn($uri); $request->method('getBody')->willReturn($requestBody); $response = $this->createMock(ResponseInterface::class); $response->expects($this->once())->method('withBody')->with($stream)->willReturnSelf(); $item = $this->createMock(CacheItemInterface::class); $item->method('isHit')->willReturn(true); $item->method('get')->willReturn([ 'response' => $response, 'body' => $httpBody, 'expiresAt' => time() + 1000000, 'createdAt' => 4711, 'etag' => [], ]); $pool = $this->createMock(CacheItemPoolInterface::class); $pool->expects($this->once())->method('getItem')->willReturn($item); $plugin = $this->createPlugin($pool, $streamFactory); $result = $plugin->handleRequest($request, $this->createFulfilledNext($response), function () { })->wait(); self::assertSame($response, $result); } public function testServeAndResaveExpiredResponse(): void { $httpBody = 'body'; $uri = $this->createMock(UriInterface::class); $uri->method('__toString')->willReturn('https://example.com/'); $requestStream = $this->createMock(StreamInterface::class); $requestStream->method('__toString')->willReturn(''); $request = $this->createMock(RequestInterface::class); $request->method('getMethod')->willReturn('GET'); $request->method('getUri')->willReturn($uri); $request->method('getBody')->willReturn($requestStream); $request->method('withHeader')->willReturnSelf(); $stream = $this->createMock(StreamInterface::class); $streamFactory = $this->createMock(StreamFactoryInterface::class); $streamFactory->expects($this->once())->method('createStream')->with($httpBody)->willReturn($stream); $response = $this->createMock(ResponseInterface::class); $response->method('getStatusCode')->willReturn(304); $response->method('getHeader')->willReturn([]); $response->expects($this->once())->method('withBody')->with($stream)->willReturnSelf(); $item = $this->createMock(CacheItemInterface::class); $item->method('isHit')->willReturn(true); $item->expects($this->once())->method('expiresAfter')->with(1060)->willReturnSelf(); $item->method('get')->willReturn([ 'response' => $response, 'body' => $httpBody, 'expiresAt' => 0, 'createdAt' => 4711, 'etag' => ['foo_etag'], ]); $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 testCachePrivateResponsesWhenAllowed(): 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('getMethod')->willReturn('GET'); $request->method('getUri')->willReturn($uri); $request->method('getBody')->willReturn($stream); $response = $this->createMock(ResponseInterface::class); $response->method('getStatusCode')->willReturn(200); $response->method('getBody')->willReturn($stream); $response->method('getHeader')->willReturnCallback(function ($header) { if ('Cache-Control' === $header) { return ['private']; } 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' => [], ]))->willReturnSelf(); $pool = $this->createMock(CacheItemPoolInterface::class); $pool->expects($this->once())->method('getItem')->willReturn($item); $pool->expects($this->once())->method('save')->with($item); $plugin = CachePlugin::clientCache($pool, $streamFactory, [ 'default_ttl' => 60, 'cache_lifetime' => 1000, ]); $result = $plugin->handleRequest($request, $this->createFulfilledNext($response), function () { })->wait(); self::assertSame($response, $result); } public function testDoNotStoreResponsesOfRequestsToBlacklistedPaths(): void { $httpBody = 'body'; $stream = $this->createMock(StreamInterface::class); $stream->method('__toString')->willReturn($httpBody); $stream->method('isSeekable')->willReturn(true); $streamFactory = $this->createMock(StreamFactoryInterface::class); $uri = $this->createMock(UriInterface::class); $uri->method('__toString')->willReturn('https://example.com/foo'); $request = $this->createMock(RequestInterface::class); $request->method('getMethod')->willReturn('GET'); $request->method('getUri')->willReturn($uri); $request->method('getBody')->willReturn($stream); $response = $this->createMock(ResponseInterface::class); $response->method('getStatusCode')->willReturn(200); $response->method('getBody')->willReturn($stream); $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 = CachePlugin::clientCache($pool, $streamFactory, [ 'default_ttl' => 60, 'cache_lifetime' => 1000, 'blacklisted_paths' => ['@/foo@'], ]); $result = $plugin->handleRequest($request, $this->createFulfilledNext($response), function () { })->wait(); self::assertSame($response, $result); } public function testStoreResponsesOfRequestsNotInBlacklistedPaths(): 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('getMethod')->willReturn('GET'); $request->method('getUri')->willReturn($uri); $request->method('getBody')->willReturn($stream); $response = $this->createMock(ResponseInterface::class); $response->method('getStatusCode')->willReturn(200); $response->method('getBody')->willReturn($stream); $response->method('getHeader')->willReturn([]); $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' => [], ]))->willReturnSelf(); $pool = $this->createMock(CacheItemPoolInterface::class); $pool->expects($this->once())->method('getItem')->willReturn($item); $pool->expects($this->once())->method('save')->with($item); $plugin = CachePlugin::clientCache($pool, $streamFactory, [ 'default_ttl' => 60, 'cache_lifetime' => 1000, 'blacklisted_paths' => ['@/foo@'], ]); $result = $plugin->handleRequest($request, $this->createFulfilledNext($response), function () { })->wait(); self::assertSame($response, $result); } public function testCustomCacheKeyGenerator(): void { $stream = $this->createMock(StreamInterface::class); $stream->expects($this->once())->method('rewind'); $streamFactory = $this->createMock(StreamFactoryInterface::class); $streamFactory->expects($this->once())->method('createStream')->willReturn($stream); $request = $this->createMock(RequestInterface::class); $request->method('getMethod')->willReturn('GET'); $response = $this->createMock(ResponseInterface::class); $response->expects($this->once())->method('withBody')->willReturnSelf(); $item = $this->createMock(CacheItemInterface::class); $item->method('isHit')->willReturn(true); $item->method('get')->willReturn([ 'response' => $response, 'body' => 'body', 'expiresAt' => null, 'createdAt' => 0, 'etag' => [], ]); $pool = $this->createMock(CacheItemPoolInterface::class); $pool->expects($this->once())->method('getItem')->willReturn($item); $generator = $this->createMock(SimpleGenerator::class); $generator->expects($this->once())->method('generate')->with($request)->willReturn('foo'); $plugin = CachePlugin::clientCache($pool, $streamFactory, [ 'cache_key_generator' => $generator, ]); $result = $plugin->handleRequest($request, $this->createFulfilledNext($response), function () { })->wait(); self::assertSame($response, $result); } } ================================================ FILE: tests/Cache/Generator/HeaderCacheKeyGeneratorTest.php ================================================ assertInstanceOf(CacheKeyGenerator::class, new HeaderCacheKeyGenerator(['Authorization', 'Content-Type'])); } public function testGenerateCacheFromRequest(): void { $uri = $this->createMock(UriInterface::class); $uri->expects($this->once())->method('__toString')->willReturn('http://example.com/foo'); $body = $this->createMock(StreamInterface::class); $body->expects($this->once())->method('__toString')->willReturn(''); $request = $this->createMock(RequestInterface::class); $request->expects($this->once())->method('getMethod')->willReturn('GET'); $request->expects($this->once())->method('getUri')->willReturn($uri); $request->expects($this->exactly(2))->method('getHeaderLine')->willReturnMap([ ['Authorization', 'bar'], ['Content-Type', 'application/baz'], ]); $request->expects($this->once())->method('getBody')->willReturn($body); $generator = new HeaderCacheKeyGenerator(['Authorization', 'Content-Type']); $this->assertSame( 'GET http://example.com/foo Authorization:"bar" Content-Type:"application/baz" ', $generator->generate($request) ); } } ================================================ FILE: tests/Cache/Generator/SimpleGeneratorTest.php ================================================ assertInstanceOf(CacheKeyGenerator::class, $generator); } public function testGenerateCacheFromRequest(): void { $uri = $this->createMock(UriInterface::class); $uri->expects($this->once())->method('__toString')->willReturn('http://example.com/foo'); $body = $this->createMock(StreamInterface::class); $body->expects($this->once())->method('__toString')->willReturn('bar'); $request = $this->createMock(RequestInterface::class); $request->expects($this->once())->method('getMethod')->willReturn('GET'); $request->expects($this->once())->method('getUri')->willReturn($uri); $request->expects($this->once())->method('getBody')->willReturn($body); $generator = new SimpleGenerator(); $this->assertSame('GET http://example.com/foo bar', $generator->generate($request)); } public function testGenerateCacheFromRequestWithNoBody(): void { $uri = $this->createMock(UriInterface::class); $uri->expects($this->once())->method('__toString')->willReturn('http://example.com/foo'); $body = $this->createMock(StreamInterface::class); $body->expects($this->once())->method('__toString')->willReturn(''); $request = $this->createMock(RequestInterface::class); $request->expects($this->once())->method('getMethod')->willReturn('GET'); $request->expects($this->once())->method('getUri')->willReturn($uri); $request->expects($this->once())->method('getBody')->willReturn($body); $generator = new SimpleGenerator(); $this->assertSame('GET http://example.com/foo', $generator->generate($request)); } }