diff --git a/src/Symfony/Bundle/ApiPlatformBundle.php b/src/Symfony/Bundle/ApiPlatformBundle.php index b4749b48d7..d89d766490 100644 --- a/src/Symfony/Bundle/ApiPlatformBundle.php +++ b/src/Symfony/Bundle/ApiPlatformBundle.php @@ -24,6 +24,7 @@ use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\JsonStreamerTransformerPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\MetadataAwareNameConverterPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\MutatorPass; +use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\PropertyInfoTagPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\SerializerMappingLoaderPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\TestClientPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\TestMercureHubPass; @@ -60,6 +61,7 @@ public function build(ContainerBuilder $container): void $container->addCompilerPass(new AuthenticatorManagerPass()); $container->addCompilerPass(new SerializerMappingLoaderPass()); $container->addCompilerPass(new MutatorPass()); + $container->addCompilerPass(new PropertyInfoTagPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -100); // 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/PropertyInfoTagPass.php b/src/Symfony/Bundle/DependencyInjection/Compiler/PropertyInfoTagPass.php new file mode 100644 index 0000000000..42c5a15f39 --- /dev/null +++ b/src/Symfony/Bundle/DependencyInjection/Compiler/PropertyInfoTagPass.php @@ -0,0 +1,57 @@ + + * + * 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; + +/** + * Bridges Symfony's public `property_info.*` tags to API Platform's private + * `api_platform.property_info.*` tags so that `api_platform.property_info` + * inherits framework- and third-party-registered extractors (e.g. Doctrine's + * `DoctrineExtractor`) without API Platform's own extractors leaking back into + * Symfony's `property_info` service. + * + * @internal + * + * @see https://github.com/api-platform/core/issues/8201 + */ +final class PropertyInfoTagPass implements CompilerPassInterface +{ + private const TAG_SUFFIXES = [ + 'list_extractor', + 'type_extractor', + 'description_extractor', + 'access_extractor', + 'initializable_extractor', + ]; + + public function process(ContainerBuilder $container): void + { + foreach (self::TAG_SUFFIXES as $suffix) { + $publicTag = 'property_info.'.$suffix; + $privateTag = 'api_platform.property_info.'.$suffix; + + foreach ($container->findTaggedServiceIds($publicTag) as $serviceId => $tags) { + $definition = $container->getDefinition($serviceId); + if ($definition->hasTag($privateTag)) { + continue; + } + foreach ($tags as $attributes) { + $definition->addTag($privateTag, $attributes); + } + } + } + } +} diff --git a/src/Symfony/Bundle/Resources/config/api.php b/src/Symfony/Bundle/Resources/config/api.php index 60ca7413de..586e2988eb 100644 --- a/src/Symfony/Bundle/Resources/config/api.php +++ b/src/Symfony/Bundle/Resources/config/api.php @@ -77,29 +77,29 @@ $services->alias('api_platform.property_accessor', 'property_accessor'); $services->set('api_platform.property_info.reflection_extractor', ReflectionExtractor::class) - ->tag('property_info.list_extractor', ['priority' => -1000]) - ->tag('property_info.type_extractor', ['priority' => -1002]) - ->tag('property_info.access_extractor', ['priority' => -1000]) - ->tag('property_info.initializable_extractor', ['priority' => -1000]); + ->tag('api_platform.property_info.list_extractor', ['priority' => -1000]) + ->tag('api_platform.property_info.type_extractor', ['priority' => -1002]) + ->tag('api_platform.property_info.access_extractor', ['priority' => -1000]) + ->tag('api_platform.property_info.initializable_extractor', ['priority' => -1000]); if (class_exists(DocBlockFactory::class)) { $services->set('api_platform.property_info.php_doc_extractor', PhpDocExtractor::class) - ->tag('property_info.description_extractor', ['priority' => -1000]) - ->tag('property_info.type_extractor', ['priority' => -1001]); + ->tag('api_platform.property_info.description_extractor', ['priority' => -1000]) + ->tag('api_platform.property_info.type_extractor', ['priority' => -1001]); } if (class_exists(PhpDocParser::class) && class_exists(ContextFactory::class)) { $services->set('api_platform.property_info.phpstan_extractor', PhpStanExtractor::class) - ->tag('property_info.type_extractor', ['priority' => -1000]); + ->tag('api_platform.property_info.type_extractor', ['priority' => -1000]); } $services->set('api_platform.property_info', PropertyInfoExtractor::class) ->args([ - tagged_iterator('property_info.list_extractor'), - tagged_iterator('property_info.type_extractor'), - tagged_iterator('property_info.description_extractor'), - tagged_iterator('property_info.access_extractor'), - tagged_iterator('property_info.initializable_extractor'), + tagged_iterator('api_platform.property_info.list_extractor'), + tagged_iterator('api_platform.property_info.type_extractor'), + tagged_iterator('api_platform.property_info.description_extractor'), + tagged_iterator('api_platform.property_info.access_extractor'), + tagged_iterator('api_platform.property_info.initializable_extractor'), ]); $services->set('api_platform.property_info.cache', PropertyInfoCacheExtractor::class) diff --git a/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index a17bd5c41c..ee7e3abe7f 100644 --- a/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -412,4 +412,36 @@ public function testPaginationMaximumItemsPerPageWhenDefaultsKeyIsMissing(): voi $this->assertTrue($this->container->hasParameter('api_platform.collection.pagination.maximum_items_per_page')); $this->assertSame(30, $this->container->getParameter('api_platform.collection.pagination.maximum_items_per_page')); } + + /** + * @see https://github.com/api-platform/core/issues/8201 + */ + public function testPropertyInfoExtractorsDoNotLeakIntoFrameworkPropertyInfo(): void + { + $config = self::DEFAULT_CONFIG; + (new ApiPlatformExtension())->load($config, $this->container); + + $services = ['api_platform.property_info.reflection_extractor']; + if (class_exists(\phpDocumentor\Reflection\DocBlockFactory::class)) { + $services[] = 'api_platform.property_info.php_doc_extractor'; + } + if (class_exists(\PHPStan\PhpDocParser\Parser\PhpDocParser::class) && class_exists(\phpDocumentor\Reflection\Types\ContextFactory::class)) { + $services[] = 'api_platform.property_info.phpstan_extractor'; + } + + foreach ($services as $service) { + $this->assertContainerHasService($service); + $tags = $this->container->getDefinition($service)->getTags(); + foreach ($tags as $name => $_) { + $this->assertStringStartsNotWith('property_info.', $name, \sprintf('Service "%s" must not use the global "property_info.*" tag namespace (leaks into Symfony\'s property_info and breaks the validator chain — issue #8201). Found tag "%s".', $service, $name)); + } + } + + $apiPlatformPropertyInfo = $this->container->getDefinition('api_platform.property_info'); + foreach ($apiPlatformPropertyInfo->getArguments() as $arg) { + if ($arg instanceof \Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument) { + $this->assertStringStartsWith('api_platform.property_info.', $arg->getTag(), \sprintf('api_platform.property_info must consume only "api_platform.property_info.*" private tags; found "%s".', $arg->getTag())); + } + } + } } diff --git a/tests/Symfony/Bundle/ApiPlatformBundleTest.php b/tests/Symfony/Bundle/ApiPlatformBundleTest.php index 660f12243d..adcfda87f2 100644 --- a/tests/Symfony/Bundle/ApiPlatformBundleTest.php +++ b/tests/Symfony/Bundle/ApiPlatformBundleTest.php @@ -25,6 +25,7 @@ use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\JsonStreamerTransformerPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\MetadataAwareNameConverterPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\MutatorPass; +use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\PropertyInfoTagPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\SerializerMappingLoaderPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\TestClientPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\TestMercureHubPass; @@ -59,6 +60,7 @@ public function testBuild(): void $this->assertContains(AuthenticatorManagerPass::class, $passClasses); $this->assertContains(SerializerMappingLoaderPass::class, $passClasses); $this->assertContains(MutatorPass::class, $passClasses); + $this->assertContains(PropertyInfoTagPass::class, $passClasses); $this->assertContains(JsonStreamerTransformerPass::class, $passClasses); } } diff --git a/tests/Symfony/Bundle/DependencyInjection/Compiler/PropertyInfoTagPassTest.php b/tests/Symfony/Bundle/DependencyInjection/Compiler/PropertyInfoTagPassTest.php new file mode 100644 index 0000000000..40a8dea514 --- /dev/null +++ b/tests/Symfony/Bundle/DependencyInjection/Compiler/PropertyInfoTagPassTest.php @@ -0,0 +1,62 @@ + + * + * 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\Symfony\Bundle\DependencyInjection\Compiler\PropertyInfoTagPass; +use PHPUnit\Framework\TestCase; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; + +/** + * @see https://github.com/api-platform/core/issues/8201 + */ +final class PropertyInfoTagPassTest extends TestCase +{ + public function testBridgesPublicTagsToPrivateNamespace(): void + { + $container = new ContainerBuilder(); + $container->setDefinition('framework.reflection_extractor', (new Definition(\stdClass::class)) + ->addTag('property_info.type_extractor', ['priority' => -100]) + ->addTag('property_info.list_extractor')); + + (new PropertyInfoTagPass())->process($container); + + $definition = $container->getDefinition('framework.reflection_extractor'); + $this->assertSame([['priority' => -100]], $definition->getTag('api_platform.property_info.type_extractor')); + $this->assertSame([[]], $definition->getTag('api_platform.property_info.list_extractor')); + } + + public function testSkipsServicesAlreadyCarryingThePrivateTag(): void + { + $container = new ContainerBuilder(); + $container->setDefinition('api_platform.property_info.reflection_extractor', (new Definition(\stdClass::class)) + ->addTag('property_info.type_extractor', ['priority' => -1002]) + ->addTag('api_platform.property_info.type_extractor', ['priority' => -1002])); + + (new PropertyInfoTagPass())->process($container); + + $tags = $container->getDefinition('api_platform.property_info.reflection_extractor')->getTag('api_platform.property_info.type_extractor'); + $this->assertCount(1, $tags, 'Pass must not re-tag services that already carry the private tag.'); + } + + public function testDoesNotTagServicesWithoutPublicTags(): void + { + $container = new ContainerBuilder(); + $container->setDefinition('unrelated_service', new Definition(\stdClass::class)); + + (new PropertyInfoTagPass())->process($container); + + $this->assertSame([], $container->getDefinition('unrelated_service')->getTags()); + } +}