Skip to content

Commit 1126dda

Browse files
committed
Builtin cache invalidation system aka make API Platform fast as hell
1 parent c86a607 commit 1126dda

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+1302
-121
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: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
Feature: Cache invalidation through 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" 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" IRIs should be purged
57+
58+
Scenario: Purge item and the related collection on update
59+
When I add "Content-Type" header equal to "application/ld+json"
60+
And I send a "DELETE" request to "/relation_embedders/1"
61+
Then the response status code should be 204
62+
And the header "Cache-Tags" should not exist
63+
And "/relation_embedders,/relation_embedders/1,/related_dummies/1" IRIs should be purged
64+
65+
Scenario: Create two Relation2
66+
When I add "Content-Type" header equal to "application/ld+json"
67+
And I send a "POST" request to "/relation2s" with body:
68+
"""
69+
{
70+
}
71+
"""
72+
And I send a "POST" request to "/relation2s" with body:
73+
"""
74+
{
75+
}
76+
"""
77+
Then the response status code should be 201
78+
79+
Scenario: Embedded collection must be listed in cache tags
80+
When I send a "GET" request to "/relation2s/1"
81+
Then the header "Cache-Tags" should be equal to "/relation2s/1"
82+
83+
Scenario: Create a Relation1
84+
When I add "Content-Type" header equal to "application/ld+json"
85+
And I send a "POST" request to "/relation1s" with body:
86+
"""
87+
{
88+
"relation2": "/relation2s/1"
89+
}
90+
"""
91+
Then the response status code should be 201
92+
And "/relation1s,/relation2s/1" IRIs should be purged
93+
94+
@dropSchema
95+
Scenario: Update a Relation1
96+
When I add "Content-Type" header equal to "application/ld+json"
97+
And I send a "PUT" request to "/relation1s/1" with body:
98+
"""
99+
{
100+
"relation2": "/relation2s/2"
101+
}
102+
"""
103+
Then the response status code should be 200
104+
And "/relation1s,/relation1s/1,/relation2s/2,/relation2s/1" IRIs should be purged

features/main/subresource.feature

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@ Feature: Subresource support
1212
And the JSON should be equal to:
1313
"""
1414
{
15-
"@context": "/contexts/Answer",
16-
"@id": "/answers/1",
17-
"@type": "Answer",
18-
"id": 1,
19-
"content": "42",
20-
"question": "/questions/1"
15+
"@context": "\/contexts\/Answer",
16+
"@id": "\/answers\/1",
17+
"@type": "Answer",
18+
"id": 1,
19+
"content": "42",
20+
"question": "\/questions\/1"
2121
}
2222
"""
2323

phpstan.neon

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ parameters:
44
excludes_analyse:
55
- tests/Fixtures/app/cache
66
ignoreErrors:
7-
- '#Call to an undefined method Symfony\\Component\\Routing\\Exception\\ExceptionInterface::getCode()#'
7+
- '#Call to an undefined method Symfony\\Component\\Routing\\Exception\\ExceptionInterface::getCode\(\)#'
88
- '#Call to an undefined method Prophecy\\Prophecy\\ObjectProphecy::[a-zA-Z0-9_]+\(\)#'
99
- '#Access to an undefined property Prophecy\\Prophecy\\ObjectProphecy::\$[a-zA-Z0-9_]+#'
1010
- '#Call to an undefined method PHPUnit_Framework_MockObject_MockObject::[a-zA-Z0-9_]+\(\)#'
11+
- '#Call to an undefined method Doctrine\\Common\\Persistence\\Mapping\\ClassMetadata::getAssociationMappings\(\)#'
1112

1213
# False positives
1314
- '#Parameter \#2 \$dqlPart of method Doctrine\\ORM\\QueryBuilder::add\(\) expects Doctrine\\ORM\\Query\\Expr\\Base, Doctrine\\ORM\\Query\\Expr\\Join\[\] given#' # Fixed in Doctrine's master

src/Api/IdentifiersExtractorInterface.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313

1414
namespace ApiPlatform\Core\Api;
1515

16+
use ApiPlatform\Core\Exception\RuntimeException;
17+
1618
/**
1719
* Extracts identifiers for a given Resource according to the retrieved Metadata.
1820
*
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
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 ApiPlatform\Core\Exception\RuntimeException;
20+
use ApiPlatform\Core\HttpCache\PurgerInterface;
21+
use Doctrine\Common\Util\ClassUtils;
22+
use Doctrine\ORM\EntityManagerInterface;
23+
use Doctrine\ORM\Event\OnFlushEventArgs;
24+
use Doctrine\ORM\Event\PreUpdateEventArgs;
25+
use Doctrine\ORM\PersistentCollection;
26+
use Symfony\Component\PropertyAccess\PropertyAccess;
27+
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
28+
29+
/**
30+
* Purges responses containing modified entities from the proxy cache.
31+
*
32+
* @author Kévin Dunglas <[email protected]>
33+
*
34+
* @experimental
35+
*/
36+
final class PurgeHttpCacheListener
37+
{
38+
private $purger;
39+
private $iriConverter;
40+
private $resourceClassResolver;
41+
private $propertyAccessor;
42+
private $tags = [];
43+
44+
public function __construct(PurgerInterface $purger, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null)
45+
{
46+
$this->purger = $purger;
47+
$this->iriConverter = $iriConverter;
48+
$this->resourceClassResolver = $resourceClassResolver;
49+
$this->propertyAccessor = $propertyAccessor ?? PropertyAccess::createPropertyAccessor();
50+
}
51+
52+
/**
53+
* Collects tags from the previous and the current version of the updated entities to purge related documents.
54+
*/
55+
public function preUpdate(PreUpdateEventArgs $eventArgs)
56+
{
57+
$object = $eventArgs->getObject();
58+
$this->gatherResourceAndItemTags($object, true);
59+
60+
$changeSet = $eventArgs->getEntityChangeSet();
61+
$associationMappings = $eventArgs->getEntityManager()->getClassMetadata(ClassUtils::getClass($eventArgs->getObject()))->getAssociationMappings();
62+
63+
foreach ($changeSet as $key => $value) {
64+
if (!isset($associationMappings[$key])) {
65+
continue;
66+
}
67+
68+
$this->addTagsFor($value[0]);
69+
$this->addTagsFor($value[1]);
70+
}
71+
}
72+
73+
/**
74+
* Collects tags from inserted and deleted entities, including relations.
75+
*/
76+
public function onFlush(OnFlushEventArgs $eventArgs)
77+
{
78+
$em = $eventArgs->getEntityManager();
79+
$uow = $em->getUnitOfWork();
80+
81+
foreach ($uow->getScheduledEntityInsertions() as $entity) {
82+
$this->gatherResourceAndItemTags($entity, false);
83+
$this->gatherRelationTags($em, $entity);
84+
}
85+
86+
foreach ($uow->getScheduledEntityUpdates() as $entity) {
87+
$this->gatherResourceAndItemTags($entity, true);
88+
$this->gatherRelationTags($em, $entity);
89+
}
90+
91+
foreach ($uow->getScheduledEntityDeletions() as $entity) {
92+
$this->gatherResourceAndItemTags($entity, true);
93+
$this->gatherRelationTags($em, $entity);
94+
}
95+
}
96+
97+
/**
98+
* Purges tags collected during this request, and clears the tag list.
99+
*/
100+
public function postFlush()
101+
{
102+
$this->purger->purge($this->tags);
103+
$this->tags = [];
104+
}
105+
106+
private function gatherResourceAndItemTags($entity, bool $purgeItem)
107+
{
108+
try {
109+
$resourceClass = $this->resourceClassResolver->getResourceClass($entity);
110+
} catch (InvalidArgumentException $e) {
111+
return;
112+
}
113+
114+
$iri = $this->iriConverter->getIriFromResourceClass($resourceClass);
115+
$this->tags[$iri] = $iri;
116+
if ($purgeItem) {
117+
$iri = $this->iriConverter->getIriFromItem($entity);
118+
$this->tags[$iri] = $iri;
119+
}
120+
}
121+
122+
private function gatherRelationTags(EntityManagerInterface $em, $entity)
123+
{
124+
$associationMappings = $em->getClassMetadata(ClassUtils::getClass($entity))->getAssociationMappings();
125+
foreach (array_keys($associationMappings) as $property) {
126+
$this->addTagsFor($this->propertyAccessor->getValue($entity, $property));
127+
}
128+
}
129+
130+
private function addTagsFor($value)
131+
{
132+
if (!is_array($value) && !$value instanceof \Traversable) {
133+
$this->addTagForItem($value);
134+
135+
return;
136+
}
137+
138+
if ($value instanceof PersistentCollection) {
139+
$value = clone $value;
140+
}
141+
142+
foreach ($value as $v) {
143+
$this->addTagForItem($v);
144+
}
145+
}
146+
147+
private function addTagForItem($value)
148+
{
149+
try {
150+
$iri = $this->iriConverter->getIriFromItem($value);
151+
$this->tags[$iri] = $iri;
152+
} catch (InvalidArgumentException $e) {
153+
} catch (RuntimeException $e) {
154+
}
155+
}
156+
}

0 commit comments

Comments
 (0)