diff --git a/CHANGELOG.md b/CHANGELOG.md index 3267267c..034632de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ See also the [GitHub releases page](https://github.com/FriendsOfSymfony/FOSHttpC ### General * Use `LegacyEventDispatcherProxy` for Symfony >= 4.3 to avoid deprecation messages. +* Added Fastly ProxyClient Adapter with ClearCapable, PurgeCapable, RefreshCapable, & TagCapable. + Fastly is a CDN originally based on Varnish 2.x, so with many of the same capabilities like VCL and more. 2.7.0 ----- diff --git a/doc/proxy-clients.rst b/doc/proxy-clients.rst index 861983fe..c6e2ae7d 100644 --- a/doc/proxy-clients.rst +++ b/doc/proxy-clients.rst @@ -23,6 +23,7 @@ This table provides of methods supported by each proxy client: Client Purge Refresh Ban Tagging Clear ============= ======= ======= ======= ======= ======= Varnish ✓ ✓ ✓ ✓ +Fastly ✓ ✓ ✓ ✓ NGINX ✓ ✓ Symfony Cache ✓ ✓ ✓ (1) ✓ (1) Noop ✓ ✓ ✓ ✓ @@ -158,6 +159,44 @@ default ``xkey-softpurge``. the ``ResponseTagger``, set it up with a :ref:`custom TagHeaderFormatter `. +Fastly Client +~~~~~~~~~~~~~~ + +The Fastly client sends HTTP requests with the ``HttpDispatcher``. Create the +dispatcher as explained above and pass it to the Fastly client:: + + use FOS\HttpCache\ProxyClient\Fastly; + + $varnish = new Fastly($httpDispatcher); + +.. note:: + + Unlike other supported proxies there is no configuration needed for the proxy itself as all invalidation is done + against `Fastly Purge API`_. But for optimal use make sure to tune configuration together with Fastly. + +You need to pass the following options to the Fastly client: + +* ``service_identifier``: Identifier for your Fastly service account. +* ``authentication_token``: User token for authentication against Fastly APIs. +* NB: To be able to clear all cache(``->clear()``), you'll need a token for user with Fastly "Engineer permissions". +* ``soft_purge`` (default: true): Boolean for doing soft purges or not on tag & url purging. + Soft purges expires the cache unlike hard purge (removal), and allow grace/stale handling within Fastly VCL. + +Additionally, you can specify the request factory used to build the +invalidation HTTP requests. If not specified, auto discovery is used - which +usually is fine. + +A full example could look like this:: + + $options = [ + 'service_identifier' => '', + 'authentication_token' => '', + 'soft_purge' => false + ]; + $requestFactory = new MyRequestFactory(); + + $varnish = new Fastly($httpDispatcher, $options, $requestFactory); + NGINX Client ~~~~~~~~~~~~ @@ -339,3 +378,4 @@ requests. .. _HTTPlug plugins: http://php-http.readthedocs.io/en/latest/plugins/index.html .. _message factory and URI factory: http://php-http.readthedocs.io/en/latest/message/message-factory.html .. _Toflar Psr6Store: https://github.com/Toflar/psr6-symfony-http-cache-store +.. _Fastly Purge API: https://docs.fastly.com/api/purge diff --git a/src/ProxyClient/Fastly.php b/src/ProxyClient/Fastly.php new file mode 100644 index 00000000..49dc7ddb --- /dev/null +++ b/src/ProxyClient/Fastly.php @@ -0,0 +1,182 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\HttpCache\ProxyClient; + +use FOS\HttpCache\ProxyClient\Invalidation\ClearCapable; +use FOS\HttpCache\ProxyClient\Invalidation\PurgeCapable; +use FOS\HttpCache\ProxyClient\Invalidation\RefreshCapable; +use FOS\HttpCache\ProxyClient\Invalidation\TagCapable; +use Symfony\Component\HttpFoundation\Request; + +/** + * Fastly HTTP cache invalidator. + * + * Additional constructor options: + * - service_identifier Identifier for your Fastly service account. + * - authentication_token Token for authentication against Fastly APIs. + * For full capabilities (incl ClearCapable) you'll need one with Fastly Engineer permissions. + * - soft_purge Boolean for doing soft purges or not on tag invalidation and url purging, default true. + * Soft purges expires cache instead of hard purge, and allow grace/stale handling. + * + * @see https://docs.fastly.com/api/purge Fastly Purge API documentation. + * + * @author Simone Fumagalli + */ +class Fastly extends HttpProxyClient implements ClearCapable, PurgeCapable, RefreshCapable, TagCapable +{ + /** + * @internal + */ + const HTTP_METHOD_PURGE = 'PURGE'; + + /** + * @internal + * + * @see https://docs.fastly.com/api/purge#purge_db35b293f8a724717fcf25628d713583 Fastly's limit on batch tag purges. + */ + const TAG_BATCH_PURGE_LIMIT = 256; + + /** + * @internal + * + * @see https://docs.fastly.com/api/purge Base url endpoint used on anything but url PURGE/GET/HEAD. + */ + const API_ENDPOINT = 'https://api.fastly.com'; + + /** + * {@inheritdoc} + * + * @see https://docs.fastly.com/api/purge#purge_db35b293f8a724717fcf25628d713583 + */ + public function invalidateTags(array $tags) + { + $url = sprintf(self::API_ENDPOINT.'/service/%s/purge', $this->options['service_identifier']); + $headers = ['Accept' => 'application/json']; + if (true === $this->options['soft_purge']) { + $headers['Fastly-Soft-Purge'] = 1; + } + + // Split tag invalidations across several requests within Fastly's tag batch invalidations limits. + foreach (\array_chunk($tags, self::TAG_BATCH_PURGE_LIMIT) as $tagChunk) { + $this->queueRequest( + Request::METHOD_POST, + $url, + $headers, + false, + json_encode(['surrogate_keys' => $tagChunk]) + ); + } + + return $this; + } + + /** + * {@inheritdoc} + * + * @see https://docs.fastly.com/api/purge#soft_purge_0c4f56f3d68e9bed44fb8b638b78ea36 + * @see https://docs.fastly.com/guides/purging/authenticating-api-purge-requests#purging-urls-with-an-api-token + */ + public function purge($url, array $headers = []) + { + if (true === $this->options['soft_purge']) { + $headers['Fastly-Soft-Purge'] = 1; + } + + $this->queueRequest( + self::HTTP_METHOD_PURGE, + $url, + $headers, + false + ); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function refresh($url, array $headers = []) + { + // First soft purge url + $this->queueRequest( + self::HTTP_METHOD_PURGE, + $url, + ['Fastly-Soft-Purge' => 1] + $headers, + false + ); + + // Secondly make sure refresh is triggered with a HEAD request + $this->queueRequest( + Request::METHOD_HEAD, + $url, + $headers, + false + ); + + return $this; + } + + /** + * {@inheritdoc} + * + * @see https://docs.fastly.com/api/purge#purge_bee5ed1a0cfd541e8b9f970a44718546 + * + * Warning: + * - Does not support soft purge, for that use an "all" key. + * - Requires a API token of a user with at least Engineer permissions. + */ + public function clear() + { + $this->queueRequest( + Request::METHOD_POST, + sprintf(self::API_ENDPOINT.'/service/%s/purge_all', $this->options['service_identifier']), + ['Accept' => 'application/json'], + false + ); + + return $this; + } + + /** + * {@inheritdoc} Always provides default authentication token on "Fastly-Key" header. + */ + protected function queueRequest($method, $url, array $headers, $validateHost = true, $body = null) + { + parent::queueRequest( + $method, + $url, + $headers + ['Fastly-Key' => $this->options['authentication_token']], + $validateHost, + $body + ); + } + + /** + * {@inheritdoc} + */ + protected function configureOptions() + { + $resolver = parent::configureOptions(); + + $resolver->setRequired([ + 'authentication_token', + 'service_identifier', + 'soft_purge', + ]); + + $resolver->setDefaults([ + 'soft_purge' => true, + ]); + + return $resolver; + } +} diff --git a/src/ProxyClient/HttpProxyClient.php b/src/ProxyClient/HttpProxyClient.php index f74592be..d8fe511e 100644 --- a/src/ProxyClient/HttpProxyClient.php +++ b/src/ProxyClient/HttpProxyClient.php @@ -13,6 +13,7 @@ use Http\Discovery\MessageFactoryDiscovery; use Http\Message\RequestFactory; +use Psr\Http\Message\StreamInterface; use Psr\Http\Message\UriInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -83,15 +84,16 @@ protected function configureOptions() /** * Create a request and queue it with the HTTP dispatcher. * - * @param string $method - * @param string|UriInterface $url - * @param array $headers - * @param bool $validateHost see Dispatcher::invalidate + * @param string $method + * @param string|UriInterface $url + * @param array $headers + * @param bool $validateHost see Dispatcher::invalidate + * @param resource|string|StreamInterface|null $body */ - protected function queueRequest($method, $url, array $headers, $validateHost = true) + protected function queueRequest($method, $url, array $headers, $validateHost = true, $body = null) { $this->httpDispatcher->invalidate( - $this->requestFactory->createRequest($method, $url, $headers), + $this->requestFactory->createRequest($method, $url, $headers, $body), $validateHost ); } diff --git a/tests/Unit/ProxyClient/FastlyTest.php b/tests/Unit/ProxyClient/FastlyTest.php new file mode 100644 index 00000000..5f585d2f --- /dev/null +++ b/tests/Unit/ProxyClient/FastlyTest.php @@ -0,0 +1,181 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\HttpCache\Tests\Unit\ProxyClient; + +use FOS\HttpCache\ProxyClient\Fastly; +use FOS\HttpCache\ProxyClient\HttpDispatcher; +use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; +use Mockery\MockInterface; +use PHPUnit\Framework\TestCase; +use Psr\Http\Message\RequestInterface; + +class FastlyTest extends TestCase +{ + use MockeryPHPUnitIntegration; + + /** + * @var HttpDispatcher|MockInterface + */ + private $httpDispatcher; + + protected function setUp() + { + parent::setUp(); + $this->httpDispatcher = \Mockery::mock(HttpDispatcher::class); + } + + protected function tearDown() + { + unset($this->httpDispatcher); + parent::tearDown(); + } + + protected function getProxyClient(array $options = []) + { + $options = [ + 'authentication_token' => 'o43r8j34hr', + 'service_identifier' => 'greenpeace', + ] + $options; + + return new Fastly($this->httpDispatcher, $options); + } + + public function testInvalidateTagsDefaultSoftPurge() + { + $fastly = $this->getProxyClient(); + + $this->httpDispatcher->shouldReceive('invalidate')->once()->with( + \Mockery::on( + function (RequestInterface $request) { + $this->assertEquals('POST', $request->getMethod()); + + $this->assertEquals('o43r8j34hr', $request->getHeaderLine('Fastly-Key')); + $this->assertEquals('application/json', $request->getHeaderLine('Accept')); + $this->assertEquals('/service/greenpeace/purge', $request->getRequestTarget()); + + $this->assertEquals('1', $request->getHeaderLine('Fastly-Soft-Purge')); + $this->assertEquals('', $request->getHeaderLine('Surrogate-Key')); + + $this->assertEquals('{"surrogate_keys":["post-1","post,type-3"]}', $request->getBody()->getContents()); + + return true; + } + ), false + ); + + $fastly->invalidateTags(['post-1', 'post,type-3']); + } + + public function testInvalidateTagsHardPurge() + { + $fastly = $this->getProxyClient(['soft_purge' => false]); + + $this->httpDispatcher->shouldReceive('invalidate')->once()->with( + \Mockery::on( + function (RequestInterface $request) { + $this->assertEquals('POST', $request->getMethod()); + + $this->assertEquals('', $request->getHeaderLine('Fastly-Soft-Purge')); + $this->assertEquals('', $request->getHeaderLine('Surrogate-Key')); + + $this->assertEquals('{"surrogate_keys":["post-1","post,type-3"]}', $request->getBody()->getContents()); + + return true; + } + ), false + ); + + $fastly->invalidateTags(['post-1', 'post,type-3']); + } + + public function testInvalidateTagsHeadersSplit() + { + $fastly = $this->getProxyClient(); + + $this->httpDispatcher->shouldReceive('invalidate')->twice(); + + $tags = []; + for ($i = 1; $i < 258; ++$i) { + $tags[] = 'post-'.$i; + } + $fastly->invalidateTags($tags); + } + + public function testPurge() + { + $fastly = $this->getProxyClient(); + + $this->httpDispatcher->shouldReceive('invalidate')->once()->with( + \Mockery::on( + function (RequestInterface $request) { + $this->assertEquals('PURGE', $request->getMethod()); + + $this->assertEquals('o43r8j34hr', $request->getHeaderLine('Fastly-Key')); + $this->assertEquals('', $request->getHeaderLine('Accept')); + $this->assertEquals('/url', $request->getRequestTarget()); + + $this->assertEquals('bar', $request->getHeaderLine('X-Foo')); + + $this->assertEquals('1', $request->getHeaderLine('Fastly-Soft-Purge')); + $this->assertEquals('', $request->getHeaderLine('Surrogate-Key')); + + return true; + } + ), false + ); + + $fastly->purge('/url', ['X-Foo' => 'bar']); + } + + public function testRefresh() + { + $fastly = $this->getProxyClient(); + + $this->httpDispatcher->shouldReceive('invalidate')->twice()->with( + \Mockery::on( + function (RequestInterface $request) { + $this->assertContains($request->getMethod(), ['PURGE', 'HEAD']); + $this->assertEquals('o43r8j34hr', $request->getHeaderLine('Fastly-Key')); + $this->assertEquals('/fresh', $request->getRequestTarget()); + + return true; + } + ), false + ); + + $fastly->refresh('/fresh'); + } + + public function testClear() + { + $fastly = $this->getProxyClient(); + + $this->httpDispatcher->shouldReceive('invalidate')->once()->with( + \Mockery::on( + function (RequestInterface $request) { + $this->assertEquals('POST', $request->getMethod()); + + $this->assertEquals('o43r8j34hr', $request->getHeaderLine('Fastly-Key')); + $this->assertEquals('application/json', $request->getHeaderLine('Accept')); + $this->assertEquals('/service/greenpeace/purge_all', $request->getRequestTarget()); + + $this->assertEquals('', $request->getHeaderLine('Fastly-Soft-Purge')); + $this->assertEquals('', $request->getHeaderLine('Surrogate-Key')); + + return true; + } + ), false + ); + + $fastly->clear(); + } +}