Skip to content

Commit b1f21e4

Browse files
committed
fix(validation): allow validation failures with messages
1 parent fd2a739 commit b1f21e4

6 files changed

Lines changed: 139 additions & 7 deletions

File tree

docs/2-features/03-validation.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,29 @@ $this->validator->validateValue('jon@doe.co', function (mixed $value) {
138138
});
139139
```
140140

141+
## Throwing validation failures manually
142+
143+
You may throw a {`Tempest\Validation\Exceptions\ValidationFailed`} exception with custom messages when validation depends on application logic instead of a validation rule.
144+
145+
```php app/PasskeyController.php
146+
use Tempest\Validation\Exceptions\ValidationFailed;
147+
148+
throw ValidationFailed::withMessages([
149+
'credential' => 'Passkey not valid',
150+
]);
151+
```
152+
153+
Multiple messages may be passed for the same field.
154+
155+
```php
156+
throw ValidationFailed::withMessages([
157+
'email' => [
158+
'Email is already taken',
159+
'Email domain is not allowed',
160+
],
161+
]);
162+
```
163+
141164
## Accessing error messages
142165

143166
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.

packages/validation/src/Exceptions/ValidationFailed.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use Exception;
88
use Tempest\Validation\FailingRule;
9+
use Tempest\Validation\Rules\Message;
910

1011
final class ValidationFailed extends Exception
1112
{
@@ -27,4 +28,31 @@ public function __construct(
2728
default => sprintf('Validation failed for %s.', is_object($subject) ? $subject::class : $subject),
2829
});
2930
}
31+
32+
/**
33+
* @param array<string,string|list<string>> $messages
34+
*/
35+
public static function withMessages(array $messages, object|string|null $subject = null, ?string $targetClass = null): self
36+
{
37+
$failingRules = [];
38+
$errorMessages = [];
39+
40+
foreach ($messages as $field => $messagesForField) {
41+
$errorMessages[$field] = is_string($messagesForField) ? [$messagesForField] : $messagesForField;
42+
43+
foreach ($errorMessages[$field] as $message) {
44+
$failingRules[$field][] = new FailingRule(
45+
rule: new Message($message),
46+
field: $field,
47+
);
48+
}
49+
}
50+
51+
return new self(
52+
failingRules: $failingRules,
53+
subject: $subject,
54+
errorMessages: $errorMessages,
55+
targetClass: $targetClass,
56+
);
57+
}
3058
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Validation\Rules;
6+
7+
use Attribute;
8+
use Tempest\Validation\HasErrorMessage;
9+
use Tempest\Validation\Rule;
10+
11+
#[Attribute]
12+
final readonly class Message implements Rule, HasErrorMessage
13+
{
14+
public function __construct(
15+
private string $message,
16+
) {}
17+
18+
public function isValid(mixed $value): bool
19+
{
20+
return false;
21+
}
22+
23+
public function getErrorMessage(): string
24+
{
25+
return $this->message;
26+
}
27+
}

packages/validation/src/Validator.php

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -222,21 +222,23 @@ public function validateValues(iterable $values, array $rules): array
222222
*/
223223
public function getErrorMessage(Rule|FailingRule $rule, ?string $field = null): string
224224
{
225-
if (is_null($this->translator)) {
226-
throw new TranslatorWasRequired();
225+
$failingRule = $rule instanceof FailingRule ? $rule : null;
226+
227+
if ($failingRule instanceof FailingRule) {
228+
$field ??= $failingRule->field;
229+
$rule = $failingRule->rule;
227230
}
228231

229232
if ($rule instanceof HasErrorMessage) {
230233
return $rule->getErrorMessage();
231234
}
232235

233-
$ruleTranslationKey = $this->getTranslationKey($rule);
234-
235-
if ($rule instanceof FailingRule) {
236-
$field ??= $rule->field;
237-
$rule = $rule->rule;
236+
if (is_null($this->translator)) {
237+
throw new TranslatorWasRequired();
238238
}
239239

240+
$ruleTranslationKey = $this->getTranslationKey($failingRule ?? $rule);
241+
240242
$variables = [
241243
'field' => $this->getFieldName($ruleTranslationKey, $field),
242244
];

packages/validation/tests/ValidatorTest.php

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,45 @@ public function test_closure_fails_with_string_response(): void
9494
$this->assertSame('I expected b', $rule->getErrorMessage());
9595
}
9696

97+
public function test_get_error_message_uses_custom_error_message_from_failing_rule(): void
98+
{
99+
$failingRules = $this->validator->validateValue('a', fn (mixed $_) => 'I expected b');
100+
101+
$this->assertSame('I expected b', $this->validator->getErrorMessage($failingRules[0]));
102+
}
103+
104+
public function test_get_error_message_does_not_require_translator_for_custom_error_message(): void
105+
{
106+
$validator = new Validator();
107+
$failingRules = $validator->validateValue('a', fn (mixed $_) => 'I expected b');
108+
109+
$this->assertSame('I expected b', $validator->getErrorMessage($failingRules[0]));
110+
}
111+
112+
public function test_validation_failed_can_be_created_from_messages(): void
113+
{
114+
$validationFailed = ValidationFailed::withMessages([
115+
'credential' => 'Passkey not valid',
116+
'email' => [
117+
'Email is already taken',
118+
'Email domain is not allowed',
119+
],
120+
]);
121+
122+
$this->assertSame(
123+
[
124+
'credential' => ['Passkey not valid'],
125+
'email' => ['Email is already taken', 'Email domain is not allowed'],
126+
],
127+
$validationFailed->errorMessages,
128+
);
129+
$this->assertCount(1, $validationFailed->failingRules['credential']);
130+
$this->assertCount(2, $validationFailed->failingRules['email']);
131+
$this->assertSame('Passkey not valid', $this->validator->getErrorMessage($validationFailed->failingRules['credential'][0]));
132+
$this->assertSame('Email is already taken', $this->validator->getErrorMessage($validationFailed->failingRules['email'][0]));
133+
$this->assertSame('Email domain is not allowed', $this->validator->getErrorMessage($validationFailed->failingRules['email'][1]));
134+
}
135+
97136
public function test_closure_passes_with_null_response(): void
98137
{
99138
$validator = $this->validator;

tests/Integration/Http/Exceptions/JsonExceptionRendererTest.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,19 @@ public function validation_failed(): void
7777
$this->assertArrayHasKey('email', $response->body['errors']);
7878
}
7979

80+
#[Test]
81+
public function validation_failed_with_messages(): void
82+
{
83+
$response = $this->renderer->render(ValidationFailed::withMessages([
84+
'credential' => 'Passkey not valid',
85+
]));
86+
87+
$this->assertInstanceOf(Json::class, $response);
88+
$this->assertSame(Status::UNPROCESSABLE_CONTENT, $response->status);
89+
$this->assertSame('Passkey not valid', $response->body['message']);
90+
$this->assertSame(['credential' => ['Passkey not valid']], $response->body['errors']);
91+
}
92+
8093
#[Test]
8194
public function access_denied_with_custom_message(): void
8295
{

0 commit comments

Comments
 (0)