From 163ed0df98103e0fa3cfff5b969db2a6b71d6524 Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 25 Jun 2026 23:25:59 +0200 Subject: [PATCH] fix(validation): allow validation failures with messages --- docs/2-features/03-validation.md | 23 +++++++++++ .../src/Exceptions/ValidationFailed.php | 28 +++++++++++++ .../validation/src/Internal/MessageRule.php | 26 +++++++++++++ packages/validation/src/Validator.php | 16 ++++---- packages/validation/tests/ValidatorTest.php | 39 +++++++++++++++++++ .../Exceptions/JsonExceptionRendererTest.php | 13 +++++++ 6 files changed, 138 insertions(+), 7 deletions(-) create mode 100644 packages/validation/src/Internal/MessageRule.php diff --git a/docs/2-features/03-validation.md b/docs/2-features/03-validation.md index c91329cbcc..5bbfaed165 100644 --- a/docs/2-features/03-validation.md +++ b/docs/2-features/03-validation.md @@ -138,6 +138,29 @@ $this->validator->validateValue('jon@doe.co', function (mixed $value) { }); ``` +## Throwing validation failures manually + +You may throw a {`Tempest\Validation\Exceptions\ValidationFailed`} exception with custom messages when validation depends on application logic instead of a validation rule. + +```php app/PasskeyController.php +use Tempest\Validation\Exceptions\ValidationFailed; + +throw ValidationFailed::withMessages([ + 'credential' => 'Passkey not valid', +]); +``` + +Multiple messages may be passed for the same field. + +```php +throw ValidationFailed::withMessages([ + 'email' => [ + 'Email is already taken', + 'Email domain is not allowed', + ], +]); +``` + ## Accessing error messages When validation fails, a list of fields and their respective failing rules is returned. You may call the `getErrorMessage` method on the validator to get a [localized](./11-localization.md) validation message. diff --git a/packages/validation/src/Exceptions/ValidationFailed.php b/packages/validation/src/Exceptions/ValidationFailed.php index 1b85612436..b9b7e25cf9 100644 --- a/packages/validation/src/Exceptions/ValidationFailed.php +++ b/packages/validation/src/Exceptions/ValidationFailed.php @@ -6,6 +6,7 @@ use Exception; use Tempest\Validation\FailingRule; +use Tempest\Validation\Internal\MessageRule; final class ValidationFailed extends Exception { @@ -27,4 +28,31 @@ public function __construct( default => sprintf('Validation failed for %s.', is_object($subject) ? $subject::class : $subject), }); } + + /** + * @param array> $messages + */ + public static function withMessages(array $messages, object|string|null $subject = null, ?string $targetClass = null): self + { + $failingRules = []; + $errorMessages = []; + + foreach ($messages as $field => $messagesForField) { + $errorMessages[$field] = is_string($messagesForField) ? [$messagesForField] : $messagesForField; + + foreach ($errorMessages[$field] as $message) { + $failingRules[$field][] = new FailingRule( + rule: new MessageRule($message), + field: $field, + ); + } + } + + return new self( + failingRules: $failingRules, + subject: $subject, + errorMessages: $errorMessages, + targetClass: $targetClass, + ); + } } diff --git a/packages/validation/src/Internal/MessageRule.php b/packages/validation/src/Internal/MessageRule.php new file mode 100644 index 0000000000..9916e3a5bf --- /dev/null +++ b/packages/validation/src/Internal/MessageRule.php @@ -0,0 +1,26 @@ +message; + } +} diff --git a/packages/validation/src/Validator.php b/packages/validation/src/Validator.php index 6e60fb6edb..444a5a6f27 100644 --- a/packages/validation/src/Validator.php +++ b/packages/validation/src/Validator.php @@ -222,21 +222,23 @@ public function validateValues(iterable $values, array $rules): array */ public function getErrorMessage(Rule|FailingRule $rule, ?string $field = null): string { - if (is_null($this->translator)) { - throw new TranslatorWasRequired(); + $failingRule = $rule instanceof FailingRule ? $rule : null; + + if ($failingRule instanceof FailingRule) { + $field ??= $failingRule->field; + $rule = $failingRule->rule; } if ($rule instanceof HasErrorMessage) { return $rule->getErrorMessage(); } - $ruleTranslationKey = $this->getTranslationKey($rule); - - if ($rule instanceof FailingRule) { - $field ??= $rule->field; - $rule = $rule->rule; + if (is_null($this->translator)) { + throw new TranslatorWasRequired(); } + $ruleTranslationKey = $this->getTranslationKey($failingRule ?? $rule); + $variables = [ 'field' => $this->getFieldName($ruleTranslationKey, $field), ]; diff --git a/packages/validation/tests/ValidatorTest.php b/packages/validation/tests/ValidatorTest.php index d8f99cfe6b..130d314ceb 100644 --- a/packages/validation/tests/ValidatorTest.php +++ b/packages/validation/tests/ValidatorTest.php @@ -94,6 +94,45 @@ public function test_closure_fails_with_string_response(): void $this->assertSame('I expected b', $rule->getErrorMessage()); } + public function test_get_error_message_uses_custom_error_message_from_failing_rule(): void + { + $failingRules = $this->validator->validateValue('a', fn (mixed $_) => 'I expected b'); + + $this->assertSame('I expected b', $this->validator->getErrorMessage($failingRules[0])); + } + + public function test_get_error_message_does_not_require_translator_for_custom_error_message(): void + { + $validator = new Validator(); + $failingRules = $validator->validateValue('a', fn (mixed $_) => 'I expected b'); + + $this->assertSame('I expected b', $validator->getErrorMessage($failingRules[0])); + } + + public function test_validation_failed_can_be_created_from_messages(): void + { + $validationFailed = ValidationFailed::withMessages([ + 'credential' => 'Passkey not valid', + 'email' => [ + 'Email is already taken', + 'Email domain is not allowed', + ], + ]); + + $this->assertSame( + [ + 'credential' => ['Passkey not valid'], + 'email' => ['Email is already taken', 'Email domain is not allowed'], + ], + $validationFailed->errorMessages, + ); + $this->assertCount(1, $validationFailed->failingRules['credential']); + $this->assertCount(2, $validationFailed->failingRules['email']); + $this->assertSame('Passkey not valid', $this->validator->getErrorMessage($validationFailed->failingRules['credential'][0])); + $this->assertSame('Email is already taken', $this->validator->getErrorMessage($validationFailed->failingRules['email'][0])); + $this->assertSame('Email domain is not allowed', $this->validator->getErrorMessage($validationFailed->failingRules['email'][1])); + } + public function test_closure_passes_with_null_response(): void { $validator = $this->validator; diff --git a/tests/Integration/Http/Exceptions/JsonExceptionRendererTest.php b/tests/Integration/Http/Exceptions/JsonExceptionRendererTest.php index 292bdcd660..97eb0b5455 100644 --- a/tests/Integration/Http/Exceptions/JsonExceptionRendererTest.php +++ b/tests/Integration/Http/Exceptions/JsonExceptionRendererTest.php @@ -77,6 +77,19 @@ public function validation_failed(): void $this->assertArrayHasKey('email', $response->body['errors']); } + #[Test] + public function validation_failed_with_messages(): void + { + $response = $this->renderer->render(ValidationFailed::withMessages([ + 'credential' => 'Passkey not valid', + ])); + + $this->assertInstanceOf(Json::class, $response); + $this->assertSame(Status::UNPROCESSABLE_CONTENT, $response->status); + $this->assertSame('Passkey not valid', $response->body['message']); + $this->assertSame(['credential' => ['Passkey not valid']], $response->body['errors']); + } + #[Test] public function access_denied_with_custom_message(): void {