diff --git a/.gitattributes b/.gitattributes index 7ae0617..533d355 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,13 +1,12 @@ -.editorconfig export-ignore -.gitattributes export-ignore -.github/ export-ignore -.gitignore export-ignore -.php_cs export-ignore -.scrutinizer.yml export-ignore -.styleci.yml export-ignore -.travis.yml export-ignore -phpspec.yml.ci export-ignore -phpspec.yml.dist export-ignore -phpunit.xml.dist export-ignore -spec/ export-ignore -tests/ export-ignore +.editorconfig export-ignore +.gitattributes export-ignore +/.github/ export-ignore +.gitignore export-ignore +/.php_cs export-ignore +/.scrutinizer.yml export-ignore +/.styleci.yml export-ignore +/behat.yml.dist export-ignore +/features/ export-ignore +/phpspec.ci.yml export-ignore +/phpspec.yml.dist export-ignore +/spec/ export-ignore diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index 49ec72b..d4ecf20 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -1,7 +1,7 @@ | Q | A | ------------ | --- -| Bug? | no -| New Feature? | no +| Bug? | no|yes +| New Feature? | no|yes | Version | Specific version or SHA of a commit diff --git a/.github/workflows/.editorconfig b/.github/workflows/.editorconfig new file mode 100644 index 0000000..7bd3346 --- /dev/null +++ b/.github/workflows/.editorconfig @@ -0,0 +1,2 @@ +[*.yml] +indent_size = 2 diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml new file mode 100644 index 0000000..d4c7e85 --- /dev/null +++ b/.github/workflows/static.yml @@ -0,0 +1,21 @@ +name: Static analysis + +on: + push: + branches: + - '*.x' + pull_request: + +jobs: + phpstan: + name: PHPStan + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: PHPStan + uses: docker://oskarstark/phpstan-ga + with: + args: analyze --no-progress diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..8e2c0c3 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,90 @@ +name: tests + +on: + push: + branches: + - '*.x' + pull_request: + +jobs: + latest: + name: PHP ${{ matrix.php }} Latest + runs-on: ubuntu-latest + strategy: + matrix: + php: ['7.1', '7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + tools: composer:v2 + coverage: none + + - name: Emulate PHP 8.3 + run: composer config platform.php 8.3.999 + if: matrix.php == '8.4' + + - name: Install PHP dependencies + run: composer update --prefer-dist --no-interaction --no-progress + + - name: Execute tests + run: composer test + + lowest: + name: PHP ${{ matrix.php }} Lowest + runs-on: ubuntu-latest + strategy: + matrix: + php: ['7.1', '7.4', '8.0'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + tools: composer:v2 + coverage: none + + - name: Install dependencies + run: | + composer require "sebastian/comparator:^3.0.2" --no-interaction --no-update + composer update --prefer-dist --prefer-stable --prefer-lowest --no-interaction --no-progress + + - name: Execute tests + run: composer test + + coverage: + name: Code Coverage + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 7.4 + tools: composer:v2 + coverage: xdebug + + - name: Install dependencies + run: | + composer require "friends-of-phpspec/phpspec-code-coverage" --no-interaction --no-update + composer update --prefer-dist --no-interaction --no-progress + + - name: Execute tests + run: composer test-ci + + - name: Upload coverage + run: | + wget https://scrutinizer-ci.com/ocular.phar + php ocular.phar code-coverage:upload --format=php-clover build/coverage.xml diff --git a/.gitignore b/.gitignore index 7608c4b..16b4a20 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ -build/ -vendor/ -composer.lock -phpspec.yml -phpunit.xml +/behat.yml +/build/ +/composer.lock +/phpspec.yml +/phpunit.xml +/vendor/ diff --git a/.styleci.yml b/.styleci.yml index 3417c41..1e6efad 100644 --- a/.styleci.yml +++ b/.styleci.yml @@ -6,6 +6,3 @@ finder: path: - "src" - "tests" - -enabled: - - short_array_syntax diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 7a4a218..0000000 --- a/.travis.yml +++ /dev/null @@ -1,41 +0,0 @@ -language: php - -sudo: false - -cache: - directories: - - $HOME/.composer/cache/files - -php: - - 5.4 - - 5.5 - - 5.6 - - 7.0 - - hhvm - -env: - global: - - TEST_COMMAND="composer test" - -branches: - except: - - /^analysis-.*$/ - -matrix: - fast_finish: true - include: - - php: 5.4 - env: COMPOSER_FLAGS="--prefer-stable --prefer-lowest" COVERAGE=true TEST_COMMAND="composer test-ci" - -before_install: - - travis_retry composer self-update - -install: - - travis_retry composer update ${COMPOSER_FLAGS} --prefer-dist --no-interaction - -script: - - $TEST_COMMAND - -after_success: - - if [[ "$COVERAGE" = true ]]; then wget https://scrutinizer-ci.com/ocular.phar; fi - - if [[ "$COVERAGE" = true ]]; then php ocular.phar code-coverage:upload --format=php-clover build/coverage.xml; fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 84dc0d1..b859565 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,144 @@ # Change Log +# Version 2 + +## 2.0.1 - 2024-10-02 + +- Test with PHP 8.3 and 8.4. + +## 2.0.0 - 2024-02-19 + +### Changed + +- Drop support of deprecated PHP-HTTP `StreamFactory`, only PSR-17 `StreamFactoryInterface` is now supported. + +# Version 1 + +## 1.8.1 - 2023-11-21 + +- Allow installation with Symfony 7. + +## 1.8.0 - 2023-04-28 + +- Avoid PHP warning about serializing resources when serializing the response by detaching the stream. + +## 1.7.6 - 2023-04-28 + +- Test with PHP 8.1 and 8.2 +- Made phpspec tests compatible with PSR-7 2.0 strict typing +- Detect `null` and use 0 explicitly to calculate expiration + +## 1.7.5 - 2022-01-18 + +- Allow installation with psr/cache 3.0 (1.0 and 2.0 are still allowed too) + +## 1.7.4 - 2021-11-30 + +### Added + +- Allow installation with Symfony 6 + +## 1.7.3 - 2021-11-03 + +### Changed + +- Be more defensive about cache hits. A cache entry can technically contain `null`. + +## 1.7.2 - 2021-04-14 + +### Added + +- Allow installation with psr/cache 2.0 (1.0 still allowed too) + +## 1.7.1 - 2020-07-13 + +### Added + +- Support for PHP 8 + +## 1.7.0 - 2019-12-17 + +### Added + +* Support for Symfony 5. +* Support for PSR-17 `StreamFactoryInterface`. +* Added `blacklisted_paths` option, which takes an array of `strings` (regular expressions) and allows to define paths, that shall not be cached in any case. + +## 1.6.0 - 2019-01-23 + +### Added + +* Support for HTTPlug 2 / PSR-18 +* Added `cache_listeners` option, which takes an array of `CacheListener`s, who get notified and can optionally act on a Response based on a cache hit or miss event. An implementation, `AddHeaderCacheListener`, is provided which will add an `X-Cache` header to the response with this information. + +## 1.5.0 - 2017-11-29 + +### Added + +* Support for Symfony 4 + +### Changed + +* Removed check if etag is a string. Etag can never be a string, it is always an array. + +## 1.4.0 - 2017-04-05 + +### Added + +- `CacheKeyGenerator` interface that allow you to configure how the PSR-6 cache key is created. There are two implementations +of this interface: `SimpleGenerator` (default) and `HeaderCacheKeyGenerator`. + +### Fixed + +- Issue where deprecation warning always was triggered. Not it is just triggered if `respect_cache_headers` is used. + +## 1.3.0 - 2017-03-28 + +### Added + +- New `methods` option which allows to configure the request methods which can be cached. +- New `respect_response_cache_directives` option to define specific cache directives to respect when handling responses. +- Introduced `CachePlugin::clientCache` and `CachePlugin::serverCache` factory methods to easily setup the plugin with + the correct config settigns for each usecase. + +### Changed + +- The `no-cache` directive is now respected by the plugin and will not cache the response. If you need the previous behaviour, configure `respect_response_cache_directives`. +- We always rewind the stream after loading response from cache. + +### Deprecated + +- The `respect_cache_headers` option is deprecated and will be removed in 2.0. This option is replaced by the new `respect_response_cache_directives` option. + If you had set `respect_cache_headers` to `false`, set the directives to `[]` to ignore all directives. + + +## 1.2.0 - 2016-08-16 + +### Changed + +- The default value for `default_ttl` is changed from `null` to `0`. + +### Fixed + +- Issue when you use `respect_cache_headers=>false` in combination with `default_ttl=>null`. +- We allow `cache_lifetime` to be set to `null`. + + +## 1.1.0 - 2016-08-04 + +### Added + +- Support for cache validation with ETag and Last-Modified headers. (Enabled automatically when the server sends the relevant headers.) +- `hash_algo` config option used for cache key generation (defaults to **sha1**). + +### Changed + +- Default hash algo used for cache generation (from **md5** to **sha1**). + +### Fixed + +- Cast max age header to integer in order to get valid expiration value. + ## 1.0.0 - 2016-05-05 diff --git a/README.md b/README.md index ce07b09..f4f9f43 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Latest Version](https://img.shields.io/github/release/php-http/cache-plugin.svg?style=flat-square)](https://github.com/php-http/cache-plugin/releases) [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE) -[![Build Status](https://img.shields.io/travis/php-http/cache-plugin.svg?style=flat-square)](https://travis-ci.org/php-http/cache-plugin) +[![Build Status](https://github.com/php-http/cache-plugin/actions/workflows/tests.yml/badge.svg)](https://github.com/php-http/cache-plugin/actions/workflows/tests.yml) [![Code Coverage](https://img.shields.io/scrutinizer/coverage/g/php-http/cache-plugin.svg?style=flat-square)](https://scrutinizer-ci.com/g/php-http/cache-plugin) [![Quality Score](https://img.shields.io/scrutinizer/g/php-http/cache-plugin.svg?style=flat-square)](https://scrutinizer-ci.com/g/php-http/cache-plugin) [![Total Downloads](https://img.shields.io/packagist/dt/php-http/cache-plugin.svg?style=flat-square)](https://packagist.org/packages/php-http/cache-plugin) @@ -15,7 +15,7 @@ Via Composer ``` bash -$ composer require php-http/cache-plugin +composer require php-http/cache-plugin ``` @@ -27,7 +27,7 @@ Please see the [official documentation](http://docs.php-http.org/en/latest/plugi ## Testing ``` bash -$ composer test +composer test ``` diff --git a/composer.json b/composer.json index 59019b5..825cfe3 100644 --- a/composer.json +++ b/composer.json @@ -11,30 +11,28 @@ } ], "require": { - "php": ">=5.4", - "psr/cache": "^1.0", - "php-http/client-common": "^1.1", - "php-http/message-factory": "^1.0", - "symfony/options-resolver": "^2.6|^3.0" + "php": "^7.1 || ^8.0", + "psr/cache": "^1.0 || ^2.0 || ^3.0", + "php-http/client-common": "^1.9 || ^2.0", + "psr/http-factory-implementation": "^1.0", + "symfony/options-resolver": "^2.6 || ^3.0 || ^4.0 || ^5.0 || ^6.0 || ^7.0" }, "require-dev": { - "phpspec/phpspec": "^2.5", - "henrikbjorn/phpspec-code-coverage" : "^1.0" + "phpspec/phpspec": "^5.1 || ^6.0 || ^7.0", + "nyholm/psr7": "^1.6.1" }, "autoload": { "psr-4": { "Http\\Client\\Common\\Plugin\\": "src/" } }, - "scripts": { - "test": "vendor/bin/phpspec run", - "test-ci": "vendor/bin/phpspec run -c phpspec.yml.ci" - }, - "extra": { - "branch-alias": { - "dev-master": "1.0-dev" + "autoload-dev": { + "psr-4": { + "spec\\Http\\Client\\Common\\Plugin\\": "spec/" } }, - "prefer-stable": true, - "minimum-stability": "dev" + "scripts": { + "test": "vendor/bin/phpspec run", + "test-ci": "vendor/bin/phpspec run -c phpspec.ci.yml" + } } diff --git a/phpspec.yml.ci b/phpspec.ci.yml similarity index 76% rename from phpspec.yml.ci rename to phpspec.ci.yml index 4407704..bd039ca 100644 --- a/phpspec.yml.ci +++ b/phpspec.ci.yml @@ -4,7 +4,7 @@ suites: psr4_prefix: Http\Client\Common\Plugin formatter.name: pretty extensions: - - PhpSpec\Extension\CodeCoverageExtension + FriendsOfPhpSpec\PhpSpec\CodeCoverage\CodeCoverageExtension: ~ code_coverage: format: clover output: build/coverage.xml diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..ac454e0 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,5 @@ +parameters: + level: 8 + paths: + - src + treatPhpDocTypesAsCertain: false diff --git a/spec/Cache/Generator/HeaderCacheKeyGeneratorSpec.php b/spec/Cache/Generator/HeaderCacheKeyGeneratorSpec.php new file mode 100644 index 0000000..2b2be5a --- /dev/null +++ b/spec/Cache/Generator/HeaderCacheKeyGeneratorSpec.php @@ -0,0 +1,42 @@ +beConstructedWith(['Authorization', 'Content-Type']); + } + + public function it_is_initializable() + { + $this->shouldHaveType(HeaderCacheKeyGenerator::class); + } + + public function it_is_a_key_generator() + { + $this->shouldImplement(CacheKeyGenerator::class); + } + + public function it_generates_cache_from_request(RequestInterface $request, UriInterface $uri, StreamInterface $body) + { + $uri->__toString()->shouldBeCalled()->willReturn('http://example.com/foo'); + + $request->getMethod()->shouldBeCalled()->willReturn('GET'); + $request->getUri()->shouldBeCalled()->willReturn($uri); + $request->getHeaderLine('Authorization')->shouldBeCalled()->willReturn('bar'); + $request->getHeaderLine('Content-Type')->shouldBeCalled()->willReturn('application/baz'); + $request->getBody()->shouldBeCalled()->willReturn($body); + $body->__toString()->shouldBeCalled()->willReturn(''); + + $this->generate($request)->shouldReturn('GET http://example.com/foo Authorization:"bar" Content-Type:"application/baz" '); + } +} diff --git a/spec/Cache/Generator/SimpleGeneratorSpec.php b/spec/Cache/Generator/SimpleGeneratorSpec.php new file mode 100644 index 0000000..8c5f930 --- /dev/null +++ b/spec/Cache/Generator/SimpleGeneratorSpec.php @@ -0,0 +1,46 @@ +shouldHaveType(SimpleGenerator::class); + } + + public function it_is_a_key_generator() + { + $this->shouldImplement(CacheKeyGenerator::class); + } + + public function it_generates_cache_from_request(RequestInterface $request, UriInterface $uri, StreamInterface $body) + { + $uri->__toString()->shouldBeCalled()->willReturn('http://example.com/foo'); + $body->__toString()->shouldBeCalled()->willReturn('bar'); + $request->getMethod()->shouldBeCalled()->willReturn('GET'); + $request->getUri()->shouldBeCalled()->willReturn($uri); + $request->getBody()->shouldBeCalled()->willReturn($body); + + $this->generate($request)->shouldReturn('GET http://example.com/foo bar'); + } + + public function it_generates_cache_from_request_with_no_body(RequestInterface $request, UriInterface $uri, StreamInterface $body) + { + $uri->__toString()->shouldBeCalled()->willReturn('http://example.com/foo'); + $body->__toString()->shouldBeCalled()->willReturn(''); + $request->getMethod()->shouldBeCalled()->willReturn('GET'); + $request->getUri()->shouldBeCalled()->willReturn($uri); + $request->getBody()->shouldBeCalled()->willReturn($body); + + // No extra space after uri + $this->generate($request)->shouldReturn('GET http://example.com/foo'); + } +} diff --git a/spec/CachePluginSpec.php b/spec/CachePluginSpec.php index a46327e..fabd8a2 100644 --- a/spec/CachePluginSpec.php +++ b/spec/CachePluginSpec.php @@ -2,51 +2,81 @@ namespace spec\Http\Client\Common\Plugin; -use Http\Message\StreamFactory; +use Http\Client\Common\Plugin\Cache\Generator\SimpleGenerator; +use PhpSpec\Wrapper\Collaborator; +use Prophecy\Argument; use Http\Promise\FulfilledPromise; use PhpSpec\ObjectBehavior; use Psr\Cache\CacheItemInterface; use Psr\Cache\CacheItemPoolInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\StreamFactoryInterface; use Psr\Http\Message\StreamInterface; +use Psr\Http\Message\UriInterface; +use Http\Client\Common\Plugin\CachePlugin; +use Http\Client\Common\Plugin; class CachePluginSpec extends ObjectBehavior { - function let(CacheItemPoolInterface $pool, StreamFactory $streamFactory) + /** + * @var StreamFactoryInterface&Collaborator + */ + private $streamFactory; + + function let(CacheItemPoolInterface $pool, StreamFactoryInterface $streamFactory) { - $this->beConstructedWith($pool, $streamFactory, ['default_ttl'=>60]); + $this->streamFactory = $streamFactory; + $this->beConstructedWith($pool, $streamFactory, [ + 'default_ttl' => 60, + 'cache_lifetime' => 1000 + ]); } function it_is_initializable(CacheItemPoolInterface $pool) { - $this->shouldHaveType('Http\Client\Common\Plugin\CachePlugin'); + $this->shouldHaveType(CachePlugin::class); } function it_is_a_plugin() { - $this->shouldImplement('Http\Client\Common\Plugin'); + $this->shouldImplement(Plugin::class); } - function it_caches_responses(CacheItemPoolInterface $pool, CacheItemInterface $item, RequestInterface $request, ResponseInterface $response, StreamInterface $stream) + function it_caches_responses(CacheItemPoolInterface $pool, CacheItemInterface $item, RequestInterface $request, UriInterface $uri, ResponseInterface $response, StreamInterface $stream) { $httpBody = 'body'; $stream->__toString()->willReturn($httpBody); $stream->isSeekable()->willReturn(true); $stream->rewind()->shouldBeCalled(); + $stream->detach()->shouldBeCalled(); $request->getMethod()->willReturn('GET'); - $request->getUri()->willReturn('/'); + $request->getUri()->willReturn($uri); + $uri->__toString()->willReturn('https://example.com/'); + $request->getBody()->shouldBeCalled()->willReturn($stream); + $response->getStatusCode()->willReturn(200); $response->getBody()->willReturn($stream); - $response->getHeader('Cache-Control')->willReturn(array()); - $response->getHeader('Expires')->willReturn(array()); + $response->getHeader('Cache-Control')->willReturn([])->shouldBeCalled(); + $response->getHeader('Expires')->willReturn([])->shouldBeCalled(); + $response->getHeader('ETag')->willReturn([])->shouldBeCalled(); + $response->withBody($stream)->shouldBeCalled()->willReturn($response); + + $this->streamFactory->createStream($httpBody)->shouldBeCalled()->willReturn($stream); - $pool->getItem('e3b717d5883a45ef9493d009741f7c64')->shouldBeCalled()->willReturn($item); + $pool->getItem(Argument::any())->shouldBeCalled()->willReturn($item); $item->isHit()->willReturn(false); - $item->set(['response' => $response, 'body' => $httpBody])->willReturn($item)->shouldBeCalled(); - $item->expiresAfter(60)->willReturn($item)->shouldBeCalled(); - $pool->save($item)->shouldBeCalled(); + $item->expiresAfter(1060)->willReturn($item)->shouldBeCalled(); + + $item->set($this->getCacheItemMatcher([ + 'response' => $response->getWrappedObject(), + 'body' => $httpBody, + 'expiresAt' => 0, + 'createdAt' => 0, + 'etag' => [] + ]))->willReturn($item)->shouldBeCalled(); + $pool->save(Argument::any())->shouldBeCalled(); $next = function (RequestInterface $request) use ($response) { return new FulfilledPromise($response->getWrappedObject()); @@ -55,15 +85,19 @@ function it_caches_responses(CacheItemPoolInterface $pool, CacheItemInterface $i $this->handleRequest($request, $next, function () {}); } - function it_doesnt_store_failed_responses(CacheItemPoolInterface $pool, CacheItemInterface $item, RequestInterface $request, ResponseInterface $response) + function it_doesnt_store_failed_responses(CacheItemPoolInterface $pool, CacheItemInterface $item, RequestInterface $request, UriInterface $uri, StreamInterface $requestBody, ResponseInterface $response) { $request->getMethod()->willReturn('GET'); - $request->getUri()->willReturn('/'); + $request->getUri()->willReturn($uri); + $uri->__toString()->willReturn('https://example.com/'); + $request->getBody()->shouldBeCalled()->willReturn($requestBody); + $requestBody->__toString()->shouldBeCalled()->willReturn('body'); + $response->getStatusCode()->willReturn(400); - $response->getHeader('Cache-Control')->willReturn(array()); - $response->getHeader('Expires')->willReturn(array()); + $response->getHeader('Cache-Control')->willReturn([]); + $response->getHeader('Expires')->willReturn([]); - $pool->getItem('e3b717d5883a45ef9493d009741f7c64')->shouldBeCalled()->willReturn($item); + $pool->getItem(Argument::any())->shouldBeCalled()->willReturn($item); $item->isHit()->willReturn(false); $next = function (RequestInterface $request) use ($response) { @@ -73,10 +107,67 @@ function it_doesnt_store_failed_responses(CacheItemPoolInterface $pool, CacheIte $this->handleRequest($request, $next, function () {}); } - function it_doesnt_store_post_requests(CacheItemPoolInterface $pool, CacheItemInterface $item, RequestInterface $request, ResponseInterface $response) + function it_doesnt_store_post_requests_by_default(CacheItemPoolInterface $pool, CacheItemInterface $item, RequestInterface $request, UriInterface $uri, ResponseInterface $response) { $request->getMethod()->willReturn('POST'); - $request->getUri()->willReturn('/'); + $request->getUri()->willReturn($uri); + $uri->__toString()->willReturn('https://example.com/'); + + $next = function (RequestInterface $request) use ($response) { + return new FulfilledPromise($response->getWrappedObject()); + }; + + $this->handleRequest($request, $next, function () {}); + } + + function it_stores_post_requests_when_allowed( + CacheItemPoolInterface $pool, + CacheItemInterface $item, + RequestInterface $request, + UriInterface $uri, + ResponseInterface $response, + StreamFactoryInterface $streamFactory, + StreamInterface $stream + ) { + $this->beConstructedWith($pool, $streamFactory, [ + 'default_ttl' => 60, + 'cache_lifetime' => 1000, + 'methods' => ['GET', 'HEAD', 'POST'] + ]); + + $httpBody = 'hello=world'; + $stream->__toString()->willReturn($httpBody); + $stream->isSeekable()->willReturn(true); + $stream->rewind()->shouldBeCalled(); + $stream->detach()->shouldBeCalled(); + + $request->getMethod()->willReturn('POST'); + $request->getUri()->willReturn($uri); + $uri->__toString()->willReturn('https://example.com/'); + $request->getBody()->willReturn($stream); + + $response->getStatusCode()->willReturn(200); + $response->getBody()->willReturn($stream); + $response->getHeader('Cache-Control')->willReturn([])->shouldBeCalled(); + $response->getHeader('Expires')->willReturn([])->shouldBeCalled(); + $response->getHeader('ETag')->willReturn([])->shouldBeCalled(); + $response->withBody($stream)->shouldBeCalled()->willReturn($response); + + $this->streamFactory->createStream($httpBody)->shouldBeCalled()->willReturn($stream); + + $pool->getItem(Argument::any())->shouldBeCalled()->willReturn($item); + $item->isHit()->willReturn(false); + $item->expiresAfter(1060)->willReturn($item)->shouldBeCalled(); + + $item->set($this->getCacheItemMatcher([ + 'response' => $response->getWrappedObject(), + 'body' => $httpBody, + 'expiresAt' => 0, + 'createdAt' => 0, + 'etag' => [] + ]))->willReturn($item)->shouldBeCalled(); + + $pool->save(Argument::any())->shouldBeCalled(); $next = function (RequestInterface $request) use ($response) { return new FulfilledPromise($response->getWrappedObject()); @@ -85,28 +176,60 @@ function it_doesnt_store_post_requests(CacheItemPoolInterface $pool, CacheItemIn $this->handleRequest($request, $next, function () {}); } + function it_does_not_allow_invalid_request_methods( + CacheItemPoolInterface $pool, + CacheItemInterface $item, + RequestInterface $request, + ResponseInterface $response, + StreamFactoryInterface $streamFactory, + StreamInterface $stream + ) { + $this + ->shouldThrow("Symfony\Component\OptionsResolver\Exception\InvalidOptionsException") + ->during('__construct', [$pool, $streamFactory, ['methods' => ['GET', 'HEAD', 'POST ']]]); + $this + ->shouldThrow("Symfony\Component\OptionsResolver\Exception\InvalidOptionsException") + ->during('__construct', [$pool, $streamFactory, ['methods' => ['GET', 'HEAD"', 'POST']]]); + $this + ->shouldThrow("Symfony\Component\OptionsResolver\Exception\InvalidOptionsException") + ->during('__construct', [$pool, $streamFactory, ['methods' => ['GET', 'head', 'POST']]]); + } - function it_calculate_age_from_response(CacheItemPoolInterface $pool, CacheItemInterface $item, RequestInterface $request, ResponseInterface $response, StreamInterface $stream) + function it_calculate_age_from_response(CacheItemPoolInterface $pool, CacheItemInterface $item, RequestInterface $request, UriInterface $uri, ResponseInterface $response, StreamInterface $stream) { $httpBody = 'body'; $stream->__toString()->willReturn($httpBody); $stream->isSeekable()->willReturn(true); $stream->rewind()->shouldBeCalled(); + $stream->detach()->shouldBeCalled(); $request->getMethod()->willReturn('GET'); - $request->getUri()->willReturn('/'); + $request->getUri()->willReturn($uri); + $uri->__toString()->willReturn('https://example.com/'); + $request->getBody()->shouldBeCalled()->willReturn($stream); + $response->getStatusCode()->willReturn(200); $response->getBody()->willReturn($stream); - $response->getHeader('Cache-Control')->willReturn(array('max-age=40')); - $response->getHeader('Age')->willReturn(array('15')); - $response->getHeader('Expires')->willReturn(array()); + $response->getHeader('Cache-Control')->willReturn(['max-age=40']); + $response->getHeader('Age')->willReturn(['15']); + $response->getHeader('Expires')->willReturn([]); + $response->getHeader('ETag')->willReturn([]); + $response->withBody($stream)->shouldBeCalled()->willReturn($response); + + $this->streamFactory->createStream($httpBody)->shouldBeCalled()->willReturn($stream); - $pool->getItem('e3b717d5883a45ef9493d009741f7c64')->shouldBeCalled()->willReturn($item); + $pool->getItem(Argument::any())->shouldBeCalled()->willReturn($item); $item->isHit()->willReturn(false); - // 40-15 should be 25 - $item->set(['response' => $response, 'body' => $httpBody])->willReturn($item)->shouldBeCalled(); - $item->expiresAfter(25)->willReturn($item)->shouldBeCalled(); + $item->set($this->getCacheItemMatcher([ + 'response' => $response->getWrappedObject(), + 'body' => $httpBody, + 'expiresAt' => 0, + 'createdAt' => 0, + 'etag' => [] + ]))->willReturn($item)->shouldBeCalled(); + // 40-15 should be 25 + the default 1000 + $item->expiresAfter(1025)->willReturn($item)->shouldBeCalled(); $pool->save($item)->shouldBeCalled(); $next = function (RequestInterface $request) use ($response) { @@ -115,4 +238,383 @@ function it_calculate_age_from_response(CacheItemPoolInterface $pool, CacheItemI $this->handleRequest($request, $next, function () {}); } + + function it_saves_etag(CacheItemPoolInterface $pool, CacheItemInterface $item, RequestInterface $request, UriInterface $uri, ResponseInterface $response, StreamInterface $stream) + { + $httpBody = 'body'; + $stream->__toString()->willReturn($httpBody); + $stream->isSeekable()->willReturn(true); + $stream->rewind()->shouldBeCalled(); + $stream->detach()->shouldBeCalled(); + $request->getBody()->shouldBeCalled()->willReturn($stream); + + $request->getMethod()->willReturn('GET'); + $request->getUri()->willReturn($uri); + $uri->__toString()->willReturn('https://example.com/'); + $response->getStatusCode()->willReturn(200); + $response->getBody()->willReturn($stream); + $response->getHeader('Cache-Control')->willReturn([]); + $response->getHeader('Expires')->willReturn([]); + $response->getHeader('ETag')->willReturn(['foo_etag']); + $response->withBody($stream)->shouldBeCalled()->willReturn($response); + + $this->streamFactory->createStream($httpBody)->shouldBeCalled()->willReturn($stream); + + $pool->getItem(Argument::any())->shouldBeCalled()->willReturn($item); + $item->isHit()->willReturn(false); + $item->expiresAfter(1060)->willReturn($item); + + $item->set($this->getCacheItemMatcher([ + 'response' => $response->getWrappedObject(), + 'body' => $httpBody, + 'expiresAt' => 0, + 'createdAt' => 0, + 'etag' => ['foo_etag'] + ]))->willReturn($item)->shouldBeCalled(); + $pool->save(Argument::any())->shouldBeCalled(); + + $next = function (RequestInterface $request) use ($response) { + return new FulfilledPromise($response->getWrappedObject()); + }; + + $this->handleRequest($request, $next, function () {}); + } + + function it_adds_etag_and_modfied_since_to_request(CacheItemPoolInterface $pool, CacheItemInterface $item, RequestInterface $request, UriInterface $uri, ResponseInterface $response, StreamInterface $stream) + { + $httpBody = 'body'; + + $request->getMethod()->willReturn('GET'); + $request->getUri()->willReturn($uri); + $uri->__toString()->willReturn('https://example.com/'); + $request->getBody()->shouldBeCalled()->willReturn($stream); + $stream->__toString()->shouldBeCalled()->willReturn(''); + + $request->withHeader('If-Modified-Since', 'Thursday, 01-Jan-70 01:18:31 GMT')->shouldBeCalled()->willReturn($request); + $request->withHeader('If-None-Match', 'foo_etag')->shouldBeCalled()->willReturn($request); + + $response->getStatusCode()->willReturn(304); + + $pool->getItem(Argument::any())->shouldBeCalled()->willReturn($item); + $item->isHit()->willReturn(true, false); + $item->get()->willReturn([ + 'response' => $response, + 'body' => $httpBody, + 'expiresAt' => 0, + 'createdAt' => 4711, + 'etag' => ['foo_etag'] + ])->shouldBeCalled(); + + $next = function (RequestInterface $request) use ($response) { + return new FulfilledPromise($response->getWrappedObject()); + }; + + $this->handleRequest($request, $next, function () {}); + } + + function it_serves_a_cached_response(CacheItemPoolInterface $pool, CacheItemInterface $item, RequestInterface $request, UriInterface $uri, StreamInterface $requestBody, ResponseInterface $response, StreamInterface $stream, StreamFactoryInterface $streamFactory) + { + $httpBody = 'body'; + + $request->getMethod()->willReturn('GET'); + $request->getUri()->willReturn($uri); + $uri->__toString()->willReturn('https://example.com/'); + $request->getBody()->shouldBeCalled()->willReturn($requestBody); + $requestBody->__toString()->shouldBeCalled()->willReturn(''); + + $pool->getItem(Argument::any())->shouldBeCalled()->willReturn($item); + $item->isHit()->willReturn(true); + $item->get()->willReturn([ + 'response' => $response, + 'body' => $httpBody, + 'expiresAt' => time()+1000000, //It is in the future + 'createdAt' => 4711, + 'etag' => [] + ])->shouldBeCalled(); + + // Make sure we add back the body + $response->withBody($stream)->willReturn($response)->shouldBeCalled(); + $streamFactory->createStream($httpBody)->shouldBeCalled()->willReturn($stream); + + $next = function (RequestInterface $request) use ($response) { + return new FulfilledPromise($response->getWrappedObject()); + }; + + $this->handleRequest($request, $next, function () {}); + } + + function it_serves_and_resaved_expired_response(CacheItemPoolInterface $pool, CacheItemInterface $item, RequestInterface $request, UriInterface $uri, StreamInterface $requestStream, ResponseInterface $response, StreamInterface $stream, StreamFactoryInterface $streamFactory) + { + $httpBody = 'body'; + + $request->getMethod()->willReturn('GET'); + $request->getUri()->willReturn($uri); + $uri->__toString()->willReturn('https://example.com/'); + $request->getBody()->shouldBeCalled()->willReturn($requestStream); + $requestStream->__toString()->willReturn(''); + + $request->withHeader(Argument::any(), Argument::any())->willReturn($request); + $request->withHeader(Argument::any(), Argument::any())->willReturn($request); + + $response->getStatusCode()->willReturn(304); + $response->getHeader('Cache-Control')->willReturn([]); + $response->getHeader('Expires')->willReturn([])->shouldBeCalled(); + + // Make sure we add back the body + $response->withBody($stream)->willReturn($response)->shouldBeCalled(); + + $pool->getItem(Argument::any())->shouldBeCalled()->willReturn($item); + $item->isHit()->willReturn(true, true); + $item->expiresAfter(1060)->willReturn($item)->shouldBeCalled(); + $item->get()->willReturn([ + 'response' => $response, + 'body' => $httpBody, + 'expiresAt' => 0, + 'createdAt' => 4711, + 'etag' => ['foo_etag'] + ])->shouldBeCalled(); + + $item->set($this->getCacheItemMatcher([ + 'response' => $response->getWrappedObject(), + 'body' => $httpBody, + 'expiresAt' => 0, + 'createdAt' => 0, + 'etag' => ['foo_etag'] + ]))->willReturn($item)->shouldBeCalled(); + $pool->save(Argument::any())->shouldBeCalled(); + + $streamFactory->createStream($httpBody)->shouldBeCalled()->willReturn($stream); + + $next = function (RequestInterface $request) use ($response) { + return new FulfilledPromise($response->getWrappedObject()); + }; + + $this->handleRequest($request, $next, function () {}); + } + + function it_caches_private_responses_when_allowed( + CacheItemPoolInterface $pool, + CacheItemInterface $item, + RequestInterface $request, + UriInterface $uri, + ResponseInterface $response, + StreamFactoryInterface $streamFactory, + StreamInterface $stream + ) { + $this->beConstructedThrough('clientCache', [$pool, $streamFactory, [ + 'default_ttl' => 60, + 'cache_lifetime' => 1000, + ]]); + + $httpBody = 'body'; + $stream->__toString()->willReturn($httpBody); + $stream->isSeekable()->willReturn(true); + $stream->rewind()->shouldBeCalled(); + $stream->detach()->shouldBeCalled(); + + $request->getMethod()->willReturn('GET'); + $request->getUri()->willReturn($uri); + $uri->__toString()->willReturn('https://example.com/'); + $request->getBody()->shouldBeCalled()->willReturn($stream); + + $response->getStatusCode()->willReturn(200); + $response->getBody()->willReturn($stream); + $response->getHeader('Cache-Control')->willReturn(['private'])->shouldBeCalled(); + $response->getHeader('Expires')->willReturn([])->shouldBeCalled(); + $response->getHeader('ETag')->willReturn([])->shouldBeCalled(); + $response->withBody($stream)->shouldBeCalled()->willReturn($response); + + $this->streamFactory->createStream($httpBody)->shouldBeCalled()->willReturn($stream); + + $pool->getItem(Argument::any())->shouldBeCalled()->willReturn($item); + $item->isHit()->willReturn(false); + $item->expiresAfter(1060)->willReturn($item)->shouldBeCalled(); + + $item->set($this->getCacheItemMatcher([ + 'response' => $response->getWrappedObject(), + 'body' => $httpBody, + 'expiresAt' => 0, + 'createdAt' => 0, + 'etag' => [] + ]))->willReturn($item)->shouldBeCalled(); + $pool->save(Argument::any())->shouldBeCalled(); + + $next = function (RequestInterface $request) use ($response) { + return new FulfilledPromise($response->getWrappedObject()); + }; + + $this->handleRequest($request, $next, function () {}); + } + + function it_does_not_store_responses_of_requests_to_blacklisted_paths( + CacheItemPoolInterface $pool, + CacheItemInterface $item, + RequestInterface $request, + UriInterface $uri, + ResponseInterface $response, + StreamFactoryInterface $streamFactory, + StreamInterface $stream + ) { + $this->beConstructedThrough('clientCache', [$pool, $streamFactory, [ + 'default_ttl' => 60, + 'cache_lifetime' => 1000, + 'blacklisted_paths' => ['@/foo@'] + ]]); + + $httpBody = 'body'; + $stream->__toString()->willReturn($httpBody); + $stream->isSeekable()->willReturn(true); + + $request->getMethod()->willReturn('GET'); + $request->getUri()->willReturn($uri); + $uri->__toString()->willReturn('https://example.com/foo'); + $request->getBody()->shouldBeCalled()->willReturn($stream); + + $response->getStatusCode()->willReturn(200); + $response->getBody()->willReturn($stream); + $response->getHeader('Cache-Control')->willReturn([])->shouldBeCalled(); + + $pool->getItem(Argument::any())->shouldBeCalled()->willReturn($item); + $item->isHit()->willReturn(false); + + $item->set($this->getCacheItemMatcher([ + 'response' => $response->getWrappedObject(), + 'body' => $httpBody, + 'expiresAt' => 0, + 'createdAt' => 0 + ]))->willReturn($item)->shouldNotBeCalled(); + $pool->save(Argument::any())->shouldNotBeCalled(); + + $next = function (RequestInterface $request) use ($response) { + return new FulfilledPromise($response->getWrappedObject()); + }; + + $this->handleRequest($request, $next, function () {}); + } + + function it_stores_responses_of_requests_not_in_blacklisted_paths( + CacheItemPoolInterface $pool, + CacheItemInterface $item, + RequestInterface $request, + UriInterface $uri, + ResponseInterface $response, + StreamFactoryInterface $streamFactory, + StreamInterface $stream + ) { + $this->beConstructedThrough('clientCache', [$pool, $streamFactory, [ + 'default_ttl' => 60, + 'cache_lifetime' => 1000, + 'blacklisted_paths' => ['@/foo@'] + ]]); + + $httpBody = 'body'; + $stream->__toString()->willReturn($httpBody); + $stream->isSeekable()->willReturn(true); + $stream->rewind()->shouldBeCalled(); + $stream->detach()->shouldBeCalled(); + + $request->getMethod()->willReturn('GET'); + $request->getUri()->willReturn($uri); + $uri->__toString()->willReturn('https://example.com/'); + $request->getBody()->shouldBeCalled()->willReturn($stream); + + $response->getStatusCode()->willReturn(200); + $response->getBody()->willReturn($stream); + $response->getHeader('Cache-Control')->willReturn([])->shouldBeCalled(); + $response->getHeader('Expires')->willReturn([])->shouldBeCalled(); + $response->getHeader('ETag')->willReturn([])->shouldBeCalled(); + $response->withBody($stream)->shouldBeCalled()->willReturn($response); + + $this->streamFactory->createStream($httpBody)->shouldBeCalled()->willReturn($stream); + + $pool->getItem(Argument::any())->shouldBeCalled()->willReturn($item); + $item->isHit()->willReturn(false); + $item->expiresAfter(1060)->willReturn($item)->shouldBeCalled(); + + $item->set($this->getCacheItemMatcher([ + 'response' => $response->getWrappedObject(), + 'body' => $httpBody, + 'expiresAt' => 0, + 'createdAt' => 0, + 'etag' => [] + ]))->willReturn($item)->shouldBeCalled(); + $pool->save(Argument::any())->shouldBeCalled(); + + $next = function (RequestInterface $request) use ($response) { + return new FulfilledPromise($response->getWrappedObject()); + }; + + $this->handleRequest($request, $next, function () {}); + } + + function it_can_be_initialized_with_custom_cache_key_generator( + CacheItemPoolInterface $pool, + CacheItemInterface $item, + StreamFactoryInterface $streamFactory, + RequestInterface $request, + UriInterface $uri, + ResponseInterface $response, + StreamInterface $stream, + SimpleGenerator $generator + ) { + $this->beConstructedThrough('clientCache', [$pool, $streamFactory, [ + 'cache_key_generator' => $generator, + ]]); + + $generator->generate($request)->shouldBeCalled()->willReturn('foo'); + + $stream->isSeekable()->willReturn(true); + $stream->rewind()->shouldBeCalled(); + $streamFactory->createStream(Argument::any())->willReturn($stream); + + $request->getMethod()->willReturn('GET'); + $request->getUri()->willReturn($uri); + $uri->__toString()->willReturn('https://example.com/'); + $response->withBody(Argument::any())->willReturn($response); + + $pool->getItem(Argument::any())->shouldBeCalled()->willReturn($item); + $item->isHit()->willReturn(true); + $item->get()->willReturn([ + 'response' => $response->getWrappedObject(), + 'body' => 'body', + 'expiresAt' => null, + 'createdAt' => 0, + 'etag' => [] + ]); + + $next = function (RequestInterface $request) use ($response) { + return new FulfilledPromise($response->getWrappedObject()); + }; + + $this->handleRequest($request, $next, function () {}); + } + + + /** + * Private function to match cache item data. + * + * @param array $expectedData + * + * @return \Closure + */ + private function getCacheItemMatcher(array $expectedData) + { + return Argument::that(function(array $actualData) use ($expectedData) { + foreach ($expectedData as $key => $value) { + if (!isset($actualData[$key])) { + return false; + } + + if ($key === 'expiresAt' || $key === 'createdAt') { + // We do not need to validate the value of these fields. + continue; + } + + if ($actualData[$key] !== $value) { + return false; + } + } + return true; + }); + } } diff --git a/src/Cache/Generator/CacheKeyGenerator.php b/src/Cache/Generator/CacheKeyGenerator.php new file mode 100644 index 0000000..3010256 --- /dev/null +++ b/src/Cache/Generator/CacheKeyGenerator.php @@ -0,0 +1,20 @@ + + */ +interface CacheKeyGenerator +{ + /** + * Generate a cache key from a Request. + * + * @return string + */ + public function generate(RequestInterface $request); +} diff --git a/src/Cache/Generator/HeaderCacheKeyGenerator.php b/src/Cache/Generator/HeaderCacheKeyGenerator.php new file mode 100644 index 0000000..16587be --- /dev/null +++ b/src/Cache/Generator/HeaderCacheKeyGenerator.php @@ -0,0 +1,38 @@ + + */ +class HeaderCacheKeyGenerator implements CacheKeyGenerator +{ + /** + * The header names we should take into account when creating the cache key. + * + * @var string[] + */ + private $headerNames; + + /** + * @param string[] $headerNames + */ + public function __construct(array $headerNames) + { + $this->headerNames = $headerNames; + } + + public function generate(RequestInterface $request) + { + $concatenatedHeaders = []; + foreach ($this->headerNames as $headerName) { + $concatenatedHeaders[] = sprintf(' %s:"%s"', $headerName, $request->getHeaderLine($headerName)); + } + + return $request->getMethod().' '.$request->getUri().implode('', $concatenatedHeaders).' '.$request->getBody(); + } +} diff --git a/src/Cache/Generator/SimpleGenerator.php b/src/Cache/Generator/SimpleGenerator.php new file mode 100644 index 0000000..4f0ee90 --- /dev/null +++ b/src/Cache/Generator/SimpleGenerator.php @@ -0,0 +1,23 @@ + + */ +class SimpleGenerator implements CacheKeyGenerator +{ + public function generate(RequestInterface $request) + { + $body = (string) $request->getBody(); + if (!empty($body)) { + $body = ' '.$body; + } + + return $request->getMethod().' '.$request->getUri().$body; + } +} diff --git a/src/Cache/Listener/AddHeaderCacheListener.php b/src/Cache/Listener/AddHeaderCacheListener.php new file mode 100644 index 0000000..282a8a3 --- /dev/null +++ b/src/Cache/Listener/AddHeaderCacheListener.php @@ -0,0 +1,40 @@ + + */ +class AddHeaderCacheListener implements CacheListener +{ + /** @var string */ + private $headerName; + + /** + * @param string $headerName + */ + public function __construct($headerName = 'X-Cache') + { + $this->headerName = $headerName; + } + + /** + * Called before the cache plugin returns the response, with information on whether that response came from cache. + * + * @param bool $fromCache Whether the `$response` was from the cache or not. + * Note that checking `$cacheItem->isHit()` is not sufficent to determine this. + * @param CacheItemInterface|null $cacheItem + * + * @return ResponseInterface + */ + public function onCacheResponse(RequestInterface $request, ResponseInterface $response, $fromCache, $cacheItem) + { + return $response->withHeader($this->headerName, $fromCache ? 'HIT' : 'MISS'); + } +} diff --git a/src/Cache/Listener/CacheListener.php b/src/Cache/Listener/CacheListener.php new file mode 100644 index 0000000..01b953c --- /dev/null +++ b/src/Cache/Listener/CacheListener.php @@ -0,0 +1,28 @@ + + */ +interface CacheListener +{ + /** + * Called before the cache plugin returns the response, with information on whether that response came from cache. + * + * @param bool $fromCache Whether the `$response` was from the cache or not. + * Note that checking `$cacheItem->isHit()` is not sufficent to determine this. + * @param CacheItemInterface|null $cacheItem + * + * @return ResponseInterface + */ + public function onCacheResponse(RequestInterface $request, ResponseInterface $response, $fromCache, $cacheItem); +} diff --git a/src/CachePlugin.php b/src/CachePlugin.php index 24bf5c7..6afb03e 100644 --- a/src/CachePlugin.php +++ b/src/CachePlugin.php @@ -3,64 +3,137 @@ namespace Http\Client\Common\Plugin; use Http\Client\Common\Plugin; -use Http\Message\StreamFactory; +use Http\Client\Common\Plugin\Exception\RewindStreamException; +use Http\Client\Common\Plugin\Cache\Generator\CacheKeyGenerator; +use Http\Client\Common\Plugin\Cache\Generator\SimpleGenerator; use Http\Promise\FulfilledPromise; +use Http\Promise\Promise; +use Psr\Cache\CacheItemInterface; use Psr\Cache\CacheItemPoolInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\StreamFactoryInterface; +use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\OptionsResolver; /** - * Allow for caching a response. + * Allow for caching a response with a PSR-6 compatible caching engine. + * + * It can follow the RFC-7234 caching specification or use a fixed cache lifetime. * * @author Tobias Nyholm */ final class CachePlugin implements Plugin { + use VersionBridgePlugin; + /** * @var CacheItemPoolInterface */ private $pool; /** - * @var StreamFactory + * @var StreamFactoryInterface */ private $streamFactory; /** - * @var array + * @var mixed[] */ private $config; /** - * @param CacheItemPoolInterface $pool - * @param StreamFactory $streamFactory - * @param array $config { + * Cache directives indicating if a response can not be cached. + * + * @var string[] + */ + private $noCacheFlags = ['no-cache', 'private', 'no-store']; + + /** + * @param mixed[] $config * - * @var bool $respect_cache_headers Whether to look at the cache directives or ignore them. - * @var int $default_ttl If we do not respect cache headers or can't calculate a good ttl, use this value. + * bool respect_cache_headers: Whether to look at the cache directives or ignore them + * int default_ttl: (seconds) If we do not respect cache headers or can't calculate a good ttl, use this value + * string hash_algo: The hashing algorithm to use when generating cache keys + * int|null cache_lifetime: (seconds) To support serving a previous stale response when the server answers 304 + * we have to store the cache for a longer time than the server originally says it is valid for. + * We store a cache item for $cache_lifetime + max age of the response. + * string[] methods: list of request methods which can be cached + * string[] blacklisted_paths: list of regex for URLs explicitly not to be cached + * string[] respect_response_cache_directives: list of cache directives this plugin will respect while caching responses + * CacheKeyGenerator cache_key_generator: an object to generate the cache key. Defaults to a new instance of SimpleGenerator + * CacheListener[] cache_listeners: an array of objects to act on the response based on the results of the cache check. + * Defaults to an empty array * } */ - public function __construct(CacheItemPoolInterface $pool, StreamFactory $streamFactory, array $config = []) + public function __construct(CacheItemPoolInterface $pool, StreamFactoryInterface $streamFactory, array $config = []) { $this->pool = $pool; $this->streamFactory = $streamFactory; + if (\array_key_exists('respect_cache_headers', $config) && \array_key_exists('respect_response_cache_directives', $config)) { + throw new \InvalidArgumentException('You can\'t provide config option "respect_cache_headers" and "respect_response_cache_directives". Use "respect_response_cache_directives" instead.'); + } + $optionsResolver = new OptionsResolver(); $this->configureOptions($optionsResolver); $this->config = $optionsResolver->resolve($config); + + if (null === $this->config['cache_key_generator']) { + $this->config['cache_key_generator'] = new SimpleGenerator(); + } + } + + /** + * This method will setup the cachePlugin in client cache mode. When using the client cache mode the plugin will + * cache responses with `private` cache directive. + * + * @param mixed[] $config For all possible config options see the constructor docs + * + * @return CachePlugin + */ + public static function clientCache(CacheItemPoolInterface $pool, StreamFactoryInterface $streamFactory, array $config = []) + { + // Allow caching of private requests + if (\array_key_exists('respect_response_cache_directives', $config)) { + $config['respect_response_cache_directives'][] = 'no-cache'; + $config['respect_response_cache_directives'][] = 'max-age'; + $config['respect_response_cache_directives'] = array_unique($config['respect_response_cache_directives']); + } else { + $config['respect_response_cache_directives'] = ['no-cache', 'max-age']; + } + + return new self($pool, $streamFactory, $config); + } + + /** + * This method will setup the cachePlugin in server cache mode. This is the default caching behavior it refuses to + * cache responses with the `private`or `no-cache` directives. + * + * @param mixed[] $config For all possible config options see the constructor docs + * + * @return CachePlugin + */ + public static function serverCache(CacheItemPoolInterface $pool, StreamFactoryInterface $streamFactory, array $config = []) + { + return new self($pool, $streamFactory, $config); } /** * {@inheritdoc} + * + * @return Promise Resolves a PSR-7 Response or fails with an Http\Client\Exception (The same as HttpAsyncClient) */ - public function handleRequest(RequestInterface $request, callable $next, callable $first) + protected function doHandleRequest(RequestInterface $request, callable $next, callable $first) { $method = strtoupper($request->getMethod()); - // if the request not is cachable, move to $next - if ($method !== 'GET' && $method !== 'HEAD') { - return $next($request); + if (!in_array($method, $this->config['methods'])) { + return $next($request)->then(function (ResponseInterface $response) use ($request) { + $response = $this->handleCacheListeners($request, $response, false, null); + + return $response; + }); } // If we can cache the request @@ -68,37 +141,113 @@ public function handleRequest(RequestInterface $request, callable $next, callabl $cacheItem = $this->pool->getItem($key); if ($cacheItem->isHit()) { - // return cached response $data = $cacheItem->get(); - $response = $data['response']; - $response = $response->withBody($this->streamFactory->createStream($data['body'])); + if (is_array($data)) { + // The array_key_exists() is to be removed in 2.0. + if (array_key_exists('expiresAt', $data) && (null === $data['expiresAt'] || time() < $data['expiresAt'])) { + // This item is still valid according to previous cache headers + $response = $this->createResponseFromCacheItem($cacheItem); + $response = $this->handleCacheListeners($request, $response, true, $cacheItem); + + return new FulfilledPromise($response); + } - return new FulfilledPromise($response); + // Add headers to ask the server if this cache is still valid + if ($modifiedSinceValue = $this->getModifiedSinceHeaderValue($cacheItem)) { + $request = $request->withHeader('If-Modified-Since', $modifiedSinceValue); + } + + if ($etag = $this->getETag($cacheItem)) { + $request = $request->withHeader('If-None-Match', $etag); + } + } } - return $next($request)->then(function (ResponseInterface $response) use ($cacheItem) { - if ($this->isCacheable($response)) { + return $next($request)->then(function (ResponseInterface $response) use ($request, $cacheItem) { + if (304 === $response->getStatusCode()) { + if (!$cacheItem->isHit()) { + /* + * We do not have the item in cache. This plugin did not add If-Modified-Since + * or If-None-Match headers. Return the response from server. + */ + return $this->handleCacheListeners($request, $response, false, $cacheItem); + } + + // The cached response we have is still valid + $data = $cacheItem->get(); + $maxAge = $this->getMaxAge($response); + $data['expiresAt'] = $this->calculateResponseExpiresAt($maxAge); + $cacheItem->set($data)->expiresAfter($this->calculateCacheItemExpiresAfter($maxAge)); + $this->pool->save($cacheItem); + + return $this->handleCacheListeners($request, $this->createResponseFromCacheItem($cacheItem), true, $cacheItem); + } + + if ($this->isCacheable($response) && $this->isCacheableRequest($request)) { + /* The PSR-7 response body is a stream. We can't expect that the response implements Serializable and handles the body. + * Therefore we store the body separately and detach the stream to avoid attempting to serialize a resource. + .* Our implementation still makes the assumption that the response object apart from the body can be serialized and deserialized. + */ $bodyStream = $response->getBody(); $body = $bodyStream->__toString(); + $bodyStream->detach(); + + $maxAge = $this->getMaxAge($response); + $cacheItem + ->expiresAfter($this->calculateCacheItemExpiresAfter($maxAge)) + ->set([ + 'response' => $response, + 'body' => $body, + 'expiresAt' => $this->calculateResponseExpiresAt($maxAge), + 'createdAt' => time(), + 'etag' => $response->getHeader('ETag'), + ]); + $this->pool->save($cacheItem); + + $bodyStream = $this->streamFactory->createStream($body); if ($bodyStream->isSeekable()) { $bodyStream->rewind(); - } else { - $response = $response->withBody($this->streamFactory->createStream($body)); } - $cacheItem->set(['response' => $response, 'body' => $body]) - ->expiresAfter($this->getMaxAge($response)); - $this->pool->save($cacheItem); + $response = $response->withBody($bodyStream); } - return $response; + return $this->handleCacheListeners($request, $response, false, $cacheItem); }); } /** - * Verify that we can cache this response. + * Calculate the timestamp when this cache item should be dropped from the cache. The lowest value that can be + * returned is $maxAge. * - * @param ResponseInterface $response + * @return int|null Unix system time passed to the PSR-6 cache + */ + private function calculateCacheItemExpiresAfter(?int $maxAge): ?int + { + if (null === $this->config['cache_lifetime'] && null === $maxAge) { + return null; + } + + return ($this->config['cache_lifetime'] ?: 0) + ($maxAge ?: 0); + } + + /** + * Calculate the timestamp when a response expires. After that timestamp, we need to send a + * If-Modified-Since / If-None-Match request to validate the response. + * + * @return int|null Unix system time. A null value means that the response expires when the cache item expires + */ + private function calculateResponseExpiresAt(?int $maxAge): ?int + { + if (null === $maxAge) { + return null; + } + + return time() + $maxAge; + } + + /** + * Verify that we can cache this response. * * @return bool */ @@ -107,11 +256,27 @@ protected function isCacheable(ResponseInterface $response) if (!in_array($response->getStatusCode(), [200, 203, 300, 301, 302, 404, 410])) { return false; } - if (!$this->config['respect_cache_headers']) { - return true; + + $nocacheDirectives = array_intersect($this->config['respect_response_cache_directives'], $this->noCacheFlags); + foreach ($nocacheDirectives as $nocacheDirective) { + if ($this->getCacheControlDirective($response, $nocacheDirective)) { + return false; + } } - if ($this->getCacheControlDirective($response, 'no-store') || $this->getCacheControlDirective($response, 'private')) { - return false; + + return true; + } + + /** + * Verify that we can cache this request. + */ + private function isCacheableRequest(RequestInterface $request): bool + { + $uri = $request->getUri()->__toString(); + foreach ($this->config['blacklisted_paths'] as $regex) { + if (1 === preg_match($regex, $uri)) { + return false; + } } return true; @@ -120,17 +285,15 @@ protected function isCacheable(ResponseInterface $response) /** * Get the value of a parameter in the cache control header. * - * @param ResponseInterface $response - * @param string $name The field of Cache-Control to fetch + * @param string $name The field of Cache-Control to fetch * - * @return bool|string The value of the directive, true if directive without value, false if directive not present. + * @return bool|string The value of the directive, true if directive without value, false if directive not present */ - private function getCacheControlDirective(ResponseInterface $response, $name) + private function getCacheControlDirective(ResponseInterface $response, string $name) { $headers = $response->getHeader('Cache-Control'); foreach ($headers as $header) { if (preg_match(sprintf('|%s=?([0-9]+)?|i', $name), $header, $matches)) { - // return the value for $name if it exists if (isset($matches[1])) { return $matches[1]; @@ -143,26 +306,21 @@ private function getCacheControlDirective(ResponseInterface $response, $name) return false; } - /** - * @param RequestInterface $request - * - * @return string - */ - private function createCacheKey(RequestInterface $request) + private function createCacheKey(RequestInterface $request): string { - return md5($request->getMethod().' '.$request->getUri()); + $key = $this->config['cache_key_generator']->generate($request); + + return hash($this->config['hash_algo'], $key); } /** - * Get a ttl in seconds. It could return null if we do not respect cache headers and got no defaultTtl. - * - * @param ResponseInterface $response + * Get a ttl in seconds. * - * @return int|null + * Returns null if we do not respect cache headers and got no defaultTtl. */ - private function getMaxAge(ResponseInterface $response) + private function getMaxAge(ResponseInterface $response): ?int { - if (!$this->config['respect_cache_headers']) { + if (!in_array('max-age', $this->config['respect_response_cache_directives'], true)) { return $this->config['default_ttl']; } @@ -171,10 +329,10 @@ private function getMaxAge(ResponseInterface $response) if (!is_bool($maxAge)) { $ageHeaders = $response->getHeader('Age'); foreach ($ageHeaders as $age) { - return $maxAge - ((int) $age); + return ((int) $maxAge) - ((int) $age); } - return $maxAge; + return (int) $maxAge; } // check for ttl in the Expires header @@ -188,17 +346,117 @@ private function getMaxAge(ResponseInterface $response) /** * Configure an options resolver. - * - * @param OptionsResolver $resolver */ - private function configureOptions(OptionsResolver $resolver) + private function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ - 'default_ttl' => null, - 'respect_cache_headers' => true, + 'cache_lifetime' => 86400 * 30, // 30 days + 'default_ttl' => 0, + // Deprecated as of v1.3, to be removed in v2.0. Use respect_response_cache_directives instead + 'respect_cache_headers' => null, + 'hash_algo' => 'sha1', + 'methods' => ['GET', 'HEAD'], + 'respect_response_cache_directives' => ['no-cache', 'private', 'max-age', 'no-store'], + 'cache_key_generator' => null, + 'cache_listeners' => [], + 'blacklisted_paths' => [], ]); + $resolver->setAllowedTypes('cache_lifetime', ['int', 'null']); $resolver->setAllowedTypes('default_ttl', ['int', 'null']); - $resolver->setAllowedTypes('respect_cache_headers', 'bool'); + $resolver->setAllowedTypes('respect_cache_headers', ['bool', 'null']); + $resolver->setAllowedTypes('methods', 'array'); + $resolver->setAllowedTypes('cache_key_generator', ['null', CacheKeyGenerator::class]); + $resolver->setAllowedTypes('blacklisted_paths', 'array'); + $resolver->setAllowedValues('hash_algo', hash_algos()); + $resolver->setAllowedValues('methods', function ($value) { + /* RFC7230 sections 3.1.1 and 3.2.6 except limited to uppercase characters. */ + $matches = preg_grep('/[^A-Z0-9!#$%&\'*+\-.^_`|~]/', $value); + + return empty($matches); + }); + $resolver->setAllowedTypes('cache_listeners', ['array']); + + $resolver->setNormalizer('respect_cache_headers', function (Options $options, $value) { + if (null !== $value) { + @trigger_error('The option "respect_cache_headers" is deprecated since version 1.3 and will be removed in 2.0. Use "respect_response_cache_directives" instead.', E_USER_DEPRECATED); + } + + return null === $value ? true : $value; + }); + + $resolver->setNormalizer('respect_response_cache_directives', function (Options $options, $value) { + if (false === $options['respect_cache_headers']) { + return []; + } + + return $value; + }); + } + + private function createResponseFromCacheItem(CacheItemInterface $cacheItem): ResponseInterface + { + $data = $cacheItem->get(); + + /** @var ResponseInterface $response */ + $response = $data['response']; + $stream = $this->streamFactory->createStream($data['body']); + + try { + $stream->rewind(); + } catch (\Exception $e) { + throw new RewindStreamException('Cannot rewind stream.', 0, $e); + } + + return $response->withBody($stream); + } + + /** + * Get the value for the "If-Modified-Since" header. + */ + private function getModifiedSinceHeaderValue(CacheItemInterface $cacheItem): ?string + { + $data = $cacheItem->get(); + // The isset() is to be removed in 2.0. + if (!isset($data['createdAt'])) { + return null; + } + + $modified = new \DateTime('@'.$data['createdAt']); + $modified->setTimezone(new \DateTimeZone('GMT')); + + return sprintf('%s GMT', $modified->format('l, d-M-y H:i:s')); + } + + /** + * Get the ETag from the cached response. + */ + private function getETag(CacheItemInterface $cacheItem): ?string + { + $data = $cacheItem->get(); + // The isset() is to be removed in 2.0. + if (!isset($data['etag'])) { + return null; + } + + foreach ($data['etag'] as $etag) { + if (!empty($etag)) { + return $etag; + } + } + + return null; + } + + /** + * Call the registered cache listeners. + */ + private function handleCacheListeners(RequestInterface $request, ResponseInterface $response, bool $cacheHit, ?CacheItemInterface $cacheItem): ResponseInterface + { + foreach ($this->config['cache_listeners'] as $cacheListener) { + $response = $cacheListener->onCacheResponse($request, $response, $cacheHit, $cacheItem); + } + + return $response; } } diff --git a/src/Exception/RewindStreamException.php b/src/Exception/RewindStreamException.php new file mode 100644 index 0000000..1b9eaee --- /dev/null +++ b/src/Exception/RewindStreamException.php @@ -0,0 +1,12 @@ + + */ +class RewindStreamException extends \RuntimeException implements Exception +{ +}