Skip to content
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

[Serializer] added the ability to gather all deserialization errors. #27136

Conversation

omissis
Copy link

@omissis omissis commented May 3, 2018

I'd like to propose a new "collect_all_errors" (boolean) setting in the deserialization context, that will have the normalizer loop through all the attributes and collect all the errors, as opposed to breaking at the first one.

If this proposal is acceptable, I'll gladly add the Doc PR and Changelog :)

Q A
Branch? master
Bug fix? no
New feature? yes
BC breaks? no
Deprecations? yes
Tests pass? yes
Fixed tickets none
License MIT
Doc PR tbd

This patch proposes an update to the Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer that allows clients to receive all deserialization errors instead of the first one only.

Additionally:

  • Introduces NotDenormalizableValueException for better separation of concerns between normalization and denormalization.

@omissis omissis force-pushed the SERIALIZER-object-normalizer-denormalize-validation-errors-list branch from 5959479 to 573de83 Compare May 3, 2018 11:05
@nicolas-grekas nicolas-grekas added this to the next milestone May 3, 2018
Copy link
Member

@dunglas dunglas left a comment

Choose a reason for hiding this comment

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

Great future, it will definitely be useful for web APIs. Can you take a look to https://github.com/symfony/symfony/blob/master/src/Symfony/Component/Serializer/Normalizer/ConstraintViolationListNormalizer.php, it would be nice to be able to serialize the resulting exception (the one that collects all violations) in the same RFC7807 format.

I strongly encourage to use a similar structure than the Validator's ConstraintViolationList, or even use it directly (we may move this class in an internal shared component for instance). It will allow to factorize some code, and all existing normalizers for this class (ConstraintViolationListNormalizer mentioned above, but also the Hydra one shipped with API Platform) will then be able to serialize the thrown exception.

Also, I'm for enabling this feature by default.


namespace Symfony\Component\Serializer\Exception;

use Throwable;
Copy link
Member

Choose a reason for hiding this comment

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

Usually we don't add use statements for classes in the global namespace, I don't know why fabbot doesn't report a problem.

namespace Symfony\Component\Serializer\Exception;

/**
* UnexpectedValuesException.
Copy link
Member

Choose a reason for hiding this comment

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

This line can be removed (it brings nothing)

Copy link
Author

Choose a reason for hiding this comment

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

ok, I added it as I see other exceptions in the namespace have it.

class UnexpectedValuesException extends \RuntimeException implements ExceptionInterface
{
/**
* @var string[]
Copy link
Member

Choose a reason for hiding this comment

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

This type information should be moved on the constructor instead.

Copy link
Author

Choose a reason for hiding this comment

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

Right. I usually specify that in both places (property's docblock and constructor's argument): would that be ok, too?

* {@inheritdoc}
*/
public function denormalize($data, $class, $format = null, array $context = array())
public function denormalize($data, $class, $format = null, array $context = array(), array &$accumulatingContext = array())
Copy link
Member

Choose a reason for hiding this comment

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

Adding this parameter is a BC break and it doesn't respect the NormalizerInterface.
But are you sure you can't reuse the standard context, using a similar trick than in: https://github.com/symfony/symfony/blob/master/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php#L375, or store an \ArrayObject.

Copy link
Author

Choose a reason for hiding this comment

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

You mean passing $context by reference? I would have to try that, but if memory doesn't fail me, I did try and it didn't work: I'll try again and see :)
The thing is, wouldn't that qualify as BC break too? I'd argue that such approach could lead to harder to find bugs as the method signatures would differ only by a &, where the interface would declare a behaviour and the implementation would allow changes to $context to ripple out of the method's scope, so I'd probably like an explicit BC break here instead. Perhaps introducing a solution akin to ContextAwareDenormalizerInterface could do(it does specify an extra argument for supportsDenormalization() compared to DenormalizerInterface)?

$value = $this->validateAndDenormalize($class, $attribute, $value, $format, $context, $accumulatingContext);
} catch (NotDenormalizableValueException $exception) {
if (isset($context[self::COLLECT_ALL_ERRORS]) && $context[self::COLLECT_ALL_ERRORS]) {
$accumulatingContext[$attribute][] = $exception->getMessage();
Copy link
Member

Choose a reason for hiding this comment

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

I suggest store references to the exceptions instead of just the message. It costs nothing and improve the evolvability.

Same everywhere.

continue;
}

if (null === $value) {
Copy link
Member

Choose a reason for hiding this comment

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

null can be an expected value. The setter must be called even if the value is null (to update an existing object for instance).

try {
$value = $this->validateAndDenormalize($class, $attribute, $value, $format, $context, $accumulatingContext);
} catch (NotDenormalizableValueException $exception) {
if (isset($context[self::COLLECT_ALL_ERRORS]) && $context[self::COLLECT_ALL_ERRORS]) {
Copy link
Member

Choose a reason for hiding this comment

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

Can be simplified in $context[self::COLLECT_ALL_ERRORS] ?? false. Same everywhere.

Copy link
Author

Choose a reason for hiding this comment

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

Ok, I wasn't sure I could use php7 features

continue;
}

throw $exception;
}
}

if (!empty($extraAttributes)) {
throw new ExtraAttributesException($extraAttributes);
Copy link
Member

Choose a reason for hiding this comment

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

Shouldn't this exception be collected too? It will be useful when creating web APIs to be able to return all errors in one time to the client, including this one.

return $this->serializer->denormalize($data, $class, $format, $childContext, $accumulatingContext);
} catch (NotDenormalizableValueException | InvalidArgumentException $e) {
if (isset($context[self::COLLECT_ALL_ERRORS]) && $context[self::COLLECT_ALL_ERRORS]) {
$accumulatingContext[$attribute][] = $e->getMessage();
Copy link
Member

Choose a reason for hiding this comment

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

You should probably merge caught exception with the existing one. It's why collecting messages not enough, it would be better to collect exceptions themselves.

The resulting document (to send to the client) should be something like:

attribute1: error message
attribute2.innerAttribute1: foo
attribute2.innerAttribute2: bar

Copy link
Author

Choose a reason for hiding this comment

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

At the moment errors live in a nested array like the following one(using your example):

$errors = [
    'attribute1' => [
        0 => 'error message',
    ],
    'attribute2' => [
        'innerAttribute1' => [
            0 => 'foo',
        ],
        'innerAttribute2' => [
            0 => 'bar',
        ]
    ],
];

Which should be fairly easy to serialize in the form you suggest.. do you think it'd be better to make it flat from the beginning? i.e.:

$errors = [
    'attribute1' => [
        0 => 'error message',
    ],
    'attribute2.innerAttribute1' => [
        0 => 'foo',
    ],
    'attribute2.innerAttribute2' => [
        0 => 'bar',
    ],
];

@dunglas
Copy link
Member

dunglas commented Sep 4, 2018

@omissis any news regarding this PR? Do you need some help?

@omissis
Copy link
Author

omissis commented Sep 4, 2018

Hi! I have been working on it a bit during summer vacations, and I was about to ping you on Slack, but I want to investigate a bit the build failures locally first, and bother you as little as possible. I will ping you soon, thx for asking :)

@Nek-
Copy link
Contributor

Nek- commented Sep 5, 2018

@omissis I'm really interested in this PR too. Feel free to ask for help if needed. I'm available on slack.

(I missed your PR and created this ticket #28358)

By passing setting "collect_all_errors" to true in the deserialization context,
the normalizer will now loop through all the attributes and collect all the errors,
as opposed to breaking at the first one.
* move from collecting error messages strings to error objects
* add ExtraAttributeException to capture errors about single, unallowed attributes
* make extra attributes errors part of the set of collected errors when 'collect_all_errors' is enabled
* fix some coding style issues
@omissis omissis force-pushed the SERIALIZER-object-normalizer-denormalize-validation-errors-list branch from 779baf8 to 3df123e Compare September 8, 2018 18:50
@omissis omissis force-pushed the SERIALIZER-object-normalizer-denormalize-validation-errors-list branch from 0ff0ff8 to 132b8c5 Compare September 8, 2018 19:36
@omissis
Copy link
Author

omissis commented Sep 9, 2018

Hey @Nek-, thanks for mentioning the card. Do you think this branch does what you need or it needs further improvemnts?

* remove unused param in UnexpectedValuesException::assertIsError
* add return typehint to UnexpectedValuesExceptionTest::wrongErrorsProvider
* change reference to RuntimeException to \RuntimeException
{
if (null === $accumulatingContext) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I guess this have sense only if $shouldCollectAllErrors is true?

Copy link
Author

Choose a reason for hiding this comment

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

👍

* @throws ExtraAttributesException Occurs when the item doesn't have attribute to receive given data
* @throws NotDenormalizableValueException Occurs when the item cannot be denormalized
* @throws LogicException Occurs when the normalizer is not supposed to denormalize
* @throws RuntimeException Occurs if the class cannot be instantiated
Copy link
Contributor

Choose a reason for hiding this comment

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

You should add here the new parameter. And also add it as parameter of the method (but commented) so it can be add in Symfony5.

}
}

if (!empty($extraAttributes)) {
throw new ExtraAttributesException($extraAttributes);
if (!$shouldCollectAllErrors) {
throw new ExtraAttributesException($extraAttributes);
Copy link
Contributor

Choose a reason for hiding this comment

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

Throwing an exception looks not really good to me because it forces everybody to catch the exception and re-throw on any normalizer that decorate the AbstractObjectNormalizer.

Here is an example of normalizer we could imagine:

class PhoneNumberNormalizer implements DenormalizerInterface
{
    public function __construct(DenormalizerInterface $objectNormalizerDecorated)
    {
        $this->decorated = $objectNormalizerDecorated;
    }

    public function denormalize($data, $class, $format = null, array $context = array(), AccumulatingContext $accumulatingContext = null)
    {
        $object = $this->decorated->denormalize($data);
        // Here the exception may be throw while we accumulate errors
        // We should catch and add a new throw (and for each normalizer)
        
        // I do some stuff with $object
        // Well, maybe we should re-throw here in case something's wrong?

        // Ok, I will re-throw (exceptions should be nested, which is another problem)
        // How am I suppose at this point know what attribute I want to add?

        // nevermind, the library is not up to date to support this feature and will generate weird behavior
    }
}

The exception is (IMHO) not designed for this kind of usage. I suggest the usage of a new concept: the context of serialization needs to be an object. And it should also contain an error object (something like your accumulatingContext). This way the user will be able to configure properly (and without only using constants) the serialization/deserialization and also will be able to get back information about what happened in the process.

This may be a lot of work but probably the real best solution. (what do you think @dunglas ?)

Note that another solution I see would be to give the AccumulationContext as user instead of just a boolean. I posted this suggestion as example here: #28358

@omissis
Copy link
Author

omissis commented Apr 9, 2019

@dunglas I'd like to close this PR so that it doesn't pollute the project's board, would it be ok?

@omissis
Copy link
Author

omissis commented May 9, 2019

closing to avoid polluting the board with a stale pr.

@omissis omissis closed this May 9, 2019
@omissis omissis deleted the SERIALIZER-object-normalizer-denormalize-validation-errors-list branch May 9, 2019 15:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants