From 761563f795ccf93944c630e1d0ae481642f40cf5 Mon Sep 17 00:00:00 2001 From: soyuka Date: Sun, 26 Jul 2026 09:23:41 +0200 Subject: [PATCH 1/2] fix(serializer): denormalize object-typed args on discriminated input DTO subclass When an operation uses a plain (non-ApiResource) input DTO carrying a Serializer DiscriminatorMap, the normalizer pins resource_class to the abstract input base. The discriminator resolves the concrete subclass for instantiation, but constructor/setter argument metadata was still read from the abstract base, so an object-typed argument declared only on the subclass resolved to no type and its nested payload was passed as a raw array, triggering a TypeError (HTTP 500). Pin resource_class to the discriminator-resolved concrete class on the input path so subclass-only argument types are found and denormalized. Regression from #7779 (4.2.17). Fixes #8414 --- src/Serializer/AbstractItemNormalizer.php | 7 +++ .../DiscriminatedInputDto/ChannelResource.php | 52 +++++++++++++++++++ .../NotificationChannelInput.php | 23 ++++++++ .../WebhookChannelInput.php | 23 ++++++++ .../DiscriminatedInputDto/WebhookConfig.php | 23 ++++++++ .../Functional/DiscriminatedInputDtoTest.php | 51 ++++++++++++++++++ 6 files changed, 179 insertions(+) create mode 100644 tests/Fixtures/TestBundle/ApiResource/DiscriminatedInputDto/ChannelResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/DiscriminatedInputDto/NotificationChannelInput.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/DiscriminatedInputDto/WebhookChannelInput.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/DiscriminatedInputDto/WebhookConfig.php create mode 100644 tests/Functional/DiscriminatedInputDtoTest.php diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 365bdbc431..9aa13951b4 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -273,6 +273,13 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a if ($this->resourceClassResolver->isResourceClass($type)) { $resourceClass = $this->resourceClassResolver->getResourceClass($objectToPopulate, $type); $context['resource_class'] = $resourceClass; + } elseif (($context['api_platform_input'] ?? false) && isset($context['resource_class']) && $context['resource_class'] !== $type) { + // A discriminated input DTO base (not itself a resource) resolves to a concrete + // subclass here: pin the resource class to that concrete class so constructor and + // setter argument metadata is read from the subclass and not the abstract base, + // which lacks subclass-only properties (and thus their types). + $resourceClass = $type; + $context['resource_class'] = $type; } if (\is_string($data)) { diff --git a/tests/Fixtures/TestBundle/ApiResource/DiscriminatedInputDto/ChannelResource.php b/tests/Fixtures/TestBundle/ApiResource/DiscriminatedInputDto/ChannelResource.php new file mode 100644 index 0000000000..03c4165261 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/DiscriminatedInputDto/ChannelResource.php @@ -0,0 +1,52 @@ + + * + * 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\ApiResource\DiscriminatedInputDto; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; + +#[ApiResource( + operations: [ + new Post( + uriTemplate: '/discriminated_notification_channels', + input: NotificationChannelInput::class, + processor: [self::class, 'process'], + ), + ] +)] +final class ChannelResource +{ + public ?int $id = 1; + + public ?string $type = null; + + public ?string $url = null; + + public ?int $retries = null; + + public static function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): self + { + if (!$data instanceof WebhookChannelInput) { + throw new \InvalidArgumentException(\sprintf('Expected "%s", got "%s".', WebhookChannelInput::class, get_debug_type($data))); + } + + $resource = new self(); + $resource->type = $data->type; + $resource->url = $data->config->url; + $resource->retries = $data->config->retries; + + return $resource; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/DiscriminatedInputDto/NotificationChannelInput.php b/tests/Fixtures/TestBundle/ApiResource/DiscriminatedInputDto/NotificationChannelInput.php new file mode 100644 index 0000000000..ed12990901 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/DiscriminatedInputDto/NotificationChannelInput.php @@ -0,0 +1,23 @@ + + * + * 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\ApiResource\DiscriminatedInputDto; + +use Symfony\Component\Serializer\Attribute\DiscriminatorMap; + +#[DiscriminatorMap(typeProperty: 'type', mapping: [ + 'webhook' => WebhookChannelInput::class, +])] +abstract class NotificationChannelInput +{ +} diff --git a/tests/Fixtures/TestBundle/ApiResource/DiscriminatedInputDto/WebhookChannelInput.php b/tests/Fixtures/TestBundle/ApiResource/DiscriminatedInputDto/WebhookChannelInput.php new file mode 100644 index 0000000000..85b48b4e8c --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/DiscriminatedInputDto/WebhookChannelInput.php @@ -0,0 +1,23 @@ + + * + * 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\ApiResource\DiscriminatedInputDto; + +final class WebhookChannelInput extends NotificationChannelInput +{ + public function __construct( + public readonly string $type, + public readonly WebhookConfig $config, + ) { + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/DiscriminatedInputDto/WebhookConfig.php b/tests/Fixtures/TestBundle/ApiResource/DiscriminatedInputDto/WebhookConfig.php new file mode 100644 index 0000000000..f27811fc7b --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/DiscriminatedInputDto/WebhookConfig.php @@ -0,0 +1,23 @@ + + * + * 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\ApiResource\DiscriminatedInputDto; + +final class WebhookConfig +{ + public function __construct( + public readonly string $url, + public readonly int $retries = 3, + ) { + } +} diff --git a/tests/Functional/DiscriminatedInputDtoTest.php b/tests/Functional/DiscriminatedInputDtoTest.php new file mode 100644 index 0000000000..254eb4dc28 --- /dev/null +++ b/tests/Functional/DiscriminatedInputDtoTest.php @@ -0,0 +1,51 @@ + + * + * 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\DiscriminatedInputDto\ChannelResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class DiscriminatedInputDtoTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ChannelResource::class]; + } + + public function testObjectTypedSubclassOnlyConstructorArgumentIsDenormalized(): void + { + self::createClient()->request('POST', '/discriminated_notification_channels', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'type' => 'webhook', + 'config' => ['url' => 'https://example.com/hook', 'retries' => 5], + ], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertJsonContains([ + 'type' => 'webhook', + 'url' => 'https://example.com/hook', + 'retries' => 5, + ]); + } +} From 6562c8731706b63d59c6a1e0b435a43e082f4ef6 Mon Sep 17 00:00:00 2001 From: Aurimas Date: Mon, 27 Jul 2026 15:10:05 +0300 Subject: [PATCH 2/2] fix(serializer): denormalize discriminated input subclass on object-to-populate --- src/Serializer/AbstractItemNormalizer.php | 13 +++-- .../DiscriminatedInputDto/ChannelResource.php | 27 +++++++++++ .../NotificationChannelInput.php | 1 + .../WebhookChannelPatchInput.php | 48 +++++++++++++++++++ .../Functional/DiscriminatedInputDtoTest.php | 18 +++++++ 5 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 tests/Fixtures/TestBundle/ApiResource/DiscriminatedInputDto/WebhookChannelPatchInput.php diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 9aa13951b4..02b84aa73c 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -273,13 +273,18 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a if ($this->resourceClassResolver->isResourceClass($type)) { $resourceClass = $this->resourceClassResolver->getResourceClass($objectToPopulate, $type); $context['resource_class'] = $resourceClass; - } elseif (($context['api_platform_input'] ?? false) && isset($context['resource_class']) && $context['resource_class'] !== $type) { + } elseif (($context['api_platform_input'] ?? false) && isset($context['resource_class'])) { // A discriminated input DTO base (not itself a resource) resolves to a concrete // subclass here: pin the resource class to that concrete class so constructor and // setter argument metadata is read from the subclass and not the abstract base, - // which lacks subclass-only properties (and thus their types). - $resourceClass = $type; - $context['resource_class'] = $type; + // which lacks subclass-only properties (and thus their types). The concrete is the + // discriminator-resolved $type on a fresh denormalization, or the class of the object + // being populated (e.g. PATCH), where $type is left as the abstract base. + $concreteClass = null !== $objectToPopulate ? $objectToPopulate::class : $type; + if ($concreteClass !== $context['resource_class']) { + $resourceClass = $concreteClass; + $context['resource_class'] = $concreteClass; + } } if (\is_string($data)) { diff --git a/tests/Fixtures/TestBundle/ApiResource/DiscriminatedInputDto/ChannelResource.php b/tests/Fixtures/TestBundle/ApiResource/DiscriminatedInputDto/ChannelResource.php index 03c4165261..084b87b314 100644 --- a/tests/Fixtures/TestBundle/ApiResource/DiscriminatedInputDto/ChannelResource.php +++ b/tests/Fixtures/TestBundle/ApiResource/DiscriminatedInputDto/ChannelResource.php @@ -15,6 +15,7 @@ use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; #[ApiResource( @@ -24,6 +25,13 @@ input: NotificationChannelInput::class, processor: [self::class, 'process'], ), + new Patch( + uriTemplate: '/discriminated_notification_channels/{id}', + input: NotificationChannelInput::class, + output: NotificationChannelInput::class, + provider: [self::class, 'provide'], + processor: [self::class, 'processPatch'], + ), ] )] final class ChannelResource @@ -49,4 +57,23 @@ public static function process(mixed $data, Operation $operation, array $uriVari return $resource; } + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): NotificationChannelInput + { + return new WebhookChannelPatchInput('webhook_patch', null); + } + + public static function processPatch(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): self + { + if (!$data instanceof WebhookChannelPatchInput) { + throw new \InvalidArgumentException(\sprintf('Expected "%s", got "%s".', WebhookChannelPatchInput::class, get_debug_type($data))); + } + + $resource = new self(); + $resource->type = $data->getType(); + $resource->url = $data->getConfig()?->url; + $resource->retries = $data->getConfig()?->retries; + + return $resource; + } } diff --git a/tests/Fixtures/TestBundle/ApiResource/DiscriminatedInputDto/NotificationChannelInput.php b/tests/Fixtures/TestBundle/ApiResource/DiscriminatedInputDto/NotificationChannelInput.php index ed12990901..5c51c86a93 100644 --- a/tests/Fixtures/TestBundle/ApiResource/DiscriminatedInputDto/NotificationChannelInput.php +++ b/tests/Fixtures/TestBundle/ApiResource/DiscriminatedInputDto/NotificationChannelInput.php @@ -17,6 +17,7 @@ #[DiscriminatorMap(typeProperty: 'type', mapping: [ 'webhook' => WebhookChannelInput::class, + 'webhook_patch' => WebhookChannelPatchInput::class, ])] abstract class NotificationChannelInput { diff --git a/tests/Fixtures/TestBundle/ApiResource/DiscriminatedInputDto/WebhookChannelPatchInput.php b/tests/Fixtures/TestBundle/ApiResource/DiscriminatedInputDto/WebhookChannelPatchInput.php new file mode 100644 index 0000000000..9baa33d048 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/DiscriminatedInputDto/WebhookChannelPatchInput.php @@ -0,0 +1,48 @@ + + * + * 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\ApiResource\DiscriminatedInputDto; + +/** + * Mutable (setter-based) discriminated-input subclass, mirroring the real-world shape that a PATCH + * populates: a nullable, object-typed property declared only on the subclass. The property is + * written through a setter during object-to-populate denormalization, not the constructor. + */ +final class WebhookChannelPatchInput extends NotificationChannelInput +{ + public function __construct( + private ?string $type = null, + private ?WebhookConfig $config = null, + ) { + } + + public function getType(): ?string + { + return $this->type; + } + + public function setType(?string $type): void + { + $this->type = $type; + } + + public function getConfig(): ?WebhookConfig + { + return $this->config; + } + + public function setConfig(?WebhookConfig $config): void + { + $this->config = $config; + } +} diff --git a/tests/Functional/DiscriminatedInputDtoTest.php b/tests/Functional/DiscriminatedInputDtoTest.php index 254eb4dc28..2c6a150720 100644 --- a/tests/Functional/DiscriminatedInputDtoTest.php +++ b/tests/Functional/DiscriminatedInputDtoTest.php @@ -48,4 +48,22 @@ public function testObjectTypedSubclassOnlyConstructorArgumentIsDenormalized(): 'retries' => 5, ]); } + + public function testObjectTypedSubclassOnlyPropertyIsDenormalizedOnPatch(): void + { + self::createClient()->request('PATCH', '/discriminated_notification_channels/1', [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => [ + 'type' => 'webhook_patch', + 'config' => ['url' => 'https://example.com/hook', 'retries' => 5], + ], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + 'type' => 'webhook_patch', + 'url' => 'https://example.com/hook', + 'retries' => 5, + ]); + } }