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, + ]); + } +}