diff --git a/features/main/patch.feature b/features/main/patch.feature new file mode 100644 index 00000000000..c2f37f67bb6 --- /dev/null +++ b/features/main/patch.feature @@ -0,0 +1,30 @@ +Feature: Sending PATCH requets + As a client software developer + I need to be able to send partial updates + + @createSchema + Scenario: Detect accepted patch formats + Given I add "Content-Type" header equal to "application/ld+json" + And I send a "POST" request to "/patch_dummies" with body: + """ + {"name": "Hello"} + """ + When I add "Content-Type" header equal to "application/ld+json" + And I send a "GET" request to "/patch_dummies/1" + Then the header "Accept-Patch" should be equal to "application/merge-patch+json, application/vnd.api+json" + + Scenario: Patch an item + When I add "Content-Type" header equal to "application/merge-patch+json" + And I send a "PATCH" request to "/patch_dummies/1" with body: + """ + {"name": "Patched"} + """ + Then the JSON node "name" should contain "Patched" + + Scenario: Remove a property according to RFC 7386 + When I add "Content-Type" header equal to "application/merge-patch+json" + And I send a "PATCH" request to "/patch_dummies/1" with body: + """ + {"name": null} + """ + Then the JSON node "name" should not exist diff --git a/src/Bridge/Symfony/Bundle/Resources/config/api.xml b/src/Bridge/Symfony/Bundle/Resources/config/api.xml index 5b07b360cf2..f34847662f7 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/api.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/api.xml @@ -82,7 +82,6 @@ - %api_platform.patch_formats% diff --git a/src/EventListener/RespondListener.php b/src/EventListener/RespondListener.php index 61c98a58157..52a0cea38af 100644 --- a/src/EventListener/RespondListener.php +++ b/src/EventListener/RespondListener.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Core\EventListener; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Util\RequestAttributesExtractor; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\GetResponseForControllerResultEvent; @@ -51,7 +52,7 @@ public function onKernelView(GetResponseForControllerResultEvent $event): void return; } - if ($controllerResult instanceof Response || !($attributes['respond'] ?? $request->attributes->getBoolean('_api_respond', false))) { + if ($controllerResult instanceof Response || !($attributes['respond'] ?? $request->attributes->getBoolean('_api_respond'))) { return; } @@ -78,6 +79,7 @@ public function onKernelView(GetResponseForControllerResultEvent $event): void $headers['Sunset'] = (new \DateTimeImmutable($sunset))->format(\DateTime::RFC1123); } + $headers = $this->addAcceptPatchHeader($headers, $attributes, $resourceMetadata); $status = $resourceMetadata->getOperationAttribute($attributes, 'status'); } @@ -87,4 +89,29 @@ public function onKernelView(GetResponseForControllerResultEvent $event): void $headers )); } + + private function addAcceptPatchHeader(array $headers, array $attributes, ResourceMetadata $resourceMetadata): array + { + if (!isset($attributes['item_operation_name'])) { + return $headers; + } + + $patchMimeTypes = []; + foreach ($resourceMetadata->getItemOperations() as $operation) { + if ('PATCH' !== ($operation['method'] ?? '') || !isset($operation['input_formats'])) { + continue; + } + + foreach ($operation['input_formats'] as $mimeTypes) { + foreach ($mimeTypes as $mimeType) { + $patchMimeTypes[] = $mimeType; + } + } + $headers['Accept-Patch'] = implode(', ', $patchMimeTypes); + + return $headers; + } + + return $headers; + } } diff --git a/src/Serializer/SerializerContextBuilder.php b/src/Serializer/SerializerContextBuilder.php index bd14625394d..73ed3bef63e 100644 --- a/src/Serializer/SerializerContextBuilder.php +++ b/src/Serializer/SerializerContextBuilder.php @@ -28,12 +28,10 @@ final class SerializerContextBuilder implements SerializerContextBuilderInterface { private $resourceMetadataFactory; - private $patchFormats; - public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, array $patchFormats = []) + public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory) { $this->resourceMetadataFactory = $resourceMetadataFactory; - $this->patchFormats = $patchFormats; } /** @@ -91,12 +89,16 @@ public function createFromRequest(Request $request, bool $normalization, array $ unset($context[DocumentationNormalizer::SWAGGER_DEFINITION_NAME]); - if ( - isset($this->patchFormats['json']) - && !isset($context['skip_null_values']) - && \in_array('application/merge-patch+json', $this->patchFormats['json'], true) - ) { - $context['skip_null_values'] = true; + if (isset($context['skip_null_values'])) { + return $context; + } + + 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; + + break; + } } return $context; diff --git a/tests/Fixtures/TestBundle/Document/PatchDummy.php b/tests/Fixtures/TestBundle/Document/PatchDummy.php new file mode 100644 index 00000000000..e553906e63e --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/PatchDummy.php @@ -0,0 +1,41 @@ + + * + * 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; + +/** + * @author Kévin Dunglas + * + * @ApiResource( + * itemOperations={ + * "get", + * "patch"={"input_formats"={"json"={"application/merge-patch+json"}, "jsonapi"}} + * } + * ) + * @ODM\Document + */ +class PatchDummy +{ + /** + * @ODM\Id(strategy="INCREMENT", type="integer") + */ + public $id; + + /** + * @ODM\Field(type="string") + */ + public $name; +} diff --git a/tests/Fixtures/TestBundle/Entity/PatchDummy.php b/tests/Fixtures/TestBundle/Entity/PatchDummy.php new file mode 100644 index 00000000000..899e916af9b --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/PatchDummy.php @@ -0,0 +1,43 @@ + + * + * 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; + +/** + * @author Kévin Dunglas + * + * @ApiResource( + * itemOperations={ + * "get", + * "patch"={"input_formats"={"json"={"application/merge-patch+json"}, "jsonapi"}} + * } + * ) + * @ORM\Entity + */ +class PatchDummy +{ + /** + * @ORM\Id + * @ORM\Column(type="integer") + * @ORM\GeneratedValue(strategy="AUTO") + */ + public $id; + + /** + * @ORM\Column(nullable=true) + */ + public $name; +}