├── .github └── workflows │ └── ci.yaml ├── .gitignore ├── .php-cs-fixer.dist.php ├── LICENSE ├── README.md ├── composer.json ├── docs ├── with_content_digests.svg └── without_content_digests.svg ├── phpunit.xml.dist ├── src ├── ClearableInterface.php ├── Psr6Store.php └── Psr6StoreInterface.php └── tests ├── Fixtures └── favicon.ico └── Psr6StoreTest.php /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: ~ 5 | push: 6 | branches: 7 | - main 8 | tags: 9 | - '*' 10 | schedule: 11 | - cron: 0 13 * * MON 12 | 13 | jobs: 14 | cs: 15 | name: Coding Style 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Setup PHP 19 | uses: shivammathur/setup-php@v2 20 | with: 21 | php-version: '8.1' 22 | coverage: none 23 | tools: php-cs-fixer 24 | 25 | - name: Checkout 26 | uses: actions/checkout@v2 27 | 28 | - name: Run the CS fixer 29 | run: php-cs-fixer fix 30 | 31 | tests: 32 | name: PHP ${{ matrix.php }} 33 | runs-on: ubuntu-latest 34 | if: github.event_name != 'push' 35 | strategy: 36 | fail-fast: false 37 | matrix: 38 | php: ['8.0', '8.1', '8.2', '8.3'] 39 | steps: 40 | - name: Setup PHP 41 | uses: shivammathur/setup-php@v2 42 | with: 43 | php-version: ${{ matrix.php }} 44 | extensions: json 45 | coverage: none 46 | 47 | - name: Checkout 48 | uses: actions/checkout@v2 49 | 50 | - name: Install the dependencies 51 | run: composer install --no-interaction --no-suggest 52 | 53 | - name: Run the unit tests 54 | run: vendor/bin/simple-phpunit 55 | 56 | prefer-lowest-tests: 57 | name: Prefer Lowest 58 | runs-on: ubuntu-latest 59 | if: github.event_name != 'push' 60 | strategy: 61 | fail-fast: false 62 | matrix: 63 | php: ['8.0', '8.1', '8.2', '8.3'] 64 | steps: 65 | - name: Setup PHP 66 | uses: shivammathur/setup-php@v2 67 | with: 68 | php-version: ${{ matrix.php }} 69 | extensions: json 70 | coverage: none 71 | 72 | - name: Checkout 73 | uses: actions/checkout@v2 74 | 75 | - name: Install the dependencies 76 | run: composer update --prefer-lowest --prefer-stable --no-interaction --no-suggest 77 | 78 | - name: Run the unit tests 79 | run: vendor/bin/simple-phpunit 80 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | .php-cs-fixer.cache 3 | .phpunit.result.cache 4 | composer.lock 5 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | 12 | EOF; 13 | 14 | $finder = PhpCsFixer\Finder::create() 15 | ->in([__DIR__.'/src', __DIR__.'/tests']) 16 | ; 17 | 18 | $config = new PhpCsFixer\Config(); 19 | $config 20 | ->setRiskyAllowed(true) 21 | ->setRules([ 22 | '@Symfony' => true, 23 | '@Symfony:risky' => true, 24 | 'array_syntax' => ['syntax' => 'short'], 25 | 'combine_consecutive_unsets' => true, 26 | 'declare_strict_types' => true, 27 | 'general_phpdoc_annotation_remove' => true, 28 | 'header_comment' => ['header' => $header], 29 | 'heredoc_to_nowdoc' => true, 30 | 'no_extra_blank_lines' => true, 31 | 'no_unreachable_default_argument_value' => true, 32 | 'no_useless_else' => true, 33 | 'no_useless_return' => true, 34 | 'no_superfluous_phpdoc_tags' => true, 35 | 'ordered_class_elements' => true, 36 | 'ordered_imports' => true, 37 | 'php_unit_strict' => true, 38 | 'phpdoc_add_missing_param_annotation' => true, 39 | 'phpdoc_order' => true, 40 | 'psr_autoloading' => true, 41 | 'strict_comparison' => true, 42 | 'strict_param' => true, 43 | 'native_function_invocation' => ['include' => ['@compiler_optimized']], 44 | 'void_return' => true, 45 | ]) 46 | ->setFinder($finder) 47 | ; 48 | 49 | return $config; 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Yanick Witschi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PSR-6 compatible Symfony HttpCache Store 2 | 3 | ## Supported branches 4 | 5 | * For PHP ^7.2 and Symfony <6, use version 3.x 6 | * For PHP ^8.0 and Symfony >6, use version 4.x 7 | 8 | ## Introduction 9 | 10 | Symfony's `HttpCache` store implementation is rather old and was developed 11 | when there were no separate components for locking and caching yet. Moreover, 12 | expired cache entries are never pruned and thus causes your cache directory 13 | to continue to grow forever until you delete it manually. 14 | 15 | Along the way, I needed support for cache invalidation based on tags which was 16 | pretty easy to implement thanks to the Symfony Cache component. 17 | 18 | This bundle thus provides an alternative `StoreInterface` implementation 19 | that… 20 | 21 | * …instead of re-implementing locking and caching mechanisms again, uses the well 22 | tested Symfony Cache and Lock components, both with the local filesystem adapters 23 | by default. 24 | * …thanks to the `TagAwareAdapterInterface` of the Cache component, supports tag 25 | based cache invalidation. 26 | * …thanks to the `PrunableInterface` of the Cache component, supports auto-pruning 27 | of expired entries on the filesystem trying to prevent flooding the filesystem. 28 | * …allows you to use a different PSR-6 cache adapters as well as a different 29 | lock adapter than the local filesystem ones. 30 | However, **be careful about choosing the right adapters**, see warning below. 31 | * …supports `BinaryFileResponse` instances. 32 | 33 | ## Installation 34 | 35 | ``` 36 | $ composer require toflar/psr6-symfony-http-cache-store 37 | ``` 38 | 39 | ## Configuration 40 | 41 | For the Symfony 4/Flex structure, you need to adjust your `index.php` like this: 42 | 43 | ```php 44 | $kernel->getCacheDir()]), 51 | null, 52 | ['debug' => $debug] 53 | ); 54 | ``` 55 | 56 | That's it, that's all there is to do. The `Psr6Store` will automatically 57 | create the best caching and locking adapters available for your local filesystem. 58 | 59 | If you want to go beyond this point, the `Psr6Store` can be configured by 60 | passing an array of `$options` in the constructor: 61 | 62 | * **cache_directory**: Path to the cache directory for the default cache 63 | adapter and lock factory. 64 | 65 | Either this or both `cache` and `lock_factory` are required. 66 | 67 | **Type**: `string` 68 | 69 | * **cache**: Explicitly specify the cache adapter you want to use. 70 | 71 | Note that if you want to make use of cache tagging, this cache must 72 | implement the `Symfony\Component\Cache\Adapter\TagAwareAdapterInterface` 73 | Make sure that `lock` and `cache` have the same scope. *See warning below!* 74 | 75 | **Type**: `Symfony\Component\Cache\Adapter\AdapterInterface` 76 | **Default**: `FilesystemAdapter` instance with `cache_directory` 77 | 78 | * **lock_factory**: Explicitly specify the lock factory you want to use. Make 79 | sure that lock and cache have the same scope. *See warning below!* 80 | 81 | **Type**: `Symfony\Component\Lock\Factory` 82 | **Default**: `Factory` with `SemaphoreStore` if supported, `FlockStore` otherwise 83 | 84 | * **prune_threshold**: Configure the number of write actions until the store 85 | will prune the expired cache entries. Pass `0` to disable automated pruning. 86 | 87 | **Type**: `int` 88 | **Default**: `500` 89 | 90 | * **cache_tags_header**: The HTTP header name that's used to check for tags. 91 | 92 | **Type**: `string` 93 | **Default**: `Cache-Tags` 94 | 95 | * **generate_content_digests**: Whether or not content digests should be generated. 96 | See "Generating Content Digests" for more information. 97 | 98 | **Type**: `boolean` 99 | **Default**: `true` 100 | 101 | ### Generating Content Digests 102 | 103 | By default, this cache implementation generates content digests. 104 | This means that the response meta data is stored separately from the 105 | response content. If multiple responses share the same content, it 106 | is stored in the cache only once. 107 | Compare the following illustrations to see the difference: 108 | 109 | **With generating content digests**: 110 | 111 | ![Illustration of the cache with generating content digests](docs/with_content_digests.svg) 112 | 113 | **Without generating content digests**: 114 | 115 | ![Illustration of the cache without generating content digests](docs/without_content_digests.svg) 116 | 117 | Generating content digests optimizes the cache so it uses up less 118 | storage. Using them, however, also comes at the costs of requiring 119 | a second round trip to fetch the content digest from the cache during 120 | the lookup process. 121 | 122 | Whether or not you want to use content digests depends on your PSR-6 123 | cache back end. If lookups are fast and storage is rather limited (e.g. Redis), 124 | you might want to use content digests. If lookups are rather slow and 125 | storage is less of an issue (e.g. Filesystem), you might want to disable 126 | them. 127 | 128 | You can control the behaviour using the `generate_content_digests` configuration 129 | option. 130 | 131 | ### Caching `BinaryFileResponse` Instances 132 | 133 | This cache implementation allows to cache `BinaryFileResponse` instances but 134 | the files are not actually copied to the cache directory. It will just try to 135 | fetch the original file and if that does not exist anymore, the store returns 136 | `null`, causing HttpCache to deal with it as a cache miss and continue normally. 137 | It is ideal for use cases such as caching `/favicon.ico` requests where you would 138 | like to prevent the application from being started and thus deliver the response 139 | from HttpCache. 140 | 141 | ### Cache Tagging 142 | 143 | Tag cache entries by adding a response header with the tags as a comma 144 | separated value. By default, that header is called `Cache-Tags`, this can be 145 | overwritten in `cache_tags_header`. 146 | 147 | To invalidate tags, call the method `Psr6Store::invalidateTags` or use the 148 | `PurgeTagsListener` from the [FOSHttpCache][3] library to handle tag 149 | invalidation requests. 150 | 151 | ### Pruning Expired Cache Items 152 | 153 | By default, this store removes expired entries from the cache after every `500` 154 | cache **write** operations. Fetching data does not affect performance. 155 | You can change the automated pruning frequency with the `prune_threshold` 156 | configuration setting. 157 | 158 | You can also manually trigger pruning by calling the `prune()` method on the 159 | cache. With this, you could for example implement a cron job that loads the store 160 | and prunes it at a configured interval, to prevent slowing down random requests 161 | that were cache misses because they have to wait for the pruning to happen. If you 162 | have set up a cron job, you should disable auto pruning by setting the threshold 163 | to `0`. 164 | 165 | ### WARNING 166 | 167 | It is possible to configure other cache adapters or lock stores than the 168 | filesystem ones. Only do this if you are sure of what you are doing. In 169 | [this pull request][1] Fabien refused to add PSR-6 store support to 170 | the Symfony `AppCache` with the following arguments: 171 | 172 | * Using a filesystem allows for `opcache` to make the cache very 173 | effective; 174 | * The cache contains some PHP (when using ESI for instance) and storing 175 | PHP in anything else than a filesystem would mean `eval()`-ing 176 | strings coming from Redis / Memcache /...; 177 | * HttpCache is triggered very early and does not have access to the 178 | container or anything else really. And it should stay that way to be 179 | efficient. 180 | 181 | While the first and third point depend on what you do and need, be sure to 182 | respect the second point. If you use network enabled caches like Redis or 183 | Memcache, make sure that they are not shared with other systems to avoid code 184 | injection! 185 | 186 | 187 | ### Credits 188 | 189 | I would like to thank [David][2] for his invaluable feedback on this library 190 | while we were working on an integration for the awesome [FOSHttpCache][3] library. 191 | 192 | [1]: https://github.com/symfony/symfony/pull/20061#issuecomment-313339092 193 | [2]: https://github.com/dbu 194 | [3]: https://github.com/FriendsOfSymfony/FOSHttpCache 195 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "toflar/psr6-symfony-http-cache-store", 3 | "description": "An alternative store implementation for Symfony's HttpCache reverse proxy that supports auto-pruning of expired entries and cache invalidation by tags.", 4 | "type": "library", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Yanick Witschi", 9 | "email": "yanick.witschi@terminal42.ch" 10 | } 11 | ], 12 | "require": { 13 | "php": "^8.0", 14 | "symfony/lock": "^6.0 || ^7.0", 15 | "symfony/cache": "^6.0 || ^7.0", 16 | "symfony/http-foundation": "^6.0 || ^7.0", 17 | "symfony/http-kernel": "^6.0 || ^7.0", 18 | "symfony/options-resolver": "^6.0 || ^7.0" 19 | }, 20 | "require-dev": { 21 | "symfony/phpunit-bridge": "^6.0 || ^7.0" 22 | }, 23 | "autoload": { 24 | "psr-4": { 25 | "Toflar\\Psr6HttpCacheStore\\": "src" 26 | } 27 | }, 28 | "minimum-stability": "dev" 29 | } 30 | -------------------------------------------------------------------------------- /docs/with_content_digests.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 |
GET /foobar1
GET /foobar1
GET /foobar2
GET /foobar2
GET /foobar3
GET /foobar3
Response Content
8843d...32878
Response Content...
Viewer does not support full SVG 1.1
-------------------------------------------------------------------------------- /docs/without_content_digests.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 |
GET /foobar1
GET /foobar1
GET /foobar2
GET /foobar2
GET /foobar3
GET /foobar3
Response Content
8843d...32878
Response Content...
Response Content
8843d...32878
Response Content...
Response Content
8843d...32878
Response Content...
Viewer does not support full SVG 1.1
-------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./src/ 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | ./tests/ 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/ClearableInterface.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | 14 | namespace Toflar\Psr6HttpCacheStore; 15 | 16 | /** 17 | * @author Yanick Witschi 18 | */ 19 | interface ClearableInterface 20 | { 21 | /** 22 | * Clears the whole store. 23 | */ 24 | public function clear(): void; 25 | } 26 | -------------------------------------------------------------------------------- /src/Psr6Store.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | 14 | namespace Toflar\Psr6HttpCacheStore; 15 | 16 | use Psr\Cache\InvalidArgumentException as CacheInvalidArgumentException; 17 | use Symfony\Component\Cache\Adapter\AdapterInterface; 18 | use Symfony\Component\Cache\Adapter\FilesystemTagAwareAdapter; 19 | use Symfony\Component\Cache\Adapter\TagAwareAdapterInterface; 20 | use Symfony\Component\Cache\CacheItem; 21 | use Symfony\Component\Cache\PruneableInterface; 22 | use Symfony\Component\HttpFoundation\BinaryFileResponse; 23 | use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException; 24 | use Symfony\Component\HttpFoundation\File\File; 25 | use Symfony\Component\HttpFoundation\Request; 26 | use Symfony\Component\HttpFoundation\Response; 27 | use Symfony\Component\Lock\Exception\InvalidArgumentException as LockInvalidArgumentException; 28 | use Symfony\Component\Lock\Exception\LockReleasingException; 29 | use Symfony\Component\Lock\LockFactory; 30 | use Symfony\Component\Lock\LockInterface; 31 | use Symfony\Component\Lock\PersistingStoreInterface; 32 | use Symfony\Component\Lock\Store\FlockStore; 33 | use Symfony\Component\Lock\Store\SemaphoreStore; 34 | use Symfony\Component\OptionsResolver\Exception\MissingOptionsException; 35 | use Symfony\Component\OptionsResolver\Options; 36 | use Symfony\Component\OptionsResolver\OptionsResolver; 37 | 38 | /** 39 | * Implements a storage for Symfony's HttpCache that supports PSR-6 cache 40 | * back ends, auto-pruning of expired entries on local filesystem and cache 41 | * invalidation by tags. 42 | * 43 | * @author Yanick Witschi 44 | */ 45 | class Psr6Store implements Psr6StoreInterface, ClearableInterface 46 | { 47 | public const NON_VARYING_KEY = 'non-varying'; 48 | public const COUNTER_KEY = 'write-operations-counter'; 49 | public const CLEANUP_LOCK_KEY = 'cleanup-lock'; 50 | 51 | private array $options; 52 | private AdapterInterface $cache; 53 | private LockFactory $lockFactory; 54 | 55 | /** 56 | * @var LockInterface[] 57 | */ 58 | private array $locks = []; 59 | 60 | private string $hashAlgorithm; 61 | 62 | /** 63 | * When creating a Psr6Store you can configure a number of options. 64 | * See the README for a list of all available options and their description. 65 | */ 66 | public function __construct(array $options = []) 67 | { 68 | $resolver = new OptionsResolver(); 69 | 70 | $resolver->setDefined('cache_directory') 71 | ->setAllowedTypes('cache_directory', 'string'); 72 | 73 | $resolver->setDefault('prune_threshold', 500) 74 | ->setAllowedTypes('prune_threshold', 'int'); 75 | 76 | $resolver->setDefault('cache_tags_header', 'Cache-Tags') 77 | ->setAllowedTypes('cache_tags_header', 'string'); 78 | 79 | $resolver->setDefault('generate_content_digests', true) 80 | ->setAllowedTypes('generate_content_digests', 'boolean'); 81 | 82 | $resolver->setDefault('cache', function (Options $options) { 83 | if (!isset($options['cache_directory'])) { 84 | throw new MissingOptionsException('The cache_directory option is required unless you set the cache explicitly'); 85 | } 86 | 87 | return new FilesystemTagAwareAdapter('', 0, $options['cache_directory']); 88 | })->setAllowedTypes('cache', AdapterInterface::class); 89 | 90 | $resolver->setDefault('lock_factory', function (Options $options) { 91 | if (!isset($options['cache_directory'])) { 92 | throw new MissingOptionsException('The cache_directory option is required unless you set the lock_factory explicitly as by default locks are also stored in the configured cache_directory.'); 93 | } 94 | 95 | $defaultLockStore = $this->getDefaultLockStore($options['cache_directory']); 96 | 97 | return new LockFactory($defaultLockStore); 98 | })->setAllowedTypes('lock_factory', LockFactory::class); 99 | 100 | $this->options = $resolver->resolve($options); 101 | $this->cache = $this->options['cache']; 102 | $this->lockFactory = $this->options['lock_factory']; 103 | $this->hashAlgorithm = \PHP_VERSION_ID >= 80100 ? 'xxh128' : 'sha256'; 104 | } 105 | 106 | public function lookup(Request $request): ?Response 107 | { 108 | $cacheKey = $this->getCacheKey($request); 109 | 110 | /** @var CacheItem $item */ 111 | $item = $this->cache->getItem($cacheKey); 112 | 113 | if (!$item->isHit()) { 114 | return null; 115 | } 116 | 117 | $entries = $item->get(); 118 | 119 | foreach ($entries as $varyKeyResponse => $responseData) { 120 | // This can only happen if one entry only 121 | if (self::NON_VARYING_KEY === $varyKeyResponse) { 122 | return $this->restoreResponse($responseData); 123 | } 124 | 125 | // Otherwise we have to see if Vary headers match 126 | $varyKeyRequest = $this->getVaryKey( 127 | $responseData['vary'], 128 | $request 129 | ); 130 | 131 | if ($varyKeyRequest === $varyKeyResponse) { 132 | return $this->restoreResponse($responseData); 133 | } 134 | } 135 | 136 | return null; 137 | } 138 | 139 | public function write(Request $request, Response $response): string 140 | { 141 | if (null === $response->getMaxAge()) { 142 | throw new \InvalidArgumentException('HttpCache should not forward any response without any cache expiration time to the store.'); 143 | } 144 | 145 | // Save the content digest if required 146 | $this->saveContentDigest($response); 147 | 148 | $cacheKey = $this->getCacheKey($request); 149 | $headers = $response->headers->all(); 150 | unset($headers['age']); 151 | 152 | /** @var CacheItem $item */ 153 | $item = $this->cache->getItem($cacheKey); 154 | 155 | if (!$item->isHit()) { 156 | $entries = []; 157 | } else { 158 | $entries = $item->get(); 159 | } 160 | 161 | // Add or replace entry with current Vary header key 162 | $varyKey = $this->getVaryKey($response->getVary(), $request); 163 | $entries[$varyKey] = [ 164 | 'vary' => $response->getVary(), 165 | 'headers' => $headers, 166 | 'status' => $response->getStatusCode(), 167 | 'uri' => $request->getUri(), // For debugging purposes 168 | ]; 169 | 170 | // Add content if content digests are disabled 171 | if (!$this->options['generate_content_digests']) { 172 | $entries[$varyKey]['content'] = $response->getContent(); 173 | } 174 | 175 | // If the response has a Vary header we remove the non-varying entry 176 | if ($response->hasVary()) { 177 | unset($entries[self::NON_VARYING_KEY]); 178 | } 179 | 180 | // Tags 181 | $tags = []; 182 | foreach ($response->headers->all($this->options['cache_tags_header']) as $header) { 183 | foreach (explode(',', $header) as $tag) { 184 | $tags[] = $tag; 185 | } 186 | } 187 | 188 | // Prune expired entries on file system if needed 189 | $this->autoPruneExpiredEntries(); 190 | 191 | $this->saveDeferred($item, $entries, $response->getMaxAge(), $tags); 192 | 193 | // Commit all deferred cache items 194 | $this->cache->commit(); 195 | 196 | return $cacheKey; 197 | } 198 | 199 | public function invalidate(Request $request): void 200 | { 201 | $cacheKey = $this->getCacheKey($request); 202 | 203 | $this->cache->deleteItem($cacheKey); 204 | } 205 | 206 | public function lock(Request $request): bool|string 207 | { 208 | $cacheKey = $this->getCacheKey($request); 209 | 210 | if (isset($this->locks[$cacheKey])) { 211 | return false; 212 | } 213 | 214 | $this->locks[$cacheKey] = $this->lockFactory 215 | ->createLock($cacheKey); 216 | 217 | return $this->locks[$cacheKey]->acquire(); 218 | } 219 | 220 | public function unlock(Request $request): bool 221 | { 222 | $cacheKey = $this->getCacheKey($request); 223 | 224 | if (!isset($this->locks[$cacheKey])) { 225 | return false; 226 | } 227 | 228 | try { 229 | $this->locks[$cacheKey]->release(); 230 | } catch (LockReleasingException) { 231 | return false; 232 | } finally { 233 | unset($this->locks[$cacheKey]); 234 | } 235 | 236 | return true; 237 | } 238 | 239 | public function isLocked(Request $request): bool 240 | { 241 | $cacheKey = $this->getCacheKey($request); 242 | 243 | if (!isset($this->locks[$cacheKey])) { 244 | return false; 245 | } 246 | 247 | return $this->locks[$cacheKey]->isAcquired(); 248 | } 249 | 250 | public function purge(string $url): bool 251 | { 252 | $cacheKey = $this->getCacheKey(Request::create($url)); 253 | 254 | return $this->cache->deleteItem($cacheKey); 255 | } 256 | 257 | public function cleanup(): void 258 | { 259 | try { 260 | foreach ($this->locks as $lock) { 261 | $lock->release(); 262 | } 263 | } catch (LockReleasingException) { 264 | // noop 265 | } finally { 266 | $this->locks = []; 267 | } 268 | } 269 | 270 | /** 271 | * The tags are set from the header configured in cache_tags_header. 272 | * 273 | * {@inheritdoc} 274 | */ 275 | public function invalidateTags(array $tags): bool 276 | { 277 | if (!$this->cache instanceof TagAwareAdapterInterface) { 278 | throw new \RuntimeException('Cannot invalidate tags on a cache 279 | implementation that does not implement the TagAwareAdapterInterface.'); 280 | } 281 | 282 | try { 283 | return $this->cache->invalidateTags($tags); 284 | } catch (CacheInvalidArgumentException) { 285 | return false; 286 | } 287 | } 288 | 289 | /** 290 | * {@inheritdoc} 291 | */ 292 | public function prune(): void 293 | { 294 | if (!$this->cache instanceof PruneableInterface) { 295 | return; 296 | } 297 | 298 | // Make sure we do not have multiple clearing or pruning processes running 299 | $lock = $this->lockFactory->createLock(self::CLEANUP_LOCK_KEY); 300 | 301 | if ($lock->acquire()) { 302 | $this->cache->prune(); 303 | 304 | $lock->release(); 305 | } 306 | } 307 | 308 | /** 309 | * {@inheritdoc} 310 | */ 311 | public function clear(): void 312 | { 313 | // Make sure we do not have multiple clearing or pruning processes running 314 | $lock = $this->lockFactory->createLock(self::CLEANUP_LOCK_KEY); 315 | 316 | if ($lock->acquire()) { 317 | $this->cache->clear(); 318 | 319 | $lock->release(); 320 | } 321 | } 322 | 323 | public function getCacheKey(Request $request): string 324 | { 325 | // Strip scheme to treat https and http the same 326 | $uri = $request->getUri(); 327 | $uri = substr($uri, \strlen($request->getScheme().'://')); 328 | 329 | return 'md'.hash($this->hashAlgorithm, $uri); 330 | } 331 | 332 | /** 333 | * @internal Do not use in public code, this is for unit testing purposes only 334 | */ 335 | public function generateContentDigest(Response $response): ?string 336 | { 337 | if ($response instanceof BinaryFileResponse) { 338 | return 'bf'.hash_file('sha256', $response->getFile()->getPathname()); 339 | } 340 | 341 | if (!$this->options['generate_content_digests']) { 342 | return null; 343 | } 344 | 345 | return 'en'.hash($this->hashAlgorithm, $response->getContent()); 346 | } 347 | 348 | private function getVaryKey(array $vary, Request $request): string 349 | { 350 | if (0 === \count($vary)) { 351 | return self::NON_VARYING_KEY; 352 | } 353 | 354 | // Normalize 355 | $vary = array_map('strtolower', $vary); 356 | sort($vary); 357 | 358 | $hashData = ''; 359 | 360 | foreach ($vary as $headerName) { 361 | if ('cookie' === $headerName) { 362 | continue; 363 | } 364 | 365 | $hashData .= $headerName.':'.$request->headers->get($headerName); 366 | } 367 | 368 | if (\in_array('cookie', $vary, true)) { 369 | $hashData .= 'cookies:'; 370 | foreach ($request->cookies->all() as $k => $v) { 371 | $hashData .= $k.'='.$v; 372 | } 373 | } 374 | 375 | return hash('sha256', $hashData); 376 | } 377 | 378 | private function saveContentDigest(Response $response): void 379 | { 380 | if ($response->headers->has('X-Content-Digest')) { 381 | return; 382 | } 383 | 384 | $contentDigest = $this->generateContentDigest($response); 385 | 386 | if (null === $contentDigest) { 387 | return; 388 | } 389 | 390 | $digestCacheItem = $this->cache->getItem($contentDigest); 391 | 392 | if ($digestCacheItem->isHit()) { 393 | $cacheValue = $digestCacheItem->get(); 394 | 395 | // BC 396 | if (\is_string($cacheValue)) { 397 | $cacheValue = [ 398 | 'expires' => 0, // Forces update to the new format 399 | 'contents' => $cacheValue, 400 | ]; 401 | } 402 | } else { 403 | $cacheValue = [ 404 | 'expires' => 0, // Forces storing the new entry 405 | 'contents' => $this->isBinaryFileResponseContentDigest($contentDigest) ? 406 | $response->getFile()->getPathname() : 407 | $response->getContent(), 408 | ]; 409 | } 410 | 411 | $responseMaxAge = (int) $response->getMaxAge(); 412 | 413 | // Update expires key and save the entry if required 414 | if ($responseMaxAge > $cacheValue['expires']) { 415 | $cacheValue['expires'] = $responseMaxAge; 416 | 417 | if (false === $this->saveDeferred($digestCacheItem, $cacheValue, $responseMaxAge)) { 418 | throw new \RuntimeException('Unable to store the entity.'); 419 | } 420 | } 421 | 422 | $response->headers->set('X-Content-Digest', $contentDigest); 423 | 424 | // Make sure the content-length header is present 425 | if (!$response->headers->has('Transfer-Encoding')) { 426 | $response->headers->set('Content-Length', (string) \strlen((string) $response->getContent())); 427 | } 428 | } 429 | 430 | /** 431 | * Test whether a given digest identifies a BinaryFileResponse. 432 | * 433 | * @param string $digest 434 | */ 435 | private function isBinaryFileResponseContentDigest($digest): bool 436 | { 437 | return 'bf' === substr($digest, 0, 2); 438 | } 439 | 440 | /** 441 | * Increases a counter every time a write action is performed and then 442 | * prunes expired cache entries if a configurable threshold is reached. 443 | * This only happens during write operations so cache retrieval is not 444 | * slowed down. 445 | */ 446 | private function autoPruneExpiredEntries(): void 447 | { 448 | if (0 === $this->options['prune_threshold']) { 449 | return; 450 | } 451 | 452 | $item = $this->cache->getItem(self::COUNTER_KEY); 453 | $counter = (int) $item->get(); 454 | 455 | if ($counter > $this->options['prune_threshold']) { 456 | $this->prune(); 457 | $counter = 0; 458 | } else { 459 | ++$counter; 460 | } 461 | 462 | $item->set($counter); 463 | 464 | $this->cache->saveDeferred($item); 465 | } 466 | 467 | private function saveDeferred(CacheItem $item, $data, ?int $expiresAfter = null, array $tags = []): bool 468 | { 469 | $item->set($data); 470 | $item->expiresAfter($expiresAfter); 471 | 472 | if (0 !== \count($tags)) { 473 | $item->tag($tags); 474 | } 475 | 476 | return $this->cache->saveDeferred($item); 477 | } 478 | 479 | /** 480 | * Restores a Response from the cached data. 481 | * 482 | * @param array $cacheData An array containing the cache data 483 | */ 484 | private function restoreResponse(array $cacheData): ?Response 485 | { 486 | // Check for content digest header 487 | if (!isset($cacheData['headers']['x-content-digest'][0])) { 488 | // No digest was generated but the content was stored inline 489 | if (isset($cacheData['content'])) { 490 | return new Response( 491 | $cacheData['content'], 492 | $cacheData['status'], 493 | $cacheData['headers'] 494 | ); 495 | } 496 | 497 | // No content digest and no inline content means we cannot restore the response 498 | return null; 499 | } 500 | 501 | $item = $this->cache->getItem($cacheData['headers']['x-content-digest'][0]); 502 | 503 | if (!$item->isHit()) { 504 | return null; 505 | } 506 | 507 | $value = $item->get(); 508 | 509 | // BC 510 | if (\is_string($value)) { 511 | $value = ['contents' => $value]; 512 | } 513 | 514 | if ($this->isBinaryFileResponseContentDigest($cacheData['headers']['x-content-digest'][0])) { 515 | try { 516 | $file = new File($value['contents']); 517 | } catch (FileNotFoundException) { 518 | return null; 519 | } 520 | 521 | return new BinaryFileResponse( 522 | $file, 523 | $cacheData['status'], 524 | $cacheData['headers'] 525 | ); 526 | } 527 | 528 | return new Response( 529 | $value['contents'], 530 | $cacheData['status'], 531 | $cacheData['headers'] 532 | ); 533 | } 534 | 535 | /** 536 | * Build and return a default lock factory for when no explicit factory 537 | * was specified. 538 | * The default factory uses the best quality lock store that is available 539 | * on this system. 540 | */ 541 | private function getDefaultLockStore(string $cacheDir): PersistingStoreInterface 542 | { 543 | try { 544 | return new SemaphoreStore(); 545 | } catch (LockInvalidArgumentException) { 546 | return new FlockStore($cacheDir); 547 | } 548 | } 549 | } 550 | -------------------------------------------------------------------------------- /src/Psr6StoreInterface.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | 14 | namespace Toflar\Psr6HttpCacheStore; 15 | 16 | use Symfony\Component\HttpKernel\HttpCache\StoreInterface; 17 | 18 | /** 19 | * Interface for a more powerful cache store that supports cache tagging 20 | * and pruning expired cache entries. 21 | * 22 | * @author Yanick Witschi 23 | */ 24 | interface Psr6StoreInterface extends StoreInterface 25 | { 26 | /** 27 | * Remove/Expire cache objects based on cache tags. 28 | * 29 | * @param array $tags Tags that should be removed/expired from the cache 30 | * 31 | * @throws \RuntimeException if incompatible cache adapter provided 32 | * 33 | * @return bool true on success, false otherwise 34 | */ 35 | public function invalidateTags(array $tags): bool; 36 | 37 | /** 38 | * Prunes expired entries. 39 | * This method must not throw any exception but silently try to 40 | * prune expired cache entries from storage if the cache adapter supports 41 | * it. 42 | */ 43 | public function prune(): void; 44 | } 45 | -------------------------------------------------------------------------------- /tests/Fixtures/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Toflar/psr6-symfony-http-cache-store/9b3eb5b5f449a886b3ce202ddf3705e3d2ac79c9/tests/Fixtures/favicon.ico -------------------------------------------------------------------------------- /tests/Psr6StoreTest.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | 14 | namespace Toflar\Psr6HttpCacheStore; 15 | 16 | use PHPUnit\Framework\TestCase; 17 | use Psr\Cache\CacheItemInterface; 18 | use Symfony\Component\Cache\Adapter\AdapterInterface; 19 | use Symfony\Component\Cache\Adapter\ArrayAdapter; 20 | use Symfony\Component\Cache\Adapter\RedisAdapter; 21 | use Symfony\Component\Cache\Adapter\TagAwareAdapter; 22 | use Symfony\Component\Cache\Adapter\TagAwareAdapterInterface; 23 | use Symfony\Component\Cache\CacheItem; 24 | use Symfony\Component\HttpFoundation\BinaryFileResponse; 25 | use Symfony\Component\HttpFoundation\Cookie; 26 | use Symfony\Component\HttpFoundation\File\File; 27 | use Symfony\Component\HttpFoundation\Request; 28 | use Symfony\Component\HttpFoundation\Response; 29 | use Symfony\Component\Lock\Exception\LockReleasingException; 30 | use Symfony\Component\Lock\LockFactory; 31 | use Symfony\Component\Lock\SharedLockInterface; 32 | use Symfony\Component\OptionsResolver\Exception\MissingOptionsException; 33 | 34 | class Psr6StoreTest extends TestCase 35 | { 36 | private Psr6Store $store; 37 | 38 | protected function setUp(): void 39 | { 40 | $this->store = new Psr6Store(['cache_directory' => sys_get_temp_dir()]); 41 | } 42 | 43 | protected function tearDown(): void 44 | { 45 | $this->getCache()->clear(); 46 | $this->store->cleanup(); 47 | } 48 | 49 | public function testCustomCacheWithoutLockFactory(): void 50 | { 51 | $this->expectException(MissingOptionsException::class); 52 | $this->expectExceptionMessage('The cache_directory option is required unless you set the lock_factory explicitly as by default locks are also stored in the configured cache_directory.'); 53 | 54 | $cache = $this->createMock(TagAwareAdapterInterface::class); 55 | 56 | new Psr6Store([ 57 | 'cache' => $cache, 58 | ]); 59 | } 60 | 61 | public function testCustomCacheAndLockFactory(): void 62 | { 63 | $cache = $this->createMock(TagAwareAdapterInterface::class); 64 | $cache->expects($this->once()) 65 | ->method('deleteItem') 66 | ->willReturn(true); 67 | $lockFactory = $this->createMock(LockFactory::class); 68 | 69 | $store = new Psr6Store([ 70 | 'cache' => $cache, 71 | 'lock_factory' => $lockFactory, 72 | ]); 73 | 74 | $store->purge('/'); 75 | } 76 | 77 | public function testItLocksTheRequest(): void 78 | { 79 | $request = Request::create('/'); 80 | $result = $this->store->lock($request); 81 | 82 | $this->assertTrue($result, 'It returns true if lock is acquired.'); 83 | $this->assertTrue($this->store->isLocked($request), 'Request is locked.'); 84 | } 85 | 86 | public function testLockReturnsFalseIfTheLockWasAlreadyAcquired(): void 87 | { 88 | $request = Request::create('/'); 89 | $this->store->lock($request); 90 | 91 | $result = $this->store->lock($request); 92 | 93 | $this->assertFalse($result, 'It returns false if lock could not be acquired.'); 94 | $this->assertTrue($this->store->isLocked($request), 'Request is locked.'); 95 | } 96 | 97 | public function testIsLockedReturnsFalseIfRequestIsNotLocked(): void 98 | { 99 | $request = Request::create('/'); 100 | $this->assertFalse($this->store->isLocked($request), 'Request is not locked.'); 101 | } 102 | 103 | public function testIsLockedReturnsTrueIfLockWasAcquired(): void 104 | { 105 | $request = Request::create('/'); 106 | $this->store->lock($request); 107 | 108 | $this->assertTrue($this->store->isLocked($request), 'Request is locked.'); 109 | } 110 | 111 | public function testUnlockReturnsFalseIfLockWasNotAcquired(): void 112 | { 113 | $request = Request::create('/'); 114 | $this->assertFalse($this->store->unlock($request), 'Request is not locked.'); 115 | } 116 | 117 | public function testUnlockReturnsTrueIfLockIsReleased(): void 118 | { 119 | $request = Request::create('/'); 120 | $this->store->lock($request); 121 | 122 | $this->assertTrue($this->store->unlock($request), 'Request was unlocked.'); 123 | $this->assertFalse($this->store->isLocked($request), 'Request is not locked.'); 124 | } 125 | 126 | public function testLocksAreReleasedOnCleanup(): void 127 | { 128 | $request = Request::create('/'); 129 | $this->store->lock($request); 130 | 131 | $this->store->cleanup(); 132 | 133 | $this->assertFalse($this->store->isLocked($request), 'Request is no longer locked.'); 134 | } 135 | 136 | public function testSameLockCanBeAcquiredAgain(): void 137 | { 138 | $request = Request::create('/'); 139 | 140 | $this->assertTrue($this->store->lock($request)); 141 | $this->assertTrue($this->store->unlock($request)); 142 | $this->assertTrue($this->store->lock($request)); 143 | } 144 | 145 | public function testThrowsIfResponseHasNoExpirationTime(): void 146 | { 147 | $request = Request::create('/'); 148 | $response = new Response('hello world', 200); 149 | 150 | $this->expectException(\InvalidArgumentException::class); 151 | $this->expectExceptionMessage('HttpCache should not forward any response without any cache expiration time to the store.'); 152 | $this->store->write($request, $response); 153 | } 154 | 155 | public function testWriteThrowsExceptionIfDigestCannotBeStored(): void 156 | { 157 | $innerCache = new ArrayAdapter(); 158 | $cache = $this->getMockBuilder(TagAwareAdapter::class) 159 | ->setConstructorArgs([$innerCache]) 160 | ->onlyMethods(['saveDeferred']) 161 | ->getMock(); 162 | 163 | $cache 164 | ->expects($this->once()) 165 | ->method('saveDeferred') 166 | ->willReturn(false); 167 | 168 | $store = new Psr6Store([ 169 | 'cache_directory' => sys_get_temp_dir(), 170 | 'cache' => $cache, 171 | ]); 172 | 173 | $request = Request::create('/'); 174 | $response = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']); 175 | 176 | $this->expectException(\RuntimeException::class); 177 | $this->expectExceptionMessage('Unable to store the entity.'); 178 | $store->write($request, $response); 179 | } 180 | 181 | public function testWriteStoresTheResponseContent(): void 182 | { 183 | $request = Request::create('/'); 184 | $response = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']); 185 | 186 | $contentDigest = $this->store->generateContentDigest($response); 187 | 188 | $this->store->write($request, $response); 189 | 190 | $this->assertTrue($this->getCache()->hasItem($contentDigest), 'Response content is stored in cache.'); 191 | $this->assertSame(['expires' => 600, 'contents' => $response->getContent()], $this->getCache()->getItem($contentDigest)->get(), 'Response content is stored in cache.'); 192 | $this->assertSame($contentDigest, $response->headers->get('X-Content-Digest'), 'Content digest is stored in the response header.'); 193 | $this->assertSame(\strlen($response->getContent()), (int) $response->headers->get('Content-Length'), 'Response content length is updated.'); 194 | } 195 | 196 | public function testWriteDoesNotStoreTheResponseContentOfNonOriginalResponse(): void 197 | { 198 | $request = Request::create('/'); 199 | $response = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']); 200 | 201 | $contentDigest = $this->store->generateContentDigest($response); 202 | 203 | $response->headers->set('X-Content-Digest', $contentDigest); 204 | 205 | $this->store->write($request, $response); 206 | 207 | $this->assertFalse($this->getCache()->hasItem($contentDigest), 'Response content is not stored in cache.'); 208 | $this->assertFalse($response->headers->has('Content-Length'), 'Response content length is not updated.'); 209 | } 210 | 211 | public function testWriteOnlyUpdatesContentLengthIfThereIsNoTransferEncodingHeader(): void 212 | { 213 | $request = Request::create('/'); 214 | $response = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']); 215 | $response->headers->set('Transfer-Encoding', 'chunked'); 216 | 217 | $this->store->write($request, $response); 218 | 219 | $this->assertFalse($response->headers->has('Content-Length'), 'Response content length is not updated.'); 220 | } 221 | 222 | public function testWriteStoresEntries(): void 223 | { 224 | $request = Request::create('/'); 225 | $response = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']); 226 | $response->headers->set('age', '120'); 227 | 228 | $cacheKey = $this->store->getCacheKey($request); 229 | 230 | $this->store->write($request, $response); 231 | 232 | $cacheItem = $this->getCache()->getItem($cacheKey); 233 | 234 | $this->assertInstanceOf(CacheItemInterface::class, $cacheItem, 'Metadata is stored in cache.'); 235 | $this->assertTrue($cacheItem->isHit(), 'Metadata is stored in cache.'); 236 | 237 | $entries = $cacheItem->get(); 238 | 239 | $this->assertTrue(\is_array($entries), 'Entries are stored in cache.'); 240 | $this->assertCount(1, $entries, 'One entry is stored.'); 241 | $this->assertSame($entries[Psr6Store::NON_VARYING_KEY]['headers'], array_diff_key($response->headers->all(), ['age' => []]), 'Response headers are stored with no age header.'); 242 | } 243 | 244 | public function testWriteAddsTags(): void 245 | { 246 | $request = Request::create('/'); 247 | $response = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']); 248 | $response->headers->set('Cache-Tags', 'foobar,other tag'); 249 | 250 | $cacheKey = $this->store->getCacheKey($request); 251 | 252 | $this->store->write($request, $response); 253 | 254 | $this->assertTrue($this->getCache()->getItem($cacheKey)->isHit()); 255 | $this->assertTrue($this->store->invalidateTags(['foobar'])); 256 | $this->assertFalse($this->getCache()->getItem($cacheKey)->isHit()); 257 | } 258 | 259 | public function testWriteAddsTagsWithMultipleHeaders(): void 260 | { 261 | $request = Request::create('/'); 262 | $response = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']); 263 | $response->headers->set('Cache-Tags', ['foobar,other tag', 'some,more', 'tags', 'split,over', 'multiple-headers']); 264 | 265 | $cacheKey = $this->store->getCacheKey($request); 266 | 267 | $this->store->write($request, $response); 268 | 269 | $this->assertTrue($this->getCache()->getItem($cacheKey)->isHit()); 270 | $this->assertTrue($this->store->invalidateTags(['multiple-headers'])); 271 | $this->assertFalse($this->getCache()->getItem($cacheKey)->isHit()); 272 | } 273 | 274 | public function testInvalidateTagsThrowsExceptionIfWrongCacheAdapterProvided(): void 275 | { 276 | $this->expectException(\RuntimeException::class); 277 | $store = new Psr6Store([ 278 | 'cache' => $this->createMock(AdapterInterface::class), 279 | 'cache_directory' => 'foobar', 280 | ]); 281 | $store->invalidateTags(['foobar']); 282 | } 283 | 284 | public function testInvalidateTagsReturnsFalseOnException(): void 285 | { 286 | $innerCache = new ArrayAdapter(); 287 | $cache = $this->getMockBuilder(TagAwareAdapter::class) 288 | ->setConstructorArgs([$innerCache]) 289 | ->setMethods(['invalidateTags']) 290 | ->getMock(); 291 | 292 | $cache 293 | ->expects($this->once()) 294 | ->method('invalidateTags') 295 | ->willThrowException(new \Symfony\Component\Cache\Exception\InvalidArgumentException()); 296 | 297 | $store = new Psr6Store([ 298 | 'cache_directory' => sys_get_temp_dir(), 299 | 'cache' => $cache, 300 | ]); 301 | 302 | $this->assertFalse($store->invalidateTags(['foobar'])); 303 | } 304 | 305 | public function testVaryResponseDropsNonVaryingOne(): void 306 | { 307 | $request = Request::create('/'); 308 | $nonVarying = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']); 309 | $varying = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public', 'Vary' => 'Foobar', 'Foobar' => 'whatever']); 310 | 311 | $this->store->write($request, $nonVarying); 312 | 313 | $cacheKey = $this->store->getCacheKey($request); 314 | $cacheItem = $this->getCache()->getItem($cacheKey); 315 | $entries = $cacheItem->get(); 316 | 317 | $this->assertCount(1, $entries); 318 | $this->assertSame(Psr6Store::NON_VARYING_KEY, key($entries)); 319 | 320 | $this->store->write($request, $varying); 321 | 322 | $cacheItem = $this->getCache()->getItem($cacheKey); 323 | 324 | $entries = $cacheItem->get(); 325 | 326 | $this->assertCount(1, $entries); 327 | $this->assertNotSame(Psr6Store::NON_VARYING_KEY, key($entries)); 328 | } 329 | 330 | public function testRegularCacheKey(): void 331 | { 332 | $request = Request::create('https://foobar.com/'); 333 | $expected = 'md'.hash( 334 | \PHP_VERSION_ID >= 80100 335 | ? 'xxh128' 336 | : 'sha256', 337 | 'foobar.com/' 338 | ); 339 | $this->assertSame($expected, $this->store->getCacheKey($request)); 340 | } 341 | 342 | public function testHttpAndHttpsGenerateTheSameCacheKey(): void 343 | { 344 | $request = Request::create('https://foobar.com/'); 345 | $cacheKeyHttps = $this->store->getCacheKey($request); 346 | $request = Request::create('http://foobar.com/'); 347 | $cacheKeyHttp = $this->store->getCacheKey($request); 348 | 349 | $this->assertSame($cacheKeyHttps, $cacheKeyHttp); 350 | } 351 | 352 | public function testDebugInfoIsAdded(): void 353 | { 354 | $request = Request::create('https://foobar.com/'); 355 | $response = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']); 356 | 357 | $this->store->write($request, $response); 358 | 359 | $cacheKey = $this->store->getCacheKey($request); 360 | $cacheItem = $this->getCache()->getItem($cacheKey); 361 | $entries = $cacheItem->get(); 362 | $this->assertSame('https://foobar.com/', $entries[Psr6Store::NON_VARYING_KEY]['uri']); 363 | } 364 | 365 | public function testRegularLookup(): void 366 | { 367 | $request = Request::create('https://foobar.com/'); 368 | $response = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']); 369 | $response->headers->set('Foobar', 'whatever'); 370 | 371 | $this->store->write($request, $response); 372 | 373 | $result = $this->store->lookup($request); 374 | 375 | $this->assertInstanceOf(Response::class, $result); 376 | $this->assertSame(200, $result->getStatusCode()); 377 | $this->assertSame('hello world', $result->getContent()); 378 | $this->assertSame('whatever', $result->headers->get('Foobar')); 379 | 380 | $this->assertSame( 381 | \PHP_VERSION_ID >= 80100 382 | ? 'endf8d09e93f874900a99b8775cc15b6c7' // xxh128 383 | : 'enb94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9' // sha256 384 | , $result->headers->get('X-Content-Digest')); 385 | } 386 | 387 | public function testRegularLookupWithContentDigestsDisabled(): void 388 | { 389 | $request = Request::create('https://foobar.com/'); 390 | $response = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']); 391 | $response->headers->set('Foobar', 'whatever'); 392 | 393 | $store = new Psr6Store([ 394 | 'cache_directory' => sys_get_temp_dir(), 395 | 'generate_content_digests' => false, 396 | ]); 397 | 398 | $store->write($request, $response); 399 | 400 | $result = $store->lookup($request); 401 | 402 | $this->assertInstanceOf(Response::class, $result); 403 | $this->assertSame(200, $result->getStatusCode()); 404 | $this->assertSame('hello world', $result->getContent()); 405 | $this->assertSame('whatever', $result->headers->get('Foobar')); 406 | $this->assertNull($result->headers->get('X-Content-Digest')); 407 | } 408 | 409 | public function testRegularLookupWithBinaryResponse(): void 410 | { 411 | $request = Request::create('https://foobar.com/'); 412 | $response = new BinaryFileResponse(__DIR__.'/Fixtures/favicon.ico', 200, ['Cache-Control' => 's-maxage=600, public']); 413 | $response->headers->set('Foobar', 'whatever'); 414 | 415 | $this->store->write($request, $response); 416 | 417 | $result = $this->store->lookup($request); 418 | 419 | $this->assertInstanceOf(BinaryFileResponse::class, $result); 420 | $this->assertSame(200, $result->getStatusCode()); 421 | $this->assertSame(__DIR__.'/Fixtures/favicon.ico', $result->getFile()->getPathname()); 422 | $this->assertSame('whatever', $result->headers->get('Foobar')); 423 | $this->assertSame('bfe8149cee23ba25e6b878864c1c8b3344ee1b3d5c6d468b2e4f7593be65bb1b68', $result->headers->get('X-Content-Digest')); 424 | } 425 | 426 | public function testRegularLookupWithBinaryResponseWithContentDigestsDisabled(): void 427 | { 428 | $request = Request::create('https://foobar.com/'); 429 | $response = new BinaryFileResponse(__DIR__.'/Fixtures/favicon.ico', 200, ['Cache-Control' => 's-maxage=600, public']); 430 | $response->headers->set('Foobar', 'whatever'); 431 | 432 | $store = new Psr6Store([ 433 | 'cache_directory' => sys_get_temp_dir(), 434 | 'generate_content_digests' => false, 435 | ]); 436 | 437 | $store->write($request, $response); 438 | 439 | $result = $store->lookup($request); 440 | 441 | $this->assertInstanceOf(BinaryFileResponse::class, $result); 442 | $this->assertSame(200, $result->getStatusCode()); 443 | $this->assertSame(__DIR__.'/Fixtures/favicon.ico', $result->getFile()->getPathname()); 444 | $this->assertSame('whatever', $result->headers->get('Foobar')); 445 | $this->assertSame('bfe8149cee23ba25e6b878864c1c8b3344ee1b3d5c6d468b2e4f7593be65bb1b68', $result->headers->get('X-Content-Digest')); 446 | } 447 | 448 | public function testRegularLookupWithRemovedBinaryResponse(): void 449 | { 450 | $request = Request::create('https://foobar.com/'); 451 | $file = new File(__DIR__.'/Fixtures/favicon.ico'); 452 | $response = new BinaryFileResponse($file, 200, ['Cache-Control' => 's-maxage=600, public']); 453 | $response->headers->set('Foobar', 'whatever'); 454 | 455 | $this->store->write($request, $response); 456 | 457 | // Now move (same as remove) the file somewhere else 458 | $movedFile = $file->move(__DIR__.'/Fixtures', 'favicon_bu.ico'); 459 | 460 | $result = $this->store->lookup($request); 461 | $this->assertNull($result); 462 | 463 | // Move back for other tests 464 | $movedFile->move(__DIR__.'/Fixtures', 'favicon.ico'); 465 | } 466 | 467 | public function testLookupWithVaryOnCookies(): void 468 | { 469 | // Cookies match 470 | $request = Request::create('https://foobar.com/', 'GET', [], ['Foo' => 'Bar'], [], ['HTTP_COOKIE' => 'Foo=Bar']); 471 | $response = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public', 'Vary' => 'Cookie']); 472 | $response->headers->setCookie(new Cookie('Foo', 'Bar', 0, '/')); 473 | 474 | $this->store->write($request, $response); 475 | 476 | $result = $this->store->lookup($request); 477 | $this->assertInstanceOf(Response::class, $result); 478 | 479 | // Cookies do not match (manually removed on request) 480 | $request = Request::create('https://foobar.com/', 'GET', [], ['Foo' => 'Bar'], [], ['HTTP_COOKIE' => 'Foo=Bar']); 481 | $request->cookies->remove('Foo'); 482 | 483 | $result = $this->store->lookup($request); 484 | $this->assertNull($result); 485 | } 486 | 487 | public function testLookupWithEmptyCache(): void 488 | { 489 | $request = Request::create('https://foobar.com/'); 490 | 491 | $result = $this->store->lookup($request); 492 | 493 | $this->assertNull($result); 494 | } 495 | 496 | public function testLookupWithVaryResponse(): void 497 | { 498 | $request = Request::create('https://foobar.com/'); 499 | $request->headers->set('Foobar', 'whatever'); 500 | $response = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public', 'Vary' => 'Foobar']); 501 | 502 | $this->store->write($request, $response); 503 | 504 | $request = Request::create('https://foobar.com/'); 505 | $result = $this->store->lookup($request); 506 | $this->assertNull($result); 507 | 508 | $request = Request::create('https://foobar.com/'); 509 | $request->headers->set('Foobar', 'whatever'); 510 | $result = $this->store->lookup($request); 511 | $this->assertSame(200, $result->getStatusCode()); 512 | $this->assertSame('hello world', $result->getContent()); 513 | $this->assertSame('Foobar', $result->headers->get('Vary')); 514 | } 515 | 516 | public function testLookupWithMultipleVaryResponse(): void 517 | { 518 | $jsonRequest = Request::create('https://foobar.com/'); 519 | $jsonRequest->headers->set('Accept', 'application/json'); 520 | $htmlRequest = Request::create('https://foobar.com/'); 521 | $htmlRequest->headers->set('Accept', 'text/html'); 522 | 523 | $jsonResponse = new Response('{}', 200, ['Cache-Control' => 's-maxage=600, public', 'Vary' => 'Accept', 'Content-Type' => 'application/json']); 524 | $htmlResponse = new Response('', 200, ['Cache-Control' => 's-maxage=600, public', 'Vary' => 'Accept', 'Content-Type' => 'text/html']); 525 | 526 | // Fill cache 527 | $this->store->write($jsonRequest, $jsonResponse); 528 | $this->store->write($htmlRequest, $htmlResponse); 529 | 530 | // Should return null because no header provided 531 | $request = Request::create('https://foobar.com/'); 532 | $result = $this->store->lookup($request); 533 | $this->assertNull($result); 534 | 535 | // Should return null because header provided but non-matching content 536 | $request = Request::create('https://foobar.com/'); 537 | $request->headers->set('Accept', 'application/xml'); 538 | $result = $this->store->lookup($request); 539 | $this->assertNull($result); 540 | 541 | // Should return a JSON response 542 | $request = Request::create('https://foobar.com/'); 543 | $request->headers->set('Accept', 'application/json'); 544 | $result = $this->store->lookup($request); 545 | $this->assertSame(200, $result->getStatusCode()); 546 | $this->assertSame('{}', $result->getContent()); 547 | $this->assertSame('Accept', $result->headers->get('Vary')); 548 | $this->assertSame('application/json', $result->headers->get('Content-Type')); 549 | 550 | // Should return an HTML response 551 | $request = Request::create('https://foobar.com/'); 552 | $request->headers->set('Accept', 'text/html'); 553 | $result = $this->store->lookup($request); 554 | $this->assertSame(200, $result->getStatusCode()); 555 | $this->assertSame('', $result->getContent()); 556 | $this->assertSame('Accept', $result->headers->get('Vary')); 557 | $this->assertSame('text/html', $result->headers->get('Content-Type')); 558 | } 559 | 560 | public function testInvalidate(): void 561 | { 562 | $request = Request::create('https://foobar.com/'); 563 | $response = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']); 564 | $response->headers->set('Foobar', 'whatever'); 565 | 566 | $this->store->write($request, $response); 567 | $cacheKey = $this->store->getCacheKey($request); 568 | 569 | $cacheItem = $this->getCache()->getItem($cacheKey); 570 | $this->assertTrue($cacheItem->isHit()); 571 | 572 | $this->store->invalidate($request); 573 | 574 | $cacheItem = $this->getCache()->getItem($cacheKey); 575 | $this->assertFalse($cacheItem->isHit()); 576 | } 577 | 578 | public function testPurge(): void 579 | { 580 | // Request 1 581 | $request1 = Request::create('https://foobar.com/'); 582 | $response1 = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']); 583 | 584 | // Request 2 585 | $request2 = Request::create('https://foobar.com/foobar'); 586 | $response2 = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']); 587 | 588 | $this->store->write($request1, $response1); 589 | $this->store->write($request2, $response2); 590 | $cacheKey1 = $this->store->getCacheKey($request1); 591 | $cacheKey2 = $this->store->getCacheKey($request2); 592 | 593 | $cacheItem1 = $this->getCache()->getItem($cacheKey1); 594 | $cacheItem2 = $this->getCache()->getItem($cacheKey2); 595 | $this->assertTrue($cacheItem1->isHit()); 596 | $this->assertTrue($cacheItem2->isHit()); 597 | 598 | $this->store->purge('https://foobar.com/'); 599 | 600 | $cacheItem1 = $this->getCache()->getItem($cacheKey1); 601 | $cacheItem2 = $this->getCache()->getItem($cacheKey2); 602 | $this->assertFalse($cacheItem1->isHit()); 603 | $this->assertTrue($cacheItem2->isHit()); 604 | } 605 | 606 | public function testClear(): void 607 | { 608 | // Request 1 609 | $request1 = Request::create('https://foobar.com/'); 610 | $response1 = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']); 611 | 612 | // Request 2 613 | $request2 = Request::create('https://foobar.com/foobar'); 614 | $response2 = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']); 615 | 616 | $this->store->write($request1, $response1); 617 | $this->store->write($request2, $response2); 618 | $cacheKey1 = $this->store->getCacheKey($request1); 619 | $cacheKey2 = $this->store->getCacheKey($request2); 620 | 621 | $cacheItem1 = $this->getCache()->getItem($cacheKey1); 622 | $cacheItem2 = $this->getCache()->getItem($cacheKey2); 623 | $this->assertTrue($cacheItem1->isHit()); 624 | $this->assertTrue($cacheItem2->isHit()); 625 | 626 | $this->store->clear(); 627 | 628 | $cacheItem1 = $this->getCache()->getItem($cacheKey1); 629 | $cacheItem2 = $this->getCache()->getItem($cacheKey2); 630 | $this->assertFalse($cacheItem1->isHit()); 631 | $this->assertFalse($cacheItem2->isHit()); 632 | } 633 | 634 | public function testPruneIgnoredIfCacheBackendDoesNotImplementPrunableInterface(): void 635 | { 636 | $cache = $this->getMockBuilder(RedisAdapter::class) 637 | ->disableOriginalConstructor() 638 | ->addMethods(['prune']) 639 | ->getMock(); 640 | $cache 641 | ->expects($this->never()) 642 | ->method('prune'); 643 | 644 | $store = new Psr6Store([ 645 | 'cache_directory' => sys_get_temp_dir(), 646 | 'cache' => $cache, 647 | ]); 648 | 649 | $store->prune(); 650 | } 651 | 652 | public function testAutoPruneExpiredEntries(): void 653 | { 654 | $innerCache = new ArrayAdapter(); 655 | $cache = $this->getMockBuilder(TagAwareAdapter::class) 656 | ->setConstructorArgs([$innerCache]) 657 | ->setMethods(['prune']) 658 | ->getMock(); 659 | 660 | $cache 661 | ->expects($this->exactly(3)) 662 | ->method('prune'); 663 | 664 | $lock = $this->createMock(SharedLockInterface::class); 665 | $lock 666 | ->expects($this->exactly(3)) 667 | ->method('acquire') 668 | ->willReturn(true); 669 | $lock 670 | ->expects($this->exactly(3)) 671 | ->method('release'); 672 | 673 | $lockFactory = $this->createMock(LockFactory::class); 674 | $lockFactory 675 | ->expects($this->any()) 676 | ->method('createLock') 677 | ->with(Psr6Store::CLEANUP_LOCK_KEY) 678 | ->willReturn($lock); 679 | 680 | $store = new Psr6Store([ 681 | 'cache' => $cache, 682 | 'prune_threshold' => 5, 683 | 'lock_factory' => $lockFactory, 684 | ]); 685 | 686 | foreach (range(1, 21) as $entry) { 687 | $request = Request::create('https://foobar.com/'.$entry); 688 | $response = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']); 689 | 690 | $store->write($request, $response); 691 | } 692 | 693 | $store->cleanup(); 694 | } 695 | 696 | public function testAutoPruneIsSkippedIfThresholdDisabled(): void 697 | { 698 | $innerCache = new ArrayAdapter(); 699 | $cache = $this->getMockBuilder(TagAwareAdapter::class) 700 | ->setConstructorArgs([$innerCache]) 701 | ->setMethods(['prune']) 702 | ->getMock(); 703 | 704 | $cache 705 | ->expects($this->never()) 706 | ->method('prune'); 707 | 708 | $store = new Psr6Store([ 709 | 'cache_directory' => sys_get_temp_dir(), 710 | 'cache' => $cache, 711 | 'prune_threshold' => 0, 712 | ]); 713 | 714 | foreach (range(1, 21) as $entry) { 715 | $request = Request::create('https://foobar.com/'.$entry); 716 | $response = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']); 717 | 718 | $store->write($request, $response); 719 | } 720 | 721 | $store->cleanup(); 722 | } 723 | 724 | public function testAutoPruneIsSkippedIfPruningIsAlreadyInProgress(): void 725 | { 726 | $innerCache = new ArrayAdapter(); 727 | $cache = $this->getMockBuilder(TagAwareAdapter::class) 728 | ->setConstructorArgs([$innerCache]) 729 | ->setMethods(['prune']) 730 | ->getMock(); 731 | 732 | $cache 733 | ->expects($this->never()) 734 | ->method('prune'); 735 | 736 | $lock = $this->createMock(SharedLockInterface::class); 737 | $lock 738 | ->expects($this->exactly(3)) 739 | ->method('acquire') 740 | ->willReturn(false); 741 | 742 | $lockFactory = $this->createMock(LockFactory::class); 743 | $lockFactory 744 | ->expects($this->any()) 745 | ->method('createLock') 746 | ->with(Psr6Store::CLEANUP_LOCK_KEY) 747 | ->willReturn($lock); 748 | 749 | $store = new Psr6Store([ 750 | 'cache' => $cache, 751 | 'prune_threshold' => 5, 752 | 'lock_factory' => $lockFactory, 753 | ]); 754 | 755 | foreach (range(1, 21) as $entry) { 756 | $request = Request::create('https://foobar.com/'.$entry); 757 | $response = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']); 758 | 759 | $store->write($request, $response); 760 | } 761 | 762 | $store->cleanup(); 763 | } 764 | 765 | public function testItFailsWithoutCacheDirectoryForCache(): void 766 | { 767 | $this->expectException(MissingOptionsException::class); 768 | new Psr6Store([]); 769 | } 770 | 771 | public function testItFailsWithoutCacheDirectoryForLockStore(): void 772 | { 773 | $this->expectException(MissingOptionsException::class); 774 | new Psr6Store(['cache' => $this->createMock(AdapterInterface::class)]); 775 | } 776 | 777 | public function testUnlockReturnsFalseOnLockReleasingException(): void 778 | { 779 | $lock = $this->createMock(SharedLockInterface::class); 780 | $lock 781 | ->expects($this->once()) 782 | ->method('release') 783 | ->willThrowException(new LockReleasingException()); 784 | 785 | $lockFactory = $this->createMock(LockFactory::class); 786 | $lockFactory 787 | ->expects($this->once()) 788 | ->method('createLock') 789 | ->willReturn($lock); 790 | 791 | $store = new Psr6Store([ 792 | 'cache' => $this->createMock(AdapterInterface::class), 793 | 'lock_factory' => $lockFactory, 794 | ]); 795 | 796 | $request = Request::create('/foobar'); 797 | $store->lock($request); 798 | 799 | $this->assertFalse($store->unlock($request)); 800 | } 801 | 802 | public function testLockReleasingExceptionIsIgnoredOnCleanup(): void 803 | { 804 | $lock = $this->createMock(SharedLockInterface::class); 805 | $lock 806 | ->expects($this->once()) 807 | ->method('release') 808 | ->willThrowException(new LockReleasingException()); 809 | 810 | $lockFactory = $this->createMock(LockFactory::class); 811 | $lockFactory 812 | ->expects($this->once()) 813 | ->method('createLock') 814 | ->willReturn($lock); 815 | 816 | $store = new Psr6Store([ 817 | 'cache' => $this->createMock(AdapterInterface::class), 818 | 'lock_factory' => $lockFactory, 819 | ]); 820 | 821 | $request = Request::create('/foobar'); 822 | $store->lock($request); 823 | $store->cleanup(); 824 | 825 | // This test will fail if an exception is thrown, otherwise we mark it 826 | // as passed. 827 | $this->addToAssertionCount(1); 828 | } 829 | 830 | /** 831 | * @dataProvider contentDigestExpiryProvider 832 | */ 833 | public function testContentDigestExpiresCorrectly(array $responseHeaders, $expectedExpiresAfter, $previousItemExpiration = 0): void 834 | { 835 | // This is the stub for the meta cache item, we're not interested in this one 836 | $cacheItem = new CacheItem(); 837 | 838 | // This is the one we're interested in this test 839 | $contentDigestCacheItem = new CacheItem(); 840 | $r = new \ReflectionProperty($contentDigestCacheItem, 'isHit'); 841 | $r->setAccessible(true); 842 | $r->setValue($contentDigestCacheItem, 0 !== $previousItemExpiration); 843 | 844 | if (0 !== $previousItemExpiration) { 845 | $r = new \ReflectionProperty($contentDigestCacheItem, 'value'); 846 | $r->setAccessible(true); 847 | $r->setValue($contentDigestCacheItem, ['expires' => $previousItemExpiration, 'contents' => 'foobar']); 848 | } else { 849 | $contentDigestCacheItem 850 | ->set(['expires' => $expectedExpiresAfter, 'contents' => 'foobar']) 851 | ->expiresAfter($expectedExpiresAfter); 852 | } 853 | 854 | $cache = $this->createMock(AdapterInterface::class); 855 | $cache 856 | ->expects($this->exactly(3)) 857 | ->method('getItem') 858 | ->withConsecutive( 859 | [ 860 | // content digest 861 | \PHP_VERSION_ID >= 80100 862 | ? 'en3c9e102628997f44ac87b0b131c6992d' // xxh128 863 | : 'enc3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2' // sha256 864 | ], 865 | [ 866 | // meta 867 | \PHP_VERSION_ID >= 80100 868 | ? 'md0d10c3ce367c3309e789ed924fa6b183' // xxh128 869 | : 'md390aa862a7f27c16d72dd40967066969e7eb4b102c6215478a275766bf046665' // sha256 870 | ], 871 | [Psr6Store::COUNTER_KEY], // write counter 872 | [ 873 | // meta again 874 | \PHP_VERSION_ID >= 80100 875 | ? 'md0d10c3ce367c3309e789ed924fa6b183' // xxh128 876 | : 'md390aa862a7f27c16d72dd40967066969e7eb4b102c6215478a275766bf046665' // sha256 877 | ] 878 | ) 879 | ->willReturnOnConsecutiveCalls($contentDigestCacheItem, $cacheItem, $cacheItem, $cacheItem); 880 | 881 | $cache 882 | ->expects($this->any()) 883 | ->method('saveDeferred') 884 | ->willReturn(true); 885 | 886 | $store = new Psr6Store([ 887 | 'cache' => $cache, 888 | 'lock_factory' => $this->createMock(LockFactory::class), 889 | ]); 890 | 891 | $response = new Response('foobar', 200, $responseHeaders); 892 | $request = Request::create('https://foobar.com/'); 893 | $store->write($request, $response); 894 | } 895 | 896 | public function contentDigestExpiryProvider() 897 | { 898 | yield 'Test no previous response should take the same max age as the current response' => [ 899 | ['Cache-Control' => 's-maxage=600, public'], 900 | 600, 901 | 0, 902 | ]; 903 | 904 | yield 'Previous max-age was higher, digest expiration should not be touched then' => [ 905 | ['Cache-Control' => 's-maxage=600, public'], 906 | 900, 907 | 900, 908 | ]; 909 | 910 | yield 'Previous max-age was lower, digest expiration should be updated' => [ 911 | ['Cache-Control' => 's-maxage=1800, public'], 912 | 1800, 913 | 900, 914 | ]; 915 | } 916 | 917 | /** 918 | * @param null $store 919 | */ 920 | private function getCache($store = null): TagAwareAdapterInterface 921 | { 922 | if (null === $store) { 923 | $store = $this->store; 924 | } 925 | 926 | $reflection = new \ReflectionClass($store); 927 | $cache = $reflection->getProperty('cache'); 928 | $cache->setAccessible(true); 929 | 930 | return $cache->getValue($this->store); 931 | } 932 | } 933 | --------------------------------------------------------------------------------