diff --git a/README.md b/README.md index 63bcfeaa2..ce23622af 100644 --- a/README.md +++ b/README.md @@ -300,6 +300,25 @@ $memoryCache = new MemoryCacheItemPool; $middleware = ApplicationDefaultCredentials::getCredentials($scope, cache: $memoryCache); ``` +### FileSystemCacheItemPool Cache +The `FileSystemCacheItemPool` class is a `PSR-6` compliant cache that stores its +serialized objects on disk, caching data between processes and making it possible +to use data between different requests. + +```php +use Google\Auth\Cache\FileSystemCacheItemPool; +use Google\Auth\ApplicationDefaultCredentials; + +// Create a Cache pool instance +$cache = new FileSystemCacheItemPool(__DIR__ . '/cache'); + +// Pass your Cache to the Auth Library +$credentials = ApplicationDefaultCredentials::getCredentials($scope, cache: $cache); + +// This token will be cached and be able to be used for the next request +$token = $credentials->fetchAuthToken(); +``` + ### Integrating with a third party cache You can use a third party that follows the `PSR-6` interface of your choice. diff --git a/src/Cache/FileSystemCacheItemPool.php b/src/Cache/FileSystemCacheItemPool.php new file mode 100644 index 000000000..ee0651a4e --- /dev/null +++ b/src/Cache/FileSystemCacheItemPool.php @@ -0,0 +1,230 @@ + + */ + private array $buffer = []; + + /** + * Creates a FileSystemCacheItemPool cache that stores values in local storage + * + * @param string $path The string representation of the path where the cache will store the serialized objects. + */ + public function __construct(string $path) + { + $this->cachePath = $path; + + if (is_dir($this->cachePath)) { + return; + } + + if (!mkdir($this->cachePath)) { + throw new ErrorException("Cache folder couldn't be created."); + } + } + + /** + * {@inheritdoc} + */ + public function getItem(string $key): CacheItemInterface + { + if (!$this->validKey($key)) { + throw new InvalidArgumentException("The key '$key' is not valid. The key should follow the pattern |^[a-zA-Z0-9_\.! ]+$|"); + } + + $item = new TypedItem($key); + + $itemPath = $this->cacheFilePath($key); + + if (!file_exists($itemPath)) { + return $item; + } + + $serializedItem = file_get_contents($itemPath); + + if ($serializedItem === false) { + return $item; + } + + $item->set(unserialize($serializedItem)); + + return $item; + } + + /** + * {@inheritdoc} + * + * @return iterable An iterable object containing all the + * A traversable collection of Cache Items keyed by the cache keys of + * each item. A Cache item will be returned for each key, even if that + * key is not found. However, if no keys are specified then an empty + * traversable MUST be returned instead. + */ + public function getItems(array $keys = []): iterable + { + $result = []; + + foreach ($keys as $key) { + $result[$key] = $this->getItem($key); + } + + return $result; + } + + /** + * {@inheritdoc} + */ + public function save(CacheItemInterface $item): bool + { + if (!$this->validKey($item->getKey())) { + return false; + } + + $itemPath = $this->cacheFilePath($item->getKey()); + $serializedItem = serialize($item->get()); + + $result = file_put_contents($itemPath, $serializedItem); + + // 0 bytes write is considered a successful operation + if ($result === false) { + return false; + } + + return true; + } + + /** + * {@inheritdoc} + */ + public function hasItem(string $key): bool + { + return $this->getItem($key)->isHit(); + } + + /** + * {@inheritdoc} + */ + public function clear(): bool + { + $this->buffer = []; + + if (!is_dir($this->cachePath)) { + return false; + } + + $files = scandir($this->cachePath); + if (!$files) { + return false; + } + + foreach ($files as $fileName) { + if ($fileName === '.' || $fileName === '..') { + continue; + } + + if (!unlink($this->cachePath . '/' . $fileName)) { + return false; + } + } + + return true; + } + + /** + * {@inheritdoc} + */ + public function deleteItem(string $key): bool + { + if (!$this->validKey($key)) { + throw new InvalidArgumentException("The key '$key' is not valid. The key should follow the pattern |^[a-zA-Z0-9_\.! ]+$|"); + } + + $itemPath = $this->cacheFilePath($key); + + if (!file_exists($itemPath)) { + return true; + } + + return unlink($itemPath); + } + + /** + * {@inheritdoc} + */ + public function deleteItems(array $keys): bool + { + $result = true; + + foreach ($keys as $key) { + if (!$this->deleteItem($key)) { + $result = false; + } + } + + return $result; + } + + /** + * {@inheritdoc} + */ + public function saveDeferred(CacheItemInterface $item): bool + { + array_push($this->buffer, $item); + + return true; + } + + /** + * {@inheritdoc} + */ + public function commit(): bool + { + $result = true; + + foreach ($this->buffer as $item) { + if (!$this->save($item)) { + $result = false; + } + } + + return $result; + } + + private function cacheFilePath(string $key): string + { + return $this->cachePath . '/' . $key; + } + + private function validKey(string $key): bool + { + return (bool) preg_match('|^[a-zA-Z0-9_\.]+$|', $key); + } +} diff --git a/tests/Cache/FileSystemCacheItemPoolTest.php b/tests/Cache/FileSystemCacheItemPoolTest.php new file mode 100644 index 000000000..a3214587a --- /dev/null +++ b/tests/Cache/FileSystemCacheItemPoolTest.php @@ -0,0 +1,220 @@ +', ',', '/', ' ', + ]; + + public function setUp(): void + { + $this->pool = new FileSystemCacheItemPool($this->defaultCacheDirectory); + } + + public function tearDown(): void + { + $files = scandir($this->defaultCacheDirectory); + + foreach($files as $fileName) { + if ($fileName === '.' || $fileName === '..') { + continue; + } + + unlink($this->defaultCacheDirectory . '/' . $fileName); + } + + rmdir($this->defaultCacheDirectory); + } + + public function testInstanceCreatesCacheFolder() + { + $this->assertTrue(file_exists($this->defaultCacheDirectory)); + $this->assertTrue(is_dir($this->defaultCacheDirectory)); + } + + public function testSaveAndGetItem() + { + $item = $this->getNewItem(); + $item->expiresAfter(60); + $this->pool->save($item); + $retrievedItem = $this->pool->getItem($item->getKey()); + + $this->assertTrue($retrievedItem->isHit()); + $this->assertEquals($retrievedItem->get(), $item->get()); + } + + public function testHasItem() + { + $item = $this->getNewItem(); + $this->assertFalse($this->pool->hasItem($item->getKey())); + $this->pool->save($item); + $this->assertTrue($this->pool->hasItem($item->getKey())); + } + + public function testDeleteItem() + { + $item = $this->getNewItem(); + $this->pool->save($item); + + $this->assertTrue($this->pool->deleteItem($item->getKey())); + $this->assertFalse($this->pool->hasItem($item->getKey())); + } + + public function testDeleteItems() + { + $items = [ + $this->getNewItem(), + $this->getNewItem('NewItem2'), + $this->getNewItem('NewItem3') + ]; + + foreach ($items as $item) { + $this->pool->save($item); + } + + $itemKeys = array_map(fn ($item) => $item->getKey(), $items); + + $result = $this->pool->deleteItems($itemKeys); + $this->assertTrue($result); + } + + public function testGetItems() + { + $items = [ + $this->getNewItem(), + $this->getNewItem('NewItem2'), + $this->getNewItem('NewItem3') + ]; + + foreach ($items as $item) { + $this->pool->save($item); + } + + $keys = array_map(fn ($item) => $item->getKey(), $items); + array_push($keys, 'NonExistant'); + + $retrievedItems = $this->pool->getItems($keys); + + foreach ($items as $item) { + $this->assertTrue($retrievedItems[$item->getKey()]->isHit()); + } + + $this->assertFalse($retrievedItems['NonExistant']->isHit()); + } + + public function testClear() + { + $item = $this->getNewItem(); + $this->pool->save($item); + $this->assertLessThan(scandir($this->defaultCacheDirectory), 2); + $this->pool->clear(); + // Clear removes all the files, but scandir returns `.` and `..` as files + $this->assertEquals(count(scandir($this->defaultCacheDirectory)), 2); + } + + public function testSaveDeferredAndCommit() + { + $item = $this->getNewItem(); + $this->pool->saveDeferred($item); + $this->assertFalse($this->pool->getItem($item->getKey())->isHit()); + + $this->pool->commit(); + $this->assertTrue($this->pool->getItem($item->getKey())->isHit()); + } + + /** + * @dataProvider provideInvalidChars + */ + public function testGetItemWithIncorrectKeyShouldThrowAnException($char) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("The key '$char' is not valid. The key should follow the pattern |^[a-zA-Z0-9_\.! ]+$|"); + $item = $this->getNewItem($char); + $this->pool->getItem($item->getKey()); + } + + /** + * @dataProvider provideInvalidChars + */ + public function testGetItemsWithIncorrectKeyShouldThrowAnException($char) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("The key '$char' is not valid. The key should follow the pattern |^[a-zA-Z0-9_\.! ]+$|"); + $item = $this->getNewItem($char); + $this->pool->getItems([$item->getKey()]); + } + + /** + * @dataProvider provideInvalidChars + */ + public function testHasItemWithIncorrectKeyShouldThrowAnException($char) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("The key '$char' is not valid. The key should follow the pattern |^[a-zA-Z0-9_\.! ]+$|"); + $item = $this->getNewItem($char); + $this->pool->hasItem($item->getKey()); + } + + /** + * @dataProvider provideInvalidChars + */ + public function testDeleteItemWithIncorrectKeyShouldThrowAnException($char) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("The key '$char' is not valid. The key should follow the pattern |^[a-zA-Z0-9_\.! ]+$|"); + $item = $this->getNewItem($char); + $this->pool->deleteItem($item->getKey()); + } + + /** + * @dataProvider provideInvalidChars + */ + public function testDeleteItemsWithIncorrectKeyShouldThrowAnException($char) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("The key '$char' is not valid. The key should follow the pattern |^[a-zA-Z0-9_\.! ]+$|"); + $item = $this->getNewItem($char); + $this->pool->deleteItems([$item->getKey()]); + } + + private function getNewItem(null|string $key = null): TypedItem + { + $item = new TypedItem($key ?? 'NewItem'); + $item->set('NewValue'); + + return $item; + } + + public function provideInvalidChars(): array + { + return array_map(fn ($char) => [$char], $this->invalidChars); + } +}