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

Introduced StructValidator to unpack validation errors for structs #452

Merged
merged 12 commits into from
Nov 20, 2024
11 changes: 11 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,14 @@ services:
$entityManagers: '%doctrine.entity_managers%'

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

Ibexa\Contracts\Core\Validation\StructValidator:
arguments:
$validator: '@validator'

Ibexa\Contracts\Core\Validation\StructWrapperValidator:
decorates: 'validator'
# Decorator priority is higher than debug.validator to ensure profiler receives struct errors
decoration_priority: 500
arguments:
$inner: '@.inner'
36 changes: 36 additions & 0 deletions src/contracts/Validation/AbstractValidationStructWrapper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?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;

/**
* @template T of object
*/
abstract class AbstractValidationStructWrapper implements ValidationStructWrapperInterface
{
/**
* @phpstan-var T
*/
protected 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;
}
}
34 changes: 34 additions & 0 deletions src/contracts/Validation/StructValidator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?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 Symfony\Component\Validator\Validator\ValidatorInterface;

final class StructValidator
{
private ValidatorInterface $validator;

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

/**
* @throws \Ibexa\Contracts\Core\Validation\ValidationFailedException
*
* @param string[] $groups
*/
public function assertValidStruct(string $name, object $struct, array $groups): void
{
$errors = $this->validator->validate($struct, null, ['Default', ...$groups]);
if ($errors->count() > 0) {
throw new ValidationFailedException($name, $errors);
}
}
}
94 changes: 94 additions & 0 deletions src/contracts/Validation/StructWrapperValidator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?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 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 StructWrapperValidator 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 ValidationStructWrapperInterface) {
return $result;
}

$unwrappedErrors = new ConstraintViolationList();

foreach ($result as $error) {
$path = $error->getPropertyPath();
$root = $error->getRoot();
if (str_starts_with($path, 'struct.')) {
$path = substr($path, strlen('struct.'));
$root = $value->getStruct();
}

$unwrappedError = new ConstraintViolation(
$error->getMessage(),
$error->getMessageTemplate(),
$error->getParameters(),
$root,
$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
);
}
}
19 changes: 19 additions & 0 deletions src/contracts/Validation/ValidationStructWrapperInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?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;
Steveb-p marked this conversation as resolved.
Show resolved Hide resolved

use Symfony\Component\Validator\Constraints as Assert;

interface ValidationStructWrapperInterface
{
/**
* @Assert\Valid()
*/
public function getStruct(): object;
}
94 changes: 94 additions & 0 deletions tests/lib/Validation/StructValidatorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?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\Tests\Core\Validation;

use Ibexa\Contracts\Core\Validation\StructValidator;
use Ibexa\Contracts\Core\Validation\ValidationFailedException;
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();
$errors = $this->createMock(ConstraintViolationListInterface::class);
$errors->method('count')->willReturn(0);

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

$this->structValidator->assertValidStruct('struct', new stdClass(), ['group']);
}

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

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

try {
$this->structValidator->assertValidStruct('struct', new stdClass(), ['group']);
} catch (ValidationFailedException $e) {
self::assertSame("Argument 'struct->property' is invalid: validation error", $e->getMessage());
self::assertSame($errors, $e->getErrors());
}
}

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

private function createExampleConstraintViolationList(
ConstraintViolationInterface $error
): ConstraintViolationListInterface {
return new ConstraintViolationList([$error]);
}
}
Loading
Loading