From edab39aff95aa2c12829b159fab4f9842c794cae Mon Sep 17 00:00:00 2001 From: matapatos Date: Fri, 30 May 2025 17:24:15 +0100 Subject: [PATCH] feat: Error handling --- src/Context.php | 18 ++ src/ErrorInfo.php | 14 +- .../ContinueValidationException.php | 9 + src/Exceptions/StopValidationException.php | 7 + src/Validator.php | 20 +- src/Validators/ChainValidator.php | 5 +- src/Validators/Types/AnyClass.php | 25 +- tests/Integration/ErrorHandlingTest.php | 244 ++++++++++++++++++ 8 files changed, 325 insertions(+), 17 deletions(-) create mode 100644 src/Exceptions/ContinueValidationException.php create mode 100644 src/Exceptions/StopValidationException.php create mode 100644 tests/Integration/ErrorHandlingTest.php diff --git a/src/Context.php b/src/Context.php index ee79d0a..cc2310f 100644 --- a/src/Context.php +++ b/src/Context.php @@ -53,6 +53,24 @@ public function has(string $propertyName): bool return array_key_exists($propertyName, $this->global); } + public function push(string $propertyName, mixed $value): void + { + if (! $this->has($propertyName)) { + $this->global[$propertyName] = []; + } + + $this->global[$propertyName][] = $value; + } + + public function pop(string $propertyName): mixed + { + if (! $this->has($propertyName)) { + return null; + } + + return array_pop($this->global[$propertyName]); + } + public function getAll(): array { return $this->global; diff --git a/src/ErrorInfo.php b/src/ErrorInfo.php index df341b0..271b575 100644 --- a/src/ErrorInfo.php +++ b/src/ErrorInfo.php @@ -27,6 +27,7 @@ use Attributes\Validation\Exceptions\ValidationException; use Exception; +use Respect\Validation\Exceptions\NestedValidationException as RespectNestedValidationException; class ErrorInfo { @@ -34,12 +35,9 @@ class ErrorInfo private array $errors = []; - private bool $rawExceptions; - - public function __construct(Context $context, bool $rawExceptions = false) + public function __construct(Context $context) { $this->context = $context; - $this->rawExceptions = $rawExceptions; } public function getErrors(): array @@ -62,7 +60,7 @@ public function hasErrors(): bool */ public function addError(Exception|string $error): void { - $propertyPath = $this->context->getOptional('propertyPath', []); + $propertyPath = $this->context->getOptional('internal.currentProperty', []); $errors = &$this->errors; foreach ($propertyPath as $property) { if (! isset($errors[$property])) { @@ -71,7 +69,11 @@ public function addError(Exception|string $error): void $errors = &$errors[$property]; } - $errors[] = $this->rawExceptions || is_string($error) ? $error : $error->getMessage(); + if ($error instanceof RespectNestedValidationException) { + $errors += array_values($error->getMessages()); + } else { + $errors[] = is_string($error) ? $error : $error->getMessage(); + } if ($this->context->get('option.stopFirstError')) { if (! is_string($error)) { diff --git a/src/Exceptions/ContinueValidationException.php b/src/Exceptions/ContinueValidationException.php new file mode 100644 index 0000000..1ee7c71 --- /dev/null +++ b/src/Exceptions/ContinueValidationException.php @@ -0,0 +1,9 @@ +validator = $this->context->getOptional(PropertyValidator::class, $validator) ?? $this->getDefaultPropertyValidator(); $this->context->set(PropertyValidator::class, $this->validator); - $factory = $this->context->getOptional(Factory::class, new Factory); + $factory = $this->context->getOptional(Factory::class) ?: new Factory; Factory::setDefaultInstance( $factory ->withRuleNamespace('Attributes\\Validation\\RulesExtractors\\Rules') @@ -65,16 +67,19 @@ public function validate(array $data, string|object $model): object $validModel = is_string($model) ? new $model : $model; $reflectionClass = new ReflectionClass($validModel); - $errorInfo = new ErrorInfo($this->context); - $this->context->set(ErrorInfo::class, $errorInfo); + $errorInfo = $this->context->getOptional(ErrorInfo::class) ?: new ErrorInfo($this->context); + $this->context->set(ErrorInfo::class, $errorInfo, override: true); foreach ($reflectionClass->getProperties() as $reflectionProperty) { $propertyName = $reflectionProperty->getName(); + $this->context->push('internal.currentProperty', $propertyName); if (! array_key_exists($propertyName, $data)) { if (! $reflectionProperty->isInitialized($validModel)) { $errorInfo->addError("Missing required property '$propertyName'"); } + $this->context->pop('internal.currentProperty'); + continue; } @@ -86,7 +91,16 @@ public function validate(array $data, string|object $model): object $this->validator->validate($property, $this->context); $reflectionProperty->setValue($validModel, $property->getValue()); } catch (ValidationException|RespectValidationException $error) { + if ($error->getMessage() == 'Invalid data' && $this->context->get('option.stopFirstError')) { + break; + } + $errorInfo->addError($error); + } catch (ContinueValidationException $error) { + } catch (StopValidationException $error) { + break; + } finally { + $this->context->pop('internal.currentProperty'); } } diff --git a/src/Validators/ChainValidator.php b/src/Validators/ChainValidator.php index 1c1eb6a..7022463 100644 --- a/src/Validators/ChainValidator.php +++ b/src/Validators/ChainValidator.php @@ -6,6 +6,7 @@ use Attributes\Validation\Context; use Attributes\Validation\ErrorInfo; +use Attributes\Validation\Exceptions\ContinueValidationException; use Attributes\Validation\Exceptions\ValidationException; use Attributes\Validation\Property; use Respect\Validation\Exceptions\ValidationException as RespectValidationException; @@ -20,6 +21,7 @@ class ChainValidator implements PropertyValidator * @param Property $property - Property to yield the rules from * * @throws ValidationException + * @throws ContinueValidationException - When errors do exist, and we still want to check the remaining data */ public function validate(Property $property, Context $context): void { @@ -29,11 +31,12 @@ public function validate(Property $property, Context $context): void $validator->validate($property, $context); } catch (ValidationException|RespectValidationException $error) { $errorInfo->addError($error); + } catch (ContinueValidationException $error) { } } if ($errorInfo->hasErrors()) { - throw new ValidationException('Invalid data', $errorInfo); + throw new ContinueValidationException('Continue validation'); } } diff --git a/src/Validators/Types/AnyClass.php b/src/Validators/Types/AnyClass.php index 4d88ef3..2f6fb66 100644 --- a/src/Validators/Types/AnyClass.php +++ b/src/Validators/Types/AnyClass.php @@ -10,6 +10,8 @@ use Attributes\Validation\Context; use Attributes\Validation\Exceptions\ContextPropertyException; +use Attributes\Validation\Exceptions\ContinueValidationException; +use Attributes\Validation\Exceptions\StopValidationException; use Attributes\Validation\Exceptions\ValidationException; use Attributes\Validation\Property; use Attributes\Validation\Validator; @@ -27,8 +29,9 @@ final class AnyClass implements BaseType * * @throws RespectValidationException - If not valid array * @throws ContextPropertyException - * @throws ValidationException * @throws ReflectionException + * @throws ContinueValidationException - When validation should keep running even when an error is encountered + * @throws StopValidationException - When we should stop when the first error is encountered */ public function validate(Property $property, Context $context): void { @@ -39,11 +42,19 @@ public function validate(Property $property, Context $context): void } v::arrayVal()->assert($value); - $clonedContext = clone $context; - $recursionLevel = $clonedContext->getOptional('internal.recursionLevel', 0); - $clonedContext->set('internal.recursionLevel', $recursionLevel + 1, override: true); - $validator = new Validator(context: $clonedContext); - $validModel = $validator->validate((array) $value, $typeHint); - $property->setValue($validModel); + $recursionLevel = $context->getOptional('internal.recursionLevel', 0); + $context->set('internal.recursionLevel', $recursionLevel + 1, override: true); + $validator = new Validator(context: $context); + try { + $validModel = $validator->validate((array) $value, $typeHint); + $property->setValue($validModel); + } catch (ValidationException) { + if ($context->get('option.stopFirstError')) { + throw new StopValidationException('Stop validation'); + } + throw new ContinueValidationException('Class validation failed'); + } finally { + $context->set('internal.recursionLevel', $recursionLevel, override: true); + } } } diff --git a/tests/Integration/ErrorHandlingTest.php b/tests/Integration/ErrorHandlingTest.php new file mode 100644 index 0000000..19b0343 --- /dev/null +++ b/tests/Integration/ErrorHandlingTest.php @@ -0,0 +1,244 @@ + 'invalid', + 'int' => 'invalid', + 'float' => 'invalid', + 'string' => ['invalid'], + 'array' => 'invalid', + 'object' => 'invalid', + ]; + try { + $validator->validate($rawData, new class + { + public bool $bool; + + public int $int; + + public float $float; + + public string $string; + + public array $array; + + public object $object; + }); + } catch (ValidationException $e) { + expect($e->getMessage()) + ->toBe('Invalid data') + ->and($e->getInfo()->getErrors()) + ->toBeArray() + ->toBe([ + 'bool' => ['"invalid" must be a boolean value'], + 'int' => ['"invalid" must be a finite number', '"invalid" must be numeric'], + 'float' => ['"invalid" must be a finite number', '"invalid" must be a float number'], + 'string' => ['`{ "invalid" }` must be a string'], + 'array' => ['"invalid" must be an array value'], + 'object' => ['"invalid" must be an array value'], + ]); + } +}) + ->group('validator', 'error-handling', 'basic'); + +test('Error handling - nested', function () { + $validator = new Validator; + $rawData = [ + 'profile' => [ + 'firstName' => 'profile.firstName', + 'lastName' => ['profile.lastName'], + 'post' => [ + 'id' => 'profile.post.id', + 'title' => ['profile.post.title'], + ], + ], + 'userType' => 'userType', + 'createdAt' => 'createdAt', + ]; + try { + $validator->validate($rawData, new Models\User); + } catch (ValidationException $e) { + expect($e->getMessage()) + ->toBe('Invalid data') + ->and($e->getInfo()->getErrors()) + ->toBeArray() + ->toBe([ + 'profile' => [ + 'lastName' => ['`{ "profile.lastName" }` must be a string'], + 'post' => [ + 'title' => ['`{ "profile.post.title" }` must be a string'], + ], + ], + 'userType' => ['"userType" must be in `{ "admin", "moderator", "guest" }`'], + 'createdAt' => ['"createdAt" must be a valid date/time in the format "2005-12-30T01:02:03+00:00"'], + ]); + } +}) + ->group('validator', 'error-handling', 'nested'); + +// Strict + +test('Error handling - strict basic', function () { + $validator = new Validator(strict: true); + $rawData = [ + 'bool' => 'invalid', + 'int' => 'invalid', + 'float' => 'invalid', + 'string' => ['invalid'], + 'array' => 'invalid', + 'object' => 'invalid', + ]; + try { + $validator->validate($rawData, new class + { + public bool $bool; + + public int $int; + + public float $float; + + public string $string; + + public array $array; + + public object $object; + }); + } catch (ValidationException $e) { + expect($e->getMessage()) + ->toBe('Invalid data') + ->and($e->getInfo()->getErrors()) + ->toBeArray() + ->toBe([ + 'bool' => ['"invalid" must be of type boolean'], + 'int' => ['"invalid" must be of type integer', '"invalid" must be a finite number'], + 'float' => ['"invalid" must be of type float', '"invalid" must be a finite number'], + 'string' => ['`{ "invalid" }` must be of type string'], + 'array' => ['"invalid" must be of type array'], + 'object' => ['"invalid" must be an array value'], + ]); + } +}) + ->group('validator', 'error-handling', 'basic', 'strict'); + +test('Error handling - strict nested', function () { + $validator = new Validator(strict: true); + $rawData = [ + 'profile' => [ + 'firstName' => 'profile.firstName', + 'lastName' => ['profile.lastName'], + 'post' => [ + 'id' => 'profile.post.id', + 'title' => ['profile.post.title'], + ], + ], + 'userType' => 'userType', + 'createdAt' => 'createdAt', + ]; + try { + $validator->validate($rawData, new Models\User); + } catch (ValidationException $e) { + expect($e->getMessage()) + ->toBe('Invalid data') + ->and($e->getInfo()->getErrors()) + ->toBeArray() + ->toBe([ + 'profile' => [ + 'lastName' => ['`{ "profile.lastName" }` must be of type string'], + 'post' => [ + 'title' => ['`{ "profile.post.title" }` must be of type string'], + ], + ], + 'userType' => ['"userType" must be in `{ "admin", "moderator", "guest" }`'], + 'createdAt' => ['"createdAt" must be a valid date/time in the format "2005-12-30T01:02:03+00:00"'], + ]); + } +}) + ->group('validator', 'error-handling', 'nested', 'strict'); + +// Stop first error + +test('Error handling - stop first error basic', function (bool $isStrict) { + $validator = new Validator(stopFirstError: true, strict: $isStrict); + $rawData = [ + 'bool' => 'invalid', + 'int' => 'invalid', + 'float' => 'invalid', + 'string' => ['invalid'], + 'array' => 'invalid', + 'object' => 'invalid', + ]; + try { + $validator->validate($rawData, new class + { + public bool $bool; + + public int $int; + + public float $float; + + public string $string; + + public array $array; + + public object $object; + }); + } catch (ValidationException $e) { + $expectedErrors = $isStrict ? ['"invalid" must be of type boolean'] : ['"invalid" must be a boolean value']; + expect($e->getMessage()) + ->toBe('Invalid data') + ->and($e->getInfo()->getErrors()) + ->toBeArray() + ->toBe(['bool' => $expectedErrors]); + } +}) + ->with([true, false]) + ->group('validator', 'error-handling', 'basic'); + +test('Error handling - stop first error nested', function (bool $isStrict) { + $validator = new Validator(stopFirstError: true, strict: $isStrict); + $rawData = [ + 'profile' => [ + 'firstName' => 'profile.firstName', + 'lastName' => ['profile.lastName'], + 'post' => [ + 'id' => 'profile.post.id', + 'title' => ['profile.post.title'], + ], + ], + 'userType' => 'userType', + 'createdAt' => 'createdAt', + ]; + try { + $validator->validate($rawData, new Models\User); + } catch (ValidationException $e) { + $expectedErrors = $isStrict ? ['`{ "profile.lastName" }` must be of type string'] : ['`{ "profile.lastName" }` must be a string']; + expect($e->getMessage()) + ->toBe('Invalid data') + ->and($e->getInfo()->getErrors()) + ->toBeArray() + ->toBe([ + 'profile' => [ + 'lastName' => $expectedErrors, + ], + ]); + } +}) + ->with([true, false]) + ->group('validator', 'error-handling', 'nested', 'strict');