diff --git a/.docker/Dockerfile.local b/.docker/Dockerfile.local new file mode 100644 index 00000000..2076a2fd --- /dev/null +++ b/.docker/Dockerfile.local @@ -0,0 +1,11 @@ +FROM php:8.1-cli-alpine +ENV XDG_CACHE_HOME=/tmp/.cache +ENV COMPOSER_CACHE_DIR=${XDG_CACHE_HOME}/composer +# posible could install cache directory here +RUN --mount=type=bind,from=mlocati/php-extension-installer:1.5,source=/usr/bin/install-php-extensions,target=/usr/local/bin/install-php-extensions \ + install-php-extensions zip xdebug-stable +RUN addgroup -S app && adduser -S app -G app +RUN mkdir -p /app && chown -R app /app && mkdir -p /tmp && chown -R app /tmp +COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer +USER app +WORKDIR /app diff --git a/.docker/xdebug.ini b/.docker/xdebug.ini new file mode 100644 index 00000000..65b1c697 --- /dev/null +++ b/.docker/xdebug.ini @@ -0,0 +1,4 @@ +[xdebug] +zend_extension="xdebug.so" +xdebug.client_host=host.docker.internal +xdebug.mode=debug diff --git a/.gitattributes b/.gitattributes index 2ea4f7b2..9052e5ee 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,3 +4,6 @@ /.github export-ignore /phpunit.xml.dist export-ignore /tests export-ignore +/.docker export-ignore +/docker-compose.yaml export-ignore +/Makefile export-ignore diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 217c1f92..c29b5589 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,21 +12,23 @@ jobs: - "lowest" - "highest" steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Install PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-versions }} - extensions: mbstring, dom + extensions: mbstring, dom, igbinary - name: Validate composer.json and composer.lock run: composer validate - name: "Composer install ${{ matrix.dependencies }} dependencies" - uses: "ramsey/composer-install@v1" + uses: ramsey/composer-install@v2 with: dependency-versions: "${{ matrix.dependencies }}" + env: + COMPOSER_PROCESS_TIMEOUT: 6000 - name: Run test suite run: composer run-script test diff --git a/.gitignore b/.gitignore index e128e8ed..d0364d3b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ composer.phar *.iml .idea/ coverage.xml +.phpunit.result.cache diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..9db22d71 --- /dev/null +++ b/Makefile @@ -0,0 +1,6 @@ +init: + docker compose run cli composer i +test: + docker compose run cli vendor/bin/phpunit +shell: + docker compose run cli sh diff --git a/README.md b/README.md index 25d60240..5b205e4d 100644 --- a/README.md +++ b/README.md @@ -290,3 +290,20 @@ See [Guzzle Cache](https://www.drupal.org/project/guzzle_cache) module. # Links that talk about the project - [Speeding Up APIs/Apps/Smart Toasters with HTTP Response Caching](https://apisyouwonthate.com/blog/speeding-up-apis-apps-smart-toasters-with-http-response-caching) - [Caching HTTP-Requests with Guzzle 6 and PSR-6](http://a.kabachnik.info/caching-http-requests-with-guzzle-6-and-psr-6.html) + +# Development + +## Docker quick start + +### Initialization +```bash +make init +``` +### Running test +```bash +make test +``` +### Entering container shell +```bash +make shell +``` diff --git a/composer.json b/composer.json index f0752430..9b787e4d 100644 --- a/composer.json +++ b/composer.json @@ -15,12 +15,13 @@ "require": { "php": ">=7.2.0", "guzzlehttp/guzzle": "^6.0 || ^7.0", + "guzzlehttp/promises": "^1.4 || ^2.0", "guzzlehttp/psr7": "^1.7.0 || ^2.0.0" }, "require-dev": { "phpunit/phpunit": "^8.5.15 || ^9.5", "doctrine/cache": "^1.10", - "league/flysystem": "^1.0", + "league/flysystem": "^2.5", "psr/cache": "^1.0", "cache/array-adapter": "^0.4 || ^0.5 || ^1.0", "illuminate/cache": "^5.0", @@ -48,5 +49,10 @@ }, "scripts": { "test": "vendor/bin/phpunit" + }, + "config": { + "allow-plugins": { + "kylekatarnls/update-helper": true + } } } diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 00000000..68d5f621 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,11 @@ +version: "3.9" +services: + cli: + build: + dockerfile: .docker/Dockerfile.local + context: . + working_dir: /app + volumes: + - ./:/app + - ~/.composer/cache/:/tmp/cache/ + - ./.docker/xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini:ro diff --git a/src/CacheEntry.php b/src/CacheEntry.php index 7e3c8bc3..4d2a965a 100644 --- a/src/CacheEntry.php +++ b/src/CacheEntry.php @@ -3,10 +3,11 @@ namespace Kevinrob\GuzzleCache; use GuzzleHttp\Psr7\PumpStream; +use Psr\Http\Message\MessageInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; -class CacheEntry +class CacheEntry implements \Serializable { /** * @var RequestInterface @@ -256,47 +257,77 @@ public function getAge() return time() - $this->dateCreated->getTimestamp(); } - public function __sleep() + public function __serialize(): array { - // Stream/Resource can't be serialized... So we copy the content into an implementation of `Psr\Http\Message\StreamInterface` - if ($this->response !== null) { - $responseBody = (string)$this->response->getBody(); - $this->response = $this->response->withBody( - new PumpStream( - new BodyStore($responseBody), - [ - 'size' => mb_strlen($responseBody), - ] - ) - ); + return [ + 'request' => self::toSerializeableMessage($this->request), + 'response' => $this->response !== null ? self::toSerializeableMessage($this->response) : null, + 'staleAt' => $this->staleAt, + 'staleIfErrorTo' => $this->staleIfErrorTo, + 'staleWhileRevalidateTo' => $this->staleWhileRevalidateTo, + 'dateCreated' => $this->dateCreated, + 'timestampStale' => $this->timestampStale, + ]; + } + + public function __unserialize(array $data): void + { + $prefix = ''; + if (isset($data["\0*\0request"])) { + // We are unserializing a cache entry which was serialized with a version < 4.1.1 + $prefix = "\0*\0"; } + $this->request = self::restoreStreamBody($data[$prefix.'request']); + $this->response = $data[$prefix.'response'] !== null ? self::restoreStreamBody($data[$prefix.'response']) : null; + $this->staleAt = $data[$prefix.'staleAt']; + $this->staleIfErrorTo = $data[$prefix.'staleIfErrorTo']; + $this->staleWhileRevalidateTo = $data[$prefix.'staleWhileRevalidateTo']; + $this->dateCreated = $data[$prefix.'dateCreated']; + $this->timestampStale = $data[$prefix.'timestampStale']; + } + + /** + * Stream/Resource can't be serialized... So we copy the content into an implementation of `Psr\Http\Message\StreamInterface` + * + * @template T of MessageInterface + * + * @param T $message + * @return T + */ + private static function toSerializeableMessage(MessageInterface $message): MessageInterface + { + $bodyString = (string)$message->getBody(); - $requestBody = (string)$this->request->getBody(); - $this->request = $this->request->withBody( + return $message->withBody( new PumpStream( - new BodyStore($requestBody), + new BodyStore($bodyString), [ - 'size' => mb_strlen($requestBody) + 'size' => mb_strlen($bodyString), ] ) ); + } - return array_keys(get_object_vars($this)); + /** + * @template T of MessageInterface + * + * @param T $message + * @return T + */ + private static function restoreStreamBody(MessageInterface $message): MessageInterface + { + return $message->withBody( + \GuzzleHttp\Psr7\Utils::streamFor((string) $message->getBody()) + ); } - public function __wakeup() + public function serialize() { - // We re-create the stream of the response - if ($this->response !== null) { - $this->response = $this->response - ->withBody( - \GuzzleHttp\Psr7\Utils::streamFor((string) $this->response->getBody()) - ); - } - $this->request = $this->request - ->withBody( - \GuzzleHttp\Psr7\Utils::streamFor((string) $this->request->getBody()) - ); + return serialize($this->__serialize()); } + public function unserialize($data) + { + $this->__unserialize(unserialize($data)); + } } diff --git a/src/CacheMiddleware.php b/src/CacheMiddleware.php index bb55f0cd..ee115208 100644 --- a/src/CacheMiddleware.php +++ b/src/CacheMiddleware.php @@ -110,7 +110,7 @@ public function getHttpMethods() */ public function purgeReValidation() { - \GuzzleHttp\Promise\inspect_all($this->waitingRevalidate); + \GuzzleHttp\Promise\Utils::inspectAll($this->waitingRevalidate); } /** @@ -237,11 +237,9 @@ function (ResponseInterface $response) use ($request, $cacheEntry) { return static::addToCache($this->cacheStorage, $request, $response, $update); }, function ($reason) use ($cacheEntry) { - if ($reason instanceof TransferException) { - $response = static::getStaleResponse($cacheEntry); - if ($response instanceof ResponseInterface) { - return $response; - } + $response = static::getStaleResponse($cacheEntry); + if ($response instanceof ResponseInterface) { + return $response; } return new RejectedPromise($reason); diff --git a/src/Storage/FlysystemStorage.php b/src/Storage/FlysystemStorage.php index e2db2e92..eecd6a94 100644 --- a/src/Storage/FlysystemStorage.php +++ b/src/Storage/FlysystemStorage.php @@ -3,9 +3,9 @@ namespace Kevinrob\GuzzleCache\Storage; use Kevinrob\GuzzleCache\CacheEntry; -use League\Flysystem\AdapterInterface; use League\Flysystem\Filesystem; -use League\Flysystem\FileNotFoundException; +use League\Flysystem\FilesystemAdapter; +use League\Flysystem\FilesystemException; class FlysystemStorage implements CacheStorageInterface { @@ -15,7 +15,7 @@ class FlysystemStorage implements CacheStorageInterface */ protected $filesystem; - public function __construct(AdapterInterface $adapter) + public function __construct(FilesystemAdapter $adapter) { $this->filesystem = new Filesystem($adapter); } @@ -25,7 +25,7 @@ public function __construct(AdapterInterface $adapter) */ public function fetch($key) { - if ($this->filesystem->has($key)) { + if ($this->filesystem->fileExists($key)) { // The file exist, read it! $data = @unserialize( $this->filesystem->read($key) @@ -44,7 +44,7 @@ public function fetch($key) */ public function save($key, CacheEntry $data) { - return $this->filesystem->put($key, serialize($data)); + $this->filesystem->write($key, serialize($data)); } /** @@ -53,8 +53,8 @@ public function save($key, CacheEntry $data) public function delete($key) { try { - return $this->filesystem->delete($key); - } catch (FileNotFoundException $ex) { + $this->filesystem->delete($key); + } catch (FilesystemException $ex) { return true; } } diff --git a/src/Storage/LaravelCacheStorage.php b/src/Storage/LaravelCacheStorage.php index 928869a5..3e3416d9 100644 --- a/src/Storage/LaravelCacheStorage.php +++ b/src/Storage/LaravelCacheStorage.php @@ -26,7 +26,7 @@ public function __construct(Cache $cache) public function fetch($key) { try { - $cache = unserialize($this->cache->get($key)); + $cache = unserialize($this->cache->get($key, '')); if ($cache instanceof CacheEntry) { return $cache; } @@ -70,7 +70,7 @@ public function delete($key) { return $this->cache->forget($key); } - + protected function getLifeTime(CacheEntry $data) { $version = app()->version(); diff --git a/tests/CacheEntryTest.php b/tests/CacheEntryTest.php index 21ed04f7..5d3a504f 100644 --- a/tests/CacheEntryTest.php +++ b/tests/CacheEntryTest.php @@ -111,6 +111,52 @@ public function testCacheEntryShouldBeSerializableWithIgBinaryWithoutWarning() } } + public function testSerializationShouldNotMutateCacheEntry() + { + $request = new Request( + 'GET', + 'test.local', + [], + 'Sample body' // Always include a body in the request to be sure there is a stream in it + ); + $response = new Response( + 200, [ + 'Cache-Control' => 'max-age=60', + ], + 'Test content' + ); + $cacheEntry = new CacheEntry($request, $response, $this->makeDateTimeOffset(10)); + + $originalCacheEntry = clone $cacheEntry; + + serialize($cacheEntry); + + $this->assertEquals($cacheEntry, $originalCacheEntry); + } + + /** + * @dataProvider versionsToTestProvider + */ + public function testPreviousUnserialize($version) + { + if (version_compare(PHP_VERSION, '7.4.0') < 0) { + $this->markTestSkipped('Compat with previous version is not available with \Serializable interface'); + } + + $cacheEntry = unserialize(file_get_contents(__DIR__."/data/{$version}_serialized_cache_entry")); + + self::assertInstanceOf(CacheEntry::class, $cacheEntry); + } + + public function versionsToTestProvider() + { + return [ + ['v4.0.0'], + ['v4.1.0'], + ['v4.1.1'], + ]; + } + private function setResponseHeader($name, $value) { $this->responseHeaders[$name] = [$value]; diff --git a/tests/CacheMiddlewareTest.php b/tests/CacheMiddlewareTest.php index a225bc71..2ec64ea1 100644 --- a/tests/CacheMiddlewareTest.php +++ b/tests/CacheMiddlewareTest.php @@ -2,9 +2,11 @@ namespace Kevinrob\GuzzleCache\Tests; +use GuzzleHttp\Promise\RejectedPromise; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; use GuzzleHttp\Psr7\Utils; +use Kevinrob\GuzzleCache\CacheEntry; use Kevinrob\GuzzleCache\CacheMiddleware as BaseCacheMiddleware; use Kevinrob\GuzzleCache\Storage\Psr6CacheStorage; use Kevinrob\GuzzleCache\Strategy\CacheStrategyInterface; @@ -35,6 +37,27 @@ public function testRewindAfterReadingStream() $this->assertEquals('seekable stream', $response->getBody()->getContents()); } + + public function testStaleOnRejected() + { + $request = new Request('GET', '/uri'); + $response = (new Response())->withHeader('Cache-Control', 'stale-if-error=120'); + $strategy = $this->createStub(CacheStrategyInterface::class); + $strategy->method('fetch')->willReturn(new CacheEntry( + $request, + $response, + new \DateTime('-1 second') + )); + $handler = function () { + return new RejectedPromise(new \RuntimeException('Unexpected error')); + }; + $middleware = new CacheMiddleware($strategy); + + $result = ($middleware($handler)($request, []))->wait(); + + $this->assertInstanceOf(ResponseInterface::class, $result); + $this->assertEquals(CacheMiddleware::HEADER_CACHE_STALE, $result->getHeaderLine(CacheMiddleware::HEADER_CACHE_INFO)); + } } class CacheMiddleware extends BaseCacheMiddleware diff --git a/tests/PrivateCacheTest.php b/tests/PrivateCacheTest.php index 1708bb02..6ee2cd57 100644 --- a/tests/PrivateCacheTest.php +++ b/tests/PrivateCacheTest.php @@ -18,7 +18,7 @@ use Kevinrob\GuzzleCache\Storage\Psr16CacheStorage; use Kevinrob\GuzzleCache\Storage\VolatileRuntimeStorage; use Kevinrob\GuzzleCache\Strategy\PrivateCacheStrategy; -use League\Flysystem\Adapter\Local; +use League\Flysystem\Local\LocalFilesystemAdapter; use PHPUnit\Framework\TestCase; class PrivateCacheTest extends TestCase @@ -89,7 +89,7 @@ public function cacheProvider() 'doctrine.chaincache' => [ new DoctrineCacheStorage(new ChainCache([new ArrayCache()])) ], 'doctrine.filesystem' => [ new DoctrineCacheStorage(new FilesystemCache($TMP_DIR)), $TMP_DIR ], 'doctrine.phpfile' => [ new DoctrineCacheStorage(new PhpFileCache($TMP_DIR)), $TMP_DIR ], - 'flysystem' => [ new FlysystemStorage(new Local($TMP_DIR)), $TMP_DIR ], + 'flysystem' => [ new FlysystemStorage(new LocalFilesystemAdapter($TMP_DIR)), $TMP_DIR ], 'psr6' => [ new Psr6CacheStorage(new ArrayCachePool()) ], 'psr16' => [ new Psr16CacheStorage(new SimpleCacheBridge(new ArrayCachePool())) ], 'compressedDoctrineStorage' => [ new CompressedDoctrineCacheStorage(new ArrayCache()) ], diff --git a/tests/PublicCacheTest.php b/tests/PublicCacheTest.php index 695d7f2d..7e39ff4f 100644 --- a/tests/PublicCacheTest.php +++ b/tests/PublicCacheTest.php @@ -22,7 +22,7 @@ use Kevinrob\GuzzleCache\Storage\Psr16CacheStorage; use Kevinrob\GuzzleCache\Storage\VolatileRuntimeStorage; use Kevinrob\GuzzleCache\Strategy\PublicCacheStrategy; -use League\Flysystem\Adapter\Local; +use League\Flysystem\Local\LocalFilesystemAdapter; use Psr\Http\Message\RequestInterface; use PHPUnit\Framework\TestCase; @@ -100,7 +100,7 @@ public function testCacheProvider() new DoctrineCacheStorage(new ChainCache([new ArrayCache()])), new DoctrineCacheStorage(new FilesystemCache($TMP_DIR)), new DoctrineCacheStorage(new PhpFileCache($TMP_DIR)), - new FlysystemStorage(new Local($TMP_DIR)), + new FlysystemStorage(new LocalFilesystemAdapter($TMP_DIR)), new Psr6CacheStorage(new ArrayCachePool()), new Psr16CacheStorage(new SimpleCacheBridge(new ArrayCachePool())), new CompressedDoctrineCacheStorage(new ArrayCache()), diff --git a/tests/data/v4.0.0_serialized_cache_entry b/tests/data/v4.0.0_serialized_cache_entry new file mode 100644 index 00000000..339af484 Binary files /dev/null and b/tests/data/v4.0.0_serialized_cache_entry differ diff --git a/tests/data/v4.1.0_serialized_cache_entry b/tests/data/v4.1.0_serialized_cache_entry new file mode 100644 index 00000000..72d74bf3 Binary files /dev/null and b/tests/data/v4.1.0_serialized_cache_entry differ diff --git a/tests/data/v4.1.1_serialized_cache_entry b/tests/data/v4.1.1_serialized_cache_entry new file mode 100644 index 00000000..4866e7b8 Binary files /dev/null and b/tests/data/v4.1.1_serialized_cache_entry differ