Skip to content

Commit f078c90

Browse files
committed
feat(valitation): add ability to specify translation keys for specific properties
1 parent bd1f916 commit f078c90

File tree

13 files changed

+205
-104
lines changed

13 files changed

+205
-104
lines changed

docs/2-features/03-validation.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ use Tempest\Support\Arr;
125125
$failures = $this->validator->validateValue('[email protected]', new Email());
126126

127127
// Map failures to their message
128-
$errors = Arr\map($failures, fn (Rule $failure) => $this->validator->getErrorMessage($failure));
128+
$errors = Arr\map($failures, fn (FailingRule $failure) => $this->validator->getErrorMessage($failure));
129129
```
130130

131131
You may also specify the field name of the validation failure to get a localized message for that field.
@@ -145,3 +145,17 @@ validation_error:
145145
.input {$field :string}
146146
{$field} must be a valid email address.
147147
```
148+
149+
Sometimes though, you may want to have a specific error message for a rule, without overriding the default translation message for that rule.
150+
151+
This can be done by using the {b`#[Tempest\Validation\TranslationKey]`} attribute on the property being validated. For instance, you may have the following object:
152+
153+
```php
154+
final class Book {
155+
#[Rules\HasLength(min: 5, max: 50)]
156+
#[TranslationKey('book_management.book_title')]
157+
public string $title;
158+
}
159+
```
160+
161+
When this rule fails, the `getErrorMessage()` method from the validator will use `validation_error.has_length.book_management.book_title` as the translation key, instead of `validation_error.has_length`.

packages/http/src/Responses/Invalid.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use Tempest\Http\Status;
1212
use Tempest\Intl\Translator;
1313
use Tempest\Support\Json;
14+
use Tempest\Validation\FailingRule;
1415
use Tempest\Validation\Rule;
1516
use Tempest\Validation\Validator;
1617

@@ -27,7 +28,7 @@ final class Invalid implements Response
2728

2829
public function __construct(
2930
Request $request,
30-
/** @var \Tempest\Validation\Rule[][] $failingRules */
31+
/** @var \Tempest\Validation\FailingRule[][] $failingRules */
3132
array $failingRules = [],
3233
) {
3334
if ($referer = $request->headers['referer'] ?? null) {
@@ -44,7 +45,7 @@ public function __construct(
4445
Json\encode(
4546
arr($failingRules)->map(
4647
fn (array $failingRulesForField) => arr($failingRulesForField)->map(
47-
fn (Rule $rule) => $this->validator->getErrorMessage($rule),
48+
fn (FailingRule $rule) => $this->validator->getErrorMessage($rule),
4849
)->toArray(),
4950
)->toArray(),
5051
),

packages/validation/src/Exceptions/PropertyValidationFailed.php

Lines changed: 0 additions & 35 deletions
This file was deleted.

packages/validation/src/Exceptions/ValidationFailed.php

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,20 @@
55
namespace Tempest\Validation\Exceptions;
66

77
use Exception;
8-
use Tempest\Validation\Rule;
8+
use Tempest\Validation\FailingRule;
99

1010
final class ValidationFailed extends Exception
1111
{
1212
/**
1313
* @template TKey of array-key
1414
*
15-
* @param array<TKey,Rule[]> $failingRules
15+
* @param array<TKey,FailingRule[]> $failingRules
1616
* @param array<TKey,string> $errorMessages
1717
*/
1818
public function __construct(
19-
public readonly array $failingRules,
20-
public readonly null|object|string $subject = null,
21-
public readonly array $errorMessages = [],
19+
private(set) array $failingRules,
20+
private(set) null|object|string $subject = null,
21+
private(set) array $errorMessages = [],
2222
) {
2323
parent::__construct(match (true) {
2424
is_null($subject) => 'Validation failed.',

packages/validation/src/Exceptions/ValueWasInvalid.php

Lines changed: 0 additions & 16 deletions
This file was deleted.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php
2+
3+
namespace Tempest\Validation;
4+
5+
/**
6+
* Represents a rule that failed during validation, including context about the failure.
7+
*/
8+
final readonly class FailingRule
9+
{
10+
/**
11+
* @param Rule $rule The rule that failed validation.
12+
* @param null|string $field The field name associated with the value that was validated and caused the failure.
13+
* @param mixed|null $value The value that was validated and caused the failure.
14+
* @param null|string $key An optional key associated with the value, used for localization.
15+
*/
16+
public function __construct(
17+
private(set) Rule $rule,
18+
private(set) ?string $field = null,
19+
private(set) mixed $value = null,
20+
private(set) ?string $key = null,
21+
) {}
22+
23+
/**
24+
* @param null|string $field The field name associated with the value that was validated and caused the failure.
25+
*/
26+
public function withField(?string $field): self
27+
{
28+
return new self(
29+
rule: $this->rule,
30+
field: $field,
31+
value: $this->value,
32+
key: $this->key,
33+
);
34+
}
35+
36+
/**
37+
* @param null|string $key An optional key associated with the value, used for localization.
38+
*/
39+
public function withKey(?string $key): self
40+
{
41+
return new self(
42+
rule: $this->rule,
43+
field: $this->field,
44+
value: $this->value,
45+
key: $key,
46+
);
47+
}
48+
49+
/**
50+
* @param null|mixed $value The value that was validated and caused the failure.
51+
*/
52+
public function withValue(mixed $value): self
53+
{
54+
return new self(
55+
rule: $this->rule,
56+
field: $this->field,
57+
value: $value,
58+
key: $this->key,
59+
);
60+
}
61+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
namespace Tempest\Validation;
4+
5+
use Attribute;
6+
7+
#[Attribute(Attribute::TARGET_PROPERTY)]
8+
final class TranslationKey
9+
{
10+
public function __construct(
11+
private(set) string $key,
12+
) {}
13+
}

packages/validation/src/Validator.php

Lines changed: 43 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -56,20 +56,20 @@ public function validateObject(object $object): void
5656
/**
5757
* Creates a {@see ValidationFailed} exception from the given rule failures, populated with error messages.
5858
*
59-
* @param array<string,Rule[]> $failingRules
59+
* @param array<string,FailingRule[]> $failingRules
6060
*/
6161
public function createValidationFailureException(array $failingRules, null|object|string $subject = null): ValidationFailed
6262
{
6363
return new ValidationFailed($failingRules, $subject, Arr\map_iterable($failingRules, function (array $rules, string $field) {
64-
return Arr\map_iterable($rules, fn (Rule $rule) => $this->getErrorMessage($rule, $field));
64+
return Arr\map_iterable($rules, fn (FailingRule $rule) => $this->getErrorMessage($rule, $field));
6565
}));
6666
}
6767

6868
/**
6969
* Validates the specified `$values` for the corresponding public properties on the specified `$class`, using built-in PHP types and attribute rules.
7070
*
7171
* @param ClassReflector|class-string $class
72-
* @return Rule[]
72+
* @return array<string,FailingRule[]>
7373
*/
7474
public function validateValuesForClass(ClassReflector|string $class, ?array $values, string $prefix = ''): array
7575
{
@@ -120,7 +120,7 @@ class: $property->getType()->asClass(),
120120
/**
121121
* Validates `$value` against the specified `$property`, using built-in PHP types and attribute rules.
122122
*
123-
* @return Rule[]
123+
* @return FailingRule[]
124124
*/
125125
public function validateValueForProperty(PropertyReflector $property, mixed $value): array
126126
{
@@ -143,14 +143,19 @@ public function validateValueForProperty(PropertyReflector $property, mixed $val
143143
$rules[] = new IsEnum($property->getType()->getName());
144144
}
145145

146-
return $this->validateValue($value, $rules);
146+
$key = $property->getAttribute(TranslationKey::class)?->key;
147+
148+
return Arr\map_iterable(
149+
array: $this->validateValue($value, $rules),
150+
map: fn (FailingRule $rule) => $rule->withKey($key),
151+
);
147152
}
148153

149154
/**
150155
* Validates the specified `$value` against the specified set of `$rules`. If a rule is a closure, it may return a string as a validation error.
151156
*
152157
* @param Rule|array<Rule|(Closure(mixed $value):string|false)>|(Closure(mixed $value):string|false) $rules
153-
* @return Rule[]
158+
* @return FailingRule[]
154159
*/
155160
public function validateValue(mixed $value, Closure|Rule|array $rules): array
156161
{
@@ -164,7 +169,7 @@ public function validateValue(mixed $value, Closure|Rule|array $rules): array
164169
$rule = $this->convertToRule($rule, $value);
165170

166171
if (! $rule->isValid($value)) {
167-
$failingRules[] = $rule;
172+
$failingRules[] = new FailingRule($rule, value: $value);
168173
}
169174
}
170175

@@ -201,21 +206,18 @@ public function validateValues(iterable $values, array $rules): array
201206
/**
202207
* Gets a localized validation error message for the specified rule.
203208
*/
204-
public function getErrorMessage(Rule $rule, ?string $field = null): string
209+
public function getErrorMessage(Rule|FailingRule $rule, ?string $field = null): string
205210
{
206211
if ($rule instanceof HasErrorMessage) {
207212
return $rule->getErrorMessage();
208213
}
209214

210-
$ruleTranslationKey = str($rule::class)
211-
->classBasename()
212-
->snake()
213-
->replaceEvery([ // those are snake case issues that we manually fix for consistency
214-
'i_pv6' => 'ipv6',
215-
'i_pv4' => 'ipv4',
216-
'reg_ex' => 'regex',
217-
])
218-
->toString();
215+
$ruleTranslationKey = $this->getTranslationKey($rule);
216+
217+
if ($rule instanceof FailingRule) {
218+
$field ??= $rule->field;
219+
$rule = $rule->rule;
220+
}
219221

220222
$variables = [
221223
'field' => $this->getFieldName($ruleTranslationKey, $field),
@@ -228,6 +230,30 @@ public function getErrorMessage(Rule $rule, ?string $field = null): string
228230
return $this->translator->translate("validation_error.{$ruleTranslationKey}", ...$variables);
229231
}
230232

233+
private function getTranslationKey(Rule|FailingRule $rule): string
234+
{
235+
$key = '';
236+
237+
if ($rule instanceof FailingRule && $rule->key) {
238+
$key .= $rule->key;
239+
}
240+
241+
if ($rule instanceof FailingRule) {
242+
$rule = $rule->rule;
243+
}
244+
245+
return str($rule::class)
246+
->classBasename()
247+
->snake()
248+
->replaceEvery([ // those are snake case issues that we manually fix for consistency
249+
'i_pv6' => 'ipv6',
250+
'i_pv4' => 'ipv4',
251+
'reg_ex' => 'regex',
252+
])
253+
->when($key !== '', fn ($s) => $s->append('.', $key))
254+
->toString();
255+
}
256+
231257
private function getFieldName(string $key, ?string $field = null): string
232258
{
233259
$translatedField = $this->translator->translate("validation_field.{$key}");

0 commit comments

Comments
 (0)