Skip to content

[Serializer] Add ability to collect denormalization errors #38472

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
use Symfony\Component\Serializer\Normalizer\ContextAwareNormalizerInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use Symfony\Component\Serializer\Result\NormalizationResult;
use Symfony\Component\Serializer\SerializerInterface;

/**
* This normalizer is only used in Debug/Dev/Messenger contexts.
Expand Down Expand Up @@ -50,6 +52,10 @@ public function normalize($object, $format = null, array $context = [])
$normalized['status'] = $status;
}

if ($context[SerializerInterface::RETURN_RESULT] ?? false) {
return NormalizationResult::success($normalized);
}

return $normalized;
}

Expand Down
1 change: 1 addition & 0 deletions src/Symfony/Component/Serializer/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ CHANGELOG
* added `FormErrorNormalizer`
* added `MimeMessageNormalizer`
* serializer mapping can be configured using php attributes
* added `SerializerInterface::RETURN_RESULT` context option to collect (de)normalization errors instead of throwing immediately

5.1.0
-----
Expand Down
41 changes: 41 additions & 0 deletions src/Symfony/Component/Serializer/InvariantViolation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Serializer;

final class InvariantViolation
{
private $normalizedValue;
private $message;
private $throwable;

public function __construct($normalizedValue, string $message, ?\Throwable $throwable = null)
{
$this->normalizedValue = $normalizedValue;
$this->message = $message;
$this->throwable = $throwable;
}

public function getNormalizedValue()
{
return $this->normalizedValue;
}

public function getMessage(): string
{
return $this->message;
}

public function getThrowable(): ?\Throwable
{
return $this->throwable;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@
use Symfony\Component\Serializer\Mapping\AttributeMetadataInterface;
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
use Symfony\Component\Serializer\Result\NormalizationResult;
use Symfony\Component\Serializer\SerializerAwareInterface;
use Symfony\Component\Serializer\SerializerAwareTrait;
use Symfony\Component\Serializer\SerializerInterface;

/**
* Normalizer implementation.
Expand Down Expand Up @@ -210,10 +212,22 @@ protected function handleCircularReference(object $object, string $format = null
{
$circularReferenceHandler = $context[self::CIRCULAR_REFERENCE_HANDLER] ?? $this->defaultContext[self::CIRCULAR_REFERENCE_HANDLER];
if ($circularReferenceHandler) {
return $circularReferenceHandler($object, $format, $context);
$result = $circularReferenceHandler($object, $format, $context);

if ($context[SerializerInterface::RETURN_RESULT] ?? false) {
return NormalizationResult::success($result);
}

return $result;
}

$exception = new CircularReferenceException(sprintf('A circular reference has been detected when serializing the object of class "%s" (configured limit: %d).', get_debug_type($object), $context[self::CIRCULAR_REFERENCE_LIMIT] ?? $this->defaultContext[self::CIRCULAR_REFERENCE_LIMIT]));

if ($context[SerializerInterface::RETURN_RESULT] ?? false) {
return NormalizationResult::failure(['' => $exception]);
}

throw new CircularReferenceException(sprintf('A circular reference has been detected when serializing the object of class "%s" (configured limit: %d).', get_debug_type($object), $context[self::CIRCULAR_REFERENCE_LIMIT] ?? $this->defaultContext[self::CIRCULAR_REFERENCE_LIMIT]));
throw $exception;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,15 @@
use Symfony\Component\Serializer\Exception\LogicException;
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
use Symfony\Component\Serializer\Exception\RuntimeException;
use Symfony\Component\Serializer\InvariantViolation;
use Symfony\Component\Serializer\Mapping\AttributeMetadataInterface;
use Symfony\Component\Serializer\Mapping\ClassDiscriminatorFromClassMetadata;
use Symfony\Component\Serializer\Mapping\ClassDiscriminatorResolverInterface;
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
use Symfony\Component\Serializer\Result\DenormalizationResult;
use Symfony\Component\Serializer\Result\NormalizationResult;
use Symfony\Component\Serializer\SerializerInterface;

/**
* Base class for a normalizer dealing with objects.
Expand Down Expand Up @@ -207,6 +211,10 @@ public function normalize($object, string $format = null, array $context = [])
return new \ArrayObject();
}

if ($context[SerializerInterface::RETURN_RESULT] ?? false) {
return NormalizationResult::success($data);
}

return $data;
}

Expand Down Expand Up @@ -311,6 +319,8 @@ public function denormalize($data, string $type, string $format = null, array $c
$object = $this->instantiateObject($normalizedData, $type, $context, $reflectionClass, $allowedAttributes, $format);
$resolvedClass = $this->objectClassResolver ? ($this->objectClassResolver)($object) : \get_class($object);

$invariantViolations = [];

foreach ($normalizedData as $attribute => $value) {
if ($this->nameConverter) {
$attribute = $this->nameConverter->denormalize($attribute, $resolvedClass, $format, $context);
Expand All @@ -332,13 +342,38 @@ public function denormalize($data, string $type, string $format = null, array $c
}

$value = $this->validateAndDenormalize($resolvedClass, $attribute, $value, $format, $context);

if ($value instanceof DenormalizationResult) {
if (!$value->isSucessful()) {
$invariantViolations += $value->getInvariantViolationsNestedIn($attribute);

continue;
}

$value = $value->getDenormalizedValue();
}

try {
$this->setAttributeValue($object, $attribute, $value, $format, $context);
} catch (InvalidArgumentException $e) {
throw new NotNormalizableValueException(sprintf('Failed to denormalize attribute "%s" value for class "%s": '.$e->getMessage(), $attribute, $type), $e->getCode(), $e);
}
}

if ($context[SerializerInterface::RETURN_RESULT] ?? false) {
if (!empty($extraAttributes)) {
$message = (new ExtraAttributesException($extraAttributes))->getMessage();

$invariantViolations[''][] = new InvariantViolation($data, $message);
}

if ([] !== $invariantViolations) {
return DenormalizationResult::failure($invariantViolations, $object);
}

return DenormalizationResult::success($object);
}

if (!empty($extraAttributes)) {
throw new ExtraAttributesException($extraAttributes);
}
Expand All @@ -364,13 +399,13 @@ abstract protected function setAttributeValue(object $object, string $attribute,
private function validateAndDenormalize(string $currentClass, string $attribute, $data, ?string $format, array $context)
{
if (null === $types = $this->getTypes($currentClass, $attribute)) {
return $data;
return $this->denormalizationSuccess($data, $context);
}

$expectedTypes = [];
foreach ($types as $type) {
if (null === $data && $type->isNullable()) {
return null;
return $this->denormalizationSuccess(null, $context);
}

$collectionValueType = $type->isCollection() ? $type->getCollectionValueTypes()[0] ?? null : null;
Expand All @@ -386,7 +421,7 @@ private function validateAndDenormalize(string $currentClass, string $attribute,
// That's why we have to transform the values, if one of these non-string basic datatypes is expected.
if (\is_string($data) && (XmlEncoder::FORMAT === $format || CsvEncoder::FORMAT === $format)) {
if ('' === $data && $type->isNullable() && \in_array($type->getBuiltinType(), [Type::BUILTIN_TYPE_BOOL, Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT], true)) {
return null;
return $this->denormalizationSuccess(null, $context);
}

switch ($type->getBuiltinType()) {
Expand All @@ -397,30 +432,36 @@ private function validateAndDenormalize(string $currentClass, string $attribute,
} elseif ('true' === $data || '1' === $data) {
$data = true;
} else {
throw new NotNormalizableValueException(sprintf('The type of the "%s" attribute for class "%s" must be bool ("%s" given).', $attribute, $currentClass, $data));
$message = sprintf('The type of the "%s" attribute for class "%s" must be bool ("%s" given).', $attribute, $currentClass, $data);

return $this->denormalizationFailure($data, $message, $context);
}
break;
case Type::BUILTIN_TYPE_INT:
if (ctype_digit($data) || '-' === $data[0] && ctype_digit(substr($data, 1))) {
$data = (int) $data;
} else {
throw new NotNormalizableValueException(sprintf('The type of the "%s" attribute for class "%s" must be int ("%s" given).', $attribute, $currentClass, $data));
$message = sprintf('The type of the "%s" attribute for class "%s" must be int ("%s" given).', $attribute, $currentClass, $data);

return $this->denormalizationFailure($data, $message, $context);
}
break;
case Type::BUILTIN_TYPE_FLOAT:
if (is_numeric($data)) {
return (float) $data;
return $this->denormalizationSuccess((float) $data, $context);
}

switch ($data) {
case 'NaN':
return \NAN;
return $this->denormalizationSuccess(\NAN, $context);
case 'INF':
return \INF;
return $this->denormalizationSuccess(\INF, $context);
case '-INF':
return -\INF;
return $this->denormalizationSuccess(-\INF, $context);
default:
throw new NotNormalizableValueException(sprintf('The type of the "%s" attribute for class "%s" must be float ("%s" given).', $attribute, $currentClass, $data));
$message = sprintf('The type of the "%s" attribute for class "%s" must be float ("%s" given).', $attribute, $currentClass, $data);

return $this->denormalizationFailure($data, $message, $context);
}

break;
Expand Down Expand Up @@ -479,19 +520,41 @@ private function validateAndDenormalize(string $currentClass, string $attribute,
// To circumvent this behavior, integers are converted to floats when denormalizing JSON based formats and when
// a float is expected.
if (Type::BUILTIN_TYPE_FLOAT === $builtinType && \is_int($data) && false !== strpos($format, JsonEncoder::FORMAT)) {
return (float) $data;
return $this->denormalizationSuccess((float) $data, $context);
}

if (('is_'.$builtinType)($data)) {
return $data;
return $this->denormalizationSuccess($data, $context);
}
}

if ($context[self::DISABLE_TYPE_ENFORCEMENT] ?? $this->defaultContext[self::DISABLE_TYPE_ENFORCEMENT] ?? false) {
return $data;
return $this->denormalizationSuccess($data, $context);
}

$message = sprintf('The type of the "%s" attribute for class "%s" must be one of "%s" ("%s" given).', $attribute, $currentClass, implode('", "', array_keys($expectedTypes)), get_debug_type($data));

return $this->denormalizationFailure($data, $message, $context);
}

private function denormalizationSuccess($denormalizedValue, array $context)
{
if ($context[SerializerInterface::RETURN_RESULT] ?? false) {
return DenormalizationResult::success($denormalizedValue);
}

return $denormalizedValue;
}

private function denormalizationFailure($normalizedValue, string $message, array $context)
{
if ($context[SerializerInterface::RETURN_RESULT] ?? false) {
$violation = new InvariantViolation($normalizedValue, $message);

return DenormalizationResult::failure(['' => [$violation]]);
}

throw new NotNormalizableValueException(sprintf('The type of the "%s" attribute for class "%s" must be one of "%s" ("%s" given).', $attribute, $currentClass, implode('", "', array_keys($expectedTypes)), get_debug_type($data)));
throw new NotNormalizableValueException($message);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@

namespace Symfony\Component\Serializer\Normalizer;

use Symfony\Component\Serializer\DenormalizationResult;
use Symfony\Component\Serializer\Exception\BadMethodCallException;
use Symfony\Component\Serializer\Exception\InvalidArgumentException;
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
use Symfony\Component\Serializer\InvariantViolation;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Serializer\SerializerAwareInterface;
use Symfony\Component\Serializer\SerializerInterface;
Expand All @@ -34,7 +36,7 @@ class ArrayDenormalizer implements ContextAwareDenormalizerInterface, Denormaliz
*
* @throws NotNormalizableValueException
*/
public function denormalize($data, string $type, string $format = null, array $context = []): array
public function denormalize($data, string $type, string $format = null, array $context = [])
{
if (null === $this->denormalizer) {
throw new BadMethodCallException('Please set a denormalizer before calling denormalize()!');
Expand All @@ -48,13 +50,44 @@ public function denormalize($data, string $type, string $format = null, array $c

$type = substr($type, 0, -2);

$invariantViolations = [];
$collectInvariantViolations = $context[SerializerInterface::RETURN_RESULT] ?? false;

$builtinType = isset($context['key_type']) ? $context['key_type']->getBuiltinType() : null;
foreach ($data as $key => $value) {
if (null !== $builtinType && !('is_'.$builtinType)($key)) {
throw new NotNormalizableValueException(sprintf('The type of the key "%s" must be "%s" ("%s" given).', $key, $builtinType, get_debug_type($key)));
$message = sprintf('The type of the key "%s" must be "%s" ("%s" given).', $key, $builtinType, get_debug_type($key));

if ($collectInvariantViolations) {
$invariantViolations[$key][] = new InvariantViolation($value, $message);

continue;
}

throw new NotNormalizableValueException($message);
}

$data[$key] = $this->denormalizer->denormalize($value, $type, $format, $context);
$denormalizedValue = $this->denormalizer->denormalize($value, $type, $format, $context);

if ($denormalizedValue instanceof DenormalizationResult) {
if (!$denormalizedValue->isSucessful()) {
$invariantViolations += $denormalizedValue->getInvariantViolationsNestedIn($key);

continue;
}

$denormalizedValue = $denormalizedValue->getDenormalizedValue();
}

$data[$key] = $denormalizedValue;
}

if ([] !== $invariantViolations) {
return DenormalizationResult::failure($invariantViolations, $data);
}

if ($collectInvariantViolations) {
return DenormalizationResult::success($data);
}

return $data;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
namespace Symfony\Component\Serializer\Normalizer;

use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
use Symfony\Component\Serializer\Result\NormalizationResult;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\ConstraintViolationListInterface;

/**
Expand Down Expand Up @@ -41,8 +43,6 @@ public function __construct($defaultContext = [], NameConverterInterface $nameCo

/**
* {@inheritdoc}
*
* @return array
*/
public function normalize($object, string $format = null, array $context = [])
{
Expand Down Expand Up @@ -103,7 +103,13 @@ public function normalize($object, string $format = null, array $context = [])
$result['instance'] = $instance;
}

return $result + ['violations' => $violations];
$result += ['violations' => $violations];

if ($context[SerializerInterface::RETURN_RESULT] ?? false) {
return NormalizationResult::success($result);
}

return $result;
}

/**
Expand Down
Loading