Skip to content

[Serializer] Add ability to collect denormalization errors #38165

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
1 change: 1 addition & 0 deletions src/Symfony/Component/Serializer/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ CHANGELOG
* added `UidNormalizer`
* added `FormErrorNormalizer`
* added `MimeMessageNormalizer`
* added `DenormalizerInterface::COLLECT_INVARIANT_VIOLATIONS` context option to collect denormalization errors instead of throwing immediately

5.1.0
-----
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?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\Exception;

use Symfony\Component\Serializer\InvariantViolation;

final class InvariantViolationException extends \RuntimeException implements ExceptionInterface
{
private $violations;

/**
* @param array<string, array<InvariantViolation>> $violations
*/
public function __construct(array $violations)
{
parent::__construct('Denormalization failed because some values were invalid.');

$this->violations = $violations;
}

/**
* @return array<string, array<InvariantViolation>>
*/
public function getViolations(): array
{
return $this->violations;
}

/**
* @return array<string, array<InvariantViolation>>
*/
public function getViolationsNestedIn(string $parentPath): array
{
if ('' === $parentPath) {
throw new \InvalidArgumentException('Parent path cannot be empty.');
}

$nestedViolations = [];

foreach ($this->violations as $path => $violations) {
$path = '' !== $path ? "{$parentPath}.{$path}" : $parentPath;

$nestedViolations[$path] = $violations;
}

return $nestedViolations;
}

/**
* @return array<string, array<string>>
*/
public function getViolationMessages(): array
{
$messages = [];

foreach ($this->violations as $path => $violations) {
foreach ($violations as $violation) {
$messages[$path][] = $violation->getMessage();
}
}

return $messages;
}
}
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;

class InvariantViolation
{
private $normalizedValue;
private $message;
private $exception;

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

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

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

public function getException(): \Throwable
{
return $this->exception;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Encoder\XmlEncoder;
use Symfony\Component\Serializer\Exception\ExtraAttributesException;
use Symfony\Component\Serializer\Exception\InvariantViolationException;
use Symfony\Component\Serializer\Exception\LogicException;
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
use Symfony\Component\Serializer\Exception\RuntimeException;
Expand Down Expand Up @@ -311,6 +312,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 @@ -331,14 +334,23 @@ public function denormalize($data, string $type, string $format = null, array $c
}
}

$value = $this->validateAndDenormalize($resolvedClass, $attribute, $value, $format, $context);
try {
$value = $this->validateAndDenormalize($resolvedClass, $attribute, $value, $format, $context);
} catch (InvariantViolationException $exception) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, the idea is that all denormalizers need to be aware of the COLLECT_INVARIANT_VIOLATIONS context key and behave differently based on this? Either throwing a NotNormalizableValueException or a InvariantViolationException? Is that correct?

Could we just catch the existing NotNormalizableValueException? Or is the problem that these error messages are currently a bit "internal" (e.g. Failed to denormalize attribute foo for class Foo\Bar) and so the new exception message allows denormalizers to advertise a safer, "validation" message? Or is there another purpose?

If the only purpose is to advertise a validation-safe message, perhaps we could add a new optional property+method (thinking out loud) to NotNormalizableValueException that contains this - e.g. getValidationMessage()? Or maybe I'm way off understanding the mechanics :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, the idea is that all denormalizers need to be aware of the COLLECT_INVARIANT_VIOLATIONS context key and behave differently based on this? Either throwing a NotNormalizableValueException or a InvariantViolationException? Is that correct?

Yes, I think collecting denormalization errors should be opt-in. Though throwing InvariantViolationException is the only implemenation I managed to get working for now.

My first attempt was to wrap all denormalizations using an interface (e.g. InvariantViolationCatcherInterface). One implementation would just throw immediatly (current behavior) and another would collect errors instead. But I faced too many issues and gave up with this approach. Maybe I should give it another try?

Could we just catch the existing NotNormalizableValueException?

Here we catch InvariantViolationException because we rely on nested denormalizers calls to denormalize nested values. Those denormalizers will only throw this kind of exception for each invariant violation if COLLECT_INVARIANT_VIOLATIONS is enabled, so if we catch one here, we can assume it's enabled without actually checking the context in this method.

I'd like to introduce an interface for exceptions that would simplify this maybe, but I'll wait until we're sure the current approach is correct.

Or is the problem that these error messages are currently a bit "internal" (e.g. Failed to denormalize attribute foo for class Foo\Bar) and so the new exception message allows denormalizers to advertise a safer, "validation" message? Or is there another purpose?

If the only purpose is to advertise a validation-safe message, perhaps we could add a new optional property+method (thinking out loud) to NotNormalizableValueException that contains this - e.g. getValidationMessage()? Or maybe I'm way off understanding the mechanics :)

Indeed, the purpose is to advertise validation-safe messages (and maybe more validation related data later). Not sure NotNormalizableValueException is suitable for that though: currently, denormalizers might throw other exceptions (e.g. UnexpectedValueException). We could either:

  • always throw NotNormalizableValueException, but replacing a thrown exception with another would be a BC break I guess;
  • add the methods you suggest to all possible exceptions.

WDYT?

$invariantViolations += $exception->getViolationsNestedIn($attribute);
}

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 ([] !== $invariantViolations) {
throw new InvariantViolationException($invariantViolations);
}

if (!empty($extraAttributes)) {
throw new ExtraAttributesException($extraAttributes);
}
Expand Down
17 changes: 14 additions & 3 deletions src/Symfony/Component/Serializer/Normalizer/ArrayDenormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

use Symfony\Component\Serializer\Exception\BadMethodCallException;
use Symfony\Component\Serializer\Exception\InvalidArgumentException;
use Symfony\Component\Serializer\Exception\InvariantViolationException;
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
use Symfony\Component\Serializer\SerializerAwareInterface;
use Symfony\Component\Serializer\SerializerInterface;
Expand Down Expand Up @@ -51,13 +52,23 @@ public function denormalize($data, string $type, string $format = null, array $c
$serializer = $this->serializer;
$type = substr($type, 0, -2);

$invariantViolations = [];

$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)));
try {
if (null !== $builtinType && !('is_'.$builtinType)($key)) {
throw new NotNormalizableValueException($key, $value, sprintf('The type of the key "%s" must be "%s" ("%s" given).', $key, $builtinType, get_debug_type($key)));
}

$data[$key] = $serializer->denormalize($value, $type, $format, $context);
} catch (InvariantViolationException $exception) {
$invariantViolations += $exception->getViolationsNestedIn($key);
}
}

$data[$key] = $serializer->denormalize($value, $type, $format, $context);
if ([] !== $invariantViolations) {
throw new InvariantViolationException($invariantViolations);
}

return $data;
Expand Down
17 changes: 17 additions & 0 deletions src/Symfony/Component/Serializer/Normalizer/DataUriNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
use Symfony\Component\Mime\MimeTypeGuesserInterface;
use Symfony\Component\Mime\MimeTypes;
use Symfony\Component\Serializer\Exception\InvalidArgumentException;
use Symfony\Component\Serializer\Exception\InvariantViolationException;
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
use Symfony\Component\Serializer\InvariantViolation;

/**
* Normalizes an {@see \SplFileInfo} object to a data URI.
Expand Down Expand Up @@ -90,6 +92,21 @@ public function supportsNormalization($data, string $format = null)
* @throws NotNormalizableValueException
*/
public function denormalize($data, string $type, string $format = null, array $context = [])
{
try {
return $this->doDenormalize($data, $type);
} catch (NotNormalizableValueException $exception) {
if ($context[self::COLLECT_INVARIANT_VIOLATIONS] ?? false) {
$violation = new InvariantViolation($data, 'This value is not a valid data URI.', $exception);

throw new InvariantViolationException(['' => [$violation]]);
}

throw $exception;
}
}

private function doDenormalize($data, string $type)
{
if (!preg_match('/^data:([a-z0-9][a-z0-9\!\#\$\&\-\^\_\+\.]{0,126}\/[a-z0-9][a-z0-9\!\#\$\&\-\^\_\+\.]{0,126}(;[a-z0-9\-]+\=[a-z0-9\-]+)?)?(;base64)?,[a-z0-9\!\$\&\\\'\,\(\)\*\+\,\;\=\-\.\_\~\:\@\/\?\%\s]*\s*$/i', $data)) {
throw new NotNormalizableValueException('The provided "data:" URI is not valid.');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
namespace Symfony\Component\Serializer\Normalizer;

use Symfony\Component\Serializer\Exception\InvalidArgumentException;
use Symfony\Component\Serializer\Exception\InvariantViolationException;
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
use Symfony\Component\Serializer\InvariantViolation;

/**
* Normalizes an instance of {@see \DateInterval} to an interval string.
Expand Down Expand Up @@ -70,6 +72,21 @@ public function hasCacheableSupportsMethod(): bool
* @throws UnexpectedValueException
*/
public function denormalize($data, string $type, string $format = null, array $context = [])
{
try {
return $this->doDenormalize($data, $context);
} catch (\Throwable $exception) {
if ($context[self::COLLECT_INVARIANT_VIOLATIONS] ?? false) {
$violation = new InvariantViolation($data, 'This value is not a valid date interval.', $exception);

throw new InvariantViolationException(['' => [$violation]]);
}

throw $exception;
}
}

private function doDenormalize($data, array $context = [])
{
if (!\is_string($data)) {
throw new InvalidArgumentException(sprintf('Data expected to be a string, "%s" given.', get_debug_type($data)));
Expand Down
17 changes: 17 additions & 0 deletions src/Symfony/Component/Serializer/Normalizer/DateTimeNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
namespace Symfony\Component\Serializer\Normalizer;

use Symfony\Component\Serializer\Exception\InvalidArgumentException;
use Symfony\Component\Serializer\Exception\InvariantViolationException;
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
use Symfony\Component\Serializer\InvariantViolation;

/**
* Normalizes an object implementing the {@see \DateTimeInterface} to a date string.
Expand Down Expand Up @@ -77,6 +79,21 @@ public function supportsNormalization($data, string $format = null)
* @throws NotNormalizableValueException
*/
public function denormalize($data, string $type, string $format = null, array $context = [])
{
try {
return $this->doDenormalize($data, $type, $context);
} catch (NotNormalizableValueException $exception) {
if ($context[self::COLLECT_INVARIANT_VIOLATIONS] ?? false) {
$violation = new InvariantViolation($data, 'This value is not a valid date.', $exception);

throw new InvariantViolationException(['' => [$violation]]);
}

throw $exception;
}
}

private function doDenormalize($data, string $type, array $context = [])
{
$dateTimeFormat = $context[self::FORMAT_KEY] ?? null;
$timezone = $this->getTimezone($context);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
*/
interface DenormalizerInterface
{
const COLLECT_INVARIANT_VIOLATIONS = 'collect_invariant_violations';

/**
* Denormalizes data back into an object of the given class.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
use Symfony\Component\Mime\Header\UnstructuredHeader;
use Symfony\Component\Mime\Message;
use Symfony\Component\Mime\Part\AbstractPart;
use Symfony\Component\Serializer\Exception\InvariantViolationException;
use Symfony\Component\Serializer\InvariantViolation;
use Symfony\Component\Serializer\SerializerAwareInterface;
use Symfony\Component\Serializer\SerializerInterface;

Expand Down Expand Up @@ -55,9 +57,19 @@ public function setSerializer(SerializerInterface $serializer)
public function normalize($object, ?string $format = null, array $context = [])
{
if ($object instanceof Headers) {
$invariantViolations = [];

$ret = [];
foreach ($this->headersProperty->getValue($object) as $name => $header) {
$ret[$name] = $this->serializer->normalize($header, $format, $context);
try {
$ret[$name] = $this->serializer->normalize($header, $format, $context);
} catch (InvariantViolationException $exception) {
$invariantViolations[''][] = new InvariantViolation($header, "Header {$name} is invalid.", $exception);
}
}

if ([] !== $invariantViolations) {
throw new InvariantViolationException($invariantViolations);
}

return $ret;
Expand Down
8 changes: 8 additions & 0 deletions src/Symfony/Component/Serializer/Normalizer/UidNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@

namespace Symfony\Component\Serializer\Normalizer;

use Symfony\Component\Serializer\Exception\InvariantViolationException;
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
use Symfony\Component\Serializer\InvariantViolation;
use Symfony\Component\Uid\AbstractUid;
use Symfony\Component\Uid\Ulid;
use Symfony\Component\Uid\Uuid;
Expand Down Expand Up @@ -42,6 +44,12 @@ public function denormalize($data, string $type, string $format = null, array $c
try {
return Ulid::class === $type ? Ulid::fromString($data) : Uuid::fromString($data);
} catch (\InvalidArgumentException $exception) {
if ($context[self::COLLECT_INVARIANT_VIOLATIONS] ?? false) {
$violation = new InvariantViolation($data, sprintf('This value is not a valid %s URI.', substr(strrchr($type, '\\'), 1)), $exception);

throw new InvariantViolationException(['' => [$violation]]);
}

throw new NotNormalizableValueException(sprintf('The data is not a valid "%s" string representation.', $type));
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\Serializer\Exception\InvariantViolationException;
use Symfony\Component\Serializer\SerializerAwareInterface;
use Symfony\Component\Serializer\SerializerAwareTrait;

Expand Down Expand Up @@ -48,7 +49,13 @@ public function denormalize($data, $class, string $format = null, array $context
$data = $this->propertyAccessor->getValue($data, $propertyPath);
}

return $this->serializer->denormalize($data, $class, $format, $context);
try {
return $this->serializer->denormalize($data, $class, $format, $context);
} catch (InvariantViolationException $exception) {
$propertyPath = str_replace('][', '.', substr($propertyPath, 1, -1));

throw new InvariantViolationException($exception->getViolationsNestedIn($propertyPath));
}
}

/**
Expand Down
Loading
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy