diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8bb20735bf..42b3b0c0ecb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -992,7 +992,7 @@ jobs: - name: Clear test app cache run: tests/Fixtures/app/console cache:clear --ansi - name: Run Behat tests - run: vendor/bin/behat --out=std --format=progress --profile=default --no-interaction + run: vendor/bin/behat --out=std --format=progress --profile=default --no-interaction --tags='~@disableForSymfonyLowest' phpunit_legacy: name: PHPUnit Legacy event listeners (PHP ${{ matrix.php }}) diff --git a/features/http_cache/tag_collector_service.feature b/features/http_cache/tag_collector_service.feature new file mode 100644 index 00000000000..74d5c0afc08 --- /dev/null +++ b/features/http_cache/tag_collector_service.feature @@ -0,0 +1,56 @@ +@sqlite +@customTagCollector +@disableForSymfonyLowest +Feature: Cache invalidation through HTTP Cache tags (custom TagCollector service) + In order to have a fast API + As an API software developer + I need to store API responses in a cache + + @createSchema + Scenario: Create a dummy resource + When I add "Content-Type" header equal to "application/ld+json" + And I send a "POST" request to "/relation_embedders" with body: + """ + { + } + """ + Then the response status code should be 201 + And the header "Cache-Tags" should not exist + + Scenario: TagCollector can identify $object (IRI is overriden with custom logic) + When I send a "GET" request to "/relation_embedders/1" + Then the response status code should be 200 + And the header "Cache-Tags" should be equal to "/RE/1#anotherRelated,/RE/1#related,/RE/1" + + Scenario: Create some embedded resources + When I add "Content-Type" header equal to "application/ld+json" + And I send a "POST" request to "/relation_embedders" with body: + """ + { + "anotherRelated": { + "name": "Related" + } + } + """ + Then the response status code should be 201 + And the header "Cache-Tags" should not exist + + Scenario: TagCollector can add cache tags for relations + When I send a "GET" request to "/relation_embedders/2" + Then the response status code should be 200 + And the header "Cache-Tags" should be equal to "/related_dummies/1#thirdLevel,/related_dummies/1,/RE/2#anotherRelated,/RE/2#related,/RE/2" + + Scenario: Create resource with extraProperties on ApiProperty + When I add "Content-Type" header equal to "application/ld+json" + And I send a "POST" request to "/extra_properties_on_properties" with body: + """ + { + } + """ + Then the response status code should be 201 + And the header "Cache-Tags" should not exist + + Scenario: TagCollector can read propertyMetadata (tag is overriden with data from extraProperties) + When I send a "GET" request to "/extra_properties_on_properties/1" + Then the response status code should be 200 + And the header "Cache-Tags" should be equal to "/extra_properties_on_properties/1#overrideRelationTag,/extra_properties_on_properties/1" diff --git a/features/http_cache/tags.feature b/features/http_cache/tags.feature index 9724c81fb6b..bcc5ed9370c 100644 --- a/features/http_cache/tags.feature +++ b/features/http_cache/tags.feature @@ -23,7 +23,7 @@ Feature: Cache invalidation through HTTP Cache tags Scenario: Tags must be set for items When I send a "GET" request to "/relation_embedders/1" Then the response status code should be 200 - And the header "Cache-Tags" should be equal to "/relation_embedders/1,/related_dummies/1,/third_levels/1" + And the header "Cache-Tags" should be equal to "/third_levels/1,/related_dummies/1,/relation_embedders/1" Scenario: Create some more resources When I add "Content-Type" header equal to "application/ld+json" @@ -42,7 +42,7 @@ Feature: Cache invalidation through HTTP Cache tags Scenario: Tags must be set for collections When I send a "GET" request to "/relation_embedders" Then the response status code should be 200 - 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" + And the header "Cache-Tags" should be equal to "/third_levels/1,/related_dummies/1,/relation_embedders/1,/third_levels/2,/related_dummies/2,/relation_embedders/2,/relation_embedders" Scenario: Purge item on update When I add "Content-Type" header equal to "application/ld+json" @@ -119,7 +119,7 @@ Feature: Cache invalidation through HTTP Cache tags When I add "Content-Type" header equal to "application/ld+json" And I send a "GET" request to "/relation3s" Then the response status code should be 200 - And the header "Cache-Tags" should be equal to "/relation3s/1,/relation2s/1,/relation2s/2,/relation3s" + And the header "Cache-Tags" should be equal to "/relation2s/1,/relation2s/2,/relation3s/1,/relation3s" Scenario: Update a collection member only (legacy non-standard PUT) When I add "Content-Type" header equal to "application/ld+json" diff --git a/src/JsonApi/Serializer/ItemNormalizer.php b/src/JsonApi/Serializer/ItemNormalizer.php index 4a48334b6b6..06181aedcf1 100644 --- a/src/JsonApi/Serializer/ItemNormalizer.php +++ b/src/JsonApi/Serializer/ItemNormalizer.php @@ -27,6 +27,7 @@ use ApiPlatform\Serializer\AbstractItemNormalizer; use ApiPlatform\Serializer\CacheKeyTrait; use ApiPlatform\Serializer\ContextTrait; +use ApiPlatform\Serializer\TagCollectorInterface; use ApiPlatform\Symfony\Security\ResourceAccessCheckerInterface; use Symfony\Component\ErrorHandler\Exception\FlattenException; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; @@ -55,9 +56,9 @@ final class ItemNormalizer extends AbstractItemNormalizer private array $componentsCache = []; - public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface|LegacyIriConverterInterface $iriConverter, ResourceClassResolverInterface|LegacyResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, ResourceAccessCheckerInterface $resourceAccessChecker = null) + public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface|LegacyIriConverterInterface $iriConverter, ResourceClassResolverInterface|LegacyResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null) { - parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker); + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector); } /** @@ -245,7 +246,7 @@ protected function normalizeRelation(ApiProperty $propertyMetadata, ?object $rel $iri = $this->iriConverter->getIriFromResource($relatedObject); $context['iri'] = $iri; - if (isset($context['resources'])) { + if (!$this->tagCollector && isset($context['resources'])) { $context['resources'][$iri] = $iri; } } @@ -263,12 +264,23 @@ protected function normalizeRelation(ApiProperty $propertyMetadata, ?object $rel return $normalizedRelatedObject; } - return [ + $context['data'] = [ 'data' => [ 'type' => $this->getResourceShortName($resourceClass), 'id' => $iri, ], ]; + + $context['iri'] = $iri; + $context['object'] = $relatedObject; + unset($context['property_metadata']); + unset($context['api_attribute']); + + if ($this->tagCollector) { + $this->tagCollector->collect($context); + } + + return $context['data']; } /** diff --git a/src/JsonLd/Serializer/ItemNormalizer.php b/src/JsonLd/Serializer/ItemNormalizer.php index 5eafbc7a31e..0df22398487 100644 --- a/src/JsonLd/Serializer/ItemNormalizer.php +++ b/src/JsonLd/Serializer/ItemNormalizer.php @@ -27,6 +27,7 @@ use ApiPlatform\Metadata\Util\ClassInfoTrait; use ApiPlatform\Serializer\AbstractItemNormalizer; use ApiPlatform\Serializer\ContextTrait; +use ApiPlatform\Serializer\TagCollectorInterface; use ApiPlatform\Symfony\Security\ResourceAccessCheckerInterface; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\Serializer\Exception\LogicException; @@ -47,9 +48,9 @@ final class ItemNormalizer extends AbstractItemNormalizer public const FORMAT = 'jsonld'; - public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface|LegacyIriConverterInterface $iriConverter, ResourceClassResolverInterface|LegacyResourceClassResolverInterface $resourceClassResolver, private readonly ContextBuilderInterface $contextBuilder, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ResourceAccessCheckerInterface $resourceAccessChecker = null) + public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface|LegacyIriConverterInterface $iriConverter, ResourceClassResolverInterface|LegacyResourceClassResolverInterface $resourceClassResolver, private readonly ContextBuilderInterface $contextBuilder, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null) { - parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker); + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector); } /** diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index af563b7e082..340240e5f57 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -63,7 +63,7 @@ abstract class AbstractItemNormalizer extends AbstractObjectNormalizer protected array $localCache = []; protected array $localFactoryOptionsCache = []; - public function __construct(protected PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, protected PropertyMetadataFactoryInterface $propertyMetadataFactory, protected LegacyIriConverterInterface|IriConverterInterface $iriConverter, protected LegacyResourceClassResolverInterface|ResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, protected ?ResourceAccessCheckerInterface $resourceAccessChecker = null) + public function __construct(protected PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, protected PropertyMetadataFactoryInterface $propertyMetadataFactory, protected LegacyIriConverterInterface|IriConverterInterface $iriConverter, protected LegacyResourceClassResolverInterface|ResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, protected ?ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null) { if (!isset($defaultContext['circular_reference_handler'])) { $defaultContext['circular_reference_handler'] = fn ($object): ?string => $this->iriConverter->getIriFromResource($object); @@ -164,16 +164,33 @@ public function normalize(mixed $object, string $format = null, array $context = $emptyResourceAsIri = $context['api_empty_resource_as_iri'] ?? false; unset($context['api_empty_resource_as_iri']); - if (isset($context['resources'])) { + if (!$this->tagCollector && isset($context['resources'])) { $context['resources'][$iri] = $iri; } + $context['object'] = $object; + $context['format'] = $format; + $data = parent::normalize($object, $format, $context); + $context['data'] = $data; + unset($context['property_metadata']); + unset($context['api_attribute']); + if ($emptyResourceAsIri && \is_array($data) && 0 === \count($data)) { + $context['data'] = $iri; + + if ($this->tagCollector) { + $this->tagCollector->collect($context); + } + return $iri; } + if ($this->tagCollector) { + $this->tagCollector->collect($context); + } + return $data; } @@ -633,7 +650,7 @@ protected function getFactoryOptions(array $context): array protected function getAttributeValue(object $object, string $attribute, string $format = null, array $context = []): mixed { $context['api_attribute'] = $attribute; - $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $this->getFactoryOptions($context)); + $context['property_metadata'] = $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $this->getFactoryOptions($context)); if ($context['api_denormalize'] ?? false) { return $this->propertyAccessor->getValue($object, $attribute); @@ -670,7 +687,15 @@ protected function getAttributeValue(object $object, string $attribute, string $ $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className); - return $this->normalizeCollectionOfRelations($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext); + $data = $this->normalizeCollectionOfRelations($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext); + $context['data'] = $data; + $context['type'] = $type; + + if ($this->tagCollector) { + $this->tagCollector->collect($context); + } + + return $data; } if ( @@ -697,7 +722,15 @@ protected function getAttributeValue(object $object, string $attribute, string $ $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className); - return $this->normalizeRelation($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext); + $data = $this->normalizeRelation($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext); + $context['data'] = $data; + $context['type'] = $type; + + if ($this->tagCollector) { + $this->tagCollector->collect($context); + } + + return $data; } if (!$this->serializer instanceof NormalizerInterface) { @@ -789,9 +822,15 @@ protected function normalizeRelation(ApiProperty $propertyMetadata, ?object $rel return $normalizedRelatedObject; } - $iri = $this->iriConverter->getIriFromResource(resource: $relatedObject, context: $context); + $context['iri'] = $iri = $this->iriConverter->getIriFromResource(resource: $relatedObject, context: $context); + $context['data'] = $iri; + $context['object'] = $relatedObject; + unset($context['property_metadata']); + unset($context['api_attribute']); - if (isset($context['resources'])) { + if ($this->tagCollector) { + $this->tagCollector->collect($context); + } elseif (isset($context['resources'])) { $context['resources'][$iri] = $iri; } diff --git a/src/Serializer/ItemNormalizer.php b/src/Serializer/ItemNormalizer.php index c5b971f7c0b..5643be3a80e 100644 --- a/src/Serializer/ItemNormalizer.php +++ b/src/Serializer/ItemNormalizer.php @@ -39,9 +39,9 @@ class ItemNormalizer extends AbstractItemNormalizer { private readonly LoggerInterface $logger; - public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, LoggerInterface $logger = null, ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, ResourceAccessCheckerInterface $resourceAccessChecker = null, array $defaultContext = []) + public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, LoggerInterface $logger = null, ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, ResourceAccessCheckerInterface $resourceAccessChecker = null, array $defaultContext = [], protected ?TagCollectorInterface $tagCollector = null) { - parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataFactory, $resourceAccessChecker); + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataFactory, $resourceAccessChecker, $tagCollector); $this->logger = $logger ?: new NullLogger(); } diff --git a/src/Serializer/TagCollectorInterface.php b/src/Serializer/TagCollectorInterface.php new file mode 100644 index 00000000000..9659c0e5d91 --- /dev/null +++ b/src/Serializer/TagCollectorInterface.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Serializer; + +/** + * Interface for collecting cache tags during normalization. + * + * @author Urban Suppiger + */ +interface TagCollectorInterface +{ + /** + * Collect cache tags for cache invalidation. + * + * @param array&array{iri?: string, data?: mixed, object?: mixed, property_metadata?: \ApiPlatform\Metadata\ApiProperty, api_attribute?: string, resources?: array} $context + */ + public function collect(array $context = []): void; +} diff --git a/src/Symfony/Bundle/Resources/config/api.xml b/src/Symfony/Bundle/Resources/config/api.xml index 8a0d37e82fc..a6261004a66 100644 --- a/src/Symfony/Bundle/Resources/config/api.xml +++ b/src/Symfony/Bundle/Resources/config/api.xml @@ -60,6 +60,8 @@ null + + diff --git a/src/Symfony/Bundle/Resources/config/hal.xml b/src/Symfony/Bundle/Resources/config/hal.xml index c83f984db06..df615a9d8fc 100644 --- a/src/Symfony/Bundle/Resources/config/hal.xml +++ b/src/Symfony/Bundle/Resources/config/hal.xml @@ -45,6 +45,7 @@ + diff --git a/src/Symfony/Bundle/Resources/config/jsonapi.xml b/src/Symfony/Bundle/Resources/config/jsonapi.xml index 0a84066acc4..05575e32d87 100644 --- a/src/Symfony/Bundle/Resources/config/jsonapi.xml +++ b/src/Symfony/Bundle/Resources/config/jsonapi.xml @@ -48,6 +48,7 @@ + diff --git a/src/Symfony/Bundle/Resources/config/jsonld.xml b/src/Symfony/Bundle/Resources/config/jsonld.xml index b5edc481ac7..72816fd74d7 100644 --- a/src/Symfony/Bundle/Resources/config/jsonld.xml +++ b/src/Symfony/Bundle/Resources/config/jsonld.xml @@ -29,6 +29,7 @@ + diff --git a/tests/Behat/HttpCacheContext.php b/tests/Behat/HttpCacheContext.php index 5ebed981123..be22cc55253 100644 --- a/tests/Behat/HttpCacheContext.php +++ b/tests/Behat/HttpCacheContext.php @@ -13,8 +13,15 @@ namespace ApiPlatform\Tests\Behat; +use ApiPlatform\Tests\Fixtures\TestBundle\HttpCache\TagCollectorCustom; use Behat\Behat\Context\Context; +use Behat\Behat\Hook\Scope\BeforeScenarioScope; +use Behat\Mink\Driver\BrowserKitDriver; +use Behat\MinkExtension\Context\MinkContext; +use FriendsOfBehat\SymfonyExtension\Context\Environment\InitializedSymfonyExtensionEnvironment; use PHPUnit\Framework\ExpectationFailedException; +use Symfony\Bundle\FrameworkBundle\KernelBrowser; +use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpKernel\KernelInterface; /** @@ -22,10 +29,19 @@ */ final class HttpCacheContext implements Context { - public function __construct(private readonly KernelInterface $kernel) + public function __construct(private readonly KernelInterface $kernel, private ContainerInterface $driverContainer) { } + /** + * @BeforeScenario @customTagCollector + */ + public function registerCustomTagCollector(BeforeScenarioScope $scope): void + { + $this->disableReboot($scope); + $this->driverContainer->set('api_platform.http_cache.tag_collector', new TagCollectorCustom()); + } + /** * @Then :iris IRIs should be purged */ @@ -40,4 +56,28 @@ public function irisShouldBePurged(string $iris): void throw new ExpectationFailedException(sprintf('IRIs "%s" does not match expected "%s".', $purgedIris, $iris)); } } + + /** + * this is necessary to allow overriding services + * see https://github.com/FriendsOfBehat/SymfonyExtension/issues/149 for details. + */ + private function disableReboot(BeforeScenarioScope $scope): void + { + $env = $scope->getEnvironment(); + if (!$env instanceof InitializedSymfonyExtensionEnvironment) { + return; + } + + $driver = $env->getContext(MinkContext::class)->getSession()->getDriver(); + if (!$driver instanceof BrowserKitDriver) { + return; + } + + $client = $driver->getClient(); + if (!$client instanceof KernelBrowser) { + return; + } + + $client->disableReboot(); + } } diff --git a/tests/Fixtures/TestBundle/Entity/ExtraPropertiesOnProperty.php b/tests/Fixtures/TestBundle/Entity/ExtraPropertiesOnProperty.php new file mode 100644 index 00000000000..59a0570c426 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/ExtraPropertiesOnProperty.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Post; +use Doctrine\ORM\Mapping as ORM; + +/** + * @author Urban Suppiger + */ +#[ApiResource( + operations: [ + new Get(), + new Post(), + ] +)] +#[ORM\Entity] +class ExtraPropertiesOnProperty +{ + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + public $id; + + #[ORM\ManyToOne(targetEntity: RelatedDummy::class, cascade: ['persist'])] + #[ApiProperty(extraProperties: ['cacheDependencies' => ['overrideRelationTag']])] + public ?RelatedDummy $relatedDummy = null; +} diff --git a/tests/Fixtures/TestBundle/HttpCache/TagCollectorCustom.php b/tests/Fixtures/TestBundle/HttpCache/TagCollectorCustom.php new file mode 100644 index 00000000000..4b74f01f743 --- /dev/null +++ b/tests/Fixtures/TestBundle/HttpCache/TagCollectorCustom.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\HttpCache; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Serializer\TagCollectorInterface; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelationEmbedder; + +/** + * Collects cache tags during normalization. + * + * @author Urban Suppiger + */ +class TagCollectorCustom implements TagCollectorInterface +{ + public const IRI_RELATION_DELIMITER = '#'; + + public function collect(array $context = []): void + { + $iri = $context['iri']; + $object = $context['object']; + + if ($object instanceof RelationEmbedder) { + $iri = '/RE/'.$object->id; + } + + if (isset($context['property_metadata'])) { + $this->addCacheTagsForRelation($context, $iri, $context['property_metadata']); + } elseif (\is_array($context['data'])) { + $this->addCacheTagForResource($context, $iri); + } + } + + private function addCacheTagForResource(array $context, ?string $iri): void + { + if (isset($context['resources']) && isset($iri)) { + $context['resources'][$iri] = $iri; + } + } + + private function addCacheTagsForRelation(array $context, ?string $iri, ApiProperty $propertyMetadata): void + { + if (isset($context['resources']) && isset($iri)) { + if (isset($propertyMetadata->getExtraProperties()['cacheDependencies'])) { + foreach ($propertyMetadata->getExtraProperties()['cacheDependencies'] as $dependency) { + $cacheTag = $iri.self::IRI_RELATION_DELIMITER.$dependency; + $context['resources'][$cacheTag] = $cacheTag; + } + } else { + $cacheTag = $iri.self::IRI_RELATION_DELIMITER.$context['api_attribute']; + $context['resources'][$cacheTag] = $cacheTag; + } + } + } +} diff --git a/tests/Fixtures/TestBundle/HttpCache/TagCollectorDefault.php b/tests/Fixtures/TestBundle/HttpCache/TagCollectorDefault.php new file mode 100644 index 00000000000..9ffae4956be --- /dev/null +++ b/tests/Fixtures/TestBundle/HttpCache/TagCollectorDefault.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\HttpCache; + +use ApiPlatform\Serializer\TagCollectorInterface; + +/** + * Collects cache tags during normalization. + * + * @author Urban Suppiger + */ +class TagCollectorDefault implements TagCollectorInterface +{ + public function collect(array $context = []): void + { + if (!isset($context['property_metadata'])) { + $this->addResourceToContext($context); + } + } + + private function addResourceToContext(array $context): void + { + $iri = $context['iri']; + + if (isset($context['resources']) && isset($iri)) { + $context['resources'][$iri] = $iri; + } + } +} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index f75fd7ef873..0de51bbbf60 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -451,3 +451,7 @@ services: ApiPlatform\Tests\Fixtures\TestBundle\Serializer\ErrorNormalizer: decorates: 'api_platform.problem.normalizer.error' arguments: [ '@.inner' ] + + api_platform.http_cache.tag_collector: + class: ApiPlatform\Tests\Fixtures\TestBundle\HttpCache\TagCollectorDefault + public: true