Skip to content

Commit 3b2659b

Browse files
committed
Builtin cache invalidation system aka make API Platform fast as hell
1 parent 8dd86b5 commit 3b2659b

File tree

28 files changed

+747
-6
lines changed

28 files changed

+747
-6
lines changed

behat.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ default:
55
- 'FeatureContext': { doctrine: '@doctrine' }
66
- 'HydraContext'
77
- 'SwaggerContext'
8+
- 'HttpCacheContext'
89
- 'Behat\MinkExtension\Context\MinkContext'
910
- 'Sanpi\Behatch\Context\RestContext'
1011
- 'Sanpi\Behatch\Context\JsonContext'

composer.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"doctrine/orm": "^2.5",
3535
"doctrine/annotations": "^1.2",
3636
"friendsofsymfony/user-bundle": "^2.0@dev",
37+
"guzzlehttp/guzzle": "^6.0",
3738
"nelmio/api-doc-bundle": "^2.11.2",
3839
"php-mock/php-mock-phpunit": "^1.1",
3940
"phpdocumentor/reflection-docblock": "^3.0",
@@ -57,6 +58,7 @@
5758
},
5859
"suggest": {
5960
"friendsofsymfony/user-bundle": "To use the FOSUserBundle bridge.",
61+
"guzzlehttp/guzzle": "To use the HTTP cache invalidation system.",
6062
"phpdocumentor/reflection-docblock": "To support extracting metadata from PHPDoc.",
6163
"psr/cache-implementation": "To use metadata caching.",
6264
"symfony/cache": "To have metadata caching when using Symfony integration.",
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
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+
use Behat\Symfony2Extension\Context\KernelAwareContext;
13+
use Symfony\Component\HttpKernel\KernelInterface;
14+
15+
/**
16+
* @author Kévin Dunglas <[email protected]>
17+
*/
18+
class HttpCacheContext implements KernelAwareContext
19+
{
20+
/**
21+
* @var KernelInterface
22+
*/
23+
private $kernel;
24+
25+
public function setKernel(KernelInterface $kernel)
26+
{
27+
$this->kernel = $kernel;
28+
}
29+
30+
/**
31+
* @Then :iris IRIs should be purged
32+
*/
33+
public function irisShouldBePurged($iris)
34+
{
35+
$purger = $this->kernel->getContainer()->get('api_platform.http_cache.purger');
36+
37+
$purgedIris = implode(',', $purger->getIris());
38+
$purger->clear();
39+
40+
if ($iris !== $purgedIris) {
41+
throw new \PHPUnit_Framework_ExpectationFailedException(
42+
sprintf('IRIs "%s" does not match expected "%s".', $purgedIris, $iris)
43+
);
44+
}
45+
}
46+
}

features/http_cache/headers.feature

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
Feature: Default values of HTTP cache headers
2+
In order to make API responses caheable
3+
As an API software developer
4+
I need to be able to set default cache headers values
5+
6+
@createSchema
7+
@dropSchema
8+
Scenario: Cache headers default value
9+
When I send a "GET" request to "/relation_embedders"
10+
Then the response status code should be 200
11+
And the header "Cache-Control" should be equal to "max-age=60, public, s-maxage=3600"
12+
And the header "Vary" should be equal to "Content-Type, Cookie"

features/http_cache/tags.feature

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
Feature: Cache invalidation trough HTTP Cache tags
2+
In order to have a fast API
3+
As an API software developer
4+
I need to store API responses in a cache
5+
6+
@createSchema
7+
Scenario: Create some embedded resources
8+
When I add "Content-Type" header equal to "application/ld+json"
9+
And I send a "POST" request to "/relation_embedders" with body:
10+
"""
11+
{
12+
"anotherRelated": {
13+
"name": "Related",
14+
"thirdLevel": {}
15+
}
16+
}
17+
"""
18+
Then the response status code should be 201
19+
And the header "Cache-Tags" should not exist
20+
And "/relation_embedders,/related_dummies,/third_levels,/relation_embedders/1,/related_dummies/1,/third_levels/1" IRIs should be purged
21+
22+
Scenario: Tags must be set for items
23+
When I send a "GET" request to "/relation_embedders/1"
24+
Then the response status code should be 200
25+
And the header "Cache-Tags" should be equal to "aa9e2bee5be20590f7dcc520ce2dffca,12a0c94f947a680d68bd6f65e025457d,91774d67418192a057e25dae00345572"
26+
27+
Scenario: Create some more resources
28+
When I add "Content-Type" header equal to "application/ld+json"
29+
And I send a "POST" request to "/relation_embedders" with body:
30+
"""
31+
{
32+
"anotherRelated": {
33+
"name": "Another Related",
34+
"thirdLevel": {}
35+
}
36+
}
37+
"""
38+
Then the response status code should be 201
39+
And the header "Cache-Tags" should not exist
40+
41+
Scenario: Tags must be set for collections
42+
When I send a "GET" request to "/relation_embedders"
43+
Then the response status code should be 200
44+
And the header "Cache-Tags" should be equal to "aa9e2bee5be20590f7dcc520ce2dffca,12a0c94f947a680d68bd6f65e025457d,91774d67418192a057e25dae00345572,0b51526d04eac211b0d5e93ce6b133e3,e3d156c7d2d2b9d2228071aca2aa71ca,4f4c0d1c39015017ca3e97b15c36fc16,245ca118219102ad043d75121434c707"
45+
46+
Scenario: Purge item on update
47+
When I add "Content-Type" header equal to "application/ld+json"
48+
And I send a "PUT" request to "/relation_embedders/1" with body:
49+
"""
50+
{
51+
"paris": "France"
52+
}
53+
"""
54+
Then the response status code should be 200
55+
And the header "Cache-Tags" should not exist
56+
And "/relation_embedders/1,/related_dummies/1,/third_levels/1" IRIs should be purged
57+
58+
@dropSchema
59+
Scenario: Purge item and the related collection on update
60+
When I add "Content-Type" header equal to "application/ld+json"
61+
And I send a "DELETE" request to "/relation_embedders/1"
62+
Then the response status code should be 204
63+
And the header "Cache-Tags" should not exist
64+
And "/relation_embedders/1,/relation_embedders" IRIs should be purged
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
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 ApiPlatform\Core\Bridge\Doctrine\EventListener;
13+
14+
use ApiPlatform\Core\Exception\InvalidArgumentException;
15+
use Doctrine\ORM\Event\OnFlushEventArgs;
16+
use Psr\Container\ContainerInterface;
17+
18+
/**
19+
* Stores IRIs of entity to purge from the proxy cache.
20+
*
21+
* @author Kévin Dunglas <[email protected]>
22+
*
23+
* @internal
24+
*/
25+
final class PurgeHttpCacheListener
26+
{
27+
private $container;
28+
29+
/**
30+
* @param ContainerInterface|\Symfony\Component\DependencyInjection\ContainerInterface $container
31+
*/
32+
public function __construct($container)
33+
{
34+
$this->container = $container;
35+
}
36+
37+
public function onFlush(OnFlushEventArgs $eventArgs)
38+
{
39+
$iriConverter = $this->container->get('api_platform.iri_converter');
40+
$resourceManager = $this->container->get('api_platform.http_cache.resource_manager');
41+
42+
$em = $eventArgs->getEntityManager();
43+
$uow = $em->getUnitOfWork();
44+
45+
foreach ($uow->getScheduledEntityInsertions() as $entity) {
46+
if (null !== $resourceClass = $this->getResource($entity)) {
47+
$resourceManager->addResource($iriConverter->getIriFromResourceClass($resourceClass));
48+
}
49+
}
50+
51+
foreach ($uow->getScheduledEntityUpdates() as $entity) {
52+
if (null !== $this->getResource($entity)) {
53+
$resourceManager->addResource($iriConverter->getIriFromItem($entity));
54+
}
55+
}
56+
57+
foreach ($uow->getScheduledEntityDeletions() as $entity) {
58+
if (null === $resourceClass = $this->getResource($entity)) {
59+
continue;
60+
}
61+
62+
$resourceManager->addResource($iriConverter->getIriFromResourceClass($resourceClass));
63+
$resourceManager->addResource($iriConverter->getIriFromItem($entity));
64+
}
65+
}
66+
67+
private function getResource($entity)
68+
{
69+
try {
70+
return $this->container->get('api_platform.resource_class_resolver')->getResourceClass($entity);
71+
} catch (InvalidArgumentException $e) {
72+
return null;
73+
}
74+
}
75+
}

src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ public function load(array $configs, ContainerBuilder $container)
8383
$this->registerBundlesConfiguration($bundles, $config, $loader);
8484
$this->registerCacheConfiguration($container);
8585
$this->registerDoctrineExtensionConfiguration($container, $config);
86+
$this->registerHttpCache($container, $config, $loader);
8687
}
8788

8889
/**
@@ -114,6 +115,10 @@ private function handleConfig(ContainerBuilder $container, array $config, array
114115
$container->setParameter('api_platform.collection.pagination.page_parameter_name', $config['collection']['pagination']['page_parameter_name']);
115116
$container->setParameter('api_platform.collection.pagination.enabled_parameter_name', $config['collection']['pagination']['enabled_parameter_name']);
116117
$container->setParameter('api_platform.collection.pagination.items_per_page_parameter_name', $config['collection']['pagination']['items_per_page_parameter_name']);
118+
$container->setParameter('api_platform.http_cache.max_age', $config['http_cache']['max_age']);
119+
$container->setParameter('api_platform.http_cache.shared_max_age', $config['http_cache']['shared_max_age']);
120+
$container->setParameter('api_platform.http_cache.vary', $config['http_cache']['vary']);
121+
$container->setParameter('api_platform.http_cache.public', $config['http_cache']['public']);
117122

118123
$container->setAlias('api_platform.operation_path_resolver.default', $config['default_operation_path_resolver']);
119124

@@ -288,6 +293,31 @@ private function registerDoctrineExtensionConfiguration(ContainerBuilder $contai
288293
}
289294
}
290295

296+
private function registerHttpCache(ContainerBuilder $container, array $config, XmlFileLoader $loader)
297+
{
298+
$loader->load('http_cache.xml');
299+
300+
if (true !== $config['http_cache']['enable_tags']) {
301+
if ($container->has('api_platform.doctrine.listener.http_cache.purge')) {
302+
$container->removeDefinition('api_platform.doctrine.listener.http_cache.purge');
303+
}
304+
305+
return;
306+
}
307+
308+
$loader->load('http_cache_tags.xml');
309+
if (null === $config['http_cache']['varnish_url']) {
310+
$container->removeDefinition('api_platform.http_cache.purger.varnish_client');
311+
312+
return;
313+
}
314+
315+
$container->getDefinition('api_platform.http_cache.purger.varnish_client')->addArgument(
316+
['base_uri' => $config['http_cache']['varnish_url']]
317+
);
318+
$container->setAlias('api_platform.http_cache.purger', 'api_platform.http_cache.purger.varnish');
319+
}
320+
291321
/**
292322
* Populates file resources lists.
293323
*

src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,23 @@ public function getConfigTreeBuilder()
9292
->end()
9393
->end()
9494
->end()
95+
96+
->arrayNode('http_cache')
97+
->addDefaultsIfNotSet()
98+
->children()
99+
->integerNode('max_age')->defaultNull()->info('Default value for the response max age.')->end()
100+
->integerNode('shared_max_age')->defaultNull()->info('Default value for the response shared (proxy) max age.')->end()
101+
->arrayNode('vary')
102+
->defaultValue(['Content-Type'])
103+
->prototype('scalar')->end()
104+
->info('Default values of the "Vary" HTTP header.')
105+
->end()
106+
->booleanNode('public')->defaultNull()->info('To make all responses public by default.')->end()
107+
->booleanNode('enable_tags')->defaultFalse()->info('Add cache tags to the response.')->end()
108+
->scalarNode('varnish_url')->defaultNull()->info('URL of the Varnish server to purge using cache tags when a resource is updated.')->end()
109+
->end()
110+
->end()
111+
95112
->end();
96113

97114
$this->addExceptionToStatusSection($rootNode);

src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,12 @@
8585
<tag name="kernel.event_listener" event="kernel.view" method="onKernelView" priority="32" />
8686
</service>
8787

88+
<service id="api_platform.doctrine.listener.http_cache.purge" class="ApiPlatform\Core\Bridge\Doctrine\EventListener\PurgeHttpCacheListener">
89+
<argument type="service" id="service_container" />
90+
91+
<tag name="doctrine.event_listener" event="onFlush" />
92+
</service>
93+
8894
<!-- Doctrine Query extensions -->
8995

9096
<service id="api_platform.doctrine.orm.query_extension.eager_loading" class="ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\EagerLoadingExtension" public="false">

src/Bridge/Symfony/Bundle/Resources/config/hal.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
<argument type="service" id="api_platform.property_accessor" />
3535
<argument type="service" id="api_platform.name_converter" on-invalid="ignore" />
3636
<argument type="service" id="serializer.mapping.class_metadata_factory" on-invalid="ignore" />
37+
<argument type="service" id="api_platform.http_cache.resource_manager" on-invalid="ignore" />
3738

3839
<tag name="serializer.normalizer" priority="8" />
3940
</service>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?xml version="1.0" ?>
2+
3+
<container xmlns="http://symfony.com/schema/dic/services"
4+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
5+
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
6+
7+
<services>
8+
<service id="api_platform.http_cache.listener.response.configure" class="ApiPlatform\Core\HttpCache\EventListener\ConfigureListener">
9+
<argument>%api_platform.http_cache.max_age%</argument>
10+
<argument>%api_platform.http_cache.shared_max_age%</argument>
11+
<argument>%api_platform.http_cache.vary%</argument>
12+
<argument>%api_platform.http_cache.public%</argument>
13+
14+
<tag name="kernel.event_listener" event="kernel.response" method="onKernelResponse" />
15+
</service>
16+
</services>
17+
</container>
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?xml version="1.0" ?>
2+
3+
<container xmlns="http://symfony.com/schema/dic/services"
4+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
5+
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
6+
7+
<services>
8+
<service id="api_platform.http_cache.resource_manager" class="ApiPlatform\Core\HttpCache\ResourceManager" public="false" />
9+
<service id="api_platform.http_cache.purger.varnish_client" class="GuzzleHttp\Client" public="false" />
10+
11+
<service id="api_platform.http_cache.purger.varnish" class="ApiPlatform\Core\HttpCache\VarnishPurger" public="false">
12+
<argument type="service" id="api_platform.http_cache.purger.varnish_client" />
13+
</service>
14+
15+
<service id="api_platform.http_cache.listener.response.add_tags" class="ApiPlatform\Core\HttpCache\EventListener\AddTagsListener">
16+
<argument type="service" id="api_platform.http_cache.resource_manager" />
17+
<argument type="service" id="api_platform.iri_converter" />
18+
19+
<tag name="kernel.event_listener" event="kernel.response" method="onKernelResponse" />
20+
</service>
21+
22+
<!-- The onKernelView method must be executed after the validation (32) but before the write (64) -->
23+
<service id="api_platform.http_cache.listener.response.purge" class="ApiPlatform\Core\HttpCache\EventListener\PurgeListener">
24+
<argument type="service" id="api_platform.http_cache.resource_manager" />
25+
<argument type="service" id="api_platform.http_cache.purger" />
26+
<argument type="service" id="api_platform.iri_converter" />
27+
28+
<tag name="kernel.event_listener" event="kernel.view" method="onKernelView" priority="48" />
29+
<tag name="kernel.event_listener" event="kernel.terminate" method="onKernelTerminate" />
30+
</service>
31+
</services>
32+
</container>

src/Bridge/Symfony/Bundle/Resources/config/jsonld.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
<argument type="service" id="api_platform.property_accessor" />
2626
<argument type="service" id="api_platform.name_converter" on-invalid="ignore" />
2727
<argument type="service" id="serializer.mapping.class_metadata_factory" on-invalid="ignore" />
28+
<argument type="service" id="api_platform.http_cache.resource_manager" on-invalid="ignore" />
2829

2930
<tag name="serializer.normalizer" priority="8" />
3031
</service>

src/Hal/Serializer/ItemNormalizer.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,14 @@ public function supportsNormalization($data, $format = null)
4242
public function normalize($object, $format = null, array $context = [])
4343
{
4444
$context['cache_key'] = $this->getHalCacheKey($format, $context);
45+
$context['iri'] = $this->iriConverter->getIriFromItem($object);
4546

4647
$rawData = parent::normalize($object, $format, $context);
4748
if (!is_array($rawData)) {
4849
return $rawData;
4950
}
5051

51-
$data = ['_links' => ['self' => ['href' => $this->iriConverter->getIriFromItem($object)]]];
52+
$data = ['_links' => ['self' => ['href' => $context['iri']]]];
5253
$components = $this->getComponents($object, $format, $context);
5354
$data = $this->populateRelation($data, $object, $format, $context, $components, 'links');
5455
$data = $this->populateRelation($data, $object, $format, $context, $components, 'embedded');

0 commit comments

Comments
 (0)