Skip to content

Commit

Permalink
Introduced StructValidator to unpack validation errors for structs
Browse files Browse the repository at this point in the history
  • Loading branch information
Steveb-p committed Nov 14, 2024
1 parent 9e313d2 commit d35f7b4
Show file tree
Hide file tree
Showing 7 changed files with 396 additions and 0 deletions.
6 changes: 6 additions & 0 deletions src/bundle/Core/Resources/config/services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -370,3 +370,9 @@ services:
$entityManagers: '%doctrine.entity_managers%'

Ibexa\Bundle\Core\Translation\Policy\PolicyTranslationDefinitionProvider: ~

Ibexa\Contracts\Core\Validation\StructValidator:
decorates: 'validator'
decoration_priority: -10
arguments:
$inner: '@.inner'
43 changes: 43 additions & 0 deletions src/contracts/Validation/AbstractValidationStructWrapper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
namespace Ibexa\Contracts\Core\Validation;

use Symfony\Component\Validator\Constraints as Assert;

/**
* @template T of object
*/
abstract class AbstractValidationStructWrapper implements ValidatorStructWrapperInterface
{
/**
* @phpstan-var T
*
* @Assert\Valid()
*/
private object $struct;

/**
* @phpstan-param T $struct
*/
public function __construct(object $struct)
{
$this->struct = $struct;
}

/**
* @phpstan-return T
*/
final public function getStruct(): object
{
return $this->struct;
}

final public function getStructName(): string
{
return 'struct';
}
}
92 changes: 92 additions & 0 deletions src/contracts/Validation/StructValidator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
namespace Ibexa\Contracts\Core\Validation;

use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
use Symfony\Component\Validator\ConstraintViolationListInterface;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Symfony\Component\Validator\Mapping\MetadataInterface;
use Symfony\Component\Validator\Validator\ContextualValidatorInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;

final class StructValidator implements ValidatorInterface
{
private ValidatorInterface $inner;

public function __construct(ValidatorInterface $inner)
{
$this->inner = $inner;
}

public function getMetadataFor($value): MetadataInterface
{
return $this->inner->getMetadataFor($value);
}

public function hasMetadataFor($value): bool
{
return $this->inner->hasMetadataFor($value);
}

public function validate($value, $constraints = null, $groups = null): ConstraintViolationListInterface
{
$result = $this->inner->validate($value, $constraints, $groups);

if (!$value instanceof ValidatorStructWrapperInterface) {
return $result;
}

$unwrappedErrors = new ConstraintViolationList();

// Skip $ from argument name
$prefix = ltrim($value->getStructName(), '$') . '.';
foreach ($result as $error) {
$path = $error->getPropertyPath();
if (str_starts_with($path, $prefix)) {
$path = substr($path, strlen($prefix));
}

$unwrappedError = new ConstraintViolation(
$error->getMessage(),
$error->getMessageTemplate(),
$error->getParameters(),
$error->getRoot(),
$path,
$error->getInvalidValue(),
$error->getPlural(),
$error->getCode(),
$error->getConstraint(),
$error->getCause()
);

$unwrappedErrors->add($unwrappedError);
}

return $unwrappedErrors;
}

public function validateProperty(object $object, string $propertyName, $groups = null): ConstraintViolationListInterface
{
return $this->inner->validatePropertyValue($object, $propertyName, $groups);
}

public function validatePropertyValue($objectOrClass, string $propertyName, $value, $groups = null): ConstraintViolationListInterface
{
return $this->inner->validatePropertyValue($objectOrClass, $propertyName, $groups);
}

public function startContext(): ContextualValidatorInterface
{
return $this->inner->startContext();
}

public function inContext(ExecutionContextInterface $context): ContextualValidatorInterface
{
return $this->inner->inContext($context);
}
}
60 changes: 60 additions & 0 deletions src/contracts/Validation/ValidationFailedException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
declare(strict_types=1);

namespace Ibexa\Contracts\Core\Validation;

use Exception;
use Ibexa\Contracts\Core\Repository\Exceptions\InvalidArgumentException;
use Symfony\Component\Validator\ConstraintViolationListInterface;

final class ValidationFailedException extends InvalidArgumentException
{
private ConstraintViolationListInterface $errors;

public function __construct(
string $argumentName,
ConstraintViolationListInterface $errors,
Exception $previous = null
) {
parent::__construct($this->createMessage($argumentName, $errors), 0, $previous);

$this->errors = $errors;
}

public function getErrors(): ConstraintViolationListInterface
{
return $this->errors;
}

private function createMessage(string $argumentName, ConstraintViolationListInterface $errors): string
{
if ($errors->count() === 0) {
throw new \InvalidArgumentException(sprintf(
'Cannot create %s with empty validation error list.',
self::class,
));
}

if ($errors->count() === 1) {
return sprintf(
"Argument '%s->%s' is invalid: %s",
$argumentName,
$errors->get(0)->getPropertyPath(),
$errors->get(0)->getMessage()
);
}

return sprintf(
"Argument '%s->%s' is invalid: %s and %d more errors",
$argumentName,
$errors->get(0)->getPropertyPath(),
$errors->get(0)->getMessage(),
$errors->count() - 1
);
}
}
12 changes: 12 additions & 0 deletions src/contracts/Validation/ValidatorStructWrapperInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
namespace Ibexa\Contracts\Core\Validation;

interface ValidatorStructWrapperInterface
{
public function getStructName(): string;
}
123 changes: 123 additions & 0 deletions tests/lib/Validation/StructValidatorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
namespace Ibexa\Tests\Core\Validation;

use Ibexa\Contracts\Core\Validation\StructValidator;
use Ibexa\Contracts\Core\Validation\ValidatorStructWrapperInterface;
use PHPUnit\Framework\TestCase;
use stdClass;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationInterface;
use Symfony\Component\Validator\ConstraintViolationList;
use Symfony\Component\Validator\ConstraintViolationListInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;

/**
* @covers \Ibexa\Contracts\Core\Validation\StructValidator
*/
final class StructValidatorTest extends TestCase
{
/** @var \Symfony\Component\Validator\Validator\ValidatorInterface&\PHPUnit\Framework\MockObject\MockObject */
private ValidatorInterface $validator;

private StructValidator $structValidator;

protected function setUp(): void
{
$this->validator = $this->createMock(ValidatorInterface::class);
$this->structValidator = new StructValidator($this->validator);
}

public function testAssertValidStructWithValidStruct(): void
{
$struct = new stdClass();
$initialErrors = $this->createMock(ConstraintViolationListInterface::class);
$initialErrors->method('count')->willReturn(0);

$this->validator
->expects(self::once())
->method('validate')
->with(
$struct,
null,
['Default', 'group']
)->willReturn($initialErrors);

$errors = $this->structValidator->validate(new stdClass(), null, ['Default', 'group']);
self::assertSame($initialErrors, $errors);
self::assertCount(0, $errors);
}

public function testAssertValidStructWithInvalidStruct(): void
{
$initialError = $this->createExampleConstraintViolation();
$initialErrors = $this->createExampleConstraintViolationList($initialError);

$this->validator
->method('validate')
->with(
new stdClass(),
null,
['Default', 'group']
)->willReturn($initialErrors);

$errors = $this->structValidator->validate(new stdClass(), null, ['Default', 'group']);
self::assertSame($initialErrors, $errors);
self::assertCount(1, $errors);

$error = $errors->get(0);
self::assertSame($initialError, $error);
self::assertEquals('validation error', $error->getMessage());
self::assertEquals('struct.property', $error->getPropertyPath());
}

public function testAssertValidStructWithInvalidWrapperStruct(): void
{
$initialError = $this->createExampleConstraintViolation();
$initialErrors = $this->createExampleConstraintViolationList($initialError);

$struct = $this->createMock(ValidatorStructWrapperInterface::class);
$struct->expects(self::once())
->method('getStructName')
->willReturn('$struct');

$this->validator
->method('validate')
->with(
$struct,
null,
['Default', 'group']
)->willReturn($initialErrors);

$errors = $this->structValidator->validate($struct, null, ['Default', 'group']);
self::assertNotSame($initialErrors, $errors);
self::assertCount(1, $errors);

$error = $errors->get(0);
self::assertNotSame($error, $initialError);
self::assertSame('validation error', $error->getMessage());
self::assertSame('property', $error->getPropertyPath());
}

private function createExampleConstraintViolation(): ConstraintViolationInterface
{
return new ConstraintViolation(
'validation error',
null,
[],
'',
'struct.property',
'example'
);
}

private function createExampleConstraintViolationList(
ConstraintViolationInterface ...$errors
): ConstraintViolationListInterface {
return new ConstraintViolationList($errors);
}
}
Loading

0 comments on commit d35f7b4

Please sign in to comment.