From b7b9bdb09221c69d6d83cc2aba1a0da9d21eeeb0 Mon Sep 17 00:00:00 2001 From: Aleksey Polyvanyi Date: Mon, 25 Nov 2024 17:32:48 +0100 Subject: [PATCH 1/4] fix(serializer): use attribute denormalization context for constructor arguments --- features/security/strong_typing.feature | 13 +++++++++++++ src/Serializer/AbstractItemNormalizer.php | 11 +++++------ tests/Fixtures/TestBundle/Entity/Dummy.php | 17 ++++++++++++++++- 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/features/security/strong_typing.feature b/features/security/strong_typing.feature index 3d4b7599a62..a32507a3dc1 100644 --- a/features/security/strong_typing.feature +++ b/features/security/strong_typing.feature @@ -89,6 +89,19 @@ Feature: Handle properly invalid data submitted to the API And the response should be in JSON And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + Scenario: Ignore date with wrong format + When I add "Content-Type" header equal to "application/ld+json" + And I send a "POST" request to "/dummies" with body: + """ + { + "name": "Invalid date format", + "dummyDateWithFormat": "2020-01-01T00:00:00+00:00" + } + """ + Then the response status code should be 400 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + Scenario: Send non-array data when an array is expected When I add "Content-Type" header equal to "application/ld+json" And I send a "POST" request to "/dummies" with body: diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 09c16ce548e..97d6a4f0c79 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -317,6 +317,8 @@ protected function instantiateObject(array &$data, string $class, array &$contex foreach ($constructorParameters as $constructorParameter) { $paramName = $constructorParameter->name; $key = $this->nameConverter ? $this->nameConverter->normalize($paramName, $class, $format, $context) : $paramName; + $attributeContext = $this->getAttributeDenormalizationContext($class, $paramName, $context); + $attributeContext['deserialization_path'] = $attributeContext['deserialization_path'] ?? $key; $allowed = false === $allowedAttributes || (\is_array($allowedAttributes) && \in_array($paramName, $allowedAttributes, true)); $ignored = !$this->isAllowedAttribute($class, $paramName, $format, $context); @@ -329,10 +331,8 @@ protected function instantiateObject(array &$data, string $class, array &$contex $params[] = $data[$paramName]; } } elseif ($allowed && !$ignored && (isset($data[$key]) || \array_key_exists($key, $data))) { - $constructorContext = $context; - $constructorContext['deserialization_path'] = $context['deserialization_path'] ?? $key; try { - $params[] = $this->createConstructorArgument($data[$key], $key, $constructorParameter, $constructorContext, $format); + $params[] = $this->createConstructorArgument($data[$key], $key, $constructorParameter, $attributeContext, $format); } catch (NotNormalizableValueException $exception) { if (!isset($context['not_normalizable_value_exceptions'])) { throw $exception; @@ -351,7 +351,6 @@ protected function instantiateObject(array &$data, string $class, array &$contex $missingConstructorArguments[] = $constructorParameter->name; } - $attributeContext = $this->getAttributeDenormalizationContext($class, $paramName, $context); $constructorParameterType = 'unknown'; $reflectionType = $constructorParameter->getType(); if ($reflectionType instanceof \ReflectionNamedType) { @@ -362,7 +361,7 @@ protected function instantiateObject(array &$data, string $class, array &$contex \sprintf('Failed to create object because the class misses the "%s" property.', $constructorParameter->name), null, [$constructorParameterType], - $attributeContext['deserialization_path'] ?? null, + $attributeContext['deserialization_path'], true ); $context['not_normalizable_value_exceptions'][] = $exception; @@ -405,7 +404,7 @@ protected function getClassDiscriminatorResolvedClass(array $data, string $class return $mappedClass; } - protected function createConstructorArgument($parameterData, string $key, \ReflectionParameter $constructorParameter, array &$context, ?string $format = null): mixed + protected function createConstructorArgument($parameterData, string $key, \ReflectionParameter $constructorParameter, array $context, ?string $format = null): mixed { return $this->createAndValidateAttributeValue($constructorParameter->name, $parameterData, $format, $context); } diff --git a/tests/Fixtures/TestBundle/Entity/Dummy.php b/tests/Fixtures/TestBundle/Entity/Dummy.php index 55dce62d2ba..fde6268a7b3 100644 --- a/tests/Fixtures/TestBundle/Entity/Dummy.php +++ b/tests/Fixtures/TestBundle/Entity/Dummy.php @@ -20,6 +20,7 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Attribute\Context; use Symfony\Component\Validator\Constraints as Assert; /** @@ -87,6 +88,14 @@ class Dummy #[ORM\Column(type: 'datetime', nullable: true)] public $dummyDate; + /** + * @var \DateTime|null A dummy date with format + */ + #[Context(denormalizationContext: ['datetime_format' => 'Y-m-d'])] + #[ApiProperty(iris: ['https://schema.org/DateTime'])] + #[ORM\Column(type: 'datetime', nullable: true)] + private $dummyDateWithFormat; + /** * @var float|null A dummy float */ @@ -140,9 +149,10 @@ public static function staticMethod(): void { } - public function __construct() + public function __construct(?\DateTime $dummyDateWithFormat = null) { $this->relatedDummies = new ArrayCollection(); + $this->dummyDateWithFormat = $dummyDateWithFormat; } public function getId() @@ -209,6 +219,11 @@ public function getDummyDate() return $this->dummyDate; } + public function getDummyDateWithFormat() + { + return $this->dummyDateWithFormat; + } + public function setDummyPrice($dummyPrice) { $this->dummyPrice = $dummyPrice; From 76f97ffff9f01cb8853526cc19140a35b28d4254 Mon Sep 17 00:00:00 2001 From: Aleksey Polyvanyi Date: Mon, 25 Nov 2024 17:50:15 +0100 Subject: [PATCH 2/4] -refactoring --- tests/Fixtures/TestBundle/Document/Dummy.php | 19 +++++++++++++++++-- tests/Fixtures/TestBundle/Entity/Dummy.php | 3 ++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/tests/Fixtures/TestBundle/Document/Dummy.php b/tests/Fixtures/TestBundle/Document/Dummy.php index 24516bfbcd4..0a544d7e3a3 100644 --- a/tests/Fixtures/TestBundle/Document/Dummy.php +++ b/tests/Fixtures/TestBundle/Document/Dummy.php @@ -20,6 +20,8 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; +use Symfony\Component\Serializer\Attribute\Context; +use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Validator\Constraints as Assert; /** @@ -28,7 +30,7 @@ * @author Kévin Dunglas * @author Alexandre Delplace */ -#[ApiResource(extraProperties: ['doctrine_mongodb' => ['execute_options' => ['allowDiskUse' => true]], 'standard_put' => false, 'rfc_7807_compliant_errors' => false], filters: ['my_dummy.mongodb.boolean', 'my_dummy.mongodb.date', 'my_dummy.mongodb.exists', 'my_dummy.mongodb.numeric', 'my_dummy.mongodb.order', 'my_dummy.mongodb.range', 'my_dummy.mongodb.search', 'my_dummy.property'])] +#[ApiResource(normalizationContext: [AbstractNormalizer::IGNORED_ATTRIBUTES => ['dummyDateWithFormat']], filters: ['my_dummy.mongodb.boolean', 'my_dummy.mongodb.date', 'my_dummy.mongodb.exists', 'my_dummy.mongodb.numeric', 'my_dummy.mongodb.order', 'my_dummy.mongodb.range', 'my_dummy.mongodb.search', 'my_dummy.property'], extraProperties: ['doctrine_mongodb' => ['execute_options' => ['allowDiskUse' => true]], 'standard_put' => false, 'rfc_7807_compliant_errors' => false])] #[ApiResource(uriTemplate: '/related_owned_dummies/{id}/owning_dummy{._format}', uriVariables: ['id' => new Link(fromClass: RelatedOwnedDummy::class, identifiers: ['id'], fromProperty: 'owningDummy')], status: 200, filters: ['my_dummy.mongodb.boolean', 'my_dummy.mongodb.date', 'my_dummy.mongodb.exists', 'my_dummy.mongodb.numeric', 'my_dummy.mongodb.order', 'my_dummy.mongodb.range', 'my_dummy.mongodb.search', 'my_dummy.property'], operations: [new Get()])] #[ApiResource(uriTemplate: '/related_owning_dummies/{id}/owned_dummy{._format}', uriVariables: ['id' => new Link(fromClass: RelatedOwningDummy::class, identifiers: ['id'], fromProperty: 'ownedDummy')], status: 200, filters: ['my_dummy.mongodb.boolean', 'my_dummy.mongodb.date', 'my_dummy.mongodb.exists', 'my_dummy.mongodb.numeric', 'my_dummy.mongodb.order', 'my_dummy.mongodb.range', 'my_dummy.mongodb.search', 'my_dummy.property'], operations: [new Get()])] #[ODM\Document] @@ -75,6 +77,13 @@ class Dummy #[ApiProperty(iris: ['https://schema.org/DateTime'])] #[ODM\Field(type: 'date', nullable: true)] public $dummyDate; + /** + * @var \DateTime|null A dummy date + */ + #[Context(denormalizationContext: ['datetime_format' => 'Y-m-d'])] + #[ApiProperty(iris: ['https://schema.org/DateTime'])] + #[ODM\Field(type: 'date', nullable: true)] + private $dummyDateWithFormat; /** * @var float|null A dummy float */ @@ -113,9 +122,10 @@ public static function staticMethod(): void { } - public function __construct() + public function __construct(?\DateTime $dummyDateWithFormat = null) { $this->relatedDummies = new ArrayCollection(); + $this->dummyDateWithFormat = $dummyDateWithFormat; } public function getId(): ?int @@ -178,6 +188,11 @@ public function getDummyDate() return $this->dummyDate; } + public function getDummyDateWithFormat() + { + return $this->dummyDateWithFormat; + } + public function setDummyPrice($dummyPrice) { $this->dummyPrice = $dummyPrice; diff --git a/tests/Fixtures/TestBundle/Entity/Dummy.php b/tests/Fixtures/TestBundle/Entity/Dummy.php index fde6268a7b3..10e59ee0d7d 100644 --- a/tests/Fixtures/TestBundle/Entity/Dummy.php +++ b/tests/Fixtures/TestBundle/Entity/Dummy.php @@ -21,6 +21,7 @@ use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute\Context; +use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Validator\Constraints as Assert; /** @@ -28,7 +29,7 @@ * * @author Kévin Dunglas */ -#[ApiResource(filters: ['my_dummy.boolean', 'my_dummy.date', 'my_dummy.exists', 'my_dummy.numeric', 'my_dummy.order', 'my_dummy.range', 'my_dummy.search', 'my_dummy.property'], extraProperties: ['standard_put' => false, 'rfc_7807_compliant_errors' => false])] +#[ApiResource(normalizationContext: [AbstractNormalizer::IGNORED_ATTRIBUTES => ['dummyDateWithFormat']], filters: ['my_dummy.boolean', 'my_dummy.date', 'my_dummy.exists', 'my_dummy.numeric', 'my_dummy.order', 'my_dummy.range', 'my_dummy.search', 'my_dummy.property'], extraProperties: ['standard_put' => false, 'rfc_7807_compliant_errors' => false])] #[ApiResource(uriTemplate: '/related_owned_dummies/{id}/owning_dummy{._format}', uriVariables: ['id' => new Link(fromClass: RelatedOwnedDummy::class, identifiers: ['id'], fromProperty: 'owningDummy')], status: 200, filters: ['my_dummy.boolean', 'my_dummy.date', 'my_dummy.exists', 'my_dummy.numeric', 'my_dummy.order', 'my_dummy.range', 'my_dummy.search', 'my_dummy.property'], operations: [new Get()])] #[ApiResource(uriTemplate: '/related_owning_dummies/{id}/owned_dummy{._format}', uriVariables: ['id' => new Link(fromClass: RelatedOwningDummy::class, identifiers: ['id'], fromProperty: 'ownedDummy')], status: 200, filters: ['my_dummy.boolean', 'my_dummy.date', 'my_dummy.exists', 'my_dummy.numeric', 'my_dummy.order', 'my_dummy.range', 'my_dummy.search', 'my_dummy.property'], operations: [new Get()])] #[ORM\Entity] From 0c907f8040ff960a5bd2749e7ea776666338444b Mon Sep 17 00:00:00 2001 From: Aleksey Polyvanyi Date: Mon, 25 Nov 2024 18:00:46 +0100 Subject: [PATCH 3/4] -refactoring --- tests/Fixtures/TestBundle/Document/Dummy.php | 6 +++--- tests/Fixtures/TestBundle/Entity/Dummy.php | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/Fixtures/TestBundle/Document/Dummy.php b/tests/Fixtures/TestBundle/Document/Dummy.php index 0a544d7e3a3..e23dc1ab485 100644 --- a/tests/Fixtures/TestBundle/Document/Dummy.php +++ b/tests/Fixtures/TestBundle/Document/Dummy.php @@ -30,9 +30,9 @@ * @author Kévin Dunglas * @author Alexandre Delplace */ -#[ApiResource(normalizationContext: [AbstractNormalizer::IGNORED_ATTRIBUTES => ['dummyDateWithFormat']], filters: ['my_dummy.mongodb.boolean', 'my_dummy.mongodb.date', 'my_dummy.mongodb.exists', 'my_dummy.mongodb.numeric', 'my_dummy.mongodb.order', 'my_dummy.mongodb.range', 'my_dummy.mongodb.search', 'my_dummy.property'], extraProperties: ['doctrine_mongodb' => ['execute_options' => ['allowDiskUse' => true]], 'standard_put' => false, 'rfc_7807_compliant_errors' => false])] -#[ApiResource(uriTemplate: '/related_owned_dummies/{id}/owning_dummy{._format}', uriVariables: ['id' => new Link(fromClass: RelatedOwnedDummy::class, identifiers: ['id'], fromProperty: 'owningDummy')], status: 200, filters: ['my_dummy.mongodb.boolean', 'my_dummy.mongodb.date', 'my_dummy.mongodb.exists', 'my_dummy.mongodb.numeric', 'my_dummy.mongodb.order', 'my_dummy.mongodb.range', 'my_dummy.mongodb.search', 'my_dummy.property'], operations: [new Get()])] -#[ApiResource(uriTemplate: '/related_owning_dummies/{id}/owned_dummy{._format}', uriVariables: ['id' => new Link(fromClass: RelatedOwningDummy::class, identifiers: ['id'], fromProperty: 'ownedDummy')], status: 200, filters: ['my_dummy.mongodb.boolean', 'my_dummy.mongodb.date', 'my_dummy.mongodb.exists', 'my_dummy.mongodb.numeric', 'my_dummy.mongodb.order', 'my_dummy.mongodb.range', 'my_dummy.mongodb.search', 'my_dummy.property'], operations: [new Get()])] +#[ApiResource(extraProperties: ['doctrine_mongodb' => ['execute_options' => ['allowDiskUse' => true]], 'standard_put' => false, 'rfc_7807_compliant_errors' => false], filters: ['my_dummy.mongodb.boolean', 'my_dummy.mongodb.date', 'my_dummy.mongodb.exists', 'my_dummy.mongodb.numeric', 'my_dummy.mongodb.order', 'my_dummy.mongodb.range', 'my_dummy.mongodb.search', 'my_dummy.property'], normalizationContext: [AbstractNormalizer::IGNORED_ATTRIBUTES => ['dummyDateWithFormat']])] +#[ApiResource(uriTemplate: '/related_owned_dummies/{id}/owning_dummy{._format}', uriVariables: ['id' => new Link(fromClass: RelatedOwnedDummy::class, identifiers: ['id'], fromProperty: 'owningDummy')], status: 200, filters: ['my_dummy.mongodb.boolean', 'my_dummy.mongodb.date', 'my_dummy.mongodb.exists', 'my_dummy.mongodb.numeric', 'my_dummy.mongodb.order', 'my_dummy.mongodb.range', 'my_dummy.mongodb.search', 'my_dummy.property'], operations: [new Get()], normalizationContext: [AbstractNormalizer::IGNORED_ATTRIBUTES => ['dummyDateWithFormat']])] +#[ApiResource(uriTemplate: '/related_owning_dummies/{id}/owned_dummy{._format}', uriVariables: ['id' => new Link(fromClass: RelatedOwningDummy::class, identifiers: ['id'], fromProperty: 'ownedDummy')], status: 200, filters: ['my_dummy.mongodb.boolean', 'my_dummy.mongodb.date', 'my_dummy.mongodb.exists', 'my_dummy.mongodb.numeric', 'my_dummy.mongodb.order', 'my_dummy.mongodb.range', 'my_dummy.mongodb.search', 'my_dummy.property'], operations: [new Get()], normalizationContext: [AbstractNormalizer::IGNORED_ATTRIBUTES => ['dummyDateWithFormat']])] #[ODM\Document] class Dummy { diff --git a/tests/Fixtures/TestBundle/Entity/Dummy.php b/tests/Fixtures/TestBundle/Entity/Dummy.php index 10e59ee0d7d..e0a1c4f071a 100644 --- a/tests/Fixtures/TestBundle/Entity/Dummy.php +++ b/tests/Fixtures/TestBundle/Entity/Dummy.php @@ -29,9 +29,9 @@ * * @author Kévin Dunglas */ -#[ApiResource(normalizationContext: [AbstractNormalizer::IGNORED_ATTRIBUTES => ['dummyDateWithFormat']], filters: ['my_dummy.boolean', 'my_dummy.date', 'my_dummy.exists', 'my_dummy.numeric', 'my_dummy.order', 'my_dummy.range', 'my_dummy.search', 'my_dummy.property'], extraProperties: ['standard_put' => false, 'rfc_7807_compliant_errors' => false])] -#[ApiResource(uriTemplate: '/related_owned_dummies/{id}/owning_dummy{._format}', uriVariables: ['id' => new Link(fromClass: RelatedOwnedDummy::class, identifiers: ['id'], fromProperty: 'owningDummy')], status: 200, filters: ['my_dummy.boolean', 'my_dummy.date', 'my_dummy.exists', 'my_dummy.numeric', 'my_dummy.order', 'my_dummy.range', 'my_dummy.search', 'my_dummy.property'], operations: [new Get()])] -#[ApiResource(uriTemplate: '/related_owning_dummies/{id}/owned_dummy{._format}', uriVariables: ['id' => new Link(fromClass: RelatedOwningDummy::class, identifiers: ['id'], fromProperty: 'ownedDummy')], status: 200, filters: ['my_dummy.boolean', 'my_dummy.date', 'my_dummy.exists', 'my_dummy.numeric', 'my_dummy.order', 'my_dummy.range', 'my_dummy.search', 'my_dummy.property'], operations: [new Get()])] +#[ApiResource(filters: ['my_dummy.boolean', 'my_dummy.date', 'my_dummy.exists', 'my_dummy.numeric', 'my_dummy.order', 'my_dummy.range', 'my_dummy.search', 'my_dummy.property'], extraProperties: ['standard_put' => false, 'rfc_7807_compliant_errors' => false], normalizationContext: [AbstractNormalizer::IGNORED_ATTRIBUTES => ['dummyDateWithFormat']])] +#[ApiResource(uriTemplate: '/related_owned_dummies/{id}/owning_dummy{._format}', uriVariables: ['id' => new Link(fromClass: RelatedOwnedDummy::class, identifiers: ['id'], fromProperty: 'owningDummy')], status: 200, filters: ['my_dummy.boolean', 'my_dummy.date', 'my_dummy.exists', 'my_dummy.numeric', 'my_dummy.order', 'my_dummy.range', 'my_dummy.search', 'my_dummy.property'], operations: [new Get()], normalizationContext: [AbstractNormalizer::IGNORED_ATTRIBUTES => ['dummyDateWithFormat']])] +#[ApiResource(uriTemplate: '/related_owning_dummies/{id}/owned_dummy{._format}', uriVariables: ['id' => new Link(fromClass: RelatedOwningDummy::class, identifiers: ['id'], fromProperty: 'ownedDummy')], status: 200, filters: ['my_dummy.boolean', 'my_dummy.date', 'my_dummy.exists', 'my_dummy.numeric', 'my_dummy.order', 'my_dummy.range', 'my_dummy.search', 'my_dummy.property'], operations: [new Get()], normalizationContext: [AbstractNormalizer::IGNORED_ATTRIBUTES => ['dummyDateWithFormat']])] #[ORM\Entity] class Dummy { From 440f97af6df7c86d26cc4b9d17d4125c66117f2c Mon Sep 17 00:00:00 2001 From: Aleksey Polyvanyi Date: Mon, 25 Nov 2024 18:12:48 +0100 Subject: [PATCH 4/4] -refactoring --- src/OpenApi/Factory/OpenApiFactory.php | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index 7bdf676f6a2..84a779ccc91 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -33,7 +33,6 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\OpenApi\Attributes\Webhook; -use ApiPlatform\OpenApi\Model; use ApiPlatform\OpenApi\Model\Components; use ApiPlatform\OpenApi\Model\Contact; use ApiPlatform\OpenApi\Model\ExternalDocumentation; @@ -512,11 +511,11 @@ private function buildOpenApiResponse(array $existingResponses, int|string $stat } /** - * @return \ArrayObject + * @return \ArrayObject */ private function buildContent(array $responseMimeTypes, array $operationSchemas): \ArrayObject { - /** @var \ArrayObject $content */ + /** @var \ArrayObject $content */ $content = new \ArrayObject(); foreach ($responseMimeTypes as $mimeType => $format) { @@ -603,11 +602,11 @@ private function getPathDescription(string $resourceShortName, string $method, b /** * @see https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#linkObject. * - * @return \ArrayObject + * @return \ArrayObject */ private function getLinks(ResourceMetadataCollection $resourceMetadataCollection, HttpOperation $currentOperation): \ArrayObject { - /** @var \ArrayObject $links */ + /** @var \ArrayObject $links */ $links = new \ArrayObject(); // Only compute get links for now