Skip to content

[Serializer] Make ProblemNormalizer give details about ValidationFailedException and PartialDenormalizationException #49944

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

Merged
merged 1 commit into from
Apr 6, 2023
Merged
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
279 changes: 145 additions & 134 deletions .github/expected-missing-return-types.diff

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,9 @@
use Symfony\Component\Serializer\Mapping\Loader\YamlFileLoader;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\Normalizer\ProblemNormalizer;
use Symfony\Component\Serializer\Normalizer\UnwrappingDenormalizer;
use Symfony\Component\Serializer\SerializerAwareInterface;
use Symfony\Component\Stopwatch\Stopwatch;
use Symfony\Component\String\LazyString;
use Symfony\Component\String\Slugger\SluggerInterface;
Expand Down Expand Up @@ -1830,6 +1832,12 @@ private function registerSerializerConfiguration(array $config, ContainerBuilder
$container->removeDefinition('serializer.normalizer.mime_message');
}

// compat with Symfony < 6.3
if (!is_subclass_of(ProblemNormalizer::class, SerializerAwareInterface::class)) {
$container->getDefinition('serializer.normalizer.problem')
->setArguments(['%kernel.debug%']);
}

$serializerLoaders = [];
if (isset($config['enable_annotations']) && $config['enable_annotations']) {
if ($container->getParameter('kernel.debug')) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@
->tag('serializer.normalizer', ['priority' => -950])

->set('serializer.normalizer.problem', ProblemNormalizer::class)
->args([param('kernel.debug')])
->args([param('kernel.debug'), '$translator' => service('translator')->nullOnInvalid()])
->tag('serializer.normalizer', ['priority' => -890])

->set('serializer.denormalizer.unwrapping', UnwrappingDenormalizer::class)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,11 +136,17 @@ private function checkTypeDeclarations(Definition $checkedDefinition, \Reflectio
$envPlaceholderUniquePrefix = $this->container->getParameterBag() instanceof EnvPlaceholderParameterBag ? $this->container->getParameterBag()->getEnvPlaceholderUniquePrefix() : null;

for ($i = 0; $i < $checksCount; ++$i) {
if (!$reflectionParameters[$i]->hasType() || $reflectionParameters[$i]->isVariadic()) {
$p = $reflectionParameters[$i];
if (!$p->hasType() || $p->isVariadic()) {
continue;
}
if (\array_key_exists($p->name, $values)) {
$i = $p->name;
} elseif (!\array_key_exists($i, $values)) {
continue;
}

$this->checkType($checkedDefinition, $values[$i], $reflectionParameters[$i], $envPlaceholderUniquePrefix);
$this->checkType($checkedDefinition, $values[$i], $p, $envPlaceholderUniquePrefix);
}

if ($reflectionFunction->isVariadic() && ($lastParameter = end($reflectionParameters))->hasType()) {
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 @@ -7,6 +7,7 @@ CHANGELOG
* Add `XmlEncoder::SAVE_OPTIONS` context option
* Add `BackedEnumNormalizer::ALLOW_INVALID_VALUES` context option
* Add method `getSupportedTypes(?string $format)` to `NormalizerInterface` and `DenormalizerInterface`
* Make `ProblemNormalizer` give details about `ValidationFailedException` and `PartialDenormalizationException`
* Deprecate `MissingConstructorArgumentsException` in favor of `MissingConstructorArgumentException`
* Deprecate `CacheableSupportsMethodInterface` in favor of the new `getSupportedTypes(?string $format)` methods

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,26 @@
*/
class PartialDenormalizationException extends UnexpectedValueException
{
private $data;
private $errors;

public function __construct($data, array $errors)
{
$this->data = $data;
$this->errors = $errors;
/**
* @param NotNormalizableValueException[] $errors
*/
public function __construct(
private mixed $data,
private array $errors,
) {
}

/**
* @return mixed
*/
public function getData()
{
return $this->data;
}

/**
* @return NotNormalizableValueException[]
*/
public function getErrors(): array
{
return $this->errors;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ public function normalize(mixed $object, string $format = null, array $context =
$violationEntry = [
'propertyPath' => $propertyPath,
'title' => $violation->getMessage(),
'template' => $violation->getMessageTemplate(),
'parameters' => $violation->getParameters(),
];
if (null !== $code = $violation->getCode()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use Symfony\Component\Mime\Header\UnstructuredHeader;
use Symfony\Component\Mime\Message;
use Symfony\Component\Mime\Part\AbstractPart;
use Symfony\Component\Serializer\Exception\LogicException;
use Symfony\Component\Serializer\SerializerAwareInterface;
use Symfony\Component\Serializer\SerializerInterface;

Expand All @@ -30,10 +31,10 @@
*/
final class MimeMessageNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface, CacheableSupportsMethodInterface
{
private $serializer;
private $normalizer;
private $headerClassMap;
private $headersProperty;
private NormalizerInterface&DenormalizerInterface $serializer;
private PropertyNormalizer $normalizer;
private array $headerClassMap;
private \ReflectionProperty $headersProperty;

public function __construct(PropertyNormalizer $normalizer)
{
Expand All @@ -57,6 +58,9 @@ public function getSupportedTypes(?string $format): array

public function setSerializer(SerializerInterface $serializer): void
{
if (!$serializer instanceof NormalizerInterface || !$serializer instanceof DenormalizerInterface) {
throw new LogicException(sprintf('The passed serializer should implement both NormalizerInterface and DenormalizerInterface, "%s" given.', get_debug_type($serializer)));
}
$this->serializer = $serializer;
$this->normalizer->setSerializer($serializer);
}
Expand Down
64 changes: 49 additions & 15 deletions src/Symfony/Component/Serializer/Normalizer/ProblemNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@
namespace Symfony\Component\Serializer\Normalizer;

use Symfony\Component\ErrorHandler\Exception\FlattenException;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Symfony\Component\Serializer\Exception\InvalidArgumentException;
use Symfony\Component\Serializer\Exception\PartialDenormalizationException;
use Symfony\Component\Serializer\SerializerAwareInterface;
use Symfony\Component\Serializer\SerializerAwareTrait;
use Symfony\Component\Validator\Exception\ValidationFailedException;
use Symfony\Contracts\Translation\TranslatorInterface;

/**
* Normalizes errors according to the API Problem spec (RFC 7807).
Expand All @@ -22,22 +28,19 @@
* @author Kévin Dunglas <dunglas@gmail.com>
* @author Yonel Ceruto <yonelceruto@gmail.com>
*/
class ProblemNormalizer implements NormalizerInterface, CacheableSupportsMethodInterface
class ProblemNormalizer implements NormalizerInterface, SerializerAwareInterface, CacheableSupportsMethodInterface
{
use SerializerAwareTrait;

public const TITLE = 'title';
public const TYPE = 'type';
public const STATUS = 'status';

private $debug;
private $defaultContext = [
self::TYPE => 'https://tools.ietf.org/html/rfc2616#section-10',
self::TITLE => 'An error occurred',
];

public function __construct(bool $debug = false, array $defaultContext = [])
{
$this->debug = $debug;
$this->defaultContext = $defaultContext + $this->defaultContext;
public function __construct(
private bool $debug = false,
private array $defaultContext = [],
private ?TranslatorInterface $translator = null,
) {
}

public function getSupportedTypes(?string $format): array
Expand All @@ -53,15 +56,46 @@ public function normalize(mixed $object, string $format = null, array $context =
throw new InvalidArgumentException(sprintf('The object must implement "%s".', FlattenException::class));
}

$data = [];
$context += $this->defaultContext;
$debug = $this->debug && ($context['debug'] ?? true);
$exception = $context['exception'] ?? null;
if ($exception instanceof HttpExceptionInterface) {
$exception = $exception->getPrevious();

if ($exception instanceof PartialDenormalizationException) {
$trans = $this->translator ? $this->translator->trans(...) : fn ($m, $p) => strtr($m, $p);
$template = 'This value should be of type {{ type }}.';
$data = [
self::TYPE => 'https://symfony.com/errors/validation',
'violations' => array_map(
fn ($e) => [
'propertyPath' => $e->getPath(),
'title' => $trans($template, [
'{{ type }}' => implode('|', $e->getExpectedTypes() ?? ['?']),
], 'validators'),
'template' => $template,
'parameter' => [
'{{ type }}' => implode('|', $e->getExpectedTypes() ?? ['?']),
],
] + ($debug || $e->canUseMessageForUser() ? ['hint' => $e->getMessage()] : []),
$exception->getErrors()
),
];
} elseif ($exception instanceof ValidationFailedException
&& $this->serializer instanceof NormalizerInterface
&& $this->serializer->supportsNormalization($exception->getViolations(), $format, $context)
) {
$data = $this->serializer->normalize($exception->getViolations(), $format, $context);
}
}

$data = [
self::TYPE => $context['type'],
self::TITLE => $context['title'],
self::STATUS => $context['status'] ?? $object->getStatusCode(),
self::TYPE => $data[self::TYPE] ?? $context[self::TYPE] ?? 'https://tools.ietf.org/html/rfc2616#section-10',
self::TITLE => $data[self::TITLE] ?? $context[self::TITLE] ?? 'An error occurred',
self::STATUS => $context[self::STATUS] ?? $object->getStatusCode(),
'detail' => $debug ? $object->getMessage() : $object->getStatusText(),
];
] + $data;
if ($debug) {
$data['class'] = $object->getClass();
$data['trace'] = $object->getTrace();
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\LogicException;
use Symfony\Component\Serializer\SerializerAwareInterface;
use Symfony\Component\Serializer\SerializerAwareTrait;

Expand All @@ -37,7 +38,7 @@ public function getSupportedTypes(?string $format): array
return ['*' => false];
}

public function denormalize(mixed $data, string $class, string $format = null, array $context = []): mixed
public function denormalize(mixed $data, string $type, string $format = null, array $context = []): mixed
{
$propertyPath = $context[self::UNWRAP_PATH];
$context['unwrapped'] = true;
Expand All @@ -50,7 +51,11 @@ public function denormalize(mixed $data, string $class, string $format = null, a
$data = $this->propertyAccessor->getValue($data, $propertyPath);
}

return $this->serializer->denormalize($data, $class, $format, $context);
if (!$this->serializer instanceof DenormalizerInterface) {
throw new LogicException('Cannot unwrap path because the injected serializer is not a denormalizer.');
}

return $this->serializer->denormalize($data, $type, $format, $context);
}

public function supportsDenormalization(mixed $data, string $type, string $format = null, array $context = []): bool
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ public function testNormalize()
[
'propertyPath' => 'd',
'title' => 'a',
'template' => 'b',
'type' => 'urn:uuid:f',
'parameters' => [
'value' => 'foo',
Expand All @@ -61,6 +62,7 @@ public function testNormalize()
[
'propertyPath' => '4',
'title' => '1',
'template' => '2',
'type' => 'urn:uuid:6',
'parameters' => [],
],
Expand All @@ -75,9 +77,9 @@ public function testNormalizeWithNameConverter()
$normalizer = new ConstraintViolationListNormalizer([], new CamelCaseToSnakeCaseNameConverter());

$list = new ConstraintViolationList([
new ConstraintViolation('too short', 'a', [], 'c', 'shortDescription', ''),
new ConstraintViolation('too short', 'a', [], '3', 'shortDescription', ''),
new ConstraintViolation('too long', 'b', [], '3', 'product.shortDescription', 'Lorem ipsum dolor sit amet'),
new ConstraintViolation('error', 'b', [], '3', '', ''),
new ConstraintViolation('error', 'c', [], '3', '', ''),
]);

$expected = [
Expand All @@ -90,16 +92,19 @@ public function testNormalizeWithNameConverter()
[
'propertyPath' => 'short_description',
'title' => 'too short',
'template' => 'a',
'parameters' => [],
],
[
'propertyPath' => 'product.short_description',
'title' => 'too long',
'template' => 'b',
'parameters' => [],
],
[
'propertyPath' => '',
'title' => 'error',
'template' => 'c',
'parameters' => [],
],
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,15 @@

use PHPUnit\Framework\TestCase;
use Symfony\Component\ErrorHandler\Exception\FlattenException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
use Symfony\Component\Serializer\Exception\PartialDenormalizationException;
use Symfony\Component\Serializer\Normalizer\ConstraintViolationListNormalizer;
use Symfony\Component\Serializer\Normalizer\ProblemNormalizer;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
use Symfony\Component\Validator\Exception\ValidationFailedException;

class ProblemNormalizerTest extends TestCase
{
Expand Down Expand Up @@ -45,4 +53,56 @@ public function testNormalize()

$this->assertSame($expected, $this->normalizer->normalize(FlattenException::createFromThrowable(new \RuntimeException('Error'))));
}

public function testNormalizePartialDenormalizationException()
{
$this->normalizer->setSerializer(new Serializer());

$expected = [
'type' => 'https://symfony.com/errors/validation',
'title' => 'An error occurred',
'status' => 422,
'detail' => 'Unprocessable Content',
'violations' => [
[
'propertyPath' => 'foo',
'title' => 'This value should be of type int.',
'template' => 'This value should be of type {{ type }}.',
'parameters' => [
'{{ type }}' => 'int',
],
'hint' => 'Invalid value',
],
],
];

$exception = NotNormalizableValueException::createForUnexpectedDataType('Invalid value', null, ['int'], 'foo', true);
$exception = new PartialDenormalizationException('Validation Failed', [$exception]);
$exception = new HttpException(422, 'Validation Failed', $exception);
$this->assertSame($expected, $this->normalizer->normalize(FlattenException::createFromThrowable($exception), null, ['exception' => $exception]));
}

public function testNormalizeValidationFailedException()
{
$this->normalizer->setSerializer(new Serializer([new ConstraintViolationListNormalizer()]));

$expected = [
'type' => 'https://symfony.com/errors/validation',
'title' => 'Validation Failed',
'status' => 422,
'detail' => 'Unprocessable Content',
'violations' => [
[
'propertyPath' => '',
'title' => 'Invalid value',
'template' => '',
'parameters' => [],
],
],
];

$exception = new ValidationFailedException('Validation Failed', new ConstraintViolationList([new ConstraintViolation('Invalid value', '', [], '', null, null)]));
$exception = new HttpException(422, 'Validation Failed', $exception);
$this->assertSame($expected, $this->normalizer->normalize(FlattenException::createFromThrowable($exception), null, ['exception' => $exception]));
}
}