From d35f7b449988dd16d8ea7ffc8c6209697c12f12e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Niedzielski?= Date: Thu, 14 Nov 2024 13:42:08 +0100 Subject: [PATCH] Introduced StructValidator to unpack validation errors for structs --- src/bundle/Core/Resources/config/services.yml | 6 + .../AbstractValidationStructWrapper.php | 43 ++++++ src/contracts/Validation/StructValidator.php | 92 +++++++++++++ .../Validation/ValidationFailedException.php | 60 +++++++++ .../ValidatorStructWrapperInterface.php | 12 ++ tests/lib/Validation/StructValidatorTest.php | 123 ++++++++++++++++++ .../ValidationFailedExceptionTest.php | 60 +++++++++ 7 files changed, 396 insertions(+) create mode 100644 src/contracts/Validation/AbstractValidationStructWrapper.php create mode 100644 src/contracts/Validation/StructValidator.php create mode 100644 src/contracts/Validation/ValidationFailedException.php create mode 100644 src/contracts/Validation/ValidatorStructWrapperInterface.php create mode 100644 tests/lib/Validation/StructValidatorTest.php create mode 100644 tests/lib/Validation/ValidationFailedExceptionTest.php diff --git a/src/bundle/Core/Resources/config/services.yml b/src/bundle/Core/Resources/config/services.yml index 0af04b30a3..782a112a19 100644 --- a/src/bundle/Core/Resources/config/services.yml +++ b/src/bundle/Core/Resources/config/services.yml @@ -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' diff --git a/src/contracts/Validation/AbstractValidationStructWrapper.php b/src/contracts/Validation/AbstractValidationStructWrapper.php new file mode 100644 index 0000000000..d96085eafa --- /dev/null +++ b/src/contracts/Validation/AbstractValidationStructWrapper.php @@ -0,0 +1,43 @@ +struct = $struct; + } + + /** + * @phpstan-return T + */ + final public function getStruct(): object + { + return $this->struct; + } + + final public function getStructName(): string + { + return 'struct'; + } +} diff --git a/src/contracts/Validation/StructValidator.php b/src/contracts/Validation/StructValidator.php new file mode 100644 index 0000000000..d8f65f7959 --- /dev/null +++ b/src/contracts/Validation/StructValidator.php @@ -0,0 +1,92 @@ +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); + } +} diff --git a/src/contracts/Validation/ValidationFailedException.php b/src/contracts/Validation/ValidationFailedException.php new file mode 100644 index 0000000000..d7ca210e42 --- /dev/null +++ b/src/contracts/Validation/ValidationFailedException.php @@ -0,0 +1,60 @@ +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 + ); + } +} diff --git a/src/contracts/Validation/ValidatorStructWrapperInterface.php b/src/contracts/Validation/ValidatorStructWrapperInterface.php new file mode 100644 index 0000000000..9e02fff664 --- /dev/null +++ b/src/contracts/Validation/ValidatorStructWrapperInterface.php @@ -0,0 +1,12 @@ +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); + } +} diff --git a/tests/lib/Validation/ValidationFailedExceptionTest.php b/tests/lib/Validation/ValidationFailedExceptionTest.php new file mode 100644 index 0000000000..fbdfe3f02f --- /dev/null +++ b/tests/lib/Validation/ValidationFailedExceptionTest.php @@ -0,0 +1,60 @@ +__property_path__' is invalid: __error__", + $exception->getMessage(), + ); + self::assertSame($errors, $exception->getErrors()); + } + + public function testMultipleErrors(): void + { + $errors = new ConstraintViolationList([ + new ConstraintViolation('__error_1__', null, [], null, '__property_path_1__', null), + new ConstraintViolation('__error_2__', null, [], null, '__property_path_2__', null), + ]); + + $exception = new ValidationFailedException('__argument_name__', $errors); + + self::assertSame( + "Argument '__argument_name__->__property_path_1__' is invalid: __error_1__ and 1 more errors", + $exception->getMessage(), + ); + self::assertSame($errors, $exception->getErrors()); + } + + public function testEmptyErrorList(): void + { + $errors = new ConstraintViolationList([]); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf( + 'Cannot create %s with empty validation error list.', + ValidationFailedException::class, + )); + new ValidationFailedException('__argument_name__', $errors); + } +}