Skip to content

Commit 2537d2a

Browse files
innocenzibrendt
authored andcommitted
feat(validation)!: add ability to specify translation keys for specific properties (#1618)
1 parent cfccb39 commit 2537d2a

File tree

12 files changed

+209
-108
lines changed

12 files changed

+209
-108
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
@@ -12,6 +12,7 @@
1212
use Tempest\Http\Status;
1313
use Tempest\Reflection\ClassReflector;
1414
use Tempest\Support\Json;
15+
use Tempest\Validation\FailingRule;
1516
use Tempest\Validation\Rule;
1617
use Tempest\Validation\Validator;
1718

@@ -31,7 +32,7 @@ final class Invalid implements Response
3132
*/
3233
public function __construct(
3334
Request $request,
34-
/** @var \Tempest\Validation\Rule[][] $failingRules */
35+
/** @var \Tempest\Validation\FailingRule[][] $failingRules */
3536
array $failingRules = [],
3637
?string $targetClass = null,
3738
) {
@@ -49,7 +50,7 @@ public function __construct(
4950
Json\encode(
5051
arr($failingRules)->map(
5152
fn (array $failingRulesForField) => arr($failingRulesForField)->map(
52-
fn (Rule $rule) => $this->validator->getErrorMessage($rule),
53+
fn (FailingRule $rule) => $this->validator->getErrorMessage($rule),
5354
)->toArray(),
5455
)->toArray(),
5556
),

packages/validation/src/Exceptions/PropertyValidationFailed.php

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

packages/validation/src/Exceptions/ValidationFailed.php

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,22 @@
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
* @param class-string|null $targetClass
1818
*/
1919
public function __construct(
20-
public readonly array $failingRules,
21-
public readonly null|object|string $subject = null,
22-
public readonly array $errorMessages = [],
23-
public readonly ?string $targetClass = null,
20+
private(set) array $failingRules,
21+
private(set) null|object|string $subject = null,
22+
private(set) array $errorMessages = [],
23+
private(set) ?string $targetClass = null,
2424
) {
2525
parent::__construct(match (true) {
2626
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: 47 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -55,26 +55,26 @@ public function validateObject(object $object): void
5555
/**
5656
* Creates a {@see ValidationFailed} exception from the given rule failures, populated with error messages.
5757
*
58-
* @param array<string,Rule[]> $failingRules
58+
* @param array<string,FailingRule[]> $failingRules
5959
* @param class-string|null $targetClass
6060
*/
6161
public function createValidationFailureException(array $failingRules, null|object|string $subject = null, ?string $targetClass = null): ValidationFailed
6262
{
6363
return new ValidationFailed(
64-
$failingRules,
65-
$subject,
66-
Arr\map_iterable($failingRules, function (array $rules, string $field) {
67-
return Arr\map_iterable($rules, fn (Rule $rule) => $this->getErrorMessage($rule, $field));
64+
failingRules: $failingRules,
65+
subject: $subject,
66+
errorMessages: Arr\map_iterable($failingRules, function (array $rules, string $field) {
67+
return Arr\map_iterable($rules, fn (FailingRule $rule) => $this->getErrorMessage($rule, $field));
6868
}),
69-
$targetClass,
69+
targetClass: $targetClass,
7070
);
7171
}
7272

7373
/**
7474
* Validates the specified `$values` for the corresponding public properties on the specified `$class`, using built-in PHP types and attribute rules.
7575
*
7676
* @param ClassReflector|class-string $class
77-
* @return Rule[]
77+
* @return array<string,FailingRule[]>
7878
*/
7979
public function validateValuesForClass(ClassReflector|string $class, ?array $values, string $prefix = ''): array
8080
{
@@ -125,7 +125,7 @@ class: $property->getType()->asClass(),
125125
/**
126126
* Validates `$value` against the specified `$property`, using built-in PHP types and attribute rules.
127127
*
128-
* @return Rule[]
128+
* @return FailingRule[]
129129
*/
130130
public function validateValueForProperty(PropertyReflector $property, mixed $value): array
131131
{
@@ -148,14 +148,19 @@ public function validateValueForProperty(PropertyReflector $property, mixed $val
148148
$rules[] = new IsEnum(enum: $property->getType()->getName(), orNull: $property->isNullable());
149149
}
150150

151-
return $this->validateValue($value, $rules);
151+
$key = $property->getAttribute(TranslationKey::class)?->key;
152+
153+
return Arr\map_iterable(
154+
array: $this->validateValue($value, $rules),
155+
map: fn (FailingRule $rule) => $rule->withKey($key),
156+
);
152157
}
153158

154159
/**
155160
* 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.
156161
*
157162
* @param Rule|array<Rule|(Closure(mixed $value):string|false)>|(Closure(mixed $value):string|false) $rules
158-
* @return Rule[]
163+
* @return FailingRule[]
159164
*/
160165
public function validateValue(mixed $value, Closure|Rule|array $rules): array
161166
{
@@ -169,7 +174,7 @@ public function validateValue(mixed $value, Closure|Rule|array $rules): array
169174
$rule = $this->convertToRule($rule, $value);
170175

171176
if (! $rule->isValid($value)) {
172-
$failingRules[] = $rule;
177+
$failingRules[] = new FailingRule($rule, value: $value);
173178
}
174179
}
175180

@@ -206,21 +211,18 @@ public function validateValues(iterable $values, array $rules): array
206211
/**
207212
* Gets a localized validation error message for the specified rule.
208213
*/
209-
public function getErrorMessage(Rule $rule, ?string $field = null): string
214+
public function getErrorMessage(Rule|FailingRule $rule, ?string $field = null): string
210215
{
211216
if ($rule instanceof HasErrorMessage) {
212217
return $rule->getErrorMessage();
213218
}
214219

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

225227
$variables = [
226228
'field' => $this->getFieldName($ruleTranslationKey, $field),
@@ -233,6 +235,30 @@ public function getErrorMessage(Rule $rule, ?string $field = null): string
233235
return $this->translator->translate("validation_error.{$ruleTranslationKey}", ...$variables);
234236
}
235237

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

0 commit comments

Comments
 (0)