From 83ca1b14960880d7329a8dc1fd0bce8a245c2467 Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 3 Jun 2026 10:58:43 +0200 Subject: [PATCH] fix(symfony): keep error serialization mapping when enable_attributes is disabled When `framework.serializer.enable_attributes: false`, Symfony registers its built-in attribute loader with `allowAnyClass: false` and no mapped classes, so it returns early for every class. As a result, api-platform's `Error` and `ValidationException` lose their `#[Groups]`, `#[SerializedName]`, and `#[Ignore]` metadata, and the problem/hydra/json:api error normalizers emit an empty payload. Register a dedicated `AttributeLoader` in `serializer.mapping.chain_loader` (and the cache warmer) that hard-codes the api-platform error classes via the `mappedClasses` argument. The loader runs regardless of the global `enable_attributes` flag, so error responses keep their structure without re-enabling attribute discovery for user classes. Fixes #8174 --- src/Symfony/Bundle/ApiPlatformBundle.php | 2 + .../ErrorResourceAttributeLoaderPass.php | 65 ++++++++ .../Symfony/Bundle/ApiPlatformBundleTest.php | 2 + .../ErrorResourceAttributeLoaderPassTest.php | 157 ++++++++++++++++++ 4 files changed, 226 insertions(+) create mode 100644 src/Symfony/Bundle/DependencyInjection/Compiler/ErrorResourceAttributeLoaderPass.php create mode 100644 tests/Symfony/Bundle/DependencyInjection/Compiler/ErrorResourceAttributeLoaderPassTest.php diff --git a/src/Symfony/Bundle/ApiPlatformBundle.php b/src/Symfony/Bundle/ApiPlatformBundle.php index d89d766490..3b034ecbfe 100644 --- a/src/Symfony/Bundle/ApiPlatformBundle.php +++ b/src/Symfony/Bundle/ApiPlatformBundle.php @@ -18,6 +18,7 @@ use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\AuthenticatorManagerPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\DataProviderPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\ElasticsearchClientPass; +use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\ErrorResourceAttributeLoaderPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\FilterPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\GraphQlResolverPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\GraphQlTypePass; @@ -60,6 +61,7 @@ public function build(ContainerBuilder $container): void $container->addCompilerPass(new TestMercureHubPass()); $container->addCompilerPass(new AuthenticatorManagerPass()); $container->addCompilerPass(new SerializerMappingLoaderPass()); + $container->addCompilerPass(new ErrorResourceAttributeLoaderPass()); $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. diff --git a/src/Symfony/Bundle/DependencyInjection/Compiler/ErrorResourceAttributeLoaderPass.php b/src/Symfony/Bundle/DependencyInjection/Compiler/ErrorResourceAttributeLoaderPass.php new file mode 100644 index 0000000000..df424a96b9 --- /dev/null +++ b/src/Symfony/Bundle/DependencyInjection/Compiler/ErrorResourceAttributeLoaderPass.php @@ -0,0 +1,65 @@ + + * + * 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 ApiPlatform\State\ApiResource\Error; +use ApiPlatform\Validator\Exception\ValidationException; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader; + +/** + * Registers a dedicated {@see AttributeLoader} in the serializer mapping chain that always + * loads metadata for api-platform's built-in error resources, regardless of the value of + * `framework.serializer.enable_attributes`. + * + * When `enable_attributes: false`, Symfony's default attribute loader is built with + * `allowAnyClass: false` and an empty mapped-classes list, so it returns early for every + * class — including {@see Error} and {@see ValidationException}. Their serialization + * groups never reach the metadata factory and the normalizer ends up producing an empty + * payload for problem/hydra/json:api error responses (see issue #8174). Forcing a + * targeted loader for these specific classes keeps error responses intact without + * re-enabling global attribute discovery. + * + * @see https://github.com/api-platform/core/issues/8174 + */ +final class ErrorResourceAttributeLoaderPass implements CompilerPassInterface +{ + public function process(ContainerBuilder $container): void + { + if (!$container->hasDefinition('serializer.mapping.chain_loader')) { + return; + } + + $mappedClasses = [ + Error::class => [Error::class], + ValidationException::class => [ValidationException::class], + ]; + + $loaderDefinition = new Definition(AttributeLoader::class, [true, $mappedClasses]); + + $chainLoader = $container->getDefinition('serializer.mapping.chain_loader'); + $loaders = $chainLoader->getArgument(0); + $loaders[] = $loaderDefinition; + $chainLoader->replaceArgument(0, $loaders); + + if ($container->hasDefinition('serializer.mapping.cache_warmer')) { + $cacheWarmer = $container->getDefinition('serializer.mapping.cache_warmer'); + $warmerLoaders = $cacheWarmer->getArgument(0); + $warmerLoaders[] = $loaderDefinition; + $cacheWarmer->replaceArgument(0, $warmerLoaders); + } + } +} diff --git a/tests/Symfony/Bundle/ApiPlatformBundleTest.php b/tests/Symfony/Bundle/ApiPlatformBundleTest.php index adcfda87f2..a8b85a8e4c 100644 --- a/tests/Symfony/Bundle/ApiPlatformBundleTest.php +++ b/tests/Symfony/Bundle/ApiPlatformBundleTest.php @@ -19,6 +19,7 @@ use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\AuthenticatorManagerPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\DataProviderPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\ElasticsearchClientPass; +use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\ErrorResourceAttributeLoaderPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\FilterPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\GraphQlResolverPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\GraphQlTypePass; @@ -59,6 +60,7 @@ public function testBuild(): void $this->assertContains(TestMercureHubPass::class, $passClasses); $this->assertContains(AuthenticatorManagerPass::class, $passClasses); $this->assertContains(SerializerMappingLoaderPass::class, $passClasses); + $this->assertContains(ErrorResourceAttributeLoaderPass::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/ErrorResourceAttributeLoaderPassTest.php b/tests/Symfony/Bundle/DependencyInjection/Compiler/ErrorResourceAttributeLoaderPassTest.php new file mode 100644 index 0000000000..80694b7151 --- /dev/null +++ b/tests/Symfony/Bundle/DependencyInjection/Compiler/ErrorResourceAttributeLoaderPassTest.php @@ -0,0 +1,157 @@ + + * + * 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\State\ApiResource\Error; +use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\ErrorResourceAttributeLoaderPass; +use ApiPlatform\Validator\Exception\ValidationException; +use PHPUnit\Framework\TestCase; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\Mapping\ClassMetadataInterface; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory; +use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader; +use Symfony\Component\Serializer\Mapping\Loader\LoaderChain; +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; +use Symfony\Component\Serializer\Serializer; + +/** + * @see https://github.com/api-platform/core/issues/8174 + */ +final class ErrorResourceAttributeLoaderPassTest extends TestCase +{ + public function testRegistersAttributeLoaderForErrorClassesInChainLoader(): void + { + $container = new ContainerBuilder(); + $container->setDefinition('serializer.mapping.chain_loader', new Definition(LoaderChain::class, [[]])); + $container->setDefinition('serializer.mapping.cache_warmer', new Definition(\stdClass::class, [[]])); + + (new ErrorResourceAttributeLoaderPass())->process($container); + + $chainLoader = $container->getDefinition('serializer.mapping.chain_loader'); + $loaders = $chainLoader->getArgument(0); + $this->assertCount(1, $loaders); + $loaderDefinition = $loaders[0]; + $this->assertInstanceOf(Definition::class, $loaderDefinition); + $this->assertSame(AttributeLoader::class, $loaderDefinition->getClass()); + + // Argument 0 is allowAnyClass (must be true so the loader doesn't return early when entered through a parent class) + $this->assertTrue($loaderDefinition->getArgument(0)); + + // Argument 1 is mappedClasses (must include api-platform's Error and ValidationException) + $mappedClasses = $loaderDefinition->getArgument(1); + $this->assertArrayHasKey(Error::class, $mappedClasses); + $this->assertArrayHasKey(ValidationException::class, $mappedClasses); + } + + public function testAlsoUpdatesCacheWarmer(): void + { + $container = new ContainerBuilder(); + $container->setDefinition('serializer.mapping.chain_loader', new Definition(LoaderChain::class, [[]])); + $container->setDefinition('serializer.mapping.cache_warmer', new Definition(\stdClass::class, [[]])); + + (new ErrorResourceAttributeLoaderPass())->process($container); + + $loaders = $container->getDefinition('serializer.mapping.cache_warmer')->getArgument(0); + $this->assertCount(1, $loaders); + $this->assertSame(AttributeLoader::class, $loaders[0]->getClass()); + } + + public function testDoesNothingWhenChainLoaderIsAbsent(): void + { + $container = new ContainerBuilder(); + + (new ErrorResourceAttributeLoaderPass())->process($container); + + $this->assertFalse($container->hasDefinition('serializer.mapping.chain_loader')); + } + + /** + * Mirrors the runtime behavior with `framework.serializer.enable_attributes: false`: + * Symfony builds the `AttributeLoader` with `allowAnyClass = false` and no mapped classes, + * so no metadata is loaded for the api-platform error resources, and the normalizer + * returns an empty payload. Once the dedicated loader is added to the chain, the metadata + * is loaded again and the payload contains the expected fields. + */ + public function testErrorNormalizationStaysPopulatedWhenAttributesAreDisabled(): void + { + // Simulates `enable_attributes: false`: Symfony's attribute loader rejects every class. + $disabledAttributeLoader = new AttributeLoader(allowAnyClass: false, mappedClasses: []); + + $emptyChain = new LoaderChain([$disabledAttributeLoader]); + $serializerEmpty = new Serializer( + [new ObjectNormalizer(new ClassMetadataFactory($emptyChain))], + [new JsonEncoder()] + ); + + $error = new Error('Bad request', 'Something is invalid', 400); + $emptyPayload = $serializerEmpty->normalize($error, 'json', [ + 'groups' => ['jsonproblem'], + 'skip_null_values' => true, + 'ignored_attributes' => ['trace', 'file', 'line', 'code', 'message', 'traceAsString', 'previous'], + ]); + + // Bug: without an attribute loader handling these classes, no property carries a group → empty. + $this->assertSame([], $emptyPayload, 'Sanity check: when no loader handles the Error class, normalization yields an empty payload.'); + + // Build a loader chain that hardcodes api-platform's error classes — equivalent to what the + // compiler pass programmatically registers in the DI container at runtime. + $errorMappedClasses = [Error::class => [Error::class], ValidationException::class => [ValidationException::class]]; + $errorLoader = new AttributeLoader(allowAnyClass: true, mappedClasses: $errorMappedClasses); + + $chain = new LoaderChain([$disabledAttributeLoader, $errorLoader]); + $serializer = new Serializer( + [new ObjectNormalizer(new ClassMetadataFactory($chain))], + [new JsonEncoder()] + ); + + $payload = $serializer->normalize($error, 'json', [ + 'groups' => ['jsonproblem'], + 'skip_null_values' => true, + 'ignored_attributes' => ['trace', 'file', 'line', 'code', 'message', 'traceAsString', 'previous'], + ]); + + $this->assertIsArray($payload); + $this->assertArrayHasKey('title', $payload); + $this->assertArrayHasKey('detail', $payload); + $this->assertArrayHasKey('status', $payload); + $this->assertSame('Bad request', $payload['title']); + $this->assertSame('Something is invalid', $payload['detail']); + $this->assertSame(400, $payload['status']); + } + + public function testTheCompilerPassDefinitionMatchesTheRuntimeExpectation(): void + { + $container = new ContainerBuilder(); + $container->setDefinition('serializer.mapping.chain_loader', new Definition(LoaderChain::class, [[]])); + $container->setDefinition('serializer.mapping.cache_warmer', new Definition(\stdClass::class, [[]])); + + (new ErrorResourceAttributeLoaderPass())->process($container); + + $loaderDefinition = $container->getDefinition('serializer.mapping.chain_loader')->getArgument(0)[0]; + /** @var AttributeLoader $loader */ + $loader = new ($loaderDefinition->getClass())(...$loaderDefinition->getArguments()); + + // Confirm that running the very definition the compiler pass produces actually loads metadata + // for the api-platform error class, even with allowAnyClass-style mapped lookups. + $classMetadata = $this->createMock(ClassMetadataInterface::class); + $classMetadata->method('getName')->willReturn(Error::class); + $classMetadata->method('getReflectionClass')->willReturn(new \ReflectionClass(Error::class)); + $classMetadata->method('getAttributesMetadata')->willReturn([]); + $classMetadata->expects($this->atLeastOnce())->method('addAttributeMetadata'); + + $this->assertTrue($loader->loadClassMetadata($classMetadata)); + } +}