diff --git a/features/main/patch.feature b/features/main/patch.feature index c2f37f67bb6..28fce116892 100644 --- a/features/main/patch.feature +++ b/features/main/patch.feature @@ -28,3 +28,34 @@ Feature: Sending PATCH requets {"name": null} """ Then the JSON node "name" should not exist + + @createSchema + Scenario: Patch the relation + Given there is a PatchDummyRelation + When I add "Content-Type" header equal to "application/merge-patch+json" + And I send a "PATCH" request to "/patch_dummy_relations/1" with body: + """ + { + "related": { + "symfony": "A new name" + } + } + """ + Then print last JSON response + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be equal to: + """ + { + "@context": "/contexts/PatchDummyRelation", + "@id": "/patch_dummy_relations/1", + "@type": "PatchDummyRelation", + "related": { + "@id": "/related_dummies/1", + "@type": "https://schema.org/Product", + "id": 1, + "symfony": "A new name" + } + } + """ diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index e30f9eab9fa..d69dcb41b60 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -577,6 +577,10 @@ protected function getAttributeValue($object, $attribute, $format = null, array $attributeValue = null; } + if ($context['api_denormalize'] ?? false) { + return $attributeValue; + } + $type = $propertyMetadata->getType(); if ( diff --git a/src/Serializer/SerializerContextBuilder.php b/src/Serializer/SerializerContextBuilder.php index fc93897d72f..0fdc2f8fdbf 100644 --- a/src/Serializer/SerializerContextBuilder.php +++ b/src/Serializer/SerializerContextBuilder.php @@ -63,7 +63,11 @@ public function createFromRequest(Request $request, bool $normalization, array $ if (!$normalization) { if (!isset($context['api_allow_update'])) { - $context['api_allow_update'] = \in_array($request->getMethod(), ['PUT', 'PATCH'], true); + $context['api_allow_update'] = \in_array($method = $request->getMethod(), ['PUT', 'PATCH'], true); + + if ($context['api_allow_update'] && 'PATCH' === $method) { + $context[AbstractItemNormalizer::DEEP_OBJECT_TO_POPULATE] = $context[AbstractItemNormalizer::DEEP_OBJECT_TO_POPULATE] ?? true; + } } if ('csv' === $request->getContentType()) { @@ -101,9 +105,10 @@ public function createFromRequest(Request $request, bool $normalization, array $ return $context; } + // TODO: We should always use `skip_null_values` but changing this would be a BC break, for now use it only when `merge-patch+json` is activated on a Resource foreach ($resourceMetadata->getItemOperations() as $operation) { if ('PATCH' === ($operation['method'] ?? '') && \in_array('application/merge-patch+json', $operation['input_formats']['json'] ?? [], true)) { - $context['skip_null_values'] = true; + $context[AbstractItemNormalizer::SKIP_NULL_VALUES] = true; break; } diff --git a/tests/Behat/DoctrineContext.php b/tests/Behat/DoctrineContext.php index b6be416ce39..a3e8c367268 100644 --- a/tests/Behat/DoctrineContext.php +++ b/tests/Behat/DoctrineContext.php @@ -61,6 +61,7 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\NetworkPathDummy as NetworkPathDummyDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\NetworkPathRelationDummy as NetworkPathRelationDummyDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\Order as OrderDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\PatchDummyRelation as PatchDummyRelationDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\Person as PersonDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\PersonToPet as PersonToPetDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\Pet as PetDocument; @@ -128,6 +129,7 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\NetworkPathDummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\NetworkPathRelationDummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Order; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\PatchDummyRelation; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Person; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\PersonToPet; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Pet; @@ -1658,6 +1660,19 @@ public function thereIsAnInitializeInput(int $id) $this->manager->flush(); } + /** + * @Given there is a PatchDummyRelation + */ + public function thereIsAPatchDummyRelation() + { + $dummy = $this->buildPatchDummyRelation(); + $related = $this->buildRelatedDummy(); + $dummy->setRelated($related); + $this->manager->persist($related); + $this->manager->persist($dummy); + $this->manager->flush(); + } + private function isOrm(): bool { return null !== $this->schemaTool; @@ -2091,4 +2106,12 @@ private function buildInitializeInput() { return $this->isOrm() ? new InitializeInput() : new InitializeInputDocument(); } + + /** + * @return PatchDummyRelation|PatchDummyRelationDocument + */ + private function buildPatchDummyRelation() + { + return $this->isOrm() ? new PatchDummyRelation() : new PatchDummyRelationDocument(); + } } diff --git a/tests/Fixtures/TestBundle/Document/PatchDummyRelation.php b/tests/Fixtures/TestBundle/Document/PatchDummyRelation.php new file mode 100644 index 00000000000..e9368e1b6e5 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/PatchDummyRelation.php @@ -0,0 +1,57 @@ + + * + * 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\Core\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; +use Symfony\Component\Serializer\Annotation\Groups; + +/** + * @author Kévin Dunglas + * + * @ApiResource( + * attributes={ + * "normalization_context"={"groups"={"chicago"}}, + * "denormalization_context"={"groups"={"chicago"}}, + * }, + * itemOperations={ + * "get", + * "patch"={"input_formats"={"json"={"application/merge-patch+json"}, "jsonapi"}} + * } + * ) + * @ODM\Document + */ +class PatchDummyRelation +{ + /** + * @ODM\Id(strategy="INCREMENT", type="integer") + */ + public $id; + + /** + * @ODM\ReferenceOne(targetDocument=RelatedDummy::class) + * @Groups({"chicago"}) + */ + protected $related; + + public function getRelated() + { + return $this->related; + } + + public function setRelated(RelatedDummy $relatedDummy) + { + $this->related = $relatedDummy; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/PatchDummyRelation.php b/tests/Fixtures/TestBundle/Entity/PatchDummyRelation.php new file mode 100644 index 00000000000..aa830e506cc --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/PatchDummyRelation.php @@ -0,0 +1,59 @@ + + * + * 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\Core\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; + +/** + * @author Kévin Dunglas + * + * @ApiResource( + * attributes={ + * "normalization_context"={"groups"={"chicago"}}, + * "denormalization_context"={"groups"={"chicago"}}, + * }, + * itemOperations={ + * "get", + * "patch"={"input_formats"={"json"={"application/merge-patch+json"}, "jsonapi"}} + * } + * ) + * @ORM\Entity + */ +class PatchDummyRelation +{ + /** + * @ORM\Id + * @ORM\Column(type="integer") + * @ORM\GeneratedValue(strategy="AUTO") + */ + public $id; + + /** + * @ORM\ManyToOne(targetEntity="RelatedDummy") + * @Groups({"chicago"}) + */ + protected $related; + + public function getRelated() + { + return $this->related; + } + + public function setRelated(RelatedDummy $relatedDummy) + { + $this->related = $relatedDummy; + } +} diff --git a/tests/Serializer/SerializerContextBuilderTest.php b/tests/Serializer/SerializerContextBuilderTest.php index a022a53454c..d73238214f2 100644 --- a/tests/Serializer/SerializerContextBuilderTest.php +++ b/tests/Serializer/SerializerContextBuilderTest.php @@ -48,8 +48,17 @@ protected function setUp(): void ] ); + $resourceMetadataWithPatch = new ResourceMetadata( + null, + null, + null, + ['patch' => ['method' => 'PATCH', 'input_formats' => ['json' => ['application/merge-patch+json']]]], + [] + ); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); $resourceMetadataFactoryProphecy->create('Foo')->willReturn($resourceMetadata); + $resourceMetadataFactoryProphecy->create('FooWithPatch')->willReturn($resourceMetadataWithPatch); $this->builder = new SerializerContextBuilder($resourceMetadataFactoryProphecy->reveal()); } @@ -85,6 +94,11 @@ public function testCreateFromRequest() $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_subresource_operation_name' => 'get', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); $expected = ['bar' => 'baz', 'subresource_operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/bars/1/foos', 'operation_type' => 'subresource', 'api_allow_update' => false, 'uri' => 'http://localhost/bars/1/foos', 'output' => null, 'input' => null, 'iri_only' => false]; $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); + + $request = Request::create('/foowithpatch/1', 'PATCH'); + $request->attributes->replace(['_api_resource_class' => 'FooWithPatch', '_api_item_operation_name' => 'patch', '_api_format' => 'json', '_api_mime_type' => 'application/json']); + $expected = ['item_operation_name' => 'patch', 'resource_class' => 'FooWithPatch', 'request_uri' => '/foowithpatch/1', 'operation_type' => 'item', 'api_allow_update' => true, 'uri' => 'http://localhost/foowithpatch/1', 'output' => null, 'input' => null, 'deep_object_to_populate' => true, 'skip_null_values' => true, 'iri_only' => false]; + $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); } public function testThrowExceptionOnInvalidRequest()