diff --git a/doc/configuration.rst b/doc/configuration.rst index aabe95662..5019dd43c 100644 --- a/doc/configuration.rst +++ b/doc/configuration.rst @@ -104,3 +104,16 @@ a serialization context from your callable and use it. You can also set a default DeserializationContextFactory with ``->setDeserializationContextFactory(function () { /* ... */ })`` to be used with methods ``deserialize()`` and ``fromArray()``. + +Fail Deserialization When Required Properties Are Missing +--------------------------------------------------------- +By default, the deserializer will ignore missing required properties - +deserialization will succeed and the properties will be left unset. + +You may want, instead, for deserialization to fail in this case. You can +configure the deserializer to fail in this way by using the `DeserializationContext` + +For example: + $object = $serializer->deserialize($json, 'MyObject`, 'json', DeserializationContext::create()->setRequireAllRequiredProperties(true)); + +If you would like this behaviour to be the default, you can set the `DeserializationContextFactory` as described above. \ No newline at end of file diff --git a/src/DeserializationContext.php b/src/DeserializationContext.php index 5c16a24db..c5ee6a301 100644 --- a/src/DeserializationContext.php +++ b/src/DeserializationContext.php @@ -13,6 +13,11 @@ class DeserializationContext extends Context */ private $depth = 0; + /** + * @var bool + */ + private $requireAllRequiredProperties = false; + public static function create(): self { return new self(); @@ -41,4 +46,16 @@ public function decreaseDepth(): void $this->depth -= 1; } + + public function setRequireAllRequiredProperties(bool $require): self + { + $this->requireAllRequiredProperties = $require; + + return $this; + } + + public function getRequireAllRequiredProperties(): bool + { + return $this->requireAllRequiredProperties; + } } diff --git a/src/Exception/PropertyMissingException.php b/src/Exception/PropertyMissingException.php new file mode 100644 index 000000000..1454c83f5 --- /dev/null +++ b/src/Exception/PropertyMissingException.php @@ -0,0 +1,9 @@ +visitor->startVisitingObject($metadata, $object, $type); foreach ($metadata->propertyMetadata as $propertyMetadata) { + $allowsNull = null === $propertyMetadata->type ? true : $this->allowsNull($propertyMetadata->type); if (null !== $this->exclusionStrategy && $this->exclusionStrategy->shouldSkipProperty($propertyMetadata, $this->context)) { continue; } @@ -218,6 +220,8 @@ public function accept($data, ?array $type = null) $cloned = clone $propertyMetadata; $cloned->setter = null; $this->accessor->setValue($object, $cloned->defaultValue, $cloned, $this->context); + } elseif (!$allowsNull && $this->context->getRequireAllRequiredProperties()) { + throw new PropertyMissingException('Property ' . $propertyMetadata->name . ' is missing from data'); } } @@ -263,4 +267,20 @@ private function afterVisitingObject(ClassMetadata $metadata, object $object, ar $this->dispatcher->dispatch('serializer.post_deserialize', $metadata->name, $this->format, new ObjectEvent($this->context, $object, $type)); } } + + private function allowsNull(array $type): bool + { + $allowsNull = false; + if ('union' === $type['name'] && isset($type['params'][0])) { + foreach ($type['params'] as $param) { + if ('NULL' === $param['name']) { + $allowsNull = true; + } + } + } elseif ('NULL' === $type['name']) { + $allowsNull = true; + } + + return $allowsNull; + } } diff --git a/tests/Serializer/JsonSerializationTest.php b/tests/Serializer/JsonSerializationTest.php index 44c22ff5f..8abcec389 100644 --- a/tests/Serializer/JsonSerializationTest.php +++ b/tests/Serializer/JsonSerializationTest.php @@ -5,10 +5,12 @@ namespace JMS\Serializer\Tests\Serializer; use JMS\Serializer\Context; +use JMS\Serializer\DeserializationContext; use JMS\Serializer\EventDispatcher\Event; use JMS\Serializer\EventDispatcher\EventSubscriberInterface; use JMS\Serializer\EventDispatcher\ObjectEvent; use JMS\Serializer\Exception\NonVisitableTypeException; +use JMS\Serializer\Exception\PropertyMissingException; use JMS\Serializer\Exception\RuntimeException; use JMS\Serializer\GraphNavigatorInterface; use JMS\Serializer\Metadata\Driver\TypedPropertiesDriver; @@ -23,6 +25,7 @@ use JMS\Serializer\Tests\Fixtures\ObjectWithObjectProperty; use JMS\Serializer\Tests\Fixtures\Tag; use JMS\Serializer\Tests\Fixtures\TypedProperties\ComplexDiscriminatedUnion; +use JMS\Serializer\Tests\Fixtures\TypedProperties\ConstructorPromotion\Vase; use JMS\Serializer\Tests\Fixtures\TypedProperties\UnionTypedProperties; use JMS\Serializer\Visitor\Factory\JsonSerializationVisitorFactory; use JMS\Serializer\Visitor\SerializationVisitorInterface; @@ -134,6 +137,7 @@ protected static function getContent($key) $outputs['inline_map'] = '{"a":"1","b":"2","c":"3"}'; $outputs['inline_empty_map'] = '{}'; $outputs['empty_object'] = '{}'; + $outputs['vase_with_plant'] = '{"color":"blue","size":"big","plant":"flower","typeOfSoil":"potting mix","daysSincePotting":-1,"weight":10}'; $outputs['inline_deserialization_map'] = '{"a":"b","c":"d","e":"5"}'; $outputs['iterable'] = '{"iterable":{"foo":"bar","bar":"foo"}}'; $outputs['iterator'] = '{"iterator":{"foo":"bar","bar":"foo"}}'; @@ -438,6 +442,37 @@ public static function getTypeHintedArraysAndStdClass() ]; } + public function testDeserializationFailureOnPropertyMissingRequiredMissing() + { + self::expectException(PropertyMissingException::class); + $this->deserialize(static::getContent('empty_object'), Author::class, DeserializationContext::create()->setRequireAllRequiredProperties(true)); + } + + public function testDeserializationFailureOnPropertyMissingUnionRequiredMissing() + { + if (PHP_VERSION_ID < 80000) { + $this->markTestSkipped(sprintf('%s requires PHP 8.0', TypedPropertiesDriver::class)); + + return; + } + + self::expectException(PropertyMissingException::class); + $this->deserialize(static::getContent('empty_object'), UnionTypedProperties::class, DeserializationContext::create()->setRequireAllRequiredProperties(true)); + } + + public function testDeserializationSuccessPropertyMissingNullablePresent() + { + if (PHP_VERSION_ID < 80000) { + $this->markTestSkipped(sprintf('%s requires PHP 8.0', TypedPropertiesDriver::class)); + + return; + } + + // Vase setters on size and weight modify the inputs from big -> huge and 10 -> 40. + $object = new Vase('blue', 'huge', 'flower', 'potting mix', -1, 40); + self::assertEquals($object, $this->deserialize(static::getContent('vase_with_plant'), Vase::class, DeserializationContext::create()->setRequireAllRequiredProperties(true))); + } + public function testDeserializingUnionProperties() { if (PHP_VERSION_ID < 80000) {