Skip to content

Commit a32a049

Browse files
committed
Builtin cache invalidation system aka make API Platform fast as hell
1 parent 8622477 commit a32a049

File tree

28 files changed

+700
-19
lines changed

28 files changed

+700
-19
lines changed

behat.yml.dist

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
- 'Behatch\Context\RestContext'
1011
- 'Behatch\Context\JsonContext'

composer.json

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

features/http_cache/headers.feature

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
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 "Etag" should be equal to '"21248afbca1f242fd3009ac7cdf13293"'
12+
And the header "Cache-Control" should be equal to "max-age=60, public, s-maxage=3600"
13+
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 "/relation_embedders/1,/related_dummies/1,/third_levels/1"
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 "/relation_embedders/1,/related_dummies/1,/third_levels/1,/relation_embedders/2,/related_dummies/2,/third_levels/2,/relation_embedders"
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,/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,/relation_embedders/1" IRIs should be purged
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
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+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Core\Bridge\Doctrine\EventListener;
15+
16+
use ApiPlatform\Core\Api\IriConverterInterface;
17+
use ApiPlatform\Core\Api\ResourceClassResolverInterface;
18+
use ApiPlatform\Core\Exception\InvalidArgumentException;
19+
use Doctrine\ORM\Event\OnFlushEventArgs;
20+
use Psr\Container\ContainerInterface;
21+
use Symfony\Component\HttpFoundation\RequestStack;
22+
23+
/**
24+
* Stores IRIs of entity to purge from the proxy cache.
25+
*
26+
* @author Kévin Dunglas <[email protected]>
27+
*
28+
* @experimental
29+
*/
30+
final class PurgeHttpCacheListener
31+
{
32+
private $requestStack;
33+
private $iriConverter;
34+
private $resourceClassResolver;
35+
36+
public function __construct(RequestStack $requestStack, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver)
37+
{
38+
$this->requestStack = $requestStack;
39+
$this->iriConverter = $iriConverter;
40+
$this->resourceClassResolver = $resourceClassResolver;
41+
}
42+
43+
public function onFlush(OnFlushEventArgs $eventArgs)
44+
{
45+
if (!$request = $this->requestStack->getCurrentRequest()) {
46+
return;
47+
}
48+
49+
$resources = $request->attributes->get('_resources', []);
50+
$uow = $eventArgs->getEntityManager()->getUnitOfWork();
51+
52+
foreach ($uow->getScheduledEntityInsertions() as $entity) {
53+
$resources = $this->purge($resources, $entity, false);
54+
}
55+
56+
foreach ($uow->getScheduledEntityUpdates() as $entity) {
57+
$resources = $this->purge($resources, $entity, true);
58+
}
59+
60+
foreach ($uow->getScheduledEntityDeletions() as $entity) {
61+
$resources = $this->purge($resources, $entity, true);
62+
}
63+
64+
$request->attributes->set('_resources', $resources);
65+
}
66+
67+
private function purge(array $resources, $entity, bool $purgeItem): array
68+
{
69+
try {
70+
$resourceClass = $this->resourceClassResolver->getResourceClass($entity);
71+
} catch (InvalidArgumentException $e) {
72+
return $resources;
73+
}
74+
75+
$iri = $this->iriConverter->getIriFromResourceClass($resourceClass);
76+
$resources[$iri] = $iri;
77+
if ($purgeItem) {
78+
$iri = $this->iriConverter->getIriFromItem($entity);
79+
$resources[$iri] = $iri;
80+
}
81+
82+
return $resources;
83+
}
84+
}

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@
2020
use Symfony\Component\Config\FileLocator;
2121
use Symfony\Component\Config\Resource\DirectoryResource;
2222
use Symfony\Component\DependencyInjection\ContainerBuilder;
23+
use Symfony\Component\DependencyInjection\DefinitionDecorator;
2324
use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
2425
use Symfony\Component\DependencyInjection\Loader\XmlFileLoader;
26+
use Symfony\Component\DependencyInjection\Reference;
2527
use Symfony\Component\Finder\Finder;
2628
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
2729
use Symfony\Component\Validator\Validator\ValidatorInterface;
@@ -96,6 +98,7 @@ public function load(array $configs, ContainerBuilder $container)
9698
$this->registerBundlesConfiguration($bundles, $config, $loader);
9799
$this->registerCacheConfiguration($container);
98100
$this->registerDoctrineExtensionConfiguration($container, $config);
101+
$this->registerHttpCache($container, $config, $loader);
99102
}
100103

101104
/**
@@ -128,6 +131,11 @@ private function handleConfig(ContainerBuilder $container, array $config, array
128131
$container->setParameter('api_platform.collection.pagination.page_parameter_name', $config['collection']['pagination']['page_parameter_name']);
129132
$container->setParameter('api_platform.collection.pagination.enabled_parameter_name', $config['collection']['pagination']['enabled_parameter_name']);
130133
$container->setParameter('api_platform.collection.pagination.items_per_page_parameter_name', $config['collection']['pagination']['items_per_page_parameter_name']);
134+
$container->setParameter('api_platform.http_cache.etag', $config['http_cache']['etag']);
135+
$container->setParameter('api_platform.http_cache.max_age', $config['http_cache']['max_age']);
136+
$container->setParameter('api_platform.http_cache.shared_max_age', $config['http_cache']['shared_max_age']);
137+
$container->setParameter('api_platform.http_cache.vary', $config['http_cache']['vary']);
138+
$container->setParameter('api_platform.http_cache.public', $config['http_cache']['public']);
131139

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

@@ -357,6 +365,37 @@ private function registerDoctrineExtensionConfiguration(ContainerBuilder $contai
357365
}
358366
}
359367

368+
private function registerHttpCache(ContainerBuilder $container, array $config, XmlFileLoader $loader)
369+
{
370+
$loader->load('http_cache.xml');
371+
372+
if (true !== $config['http_cache']['invalidation']['enabled']) {
373+
if ($container->has('api_platform.doctrine.listener.http_cache.purge')) {
374+
$container->removeDefinition('api_platform.doctrine.listener.http_cache.purge');
375+
}
376+
377+
return;
378+
}
379+
380+
$loader->load('http_cache_tags.xml');
381+
if (!$config['http_cache']['invalidation']['varnish_urls']) {
382+
return;
383+
}
384+
385+
$references = [];
386+
foreach ($config['http_cache']['invalidation']['varnish_urls'] as $url) {
387+
$id = sprintf('api_platform.http_cache.purger.varnish_client.%s', $url);
388+
$references[] = new Reference($id);
389+
390+
$definition = new DefinitionDecorator('api_platform.http_cache.purger.varnish_client');
391+
$definition->addArgument(['base_uri' => $url]);
392+
$container->setDefinition($id, $definition);
393+
}
394+
395+
$container->getDefinition('api_platform.http_cache.purger.varnish')->addArgument($references);
396+
$container->setAlias('api_platform.http_cache.purger', 'api_platform.http_cache.purger.varnish');
397+
}
398+
360399
/**
361400
* Normalizes the format from config to the one accepted by Symfony HttpFoundation.
362401
*

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,33 @@ public function getConfigTreeBuilder()
113113
->end()
114114
->end()
115115
->end()
116+
117+
->arrayNode('http_cache')
118+
->addDefaultsIfNotSet()
119+
->children()
120+
->booleanNode('etag')->defaultTrue()->info('Automatically generate etags for API responses.')->end()
121+
->integerNode('max_age')->defaultNull()->info('Default value for the response max age.')->end()
122+
->integerNode('shared_max_age')->defaultNull()->info('Default value for the response shared (proxy) max age.')->end()
123+
->arrayNode('vary')
124+
->defaultValue(['Content-Type'])
125+
->prototype('scalar')->end()
126+
->info('Default values of the "Vary" HTTP header.')
127+
->end()
128+
->booleanNode('public')->defaultNull()->info('To make all responses public by default.')->end()
129+
->arrayNode('invalidation')
130+
->info('Enable the tags-based cache invalidation system.')
131+
->canBeEnabled()
132+
->children()
133+
->arrayNode('varnish_urls')
134+
->defaultValue([])
135+
->prototype('scalar')->end()
136+
->info('URLs of the Varnish servers to purge using cache tags when a resource is updated.')
137+
->end()
138+
->end()
139+
->end()
140+
->end()
141+
->end()
142+
116143
->end();
117144

118145
$this->addExceptionToStatusSection($rootNode);

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,14 @@
103103
<tag name="kernel.event_listener" event="kernel.view" method="onKernelView" priority="32" />
104104
</service>
105105

106+
<service id="api_platform.doctrine.listener.http_cache.purge" class="ApiPlatform\Core\Bridge\Doctrine\EventListener\PurgeHttpCacheListener">
107+
<argument type="service" id="request_stack" />
108+
<argument type="service" id="api_platform.iri_converter" />
109+
<argument type="service" id="api_platform.resource_class_resolver" />
110+
111+
<tag name="doctrine.event_listener" event="onFlush" />
112+
</service>
113+
106114
<!-- Doctrine Query extensions -->
107115

108116
<service id="api_platform.doctrine.orm.query_extension.eager_loading" class="ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\EagerLoadingExtension" public="false">
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
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\AddHeadersListener">
9+
<argument>%api_platform.http_cache.etag%</argument>
10+
<argument>%api_platform.http_cache.max_age%</argument>
11+
<argument>%api_platform.http_cache.shared_max_age%</argument>
12+
<argument>%api_platform.http_cache.vary%</argument>
13+
<argument>%api_platform.http_cache.public%</argument>
14+
15+
<tag name="kernel.event_listener" event="kernel.response" method="onKernelResponse" priority="-1" />
16+
</service>
17+
</services>
18+
</container>
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
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.purger.varnish_client" class="GuzzleHttp\Client" abstract="true" public="false" />
9+
<service id="api_platform.http_cache.purger.varnish" class="ApiPlatform\Core\HttpCache\VarnishPurger" public="false" />
10+
11+
<service id="api_platform.http_cache.listener.response.add_tags" class="ApiPlatform\Core\HttpCache\EventListener\AddTagsListener">
12+
<argument type="service" id="api_platform.iri_converter" />
13+
14+
<tag name="kernel.event_listener" event="kernel.response" method="onKernelResponse" priority="-2" />
15+
</service>
16+
17+
<!-- The onKernelView method must be executed after the validation (32) but before the write (64) -->
18+
<service id="api_platform.http_cache.listener.response.purge" class="ApiPlatform\Core\HttpCache\EventListener\PurgeListener">
19+
<argument type="service" id="api_platform.http_cache.purger" />
20+
21+
<tag name="kernel.event_listener" event="kernel.terminate" method="onKernelTerminate" />
22+
</service>
23+
</services>
24+
</container>

0 commit comments

Comments
 (0)