Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/JsonApi/Serializer/ItemNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,12 @@ public function supportsDenormalization(mixed $data, string $type, ?string $form
*/
public function denormalize(mixed $data, string $class, ?string $format = null, array $context = []): mixed
{
// When re-entering for input DTO denormalization, data has already been
// unwrapped from the JSON:API structure by the first pass. Skip extraction.
if (isset($context['api_platform_input'])) {
return parent::denormalize($data, $class, $format, $context);
}

// Avoid issues with proxies if we populated the object
if (!isset($context[self::OBJECT_TO_POPULATE]) && isset($data['data']['id'])) {
if (true !== ($context['api_allow_update'] ?? true)) {
Expand Down
20 changes: 20 additions & 0 deletions src/JsonApi/Tests/Fixtures/InputDto.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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\JsonApi\Tests\Fixtures;

final class InputDto
{
public string $title = '';
public string $body = '';
}
85 changes: 85 additions & 0 deletions src/JsonApi/Tests/Serializer/ItemNormalizerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use ApiPlatform\JsonApi\Serializer\ReservedAttributeNameConverter;
use ApiPlatform\JsonApi\Tests\Fixtures\CircularReference;
use ApiPlatform\JsonApi\Tests\Fixtures\Dummy;
use ApiPlatform\JsonApi\Tests\Fixtures\InputDto;
use ApiPlatform\JsonApi\Tests\Fixtures\RelatedDummy;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
Expand All @@ -38,6 +39,7 @@
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Serializer\SerializerInterface;
Expand Down Expand Up @@ -577,4 +579,87 @@ public function testNormalizeWithNullToOneAndEmptyToManyRelationships(): void
$this->assertArrayHasKey('relatedDummies', $result['data']['relationships']);
$this->assertSame(['data' => []], $result['data']['relationships']['relatedDummies']);
}

/**
* Reproducer for https://github.com/api-platform/core/issues/7794.
*
* When a resource uses an input DTO, AbstractItemNormalizer::denormalize() re-enters
* the serializer with the already-unwrapped (flat) data plus an 'api_platform_input'
* context flag. Without the guard, JsonApi\ItemNormalizer::denormalize() runs a second
* time on the flat data, tries to read $data['data']['attributes'] and gets null,
* which nulls every DTO property.
*/
public function testDenormalizeInputDtoDoesNotDoubleUnwrapJsonApiStructure(): void
{
$jsonApiData = [
'data' => [
'type' => 'dummy',
'attributes' => [
'title' => 'Hello',
'body' => 'World',
],
],
];

$propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class);
$propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->willReturn(new PropertyNameCollection([]));
$propertyNameCollectionFactoryProphecy->create(InputDto::class, Argument::any())->willReturn(new PropertyNameCollection(['title', 'body']));

$propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class);
$propertyMetadataFactoryProphecy->create(InputDto::class, 'title', Argument::any())->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withWritable(true));
$propertyMetadataFactoryProphecy->create(InputDto::class, 'body', Argument::any())->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withWritable(true));

$iriConverterProphecy = $this->prophesize(IriConverterInterface::class);

$propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class);
$propertyAccessorProphecy->setValue(Argument::type(InputDto::class), Argument::type('string'), Argument::any())
->will(static function ($args): void {
$args[0]->{$args[1]} = $args[2];
});

$resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class);
$resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class);
$resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true);
$resourceClassResolverProphecy->isResourceClass(InputDto::class)->willReturn(false);
$resourceClassResolverProphecy->getResourceClass(null, InputDto::class)->willReturn(InputDto::class);

$resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class);
$resourceMetadataCollectionFactory->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [
(new ApiResource())->withOperations(new Operations([new Get(name: 'get')])),
]));

$normalizer = new ItemNormalizer(
$propertyNameCollectionFactoryProphecy->reveal(),
$propertyMetadataFactoryProphecy->reveal(),
$iriConverterProphecy->reveal(),
$resourceClassResolverProphecy->reveal(),
$propertyAccessorProphecy->reveal(),
new ReservedAttributeNameConverter(),
null,
[],
$resourceMetadataCollectionFactory->reveal(),
);

// Create a mock serializer that simulates the real serializer chain:
// when re-entering for the input DTO, it calls back into the normalizer.
$serializerProphecy = $this->prophesize(SerializerInterface::class);
$serializerProphecy->willImplement(DenormalizerInterface::class);
$serializerProphecy->willImplement(NormalizerInterface::class);
$serializerProphecy->denormalize(Argument::type('array'), InputDto::class, ItemNormalizer::FORMAT, Argument::type('array'))
->will(static function ($args) use ($normalizer) {
// This simulates the serializer re-entering the normalizer chain
return $normalizer->denormalize($args[0], $args[1], $args[2], $args[3]);
});

$normalizer->setSerializer($serializerProphecy->reveal());

$result = $normalizer->denormalize($jsonApiData, Dummy::class, ItemNormalizer::FORMAT, [
'input' => ['class' => InputDto::class],
'resource_class' => Dummy::class,
]);

$this->assertInstanceOf(InputDto::class, $result);
$this->assertSame('Hello', $result->title);
$this->assertSame('World', $result->body);
}
}
2 changes: 1 addition & 1 deletion src/JsonApi/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"api-platform/documentation": "^4.2",
"api-platform/json-schema": "^4.2",
"api-platform/metadata": "^4.2",
"api-platform/serializer": "^4.2.4",
"api-platform/serializer": "^4.2.18",
"api-platform/state": "^4.2.4",
"symfony/error-handler": "^6.4 || ^7.0 || ^8.0",
"symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0",
Expand Down
1 change: 1 addition & 0 deletions src/Serializer/AbstractItemNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,7 @@ protected function instantiateObject(array &$data, string $class, array &$contex
} else {
if (!isset($context['not_normalizable_value_exceptions'])) {
$missingConstructorArguments[] = $constructorParameter->name;
continue;
}

$constructorParameterType = 'unknown';
Expand Down
42 changes: 42 additions & 0 deletions src/Serializer/Tests/AbstractItemNormalizerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\DummyTableInheritance;
use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\DummyTableInheritanceChild;
use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\DummyTableInheritanceRelated;
use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\DummyWithMultipleRequiredConstructorArgs;
use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\NonCloneableDummy;
use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\PropertyCollectionIriOnly;
use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\PropertyCollectionIriOnlyRelation;
Expand All @@ -51,6 +52,7 @@
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;
use Symfony\Component\PropertyInfo\Type as LegacyType;
use Symfony\Component\Serializer\Exception\MissingConstructorArgumentsException;
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
Expand Down Expand Up @@ -1937,6 +1939,46 @@ public function testSupportsNormalizationWithApiPlatformOutputContext(): void
'api_platform_output_class' => 'SomeOtherClass',
]));
}

public function testDenormalizeReportsAllMissingConstructorArguments(): void
{
$data = [];

$propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class);
$propertyNameCollectionFactoryProphecy->create(DummyWithMultipleRequiredConstructorArgs::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['title', 'rating', 'comment']));

$propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class);

if (!method_exists(PropertyInfoExtractor::class, 'getType')) {
$propertyMetadataFactoryProphecy->create(DummyWithMultipleRequiredConstructorArgs::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withReadable(true)->withWritable(true));
$propertyMetadataFactoryProphecy->create(DummyWithMultipleRequiredConstructorArgs::class, 'rating', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)])->withReadable(true)->withWritable(true));
$propertyMetadataFactoryProphecy->create(DummyWithMultipleRequiredConstructorArgs::class, 'comment', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withReadable(true)->withWritable(true));
} else {
$propertyMetadataFactoryProphecy->create(DummyWithMultipleRequiredConstructorArgs::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withWritable(true));
$propertyMetadataFactoryProphecy->create(DummyWithMultipleRequiredConstructorArgs::class, 'rating', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::int())->withReadable(true)->withWritable(true));
$propertyMetadataFactoryProphecy->create(DummyWithMultipleRequiredConstructorArgs::class, 'comment', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withWritable(true));
}

$iriConverterProphecy = $this->prophesize(IriConverterInterface::class);
$propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class);
$resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class);
$resourceClassResolverProphecy->getResourceClass(null, DummyWithMultipleRequiredConstructorArgs::class)->willReturn(DummyWithMultipleRequiredConstructorArgs::class);
$resourceClassResolverProphecy->isResourceClass(DummyWithMultipleRequiredConstructorArgs::class)->willReturn(true);

$serializerProphecy = $this->prophesize(SerializerInterface::class);
$serializerProphecy->willImplement(NormalizerInterface::class);

$normalizer = new class($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), null, null, [], null, null) extends AbstractItemNormalizer {};
$normalizer->setSerializer($serializerProphecy->reveal());

try {
$normalizer->denormalize($data, DummyWithMultipleRequiredConstructorArgs::class);
$this->fail('Expected MissingConstructorArgumentsException was not thrown');
} catch (MissingConstructorArgumentsException $e) {
$this->assertCount(3, $e->getMissingConstructorArguments(), 'All three missing constructor arguments (title, rating, comment) should be reported, not just the first one.');
$this->assertSame(['title', 'rating', 'comment'], $e->getMissingConstructorArguments());
}
}
}

class ObjectWithBasicProperties
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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\Serializer\Tests\Fixtures\ApiResource;

use ApiPlatform\Metadata\ApiResource;

#[ApiResource]
final class DummyWithMultipleRequiredConstructorArgs
{
public function __construct(
public string $title,
public int $rating,
public string $comment,
) {
}
}
23 changes: 23 additions & 0 deletions tests/Fixtures/TestBundle/ApiResource/JsonApiInputDto.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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;

/**
* Input DTO for JsonApiInputResource.
*/
final class JsonApiInputDto
{
public string $title = '';
public string $body = '';
}
49 changes: 49 additions & 0 deletions tests/Fixtures/TestBundle/ApiResource/JsonApiInputResource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Post;

/**
* Reproducer for https://github.com/api-platform/core/issues/7794.
*
* When using input DTOs with JSON:API format, the JsonApi\ItemNormalizer must
* not unwrap the data twice. On re-entry for the input DTO, the data is already
* flat (attributes have been extracted from the JSON:API structure).
*/
#[Post(
uriTemplate: '/jsonapi_input_test',
formats: ['jsonapi' => ['application/vnd.api+json']],
input: JsonApiInputDto::class,
processor: [self::class, 'process'],
)]
class JsonApiInputResource
{
public ?string $id = null;
public string $title = '';
public string $body = '';

public static function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): self
{
\assert($data instanceof JsonApiInputDto);

$resource = new self();
$resource->id = '1';
$resource->title = $data->title;
$resource->body = $data->body;

return $resource;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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;

/**
* Input DTO with required constructor args — no defaults, not nullable.
*
* Used to reproduce the Sylius failures where only the first missing
* constructor argument is reported instead of all of them.
*/
final class JsonApiRequiredFieldsInputDto
{
public function __construct(
public string $title,
public int $rating,
public string $comment,
) {
}
}
Loading
Loading