diff --git a/src/Symfony/Bundle/ApiPlatformBundle.php b/src/Symfony/Bundle/ApiPlatformBundle.php index f887630f0d5..2908a7ada44 100644 --- a/src/Symfony/Bundle/ApiPlatformBundle.php +++ b/src/Symfony/Bundle/ApiPlatformBundle.php @@ -22,6 +22,7 @@ use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\FilterPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\GraphQlResolverPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\GraphQlTypePass; +use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\ItemNormalizerDecorationBcPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\JsonStreamerTransformerPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\McpRegistryPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\MetadataAwareNameConverterPass; @@ -66,6 +67,8 @@ public function build(ContainerBuilder $container): void $container->addCompilerPass(new MutatorPass()); $container->addCompilerPass(new McpRegistryPass()); $container->addCompilerPass(new PropertyInfoTagPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -100); + // Runs after AutowireAsDecoratorPass (100) and before FrameworkBundle's SerializerPass (0), which reads the serializer.normalizer tag. + $container->addCompilerPass(new ItemNormalizerDecorationBcPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 10); // Must run after Symfony's TransformerPass so we can rely on the value_object_transformer tag being processed. $container->addCompilerPass(new JsonStreamerTransformerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -10); } diff --git a/src/Symfony/Bundle/DependencyInjection/Compiler/ItemNormalizerDecorationBcPass.php b/src/Symfony/Bundle/DependencyInjection/Compiler/ItemNormalizerDecorationBcPass.php new file mode 100644 index 00000000000..25235c5e6e8 --- /dev/null +++ b/src/Symfony/Bundle/DependencyInjection/Compiler/ItemNormalizerDecorationBcPass.php @@ -0,0 +1,87 @@ + + * + * 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\Symfony\Bundle\DependencyInjection\Compiler; + +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; + +/** + * Detaches an item denormalizer from the serializer chain when a user decorator of the matching normalizer implements {@see DenormalizerInterface}. + * + * @internal + * + * @todo remove in 6.0 + */ +final class ItemNormalizerDecorationBcPass implements CompilerPassInterface +{ + private const PAIRS = [ + 'api_platform.serializer.normalizer.item' => 'api_platform.serializer.denormalizer.item', + 'api_platform.jsonld.normalizer.item' => 'api_platform.jsonld.denormalizer.item', + 'api_platform.jsonapi.normalizer.item' => 'api_platform.jsonapi.denormalizer.item', + 'api_platform.graphql.normalizer.item' => 'api_platform.graphql.denormalizer.item', + ]; + + public function process(ContainerBuilder $container): void + { + foreach (self::PAIRS as $normalizerId => $denormalizerId) { + if (!$container->hasDefinition($normalizerId) || !$container->hasDefinition($denormalizerId)) { + continue; + } + + $decorators = $this->findUserDecorators($container, $normalizerId); + if ([] === $decorators || [] !== $this->findUserDecorators($container, $denormalizerId, false)) { + continue; + } + + $container->getDefinition($denormalizerId)->clearTag('serializer.normalizer'); + + foreach ($decorators as $id) { + trigger_deprecation('api-platform/core', '4.4', 'Service "%s" decorates "%s" and implements "%s": denormalization is routed through the decorated normalizer for backward compatibility. Decorate "%s" instead.', $id, $normalizerId, DenormalizerInterface::class, $denormalizerId); + } + } + } + + /** + * @return list + */ + private function findUserDecorators(ContainerBuilder $container, string $decoratedId, bool $onlyDenormalizers = true): array + { + $ids = []; + foreach ($container->getDefinitions() as $id => $definition) { + if (str_starts_with((string) $id, 'api_platform.') || ($definition->getDecoratedService()[0] ?? null) !== $decoratedId) { + continue; + } + + if ($onlyDenormalizers && !$this->implementsDenormalizer($container, $definition->getClass())) { + continue; + } + + $ids[] = $id; + } + + return $ids; + } + + private function implementsDenormalizer(ContainerBuilder $container, ?string $class): bool + { + if (null === $class) { + return false; + } + + $class = $container->getParameterBag()->resolveValue($class); + + return \is_string($class) && true === $container->getReflectionClass($class, false)?->implementsInterface(DenormalizerInterface::class); + } +} diff --git a/tests/Fixtures/TestBundle/Serializer/Decorator/DenormalizingJsonLdItemNormalizerDecorator.php b/tests/Fixtures/TestBundle/Serializer/Decorator/DenormalizingJsonLdItemNormalizerDecorator.php new file mode 100644 index 00000000000..e9b1d249db2 --- /dev/null +++ b/tests/Fixtures/TestBundle/Serializer/Decorator/DenormalizingJsonLdItemNormalizerDecorator.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\Serializer\Decorator; + +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ValidateOnce; +use Symfony\Component\DependencyInjection\Attribute\AsDecorator; +use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\SerializerAwareInterface; +use Symfony\Component\Serializer\SerializerInterface; + +#[AsDecorator('api_platform.jsonld.normalizer.item')] +final class DenormalizingJsonLdItemNormalizerDecorator implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface +{ + public function __construct(#[AutowireDecorated] private readonly NormalizerInterface&DenormalizerInterface&SerializerAwareInterface $inner) + { + } + + public function normalize(mixed $data, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + { + return $this->inner->normalize($data, $format, $context); + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $this->inner->supportsNormalization($data, $format, $context); + } + + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed + { + $object = $this->inner->denormalize($data, $type, $format, $context); + + if ($object instanceof ValidateOnce) { + $object->name = 'decorated'; + } + + return $object; + } + + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool + { + return $this->inner->supportsDenormalization($data, $type, $format, $context); + } + + public function setSerializer(SerializerInterface $serializer): void + { + $this->inner->setSerializer($serializer); + } + + public function getSupportedTypes(?string $format): array + { + return $this->inner->getSupportedTypes($format); + } +} diff --git a/tests/Fixtures/TestBundle/Serializer/Decorator/NormalizeOnlyItemNormalizerDecorator.php b/tests/Fixtures/TestBundle/Serializer/Decorator/NormalizeOnlyItemNormalizerDecorator.php new file mode 100644 index 00000000000..ea52b59e9d9 --- /dev/null +++ b/tests/Fixtures/TestBundle/Serializer/Decorator/NormalizeOnlyItemNormalizerDecorator.php @@ -0,0 +1,38 @@ + + * + * 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\Serializer\Decorator; + +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +final class NormalizeOnlyItemNormalizerDecorator implements NormalizerInterface +{ + public function __construct(private readonly NormalizerInterface $inner) + { + } + + public function normalize(mixed $data, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + { + return $this->inner->normalize($data, $format, $context); + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $this->inner->supportsNormalization($data, $format, $context); + } + + public function getSupportedTypes(?string $format): array + { + return $this->inner->getSupportedTypes($format); + } +} diff --git a/tests/Functional/DecoratedItemNormalizerDenormalizationTest.php b/tests/Functional/DecoratedItemNormalizerDenormalizationTest.php new file mode 100644 index 00000000000..755f3b20c0f --- /dev/null +++ b/tests/Functional/DecoratedItemNormalizerDenormalizationTest.php @@ -0,0 +1,80 @@ + + * + * 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\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ValidateOnce; +use ApiPlatform\Tests\Fixtures\TestBundle\Serializer\Decorator\DenormalizingJsonLdItemNormalizerDecorator; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; +use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +class DecoratedItemNormalizerAppKernel extends \AppKernel +{ + public function getCacheDir(): string + { + return parent::getCacheDir().'/decorated_item_normalizer'; + } + + public function getLogDir(): string + { + return parent::getLogDir().'/decorated_item_normalizer'; + } + + protected function configureContainer(ContainerBuilder $c, LoaderInterface $loader): void + { + parent::configureContainer($c, $loader); + + $loader->load(static function (ContainerBuilder $container): void { + $container->register(DenormalizingJsonLdItemNormalizerDecorator::class) + ->setAutowired(true) + ->setAutoconfigured(true); + }); + } +} + +final class DecoratedItemNormalizerDenormalizationTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ValidateOnce::class]; + } + + protected static function getKernelClass(): string + { + return DecoratedItemNormalizerAppKernel::class; + } + + #[IgnoreDeprecations] + public function testDecoratorOfTheItemNormalizerStillDenormalizes(): void + { + $this->expectUserDeprecationMessage('Since api-platform/core 4.4: Calling "denormalize()" on "ApiPlatform\JsonLd\Serializer\ItemNormalizer" is deprecated, use "ApiPlatform\JsonLd\Serializer\ItemDenormalizer" instead.'); + + $response = self::createClient()->request('POST', '/validate_once', [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'json' => ['name' => 'original'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertSame('decorated', $response->toArray()['name']); + } +} diff --git a/tests/Symfony/Bundle/DependencyInjection/Compiler/ItemNormalizerDecorationBcPassTest.php b/tests/Symfony/Bundle/DependencyInjection/Compiler/ItemNormalizerDecorationBcPassTest.php new file mode 100644 index 00000000000..7d16230b07e --- /dev/null +++ b/tests/Symfony/Bundle/DependencyInjection/Compiler/ItemNormalizerDecorationBcPassTest.php @@ -0,0 +1,131 @@ + + * + * 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\Symfony\Bundle\DependencyInjection\Compiler; + +use ApiPlatform\Elasticsearch\Serializer\ItemNormalizer as ElasticsearchItemNormalizer; +use ApiPlatform\JsonLd\Serializer\ItemDenormalizer as JsonLdItemDenormalizer; +use ApiPlatform\JsonLd\Serializer\ItemNormalizer as JsonLdItemNormalizer; +use ApiPlatform\Serializer\ItemDenormalizer; +use ApiPlatform\Serializer\ItemNormalizer; +use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\ItemNormalizerDecorationBcPass; +use ApiPlatform\Tests\Fixtures\TestBundle\Serializer\Decorator\DenormalizingJsonLdItemNormalizerDecorator; +use ApiPlatform\Tests\Fixtures\TestBundle\Serializer\Decorator\NormalizeOnlyItemNormalizerDecorator; +use PHPUnit\Framework\TestCase; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; + +final class ItemNormalizerDecorationBcPassTest extends TestCase +{ + public function testDenormalizingDecoratorDetachesDenormalizerFromSerializerChain(): void + { + $expected = 'Since api-platform/core 4.4: Service "user.item_normalizer" decorates "api_platform.jsonld.normalizer.item" and implements "Symfony\Component\Serializer\Normalizer\DenormalizerInterface": denormalization is routed through the decorated normalizer for backward compatibility. Decorate "api_platform.jsonld.denormalizer.item" instead.'; + + $container = $this->createContainer(); + $container->setDefinition('user.item_normalizer', (new Definition(DenormalizingJsonLdItemNormalizerDecorator::class)) + ->setDecoratedService('api_platform.jsonld.normalizer.item')); + + $deprecations = []; + set_error_handler(static function (int $type, string $message) use (&$deprecations): bool { + $deprecations[] = $message; + + return true; + }, \E_USER_DEPRECATED); + + try { + (new ItemNormalizerDecorationBcPass())->process($container); + } finally { + restore_error_handler(); + } + + $this->assertSame([$expected], $deprecations); + + $this->assertFalse($container->getDefinition('api_platform.jsonld.denormalizer.item')->hasTag('serializer.normalizer')); + $this->assertTrue($container->getDefinition('api_platform.jsonld.normalizer.item')->hasTag('serializer.normalizer')); + $this->assertTrue($container->getDefinition('api_platform.serializer.denormalizer.item')->hasTag('serializer.normalizer')); + } + + public function testNormalizeOnlyDecoratorKeepsDenormalizerTag(): void + { + $container = $this->createContainer(); + $container->setDefinition('user.item_normalizer', (new Definition(NormalizeOnlyItemNormalizerDecorator::class)) + ->setDecoratedService('api_platform.jsonld.normalizer.item')); + + (new ItemNormalizerDecorationBcPass())->process($container); + + $this->assertTrue($container->getDefinition('api_platform.jsonld.denormalizer.item')->hasTag('serializer.normalizer')); + } + + public function testDecoratorOfTheDenormalizerKeepsDenormalizerTag(): void + { + $container = $this->createContainer(); + $container->setDefinition('user.item_normalizer', (new Definition(DenormalizingJsonLdItemNormalizerDecorator::class)) + ->setDecoratedService('api_platform.jsonld.normalizer.item')); + $container->setDefinition('user.item_denormalizer', (new Definition(DenormalizingJsonLdItemNormalizerDecorator::class)) + ->setDecoratedService('api_platform.jsonld.denormalizer.item')); + + (new ItemNormalizerDecorationBcPass())->process($container); + + $this->assertTrue($container->getDefinition('api_platform.jsonld.denormalizer.item')->hasTag('serializer.normalizer')); + } + + public function testApiPlatformDecoratorKeepsDenormalizerTag(): void + { + $container = $this->createContainer(); + $container->setDefinition('api_platform.elasticsearch.normalizer.item', (new Definition(ElasticsearchItemNormalizer::class)) + ->setDecoratedService('api_platform.serializer.normalizer.item')); + + (new ItemNormalizerDecorationBcPass())->process($container); + + $this->assertTrue($container->getDefinition('api_platform.serializer.denormalizer.item')->hasTag('serializer.normalizer')); + } + + public function testApiPlatformDecoratorOfTheDenormalizerDoesNotCountAsMigrated(): void + { + $expected = 'Since api-platform/core 4.4: Service "user.item_normalizer" decorates "api_platform.serializer.normalizer.item" and implements "Symfony\\Component\\Serializer\\Normalizer\\DenormalizerInterface": denormalization is routed through the decorated normalizer for backward compatibility. Decorate "api_platform.serializer.denormalizer.item" instead.'; + + $container = $this->createContainer(); + $container->setDefinition('user.item_normalizer', (new Definition(DenormalizingJsonLdItemNormalizerDecorator::class)) + ->setDecoratedService('api_platform.serializer.normalizer.item')); + $container->setDefinition('api_platform.elasticsearch.denormalizer.item', (new Definition(ElasticsearchItemNormalizer::class)) + ->setDecoratedService('api_platform.serializer.denormalizer.item')); + + $deprecations = []; + set_error_handler(static function (int $type, string $message) use (&$deprecations): bool { + $deprecations[] = $message; + + return true; + }, \E_USER_DEPRECATED); + + try { + (new ItemNormalizerDecorationBcPass())->process($container); + } finally { + restore_error_handler(); + } + + $this->assertSame([$expected], $deprecations); + + $this->assertFalse($container->getDefinition('api_platform.serializer.denormalizer.item')->hasTag('serializer.normalizer')); + } + + private function createContainer(): ContainerBuilder + { + $container = new ContainerBuilder(); + $container->setDefinition('api_platform.serializer.normalizer.item', (new Definition(ItemNormalizer::class))->addTag('serializer.normalizer', ['priority' => -895])); + $container->setDefinition('api_platform.serializer.denormalizer.item', (new Definition(ItemDenormalizer::class))->addTag('serializer.normalizer', ['priority' => -894])); + $container->setDefinition('api_platform.jsonld.normalizer.item', (new Definition(JsonLdItemNormalizer::class))->addTag('serializer.normalizer', ['priority' => -890])); + $container->setDefinition('api_platform.jsonld.denormalizer.item', (new Definition(JsonLdItemDenormalizer::class))->addTag('serializer.normalizer', ['priority' => -889])); + + return $container; + } +}