diff --git a/features/main/put_collection.feature b/features/main/put_collection.feature new file mode 100644 index 00000000000..314f581f145 --- /dev/null +++ b/features/main/put_collection.feature @@ -0,0 +1,31 @@ +Feature: Update an embed collection with PUT + As a client software developer + I need to be able to update an embed collection + + Background: + Given I add "Content-Type" header equal to "application/ld+json" + + @createSchema + Scenario: Update embed collection + And I send a "POST" request to "/issue5584_employees" with body: + """ + {"name": "One"} + """ + Then I add "Content-Type" header equal to "application/ld+json" + And I send a "POST" request to "/issue5584_employees" with body: + """ + {"name": "Two"} + """ + Then print last JSON response + Then I add "Content-Type" header equal to "application/ld+json" + And I send a "POST" request to "/issue5584_businesses" with body: + """ + {"name": "Business"} + """ + Then I add "Content-Type" header equal to "application/ld+json" + And I send a "PUT" request to "/issue5584_businesses/1" with body: + """ + {"name": "Business", "businessEmployees": [{"@id": "/issue5584_employees/1", "id": 1}, {"@id": "/issue5584_employees/2", "id": 2}]} + """ + And the JSON node "businessEmployees[0].name" should contain 'One' + And the JSON node "businessEmployees[1].name" should contain 'Two' diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index ffa7fa02546..0c03b4da6b4 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -495,6 +495,11 @@ protected function denormalizeCollection(string $attribute, ApiProperty $propert $collectionKeyType = $type->getCollectionKeyTypes()[0] ?? null; $collectionKeyBuiltinType = $collectionKeyType?->getBuiltinType(); + $childContext = $this->createChildContext(['resource_class' => $className] + $context, $attribute, $format); + unset($childContext['uri_variables']); + if ($this->resourceMetadataCollectionFactory) { + $childContext['operation'] = $this->resourceMetadataCollectionFactory->create($className)->getOperation(); + } $values = []; foreach ($value as $index => $obj) { @@ -502,7 +507,7 @@ protected function denormalizeCollection(string $attribute, ApiProperty $propert throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the key "%s" must be "%s", "%s" given.', $index, $collectionKeyBuiltinType, \gettype($index)), $index, [$collectionKeyBuiltinType], ($context['deserialization_path'] ?? false) ? sprintf('key(%s)', $context['deserialization_path']) : null, true); } - $values[$index] = $this->denormalizeRelation($attribute, $propertyMetadata, $className, $obj, $format, $this->createChildContext($context, $attribute, $format)); + $values[$index] = $this->denormalizeRelation($attribute, $propertyMetadata, $className, $obj, $format, $childContext); } return $values; @@ -753,7 +758,7 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value return $value; } - if (null === $value && ($type->isNullable() || ($context[static::DISABLE_TYPE_ENFORCEMENT] ?? false))) { + if (null === $value && $type->isNullable() || ($context[static::DISABLE_TYPE_ENFORCEMENT] ?? false)) { return $value; } @@ -773,7 +778,6 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value && $this->resourceClassResolver->isResourceClass($className) ) { $resourceClass = $this->resourceClassResolver->getResourceClass(null, $className); - $context['resource_class'] = $resourceClass; return $this->denormalizeCollection($attribute, $propertyMetadata, $type, $resourceClass, $value, $format, $context); } @@ -785,6 +789,7 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value $resourceClass = $this->resourceClassResolver->getResourceClass(null, $className); $childContext = $this->createChildContext($context, $attribute, $format); $childContext['resource_class'] = $resourceClass; + unset($childContext['uri_variables']); if ($this->resourceMetadataCollectionFactory) { $childContext['operation'] = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation(); } @@ -857,7 +862,7 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value } } - if ($context[static::DISABLE_TYPE_ENFORCEMENT] ?? false) { + if ($context[static::DISABLE_TYPE_ENFORCEMENT] ?? false) { // @phpstan-ignore-line return $value; } diff --git a/tests/Fixtures/TestBundle/Entity/Issue5587/Business.php b/tests/Fixtures/TestBundle/Entity/Issue5587/Business.php new file mode 100644 index 00000000000..219a851509b --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Issue5587/Business.php @@ -0,0 +1,101 @@ + + * + * 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\Entity\Issue5587; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\Put; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; + +#[ApiResource( + shortName: 'issue5584_business', + operations: [ + new Get(uriTemplate: 'issue5584_businesses/{id}'), + new Post(uriTemplate: 'issue5584_businesses'), + new Put(uriTemplate: 'issue5584_businesses/{id}'), + ], + normalizationContext: [ + 'groups' => ['r'], + ], + denormalizationContext: [ + 'groups' => ['w'], + ], +)] +#[ORM\Table(name: 'issue5584_business')] +#[ORM\Entity()] +class Business +{ + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'IDENTITY')] + #[ORM\Column(type: 'integer')] + #[Groups(['w', 'r'])] + private $id; + #[ORM\Column(type: 'string', length: 255, nullable: true)] + #[Groups(['w', 'r'])] + private $name; + /** @var Collection|Employee[] */ + #[ORM\JoinTable(name: 'issue5584_business_users')] + #[ORM\ManyToMany(targetEntity: Employee::class, inversedBy: 'businesses')] + #[Groups(['w', 'r'])] + private $businessEmployees; + + public function __construct() + { + $this->businessEmployees = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(?string $name): self + { + $this->name = $name; + + return $this; + } + + public function getBusinessEmployees(): Collection + { + return $this->businessEmployees; + } + + public function addBusinessEmployee(Employee $businessEmployee): self + { + if (!$this->businessEmployees->contains($businessEmployee)) { + $this->businessEmployees[] = $businessEmployee; + } + + return $this; + } + + public function removeBusinessEmployee(Employee $businessEmployee): self + { + if ($this->businessEmployees->contains($businessEmployee)) { + $this->businessEmployees->removeElement($businessEmployee); + } + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Issue5587/Employee.php b/tests/Fixtures/TestBundle/Entity/Issue5587/Employee.php new file mode 100644 index 00000000000..4d37d03007b --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Issue5587/Employee.php @@ -0,0 +1,52 @@ + + * + * 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\Entity\Issue5587; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Post; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; + +#[ApiResource( + shortName: 'issue5584_employee', + operations: [ + new Post(uriTemplate: 'issue5584_employees'), + ], + normalizationContext: [ + 'groups' => ['r'], + ], + denormalizationContext: [ + 'groups' => ['w'], + ], +)] +#[ORM\Table(name: 'issue5584_employee')] +#[ORM\Entity()] +class Employee +{ + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'IDENTITY')] + #[ORM\Column(type: 'integer')] + #[Groups(['w', 'r'])] + private $id; + #[ORM\Column(type: 'string', length: 255, nullable: true)] + #[Groups(['w', 'r'])] + public $name; + #[ORM\ManyToMany(targetEntity: Business::class, mappedBy: 'businessEmployees')] + public $businesses; + + public function getId(): ?int + { + return $this->id; + } +}