Skip to content

Commit 48cec17

Browse files
authored
Merge pull request #516 from FriendsOfSymfony/simonrjones-cloudflare-support
Simonrjones cloudflare support
2 parents 843a102 + 645cd97 commit 48cec17

File tree

6 files changed

+417
-7
lines changed

6 files changed

+417
-7
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ See also the [GitHub releases page](https://github.com/FriendsOfSymfony/FOSHttpC
66
2.12.0 (unreleased)
77
-------------------
88

9+
### Cloudflare
10+
11+
* Added Cloudflare ProxyClient Adapter with ClearCapable, PurgeCapable and
12+
TagCapable. This allows to use FOSHttpCache to invalidate caches on
13+
Cloudflare. See the "Proxy Client" section of the documentation for how to
14+
configure the Cloudflare client.
15+
916
### Varnish Cache
1017

1118
* Added a `fos_user_context_hash` method to be called in `vcl_hash` when using the user context

doc/proxy-clients.rst

Lines changed: 86 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,13 @@ Varnish ✓ ✓ ✓ ✓
2626
Fastly ✓ ✓ ✓ ✓
2727
NGINX ✓ ✓
2828
Symfony Cache ✓ ✓ ✓ (1) ✓ (1)
29+
Cloudflare ✓ ✓ (2) ✓
2930
Noop ✓ ✓ ✓ ✓ ✓
3031
Multiplexer ✓ ✓ ✓ ✓ ✓
3132
============= ======= ======= ======= ======= =======
3233

33-
(1): Only when using `Toflar Psr6Store`_.
34+
| (1): Only when using `Toflar Psr6Store`_.
35+
| (2): Only available with `Cloudflare Enterprise`_.
3436
3537
If needed, you can also implement your own client for other needs. Have a look
3638
at the interfaces in namespace ``FOS\HttpCache\ProxyClient\Invalidation``.
@@ -90,7 +92,8 @@ Varnish Client
9092
~~~~~~~~~~~~~~
9193

9294
The Varnish client sends HTTP requests with the ``HttpDispatcher``. Create the
93-
dispatcher as explained above and pass it to the Varnish client::
95+
dispatcher as explained :ref:`above <HTTP client configuration>` and pass it to
96+
the Varnish client::
9497

9598
use FOS\HttpCache\ProxyClient\Varnish;
9699

@@ -163,11 +166,12 @@ Fastly Client
163166
~~~~~~~~~~~~~~
164167

165168
The Fastly client sends HTTP requests with the ``HttpDispatcher``. Create the
166-
dispatcher as explained above and pass it to the Fastly client::
169+
dispatcher as explained :ref:`above <HTTP client configuration>` and pass it to
170+
the Fastly client::
167171

168172
use FOS\HttpCache\ProxyClient\Fastly;
169173

170-
$varnish = new Fastly($httpDispatcher);
174+
$fastly = new Fastly($httpDispatcher);
171175

172176
.. note::
173177

@@ -195,13 +199,14 @@ A full example could look like this::
195199
];
196200
$requestFactory = new MyRequestFactory();
197201

198-
$varnish = new Fastly($httpDispatcher, $options, $requestFactory);
202+
$fastly = new Fastly($httpDispatcher, $options, $requestFactory);
199203

200204
NGINX Client
201205
~~~~~~~~~~~~
202206

203207
The NGINX client sends HTTP requests with the ``HttpDispatcher``. Create the
204-
dispatcher as explained above and pass it to the NGINX client::
208+
dispatcher as explained :ref:`above <HTTP client configuration>` and pass it to
209+
the NGINX client::
205210

206211
use FOS\HttpCache\ProxyClient\Nginx;
207212

@@ -224,7 +229,8 @@ Symfony Client
224229
~~~~~~~~~~~~~~
225230

226231
The Symfony client sends HTTP requests with the ``HttpDispatcher``. Create the
227-
dispatcher as explained above and pass it to the Symfony client::
232+
dispatcher as explained :ref:`above <HTTP client configuration>` and pass it to
233+
the Symfony client::
228234

229235
use FOS\HttpCache\ProxyClient\Symfony;
230236

@@ -296,6 +302,74 @@ And adapt your bootstrapping code to use the cache kernel::
296302
$response = $cacheKernel->handle($request);
297303
...
298304

305+
Cloudflare Client
306+
~~~~~~~~~~~~~~~~~
307+
308+
.. note::
309+
310+
Cloudflare does not cache HTML pages by default. To cache them, you need to
311+
enable `custom caching with page rules`_ in the Cloudflare administration
312+
interface.
313+
314+
The Cloudflare client does invalidation requests with the `Cloudflare Purge API`_.
315+
316+
The `Cloudflare`_ client sends HTTP requests with the ``HttpDispatcher``.
317+
Create the dispatcher as explained :ref:`above <HTTP client configuration>`.
318+
Set the `server` list to the Cloudflare API `['https://api.cloudflare.com']`.
319+
Do not specify a base URI. The Cloudflare client does not work with base URIs,
320+
you need to always specify the full URL including domain name.
321+
322+
Then create the Cloudflare client with the dispatcher. You also need to pass
323+
the following options:
324+
325+
* ``authentication_token``: User API token for authentication against
326+
Cloudflare APIs, requires `Zone.Cache` Purge permissions.
327+
* ``zone_identifier``: Identifier for the Cloudflare zone you want to purge the
328+
cache for (see below how to obtain the identifier for your domain).
329+
330+
A full example could look like this::
331+
332+
use FOS\HttpCache\CacheInvalidator;
333+
use FOS\HttpCache\ProxyClient\Cloudflare;
334+
use FOS\HttpCache\ProxyClient\HttpDispatcher;
335+
336+
$options = [
337+
'authentication_token' => '<user-authentication-token>',
338+
'zone_identifier' => '<my-zone-identifier>',
339+
];
340+
341+
$httpDispatcher = new HttpDispatcher(['https://api.cloudflare.com']);
342+
$cloudflare = new Cloudflare($httpDispatcher, $options);
343+
$cacheInvalidator = new CacheInvalidator($cloudflare);
344+
345+
When purging the cache by URL, see the `Cloudflare Purge by URL`_ docs for
346+
information about how Cloudflare purges by URL and what headers you can
347+
pass to a :doc:`invalidatePath() <cache-invalidator>` request to clear the
348+
cache correctly.
349+
350+
You need to always specify the domain to invalidate (the base URI mechanism of
351+
the HttpDispatcher is not available for Cloudflare)::
352+
353+
$cacheInvalidator->invalidatePath('https://example.com/path')->flush();
354+
355+
.. note::
356+
357+
Cloudflare supports different cache purge methods depending on your account.
358+
All Cloudflare accounts support purging the cache by URL and clearing all
359+
cache items. You need a `Cloudflare Enterprise`_ account to purge by cache
360+
tags.
361+
362+
Zone identifier
363+
^^^^^^^^^^^^^^^
364+
To find the zone identifier for your domain request this from the API::
365+
366+
curl -X GET "https://api.cloudflare.com/client/v4/zones?name={DOMAIN.COM}" \
367+
-H "Authorization: Bearer {API TOKEN}" \
368+
-H "Content-Type:application/json"
369+
370+
The zone identifier is returned in the ``id`` field of the results and is a
371+
32-character hexadecimal string.
372+
299373
Noop Client
300374
~~~~~~~~~~~
301375

@@ -379,3 +453,8 @@ requests.
379453
.. _message factory and URI factory: http://php-http.readthedocs.io/en/latest/message/message-factory.html
380454
.. _Toflar Psr6Store: https://github.com/Toflar/psr6-symfony-http-cache-store
381455
.. _Fastly Purge API: https://docs.fastly.com/api/purge
456+
.. _Cloudflare: https://developers.cloudflare.com/cache/
457+
.. _custom caching with page rules: https://support.cloudflare.com/hc/en-us/articles/360021023712-Best-Practices-Speed-up-your-Site-with-Custom-Caching-via-Cloudflare-Page-Rules
458+
.. _Cloudflare Purge API: https://api.cloudflare.com/#zone-purge-all-files
459+
.. _Cloudflare Enterprise: https://developers.cloudflare.com/cache/how-to/purge-cache#cache-tags-enterprise-only
460+
.. _Cloudflare Purge by URL: https://developers.cloudflare.com/cache/how-to/purge-cache#purge-by-single-file-by-url

doc/spelling_word_list.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,4 @@ inline
3232
Noop
3333
xkey
3434
ykey
35+
Cloudflare

src/ProxyClient/Cloudflare.php

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the FOSHttpCache package.
5+
*
6+
* (c) FriendsOfSymfony <http://friendsofsymfony.github.com/>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace FOS\HttpCache\ProxyClient;
13+
14+
use FOS\HttpCache\ProxyClient\Invalidation\ClearCapable;
15+
use FOS\HttpCache\ProxyClient\Invalidation\PurgeCapable;
16+
use FOS\HttpCache\ProxyClient\Invalidation\TagCapable;
17+
use Http\Message\RequestFactory;
18+
19+
/**
20+
* Cloudflare HTTP cache invalidator.
21+
*
22+
* Additional constructor options:
23+
* - zone_identifier Identifier for your Cloudflare zone you want to purge the cache for
24+
* - authentication_token API authorization token, requires Zone.Cache Purge permissions
25+
*
26+
* @author Simon Jones <[email protected]>
27+
*/
28+
class Cloudflare extends HttpProxyClient implements ClearCapable, PurgeCapable, TagCapable
29+
{
30+
/**
31+
* @see https://api.cloudflare.com/#getting-started-endpoints
32+
*/
33+
private const API_ENDPOINT = '/client/v4';
34+
35+
/**
36+
* Batch URL purge limit.
37+
*
38+
* @see https://api.cloudflare.com/#zone-purge-files-by-url
39+
*/
40+
private const URL_BATCH_PURGE_LIMIT = 30;
41+
42+
/**
43+
* Array of data to send to Cloudflare for purge by URLs request.
44+
*
45+
* To reduce the number of requests to cloudflare, we buffer the URLs.
46+
* During flush, we build requests with batches of URL_BATCH_PURGE_LIMIT.
47+
*
48+
* @var array<string|array{url: string, headers: string[]}>
49+
*/
50+
private $purgeByUrlsData = [];
51+
52+
public function __construct(
53+
Dispatcher $httpDispatcher,
54+
array $options = [],
55+
RequestFactory $messageFactory = null
56+
) {
57+
if (!function_exists('json_encode')) {
58+
throw new \Exception('ext-json is required for cloudflare invalidation');
59+
}
60+
61+
parent::__construct($httpDispatcher, $options, $messageFactory);
62+
}
63+
64+
/**
65+
* {@inheritdoc}
66+
*
67+
* Tag invalidation only available with Cloudflare enterprise account
68+
*
69+
* @see https://api.cloudflare.com/#zone-purge-files-by-cache-tags,-host-or-prefix
70+
*/
71+
public function invalidateTags(array $tags)
72+
{
73+
$this->queueRequest(
74+
'POST',
75+
sprintf(self::API_ENDPOINT.'/zones/%s/purge_cache', $this->options['zone_identifier']),
76+
[],
77+
false,
78+
$this->json_encode(['tags' => $tags])
79+
);
80+
81+
return $this;
82+
}
83+
84+
/**
85+
* {@inheritdoc}
86+
*
87+
* @see https://api.cloudflare.com/#zone-purge-files-by-url
88+
* @see https://developers.cloudflare.com/cache/how-to/purge-cache#purge-by-single-file-by-url For details on headers you can pass to clear the cache correctly
89+
*/
90+
public function purge($url, array $headers = [])
91+
{
92+
if (!empty($headers)) {
93+
$this->purgeByUrlsData[] = [
94+
'url' => $url,
95+
'headers' => $headers,
96+
];
97+
} else {
98+
$this->purgeByUrlsData[] = $url;
99+
}
100+
101+
return $this;
102+
}
103+
104+
/**
105+
* {@inheritdoc}
106+
*
107+
* @see https://api.cloudflare.com/#zone-purge-all-files
108+
*/
109+
public function clear()
110+
{
111+
$this->queueRequest(
112+
'POST',
113+
sprintf(self::API_ENDPOINT.'/zones/%s/purge_cache', $this->options['zone_identifier']),
114+
['Accept' => 'application/json'],
115+
false,
116+
$this->json_encode(['purge_everything' => true])
117+
);
118+
119+
return $this;
120+
}
121+
122+
/**
123+
* {@inheritdoc} Queue requests for purge by URLs
124+
*/
125+
public function flush()
126+
{
127+
// Queue requests for purge by URL
128+
foreach (\array_chunk($this->purgeByUrlsData, self::URL_BATCH_PURGE_LIMIT) as $urlChunk) {
129+
$this->queueRequest(
130+
'POST',
131+
sprintf(self::API_ENDPOINT.'/zones/%s/purge_cache', $this->options['zone_identifier']),
132+
[],
133+
false,
134+
$this->json_encode(['files' => $urlChunk])
135+
);
136+
}
137+
$this->purgeByUrlsData = [];
138+
139+
return parent::flush();
140+
}
141+
142+
/**
143+
* {@inheritdoc} Always provides authentication token
144+
*/
145+
protected function queueRequest($method, $url, array $headers, $validateHost = true, $body = null)
146+
{
147+
parent::queueRequest(
148+
$method,
149+
$url,
150+
$headers + ['Authorization' => 'Bearer '.$this->options['authentication_token']],
151+
$validateHost,
152+
$body
153+
);
154+
}
155+
156+
/**
157+
* {@inheritdoc}
158+
*/
159+
protected function configureOptions()
160+
{
161+
$resolver = parent::configureOptions();
162+
163+
$resolver->setRequired([
164+
'authentication_token',
165+
'zone_identifier',
166+
]);
167+
168+
return $resolver;
169+
}
170+
171+
private function json_encode(array $data): string
172+
{
173+
$json = json_encode($data, JSON_UNESCAPED_SLASHES);
174+
if (false === $json) {
175+
throw new \InvalidArgumentException(sprintf('Cannot encode "$data": %s', json_last_error_msg()));
176+
}
177+
178+
return $json;
179+
}
180+
}

src/ProxyClient/Fastly.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use FOS\HttpCache\ProxyClient\Invalidation\PurgeCapable;
1616
use FOS\HttpCache\ProxyClient\Invalidation\RefreshCapable;
1717
use FOS\HttpCache\ProxyClient\Invalidation\TagCapable;
18+
use Http\Message\RequestFactory;
1819

1920
/**
2021
* Fastly HTTP cache invalidator.
@@ -44,6 +45,18 @@ class Fastly extends HttpProxyClient implements ClearCapable, PurgeCapable, Refr
4445
*/
4546
private const API_ENDPOINT = 'https://api.fastly.com';
4647

48+
public function __construct(
49+
Dispatcher $httpDispatcher,
50+
array $options = [],
51+
RequestFactory $messageFactory = null
52+
) {
53+
if (!function_exists('json_encode')) {
54+
throw new \Exception('ext-json is required for fastly invalidation');
55+
}
56+
57+
parent::__construct($httpDispatcher, $options, $messageFactory);
58+
}
59+
4760
/**
4861
* {@inheritdoc}
4962
*

0 commit comments

Comments
 (0)