From e2467a80d2dcb92ce2120b821023c22d9cb5de01 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 11 May 2026 15:13:14 +0200 Subject: [PATCH 1/2] fix(serializer): translate PropertyAccess type mismatches to NotNormalizableValueException MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit | Q | A | ------------- | --- | Branch? | 4.3 | Tickets | symfony/symfony#64159 | License | MIT | Doc PR | ∅ AbstractItemNormalizer::setAttributeValue() now catches PropertyAccess\InvalidArgumentException and rethrows as NotNormalizableValueException, mirroring the contract ObjectNormalizer implements per symfony/symfony#64067. Without this, null on a non-nullable typed property bubbled up as HTTP 500 instead of a 4xx denormalization error. --- src/Serializer/AbstractItemNormalizer.php | 4 ++ .../NullOnNonNullableResource.php | 61 ++++++++++++++++++ .../NullOnNonNullablePropertyTest.php | 62 +++++++++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 tests/Fixtures/TestBundle/ApiResource/NullOnNonNullableProperty/NullOnNonNullableResource.php create mode 100644 tests/Functional/NullOnNonNullablePropertyTest.php 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/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']); + } +} From 9cb1514c67bb744ec9b9b90f0a29bbaa5210ea0c Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 11 May 2026 15:34:02 +0200 Subject: [PATCH 2/2] chore(serializer): bump symfony/serializer minimum to require setAttributeValue contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit | Q | A | ------------- | --- | Branch? | 4.3 | Tickets | symfony/symfony#64067 | License | MIT | Doc PR | ∅ The COLLECT_DENORMALIZATION_ERRORS aggregation in AbstractObjectNormalizer was reworked in symfony/symfony#64067 (released in 6.4.37 / 7.4.9 / 8.0.9). Now that AbstractItemNormalizer implements that contract, older Symfony parents would no longer aggregate our translated exceptions and would respond with 400 instead of 422. --- composer.json | 2 +- src/Serializer/composer.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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": {