diff --git a/.gitattributes b/.gitattributes index c47225a..533d355 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,11 +5,8 @@ /.php_cs export-ignore /.scrutinizer.yml export-ignore /.styleci.yml export-ignore -/.travis.yml export-ignore /behat.yml.dist export-ignore /features/ export-ignore /phpspec.ci.yml export-ignore /phpspec.yml.dist export-ignore -/phpunit.xml.dist export-ignore /spec/ export-ignore -/tests/ export-ignore 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/.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 44dc5af..0000000 --- a/.travis.yml +++ /dev/null @@ -1,44 +0,0 @@ -language: php - -sudo: false - -cache: - directories: - - $HOME/.composer/cache/files - -php: - - 5.4 - - 5.5 - - 5.6 - - 7.0 - - 7.1 - - 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: - - if [[ $COVERAGE != true ]]; then phpenv config-rm xdebug.ini || true; fi - -install: - # To be removed when this issue will be resolved: https://github.com/composer/composer/issues/5355 - - if [[ "$COMPOSER_FLAGS" == *"--prefer-lowest"* ]]; then travis_retry composer update --prefer-dist --no-interaction --prefer-stable --quiet; fi - - 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 d6c3ed3..b859565 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,122 @@ # 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`. +- The default value for `default_ttl` is changed from `null` to `0`. ### Fixed 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 646bbc5..825cfe3 100644 --- a/composer.json +++ b/composer.json @@ -11,15 +11,15 @@ } ], "require": { - "php": "^5.4 || ^7.0", - "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": { @@ -34,10 +34,5 @@ "scripts": { "test": "vendor/bin/phpspec run", "test-ci": "vendor/bin/phpspec run -c phpspec.ci.yml" - }, - "extra": { - "branch-alias": { - "dev-master": "1.2-dev" - } } } diff --git a/phpspec.ci.yml b/phpspec.ci.yml index 4407704..bd039ca 100644 --- a/phpspec.ci.yml +++ 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 97afd55..fabd8a2 100644 --- a/spec/CachePluginSpec.php +++ b/spec/CachePluginSpec.php @@ -2,20 +2,31 @@ namespace spec\Http\Client\Common\Plugin; +use Http\Client\Common\Plugin\Cache\Generator\SimpleGenerator; +use PhpSpec\Wrapper\Collaborator; use Prophecy\Argument; -use Http\Message\StreamFactory; 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->streamFactory = $streamFactory; $this->beConstructedWith($pool, $streamFactory, [ 'default_ttl' => 60, 'cache_lifetime' => 1000 @@ -24,30 +35,37 @@ function let(CacheItemPoolInterface $pool, StreamFactory $streamFactory) 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())->shouldBeCalled(); - $response->getHeader('Expires')->willReturn(array())->shouldBeCalled(); - $response->getHeader('ETag')->willReturn(array())->shouldBeCalled(); + $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('d20f64acc6e70b6079845f2fe357732929550ae1')->shouldBeCalled()->willReturn($item); + $pool->getItem(Argument::any())->shouldBeCalled()->willReturn($item); $item->isHit()->willReturn(false); $item->expiresAfter(1060)->willReturn($item)->shouldBeCalled(); @@ -67,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('d20f64acc6e70b6079845f2fe357732929550ae1')->shouldBeCalled()->willReturn($item); + $pool->getItem(Argument::any())->shouldBeCalled()->willReturn($item); $item->isHit()->willReturn(false); $next = function (RequestInterface $request) use ($response) { @@ -85,10 +107,11 @@ 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()); @@ -97,24 +120,105 @@ function it_doesnt_store_post_requests(CacheItemPoolInterface $pool, CacheItemIn $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()); + }; + + $this->handleRequest($request, $next, function () {}); + } - function it_calculate_age_from_response(CacheItemPoolInterface $pool, CacheItemInterface $item, RequestInterface $request, ResponseInterface $response, StreamInterface $stream) + 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, 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('ETag')->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); - $pool->getItem('d20f64acc6e70b6079845f2fe357732929550ae1')->shouldBeCalled()->willReturn($item); + $this->streamFactory->createStream($httpBody)->shouldBeCalled()->willReturn($stream); + + $pool->getItem(Argument::any())->shouldBeCalled()->willReturn($item); $item->isHit()->willReturn(false); $item->set($this->getCacheItemMatcher([ @@ -135,22 +239,28 @@ function it_calculate_age_from_response(CacheItemPoolInterface $pool, CacheItemI $this->handleRequest($request, $next, function () {}); } - function it_saves_etag(CacheItemPoolInterface $pool, CacheItemInterface $item, RequestInterface $request, ResponseInterface $response, StreamInterface $stream) + 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('/'); + $request->getUri()->willReturn($uri); + $uri->__toString()->willReturn('https://example.com/'); $response->getStatusCode()->willReturn(200); $response->getBody()->willReturn($stream); - $response->getHeader('Cache-Control')->willReturn(array()); - $response->getHeader('Expires')->willReturn(array()); - $response->getHeader('ETag')->willReturn(array('foo_etag')); + $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('d20f64acc6e70b6079845f2fe357732929550ae1')->shouldBeCalled()->willReturn($item); + $pool->getItem(Argument::any())->shouldBeCalled()->willReturn($item); $item->isHit()->willReturn(false); $item->expiresAfter(1060)->willReturn($item); @@ -170,19 +280,22 @@ function it_saves_etag(CacheItemPoolInterface $pool, CacheItemInterface $item, R $this->handleRequest($request, $next, function () {}); } - function it_adds_etag_and_modfied_since_to_request(CacheItemPoolInterface $pool, CacheItemInterface $item, RequestInterface $request, ResponseInterface $response, StreamInterface $stream) + 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('/'); + $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('d20f64acc6e70b6079845f2fe357732929550ae1')->shouldBeCalled()->willReturn($item); + $pool->getItem(Argument::any())->shouldBeCalled()->willReturn($item); $item->isHit()->willReturn(true, false); $item->get()->willReturn([ 'response' => $response, @@ -199,14 +312,17 @@ function it_adds_etag_and_modfied_since_to_request(CacheItemPoolInterface $pool, $this->handleRequest($request, $next, function () {}); } - function it_servces_a_cached_response(CacheItemPoolInterface $pool, CacheItemInterface $item, RequestInterface $request, ResponseInterface $response, StreamInterface $stream, StreamFactory $streamFactory) + 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('/'); + $request->getUri()->willReturn($uri); + $uri->__toString()->willReturn('https://example.com/'); + $request->getBody()->shouldBeCalled()->willReturn($requestBody); + $requestBody->__toString()->shouldBeCalled()->willReturn(''); - $pool->getItem('d20f64acc6e70b6079845f2fe357732929550ae1')->shouldBeCalled()->willReturn($item); + $pool->getItem(Argument::any())->shouldBeCalled()->willReturn($item); $item->isHit()->willReturn(true); $item->get()->willReturn([ 'response' => $response, @@ -227,24 +343,27 @@ function it_servces_a_cached_response(CacheItemPoolInterface $pool, CacheItemInt $this->handleRequest($request, $next, function () {}); } - function it_serves_and_resaved_expired_response(CacheItemPoolInterface $pool, CacheItemInterface $item, RequestInterface $request, ResponseInterface $response, StreamInterface $stream, StreamFactory $streamFactory) + 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('/'); + $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(array()); - $response->getHeader('Expires')->willReturn(array())->shouldBeCalled(); + $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('d20f64acc6e70b6079845f2fe357732929550ae1')->shouldBeCalled()->willReturn($item); + $pool->getItem(Argument::any())->shouldBeCalled()->willReturn($item); $item->isHit()->willReturn(true, true); $item->expiresAfter(1060)->willReturn($item)->shouldBeCalled(); $item->get()->willReturn([ @@ -273,6 +392,203 @@ function it_serves_and_resaved_expired_response(CacheItemPoolInterface $pool, Ca $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. 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 5053aad..6afb03e 100644 --- a/src/CachePlugin.php +++ b/src/CachePlugin.php @@ -3,69 +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 (seconds) If we do not respect cache headers or can't calculate a good ttl, use this - * value - * @var string $hash_algo The hashing algorithm to use when generating cache keys - * @var int $cache_lifetime (seconds) To support serving a previous stale response when the server answers 304 + * 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 @@ -74,30 +142,35 @@ public function handleRequest(RequestInterface $request, callable $next, callabl if ($cacheItem->isHit()) { $data = $cacheItem->get(); - // The array_key_exists() is to be removed in 2.0. - if (array_key_exists('expiresAt', $data) && ($data['expiresAt'] === null || time() < $data['expiresAt'])) { - // This item is still valid according to previous cache headers - return new FulfilledPromise($this->createResponseFromCacheItem($cacheItem)); - } + 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); + } - // Add headers to ask the server if this cache is still valid - if ($modifiedSinceValue = $this->getModifiedSinceHeaderValue($cacheItem)) { - $request = $request->withHeader('If-Modified-Since', $modifiedSinceValue); - } + // 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); + if ($etag = $this->getETag($cacheItem)) { + $request = $request->withHeader('If-None-Match', $etag); + } } } - return $next($request)->then(function (ResponseInterface $response) use ($cacheItem) { + 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 $response; + return $this->handleCacheListeners($request, $response, false, $cacheItem); } // The cached response we have is still valid @@ -107,17 +180,17 @@ public function handleRequest(RequestInterface $request, callable $next, callabl $cacheItem->set($data)->expiresAfter($this->calculateCacheItemExpiresAfter($maxAge)); $this->pool->save($cacheItem); - return $this->createResponseFromCacheItem($cacheItem); + return $this->handleCacheListeners($request, $this->createResponseFromCacheItem($cacheItem), true, $cacheItem); } - if ($this->isCacheable($response)) { + 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(); - if ($bodyStream->isSeekable()) { - $bodyStream->rewind(); - } else { - $response = $response->withBody($this->streamFactory->createStream($body)); - } + $bodyStream->detach(); $maxAge = $this->getMaxAge($response); $cacheItem @@ -130,9 +203,16 @@ public function handleRequest(RequestInterface $request, callable $next, callabl 'etag' => $response->getHeader('ETag'), ]); $this->pool->save($cacheItem); + + $bodyStream = $this->streamFactory->createStream($body); + if ($bodyStream->isSeekable()) { + $bodyStream->rewind(); + } + + $response = $response->withBody($bodyStream); } - return $response; + return $this->handleCacheListeners($request, $response, false, $cacheItem); }); } @@ -140,31 +220,27 @@ public function handleRequest(RequestInterface $request, callable $next, callabl * Calculate the timestamp when this cache item should be dropped from the cache. The lowest value that can be * returned is $maxAge. * - * @param int|null $maxAge - * * @return int|null Unix system time passed to the PSR-6 cache */ - private function calculateCacheItemExpiresAfter($maxAge) + private function calculateCacheItemExpiresAfter(?int $maxAge): ?int { - if ($this->config['cache_lifetime'] === null && $maxAge === null) { - return; + if (null === $this->config['cache_lifetime'] && null === $maxAge) { + return null; } - return $this->config['cache_lifetime'] + $maxAge; + 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. * - * @param int|null $maxAge - * * @return int|null Unix system time. A null value means that the response expires when the cache item expires */ - private function calculateResponseExpiresAt($maxAge) + private function calculateResponseExpiresAt(?int $maxAge): ?int { - if ($maxAge === null) { - return; + if (null === $maxAge) { + return null; } return time() + $maxAge; @@ -173,8 +249,6 @@ private function calculateResponseExpiresAt($maxAge) /** * Verify that we can cache this response. * - * @param ResponseInterface $response - * * @return bool */ protected function isCacheable(ResponseInterface $response) @@ -182,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; @@ -195,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 */ - 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]; @@ -218,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 hash($this->config['hash_algo'], $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']; } @@ -246,7 +329,7 @@ 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 (int) $maxAge; @@ -263,53 +346,80 @@ 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([ 'cache_lifetime' => 86400 * 30, // 30 days 'default_ttl' => 0, - 'respect_cache_headers' => true, + // 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; + }); } - /** - * @param CacheItemInterface $cacheItem - * - * @return ResponseInterface - */ - private function createResponseFromCacheItem(CacheItemInterface $cacheItem) + private function createResponseFromCacheItem(CacheItemInterface $cacheItem): ResponseInterface { $data = $cacheItem->get(); /** @var ResponseInterface $response */ $response = $data['response']; - $response = $response->withBody($this->streamFactory->createStream($data['body'])); + $stream = $this->streamFactory->createStream($data['body']); - return $response; + try { + $stream->rewind(); + } catch (\Exception $e) { + throw new RewindStreamException('Cannot rewind stream.', 0, $e); + } + + return $response->withBody($stream); } /** - * Get the value of the "If-Modified-Since" header. - * - * @param CacheItemInterface $cacheItem - * - * @return string|null + * Get the value for the "If-Modified-Since" header. */ - private function getModifiedSinceHeaderValue(CacheItemInterface $cacheItem) + private function getModifiedSinceHeaderValue(CacheItemInterface $cacheItem): ?string { $data = $cacheItem->get(); // The isset() is to be removed in 2.0. if (!isset($data['createdAt'])) { - return; + return null; } $modified = new \DateTime('@'.$data['createdAt']); @@ -320,21 +430,13 @@ private function getModifiedSinceHeaderValue(CacheItemInterface $cacheItem) /** * Get the ETag from the cached response. - * - * @param CacheItemInterface $cacheItem - * - * @return string|null */ - private function getETag(CacheItemInterface $cacheItem) + private function getETag(CacheItemInterface $cacheItem): ?string { $data = $cacheItem->get(); // The isset() is to be removed in 2.0. if (!isset($data['etag'])) { - return; - } - - if (!is_array($data['etag'])) { - return $data['etag']; + return null; } foreach ($data['etag'] as $etag) { @@ -342,5 +444,19 @@ private function getETag(CacheItemInterface $cacheItem) 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 +{ +}