├── .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 | 
112 |
113 | **Without generating content digests**:
114 |
115 | 
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 |
--------------------------------------------------------------------------------
/docs/without_content_digests.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------