Skip to content

Commit 38217ee

Browse files
innocenzibrendt
authored andcommitted
feat(validator)!: add localization support for validation error messages (#1444)
1 parent ba2ef8a commit 38217ee

File tree

172 files changed

+2393
-1389
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

172 files changed

+2393
-1389
lines changed

docs/2-features/03-validation.md

Lines changed: 37 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,10 @@ While validation and [data mapping](./01-mapper) often work together, the two ar
1111

1212
## Validating against objects
1313

14-
When you have raw data and an associated model or data transfer object, you may use the `validateValuesForClass` method on the {b`\Tempest\Validation\Validator`}.
14+
When you have raw data and an associated model or data transfer object, you may use the `validateValuesForClass()` method on the {b`\Tempest\Validation\Validator`}. Note that the validator needs to be [resolved from the container](../1-essentials/05-container.md#injecting-dependencies).
1515

1616
```php
17-
use Tempest\Validation\Validator;
18-
19-
$validator = new Validator();
20-
$failingRules = $validator->validateValuesForClass(Book::class, [
17+
$failingRules = $this->validator->validateValuesForClass(Book::class, [
2118
'title' => 'Timeline Taxi',
2219
'description' => 'My sci-fi novel',
2320
'publishedAt' => '2024-10-01',
@@ -50,18 +47,20 @@ use Tempest\Validation\Rules;
5047

5148
final class Book
5249
{
53-
#[Rules\Length(min: 5, max: 50)]
50+
#[Rules\HasLength(min: 5, max: 50)]
5451
public string $title;
5552

56-
#[Rules\NotEmpty]
53+
#[Rules\IsNotEmptyString]
5754
public string $description;
5855

59-
#[Rules\DateTimeFormat('Y-m-d')]
56+
#[Rules\HasDateTimeFormat('Y-m-d')]
6057
public ?DateTime $publishedAt = null;
6158
}
6259
```
6360

61+
:::info
6462
A list of all available validation rules can be found on [GitHub](https://github.com/tempestphp/tempest-framework/tree/main/packages/validation/src/Rules).
63+
:::
6564

6665
### Skipping validation
6766

@@ -79,54 +78,70 @@ final class Book
7978

8079
## Validating against specific rules
8180

82-
If you don't have a model or data transfer object to validate data against, you may alternatively use the `validateValues` and provide an array of rules.
81+
If you don't have a model or data transfer object to validate data against, you may alternatively use the `validateValues()` and provide an array of rules.
8382

8483
```php
85-
$validator->validateValues([
84+
$this->validator->validateValues([
8685
'name' => 'Jon Doe',
8786
'email' => '[email protected]',
8887
'age' => 25,
8988
], [
90-
'name' => [new IsString(), new NotNull()],
91-
'email' => [new Email()],
92-
'age' => [new IsInteger(), new NotNull()],
89+
'name' => [new IsString(), new IsNotNull()],
90+
'email' => [new IsEmail()],
91+
'age' => [new IsInteger(), new IsNotNull()],
9392
]);
9493
```
9594

9695
If validation fails, `validateValues()` returns a list of fields and their respective failing rules.
9796

97+
:::info
9898
A list of all available validation rules can be found on [GitHub](https://github.com/tempestphp/tempest-framework/tree/main/packages/validation/src/Rules).
99+
:::
99100

100101
## Validating a single value
101102

102-
You may validate a single value against a set of rules using the `validateValue` method.
103+
You may validate a single value against a set of rules using the `validateValue()` method.
103104

104105
```php
105-
$validator->validateValue('[email protected]', [new Email()]);
106+
$this->validator->validateValue('[email protected]', [new IsEmail()]);
106107
```
107108

108109
Alternatively, you may provide a closure for validation. The closure should return `true` if validation passes, or `false` otherwise. You may also return a string to specify the validation failure message.
109110

110111
```php
111-
$validator->validateValue('[email protected]', function (mixed $value) {
112+
$this->validator->validateValue('[email protected]', function (mixed $value) {
112113
return str_contains($value, '@');
113114
});
114115
```
115116

116117
## Accessing error messages
117118

118-
When validation fails, a list of fields and their respective failing rules is returned. You may call the `message` method on any rule to get a validation message.
119+
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.
119120

120121
```php
121122
use Tempest\Support\Arr;
122123

123124
// Validate some value
124-
$failures = $validator->validateValue('[email protected]', new Email());
125+
$failures = $this->validator->validateValue('[email protected]', new Email());
125126

126127
// Map failures to their message
127-
$errors = Arr\map($failures, fn (Rule $failure) => $failure->message());
128+
$errors = Arr\map($failures, fn (Rule $failure) => $this->validator->getErrorMessage($failure));
128129
```
129130

130-
:::info
131-
Note that we expect to improve the way validation messages work in the future. See [this conversation](https://discord.com/channels/1236153076688359495/1294321824498323547/1294321824498323547) on our [Discord server](https://tempestphp.com/discord).
132-
:::
131+
You may also specify the field name of the validation failure to get a localized message for that field.
132+
133+
```php
134+
$this->validator->getErrorMessage($failure, 'email');
135+
// => 'Email must be a valid email address'
136+
```
137+
138+
## Overriding translation messages
139+
140+
You may override the default validation messages by adding a [translation file](../2-features/11-localization.md#defining-translation-messages) anywhere in your codebase. Note that Tempest uses the [MessageFormat 2.0](https://messageformat.unicode.org/) format for localization.
141+
142+
```php app/Localization/validation.en.yml
143+
validation_error:
144+
is_email: |
145+
.input {$field :string}
146+
{$field} must be a valid email address.
147+
```

packages/console/src/Components/InteractiveComponentRenderer.php

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@
1111
use Tempest\Console\InteractiveConsoleComponent;
1212
use Tempest\Console\Key;
1313
use Tempest\Console\Terminal\Terminal;
14+
use Tempest\Intl\Translator;
1415
use Tempest\Reflection\ClassReflector;
1516
use Tempest\Reflection\MethodReflector;
16-
use Tempest\Validation\Exceptions\ValueWasInvalid;
17-
use Tempest\Validation\Rule;
17+
use Tempest\Support\Arr;
1818
use Tempest\Validation\Validator;
1919

2020
use function Tempest\Support\arr;
@@ -27,6 +27,10 @@ final class InteractiveComponentRenderer
2727

2828
private bool $shouldRerender = true;
2929

30+
public function __construct(
31+
private readonly Validator $validator,
32+
) {}
33+
3034
public function render(Console $console, InteractiveConsoleComponent $component, array $validation = []): mixed
3135
{
3236
$clone = clone $this;
@@ -144,12 +148,12 @@ private function applyKey(InteractiveConsoleComponent $component, Console $conso
144148
// If something's returned, we'll need to validate the result
145149
$this->validationErrors = [];
146150

147-
$failingRule = $this->validate($return, $validation);
151+
$validationError = $this->validate($return, $validation);
148152

149153
// If invalid, we'll remember the validation message and continue
150-
if ($failingRule !== null) {
154+
if ($validationError !== null) {
151155
$component->setState(ComponentState::ERROR);
152-
$this->validationErrors[] = $failingRule->message();
156+
$this->validationErrors[] = $validationError;
153157
Fiber::suspend();
154158

155159
continue;
@@ -224,9 +228,17 @@ private function resolveHandlers(InteractiveConsoleComponent $component): array
224228
/**
225229
* @param \Tempest\Validation\Rule[] $validation
226230
*/
227-
private function validate(mixed $value, array $validation): ?Rule
231+
private function validate(mixed $value, array $validation): ?string
228232
{
229-
return new Validator()->validateValue($value, $validation)[0] ?? null;
233+
$failingRules = $this->validator->validateValue($value, $validation);
234+
235+
if ($failingRules === []) {
236+
return null;
237+
}
238+
239+
return $this->validator->getErrorMessage(
240+
rule: Arr\first($failingRules),
241+
);
230242
}
231243

232244
public function isComponentSupported(Console $console, InteractiveConsoleComponent $component): bool

packages/console/src/Middleware/InvalidCommandMiddleware.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@
1616
use Tempest\Core\Priority;
1717
use Tempest\Validation\Rules\IsBoolean;
1818
use Tempest\Validation\Rules\IsEnum;
19-
use Tempest\Validation\Rules\NotEmpty;
20-
use Tempest\Validation\Rules\Numeric;
19+
use Tempest\Validation\Rules\IsNotEmptyString;
20+
use Tempest\Validation\Rules\IsNumeric;
2121

2222
use function Tempest\Support\str;
2323

@@ -70,10 +70,10 @@ private function retry(Invocation $invocation, InvalidCommandException $exceptio
7070
validation: array_filter([
7171
$isEnum
7272
? new IsEnum($argument->type)
73-
: new NotEmpty(),
73+
: new IsNotEmptyString(),
7474
match ($argument->type) {
7575
'bool' => new IsBoolean(),
76-
'int' => new Numeric(),
76+
'int' => new IsNumeric(),
7777
default => null,
7878
},
7979
]),

packages/core/src/PublishesFiles.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
use Tempest\Support\Json;
2222
use Tempest\Support\Str\ImmutableString;
2323
use Tempest\Validation\Rules\EndsWith;
24-
use Tempest\Validation\Rules\NotEmpty;
24+
use Tempest\Validation\Rules\IsNotEmptyString;
2525
use Throwable;
2626

2727
use function strlen;
@@ -174,7 +174,7 @@ public function promptTargetPath(string $suggestedPath, ?array $rules = null): s
174174
$targetPath = $this->console->ask(
175175
question: sprintf('Where do you want to save the file <em>%s</em>?', $className),
176176
default: to_relative_path(root_path(), $suggestedPath),
177-
validation: $rules ?? [new NotEmpty(), new EndsWith('.php')],
177+
validation: $rules ?? [new IsNotEmptyString(), new EndsWith('.php')],
178178
);
179179

180180
return to_absolute_path(root_path(), $targetPath);

packages/database/src/Builder/ModelInspector.php

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ final class ModelInspector
3232

3333
private(set) object|string $instance;
3434

35+
private Validator $validator {
36+
get => get(Validator::class);
37+
}
38+
3539
public function __construct(
3640
private(set) object|string $model,
3741
) {
@@ -344,7 +348,6 @@ public function validate(mixed ...$data): void
344348
return;
345349
}
346350

347-
$validator = new Validator();
348351
$failingRules = [];
349352

350353
foreach ($data as $key => $value) {
@@ -354,7 +357,7 @@ public function validate(mixed ...$data): void
354357
continue;
355358
}
356359

357-
$failingRulesForProperty = $validator->validateValueForProperty(
360+
$failingRulesForProperty = $this->validator->validateValueForProperty(
358361
$property,
359362
$value,
360363
);
@@ -365,7 +368,7 @@ public function validate(mixed ...$data): void
365368
}
366369

367370
if ($failingRules !== []) {
368-
throw new ValidationFailed($this->reflector->getName(), $failingRules);
371+
throw new ValidationFailed($failingRules, $this->reflector->getName());
369372
}
370373
}
371374

packages/database/src/Commands/MakeMigrationCommand.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
use Tempest\Generation\Exceptions\FileGenerationFailedException;
1717
use Tempest\Generation\Exceptions\FileGenerationWasAborted;
1818
use Tempest\Validation\Rules\EndsWith;
19-
use Tempest\Validation\Rules\NotEmpty;
19+
use Tempest\Validation\Rules\IsNotEmptyString;
2020

2121
use function Tempest\Support\str;
2222

@@ -74,7 +74,7 @@ private function generateRawFile(
7474
->toString();
7575

7676
$targetPath = $this->promptTargetPath($suggestedPath, rules: [
77-
new NotEmpty(),
77+
new IsNotEmptyString(),
7878
new EndsWith('.sql'),
7979
]);
8080
$shouldOverride = $this->askForOverride($targetPath);

packages/database/src/Stubs/DatabaseModelStub.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@
55
namespace Tempest\Database\Stubs;
66

77
use Tempest\Database\IsDatabaseModel;
8-
use Tempest\Validation\Rules\Length;
8+
use Tempest\Validation\Rules\HasLength;
99

1010
final class DatabaseModelStub
1111
{
1212
use IsDatabaseModel;
1313

1414
public function __construct(
15-
#[Length(min: 1, max: 120)]
15+
#[HasLength(min: 1, max: 120)]
1616
public string $title,
1717
) {}
1818
}

packages/http/src/Mappers/PsrRequestToGenericRequestMapper.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
final readonly class PsrRequestToGenericRequestMapper implements Mapper
2222
{
2323
public function __construct(
24-
private readonly Encrypter $encrypter,
24+
private Encrypter $encrypter,
2525
) {}
2626

2727
public function canMap(mixed $from, mixed $to): bool

packages/http/src/Mappers/RequestToObjectMapper.php

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@
1919

2020
final readonly class RequestToObjectMapper implements Mapper
2121
{
22+
public function __construct(
23+
private Validator $validator,
24+
) {}
25+
2226
public function canMap(mixed $from, mixed $to): bool
2327
{
2428
return $from instanceof Request;
@@ -53,10 +57,10 @@ public function map(mixed $from, mixed $to): array|object
5357
];
5458
}
5559

56-
$failingRules = new Validator()->validateValuesForClass($to, $data);
60+
$failingRules = $this->validator->validateValuesForClass($to, $data);
5761

5862
if ($failingRules !== []) {
59-
throw new ValidationFailed($from, $failingRules);
63+
throw $this->validator->createValidationFailureException($failingRules, $from);
6064
}
6165

6266
return map($data)->with(ArrayToObjectMapper::class)->to($to);

packages/http/src/Responses/Invalid.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,22 @@
99
use Tempest\Http\Response;
1010
use Tempest\Http\Session\Session;
1111
use Tempest\Http\Status;
12+
use Tempest\Intl\Translator;
1213
use Tempest\Support\Json;
1314
use Tempest\Validation\Rule;
15+
use Tempest\Validation\Validator;
1416

17+
use function Tempest\get;
1518
use function Tempest\Support\arr;
1619

1720
final class Invalid implements Response
1821
{
1922
use IsResponse;
2023

24+
public Validator $validator {
25+
get => get(Validator::class);
26+
}
27+
2128
public function __construct(
2229
Request $request,
2330
/** @var \Tempest\Validation\Rule[][] $failingRules */
@@ -36,7 +43,7 @@ public function __construct(
3643
'x-validation',
3744
Json\encode(
3845
arr($failingRules)->map(fn (array $failingRulesForField) => arr($failingRulesForField)->map(
39-
fn (Rule $rule) => $rule->message(),
46+
fn (Rule $rule) => $this->validator->getErrorMessage($rule),
4047
)->toArray())->toArray(),
4148
),
4249
);

0 commit comments

Comments
 (0)