diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 365bdbc431..02b84aa73c 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -273,6 +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'])) { + // 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). 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 new file mode 100644 index 0000000000..084b87b314 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/DiscriminatedInputDto/ChannelResource.php @@ -0,0 +1,79 @@ + + * + * 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\Patch; +use ApiPlatform\Metadata\Post; + +#[ApiResource( + operations: [ + new Post( + uriTemplate: '/discriminated_notification_channels', + 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 +{ + 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; + } + + 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 new file mode 100644 index 0000000000..5c51c86a93 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/DiscriminatedInputDto/NotificationChannelInput.php @@ -0,0 +1,24 @@ + + * + * 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, + 'webhook_patch' => WebhookChannelPatchInput::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/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/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..2c6a150720 --- /dev/null +++ b/tests/Functional/DiscriminatedInputDtoTest.php @@ -0,0 +1,69 @@ + + * + * 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, + ]); + } + + 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, + ]); + } +}