diff --git a/composer.json b/composer.json index 56e32f271a..3d30deea4e 100644 --- a/composer.json +++ b/composer.json @@ -117,7 +117,7 @@ "symfony/http-kernel": "^6.4.13 || ^7.0 || ^8.0", "symfony/property-access": "^6.4 || ^7.0 || ^8.0", "symfony/property-info": "^6.4 || ^7.1 || ^8.0", - "symfony/serializer": "^6.4 || ^7.0 || ^8.0", + "symfony/serializer": "^6.4.37 || ^7.4.9 || ^8.0.9", "symfony/translation-contracts": "^3.3", "symfony/type-info": "^7.4 || ^8.0", "symfony/validator": "^6.4.11 || ^7.1 || ^8.0", diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 67c8ff4744..3c14013ebc 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -28,6 +28,8 @@ use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; use ApiPlatform\Metadata\Util\CloneTrait; +use Symfony\Component\PropertyAccess\Exception\InvalidArgumentException as PropertyAccessInvalidArgumentException; +use Symfony\Component\PropertyAccess\Exception\InvalidTypeException; use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; @@ -554,6 +556,8 @@ protected function setAttributeValue(object $object, string $attribute, mixed $v if (!isset($context['not_normalizable_value_exceptions'])) { throw $exception; } + } catch (PropertyAccessInvalidArgumentException $exception) { + throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('Failed to denormalize attribute "%s" value for class "%s": %s', $attribute, $object::class, $exception->getMessage()), $value, $exception instanceof InvalidTypeException ? [$exception->expectedType] : ['unknown'], $context['deserialization_path'] ?? null, false, $exception->getCode(), $exception); } } diff --git a/src/Serializer/composer.json b/src/Serializer/composer.json index 6bd700028c..36b4517b39 100644 --- a/src/Serializer/composer.json +++ b/src/Serializer/composer.json @@ -27,7 +27,7 @@ "api-platform/state": "^4.3", "symfony/property-access": "^6.4 || ^7.0 || ^8.0", "symfony/property-info": "^6.4 || ^7.1 || ^8.0", - "symfony/serializer": "^6.4 || ^7.0 || ^8.0", + "symfony/serializer": "^6.4.37 || ^7.4.9 || ^8.0.9", "symfony/validator": "^6.4.11 || ^7.0 || ^8.0" }, "require-dev": { diff --git a/tests/Fixtures/TestBundle/ApiResource/NullOnNonNullableProperty/NullOnNonNullableResource.php b/tests/Fixtures/TestBundle/ApiResource/NullOnNonNullableProperty/NullOnNonNullableResource.php new file mode 100644 index 0000000000..fb6a59ee8f --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/NullOnNonNullableProperty/NullOnNonNullableResource.php @@ -0,0 +1,61 @@ + + * + * 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\NullOnNonNullableProperty; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Post; +use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; + +#[Get( + shortName: 'NullOnNonNullableResource', + uriTemplate: '/null_on_non_nullable_resources/{id}', + provider: [self::class, 'provide'], +)] +#[Post( + shortName: 'NullOnNonNullableResource', + uriTemplate: '/null_on_non_nullable_resources', + processor: [self::class, 'process'], + denormalizationContext: [AbstractObjectNormalizer::DISABLE_TYPE_ENFORCEMENT => true], +)] +#[Post( + shortName: 'NullOnNonNullableResource', + uriTemplate: '/null_on_non_nullable_resources_collect', + processor: [self::class, 'process'], + denormalizationContext: [ + AbstractObjectNormalizer::DISABLE_TYPE_ENFORCEMENT => true, + DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS => true, + ], +)] +class NullOnNonNullableResource +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + public string $name; + + public static function provide(): self + { + $r = new self(); + $r->name = 'foo'; + + return $r; + } + + public static function process(self $data): self + { + return $data; + } +} diff --git a/tests/Functional/NullOnNonNullablePropertyTest.php b/tests/Functional/NullOnNonNullablePropertyTest.php new file mode 100644 index 0000000000..d6aa24c078 --- /dev/null +++ b/tests/Functional/NullOnNonNullablePropertyTest.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\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\NullOnNonNullableProperty\NullOnNonNullableResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +/** @see https://github.com/symfony/symfony/issues/64159 */ +final class NullOnNonNullablePropertyTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [NullOnNonNullableResource::class]; + } + + public function testNullOnNonNullablePropertyReturns400(): void + { + $response = self::createClient()->request('POST', '/null_on_non_nullable_resources', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => null], + ]); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); + $body = $response->toArray(false); + $this->assertStringContainsString('Expected argument of type "string", "null" given at property path "name"', $body['hydra:description'] ?? $body['detail'] ?? ''); + } + + public function testNullOnNonNullablePropertyReturns422WhenCollectingErrors(): void + { + $response = self::createClient()->request('POST', '/null_on_non_nullable_resources_collect', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => null], + ]); + + $this->assertResponseStatusCodeSame(422); + $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); + + $content = $response->toArray(false); + $this->assertArrayHasKey('violations', $content); + $this->assertSame('name', $content['violations'][0]['propertyPath']); + } +}