Skip to content

Commit

Permalink
fix(validation): normalize constraint violation list (#5866)
Browse files Browse the repository at this point in the history
  • Loading branch information
soyuka committed Oct 5, 2023
1 parent 2080936 commit d42f00c
Show file tree
Hide file tree
Showing 11 changed files with 328 additions and 68 deletions.
33 changes: 33 additions & 0 deletions features/main/validation.feature
Expand Up @@ -73,6 +73,39 @@ Feature: Using validations groups
"""
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"

@createSchema
Scenario: Create a resource with serializedName property
When I add "Content-Type" header equal to "application/ld+json"
And I send a "POST" request to "dummy_validation_serialized_name" with body:
"""
{
"code": "My Dummy"
}
"""
Then the response status code should be 422
And the response should be in JSON
And the JSON should be equal to:
"""
{
"@id": "/validation_errors/ad32d13f-c3d4-423b-909a-857b961eb720",
"@type": "ConstraintViolationList",
"status": 422,
"violations": [
{
"propertyPath": "test",
"message": "This value should not be null.",
"code": "ad32d13f-c3d4-423b-909a-857b961eb720"
}
],
"hydra:title": "An error occurred",
"hydra:description": "title: This value should not be null.",
"type": "/validation_errors/ad32d13f-c3d4-423b-909a-857b961eb720",
"title": "An error occurred",
"detail": "title: This value should not be null."
}
"""
And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8"

@!mongodb
@createSchema
Scenario: Create a resource with collectDenormalizationErrors
Expand Down
38 changes: 38 additions & 0 deletions src/Hal/Serializer/ConstraintViolationListNormalizer.php
@@ -0,0 +1,38 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Hal\Serializer;

use ApiPlatform\Serializer\AbstractConstraintViolationListNormalizer;
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;

/**
* Converts {@see \Symfony\Component\Validator\ConstraintViolationListInterface} to a Hal error representation.
*/
final class ConstraintViolationListNormalizer extends AbstractConstraintViolationListNormalizer
{
public const FORMAT = 'json';

public function __construct(array $serializePayloadFields = null, NameConverterInterface $nameConverter = null)
{
parent::__construct($serializePayloadFields, $nameConverter);
}

/**
* {@inheritdoc}
*/
public function normalize(mixed $object, string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null
{
return $this->getViolations($object);
}
}
13 changes: 3 additions & 10 deletions src/Hydra/Serializer/ConstraintViolationListNormalizer.php
Expand Up @@ -26,7 +26,8 @@ final class ConstraintViolationListNormalizer extends AbstractConstraintViolatio
{
public const FORMAT = 'jsonld';

public function __construct(private readonly UrlGeneratorInterface $urlGenerator, array $serializePayloadFields = null, NameConverterInterface $nameConverter = null)
// @phpstan-ignore-next-line prevent BC break (can't remove this useless argument)
public function __construct(private readonly ?UrlGeneratorInterface $urlGenerator = null, array $serializePayloadFields = null, NameConverterInterface $nameConverter = null)
{
parent::__construct($serializePayloadFields, $nameConverter);
}
Expand All @@ -36,14 +37,6 @@ public function __construct(private readonly UrlGeneratorInterface $urlGenerator
*/
public function normalize(mixed $object, string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null
{
[$messages, $violations] = $this->getMessagesAndViolations($object);

return [
'@context' => $this->urlGenerator->generate('api_jsonld_context', ['shortName' => 'ConstraintViolationList']),
'@type' => 'ConstraintViolationList',
'hydra:title' => $context['title'] ?? 'An error occurred',
'hydra:description' => $messages ? implode("\n", $messages) : (string) $object,
'violations' => $violations,
];
return $this->getViolations($object);
}
}
Expand Up @@ -20,6 +20,7 @@
* Converts {@see \Symfony\Component\Validator\ConstraintViolationListInterface} the API Problem spec (RFC 7807).
*
* @see https://tools.ietf.org/html/rfc7807
* @deprecated this is not used anymore internally and will be removed in 4.0
*
* @author Kévin Dunglas <dunglas@gmail.com>
*/
Expand Down
46 changes: 46 additions & 0 deletions src/Serializer/AbstractConstraintViolationListNormalizer.php
Expand Up @@ -65,8 +65,54 @@ public function hasCacheableSupportsMethod(): bool
return true;
}

/**
* return string[].
*/
protected function getViolations(ConstraintViolationListInterface $constraintViolationList): array
{
$violations = [];

foreach ($constraintViolationList as $violation) {
$class = \is_object($root = $violation->getRoot()) ? $root::class : null;

if ($this->nameConverter instanceof AdvancedNameConverterInterface) {
$propertyPath = $this->nameConverter->normalize($violation->getPropertyPath(), $class, static::FORMAT);
} elseif ($this->nameConverter instanceof NameConverterInterface) {
$propertyPath = $this->nameConverter->normalize($violation->getPropertyPath());
} else {
$propertyPath = $violation->getPropertyPath();
}

$violationData = [
'propertyPath' => $propertyPath,
'message' => $violation->getMessage(),
'code' => $violation->getCode(),
];

if ($hint = $violation->getParameters()['hint'] ?? false) {
$violationData['hint'] = $hint;
}

$constraint = $violation instanceof ConstraintViolation ? $violation->getConstraint() : null;
if (
[] !== $this->serializePayloadFields
&& $constraint
&& $constraint->payload
// If some fields are whitelisted, only them are added
&& $payloadFields = null === $this->serializePayloadFields ? $constraint->payload : array_intersect_key($constraint->payload, $this->serializePayloadFields)
) {
$violationData['payload'] = $payloadFields;
}

$violations[] = $violationData;
}

return $violations;
}

protected function getMessagesAndViolations(ConstraintViolationListInterface $constraintViolationList): array
{
trigger_deprecation('api-platform', '3.2', sprintf('"%s::%s" will be removed in 4.0, use "%1$s::%s', __CLASS__, __METHOD__, 'getViolations'));
$violations = $messages = [];

foreach ($constraintViolationList as $violation) {
Expand Down
7 changes: 7 additions & 0 deletions src/Symfony/Bundle/Resources/config/hal.xml
Expand Up @@ -57,6 +57,13 @@
<!-- Run after serializer.denormalizer.array but before serializer.normalizer.object -->
<tag name="serializer.normalizer" priority="-995" />
</service>

<service id="api_platform.hal.normalizer.constraint_violation_list" class="ApiPlatform\Hal\Serializer\ConstraintViolationListNormalizer" public="false">
<argument>%api_platform.validator.serialize_payload_fields%</argument>
<argument type="service" id="api_platform.name_converter" on-invalid="ignore" />

<tag name="serializer.normalizer" priority="-780" />
</service>
</services>

</container>
22 changes: 2 additions & 20 deletions src/Symfony/Validator/Exception/ValidationException.php
Expand Up @@ -59,11 +59,6 @@ public function __construct(private readonly ConstraintViolationListInterface $c
parent::__construct($message ?: $this->__toString(), $code, $previous, $errorTitle);
}

public function getConstraintViolationList(): ConstraintViolationListInterface
{
return $this->constraintViolationList;
}

public function getId(): string
{
$ids = [];
Expand Down Expand Up @@ -148,21 +143,8 @@ public function getInstance(): ?string

#[SerializedName('violations')]
#[Groups(['json', 'jsonld', 'legacy_jsonld', 'legacy_jsonproblem', 'legacy_json'])]
public function getViolations(): iterable
public function getConstraintViolationList(): ConstraintViolationListInterface
{
foreach ($this->getConstraintViolationList() as $violation) {
$propertyPath = $violation->getPropertyPath();
$violationData = [
'propertyPath' => $propertyPath,
'message' => $violation->getMessage(),
'code' => $violation->getCode(),
];

if ($hint = $violation->getParameters()['hint'] ?? false) {
$violationData['hint'] = $hint;
}

yield $violationData;
}
return $this->constraintViolationList;
}
}
@@ -0,0 +1,84 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace Fixtures\TestBundle\Document;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM;
use Symfony\Component\Serializer\Annotation\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;

#[ApiResource(operations: [
new GetCollection(),
new Post(uriTemplate: 'dummy_validation_serialized_name'),
]
)]
#[ODM\Document]
class DummyValidationSerializedName
{
/**
* @var int|null The id
*/
#[ODM\Id(strategy: 'INCREMENT', type: 'int')]
private ?int $id = null;
/**
* @var string|null The dummy title
*/
#[ODM\Field(type: 'string', nullable: true)]
#[Assert\NotNull()]
#[SerializedName('test')]
private ?string $title = null;
/**
* @var string The dummy code
*/
#[ODM\Field(type: 'string')]
private string $code;

public function getId(): ?int
{
return $this->id;
}

public function setId(int $id): self
{
$this->id = $id;

return $this;
}

public function getTitle(): ?string
{
return $this->title;
}

public function setTitle(?string $title): self
{
$this->title = $title;

return $this;
}

public function getCode(): ?string
{
return $this->code;
}

public function setCode(string $code): self
{
$this->code = $code;

return $this;
}
}
86 changes: 86 additions & 0 deletions tests/Fixtures/TestBundle/Entity/DummyValidationSerializedName.php
@@ -0,0 +1,86 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;

#[ApiResource(operations: [
new GetCollection(),
new Post(uriTemplate: 'dummy_validation_serialized_name'),
]
)]
#[ORM\Entity]
class DummyValidationSerializedName
{
/**
* @var int|null The id
*/
#[ORM\Column(type: 'integer')]
#[ORM\Id]
#[ORM\GeneratedValue(strategy: 'AUTO')]
private ?int $id = null;
/**
* @var string|null The dummy title
*/
#[ORM\Column(nullable: true)]
#[Assert\NotNull()]
#[SerializedName('test')]
private ?string $title = null;
/**
* @var string The dummy code
*/
#[ORM\Column]
private string $code;

public function getId(): ?int
{
return $this->id;
}

public function setId(int $id): self
{
$this->id = $id;

return $this;
}

public function getTitle(): ?string
{
return $this->title;
}

public function setTitle(?string $title): self
{
$this->title = $title;

return $this;
}

public function getCode(): ?string
{
return $this->code;
}

public function setCode(string $code): self
{
$this->code = $code;

return $this;
}
}

0 comments on commit d42f00c

Please sign in to comment.