-
-
Notifications
You must be signed in to change notification settings - Fork 9.4k
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
[Serializer] added the ability to gather all deserialization errors. #27136
Conversation
5959479
to
573de83
Compare
There was a problem hiding this 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; |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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[] |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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()) |
There was a problem hiding this comment.
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
.
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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]) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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',
],
];
573de83
to
779baf8
Compare
@omissis any news regarding this PR? Do you need some help? |
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 :) |
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
779baf8
to
3df123e
Compare
0ff0ff8
to
132b8c5
Compare
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) { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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
@dunglas I'd like to close this PR so that it doesn't pollute the project's board, would it be ok? |
closing to avoid polluting the board with a stale pr. |
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 :)
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:
NotDenormalizableValueException
for better separation of concerns between normalization and denormalization.