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/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 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/Document/Dummy.php b/tests/Fixtures/TestBundle/Document/Dummy.php index 24516bfbcd4..e23dc1ab485 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,9 +30,9 @@ * @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(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 { @@ -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 55dce62d2ba..e0a1c4f071a 100644 --- a/tests/Fixtures/TestBundle/Entity/Dummy.php +++ b/tests/Fixtures/TestBundle/Entity/Dummy.php @@ -20,6 +20,8 @@ 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\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Validator\Constraints as Assert; /** @@ -27,9 +29,9 @@ * * @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(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 { @@ -87,6 +89,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 +150,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 +220,11 @@ public function getDummyDate() return $this->dummyDate; } + public function getDummyDateWithFormat() + { + return $this->dummyDateWithFormat; + } + public function setDummyPrice($dummyPrice) { $this->dummyPrice = $dummyPrice;