Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 37 additions & 22 deletions docs/2-features/03-validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,10 @@ While validation and [data mapping](./01-mapper) often work together, the two ar

## Validating against objects

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`}.
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).

```php
use Tempest\Validation\Validator;

$validator = new Validator();
$failingRules = $validator->validateValuesForClass(Book::class, [
$failingRules = $this->validator->validateValuesForClass(Book::class, [
'title' => 'Timeline Taxi',
'description' => 'My sci-fi novel',
'publishedAt' => '2024-10-01',
Expand Down Expand Up @@ -50,18 +47,20 @@ use Tempest\Validation\Rules;

final class Book
{
#[Rules\Length(min: 5, max: 50)]
#[Rules\HasLength(min: 5, max: 50)]
public string $title;

#[Rules\NotEmpty]
#[Rules\IsNotEmptyString]
public string $description;

#[Rules\DateTimeFormat('Y-m-d')]
#[Rules\HasDateTimeFormat('Y-m-d')]
public ?DateTime $publishedAt = null;
}
```

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

### Skipping validation

Expand All @@ -79,54 +78,70 @@ final class Book

## Validating against specific rules

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

```php
$validator->validateValues([
$this->validator->validateValues([
'name' => 'Jon Doe',
'email' => '[email protected]',
'age' => 25,
], [
'name' => [new IsString(), new NotNull()],
'email' => [new Email()],
'age' => [new IsInteger(), new NotNull()],
'name' => [new IsString(), new IsNotNull()],
'email' => [new IsEmail()],
'age' => [new IsInteger(), new IsNotNull()],
]);
```

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

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

## Validating a single value

You may validate a single value against a set of rules using the `validateValue` method.
You may validate a single value against a set of rules using the `validateValue()` method.

```php
$validator->validateValue('[email protected]', [new Email()]);
$this->validator->validateValue('[email protected]', [new IsEmail()]);
```

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.

```php
$validator->validateValue('[email protected]', function (mixed $value) {
$this->validator->validateValue('[email protected]', function (mixed $value) {
return str_contains($value, '@');
});
```

## Accessing error messages

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

```php
use Tempest\Support\Arr;

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

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

:::info
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).
:::
You may also specify the field name of the validation failure to get a localized message for that field.

```php
$this->validator->getErrorMessage($failure, 'email');
// => 'Email must be a valid email address'
```

## Overriding translation messages

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.

```php app/Localization/validation.en.yml
validation_error:
is_email: |
.input {$field :string}
{$field} must be a valid email address.
```
26 changes: 19 additions & 7 deletions packages/console/src/Components/InteractiveComponentRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@
use Tempest\Console\InteractiveConsoleComponent;
use Tempest\Console\Key;
use Tempest\Console\Terminal\Terminal;
use Tempest\Intl\Translator;
use Tempest\Reflection\ClassReflector;
use Tempest\Reflection\MethodReflector;
use Tempest\Validation\Exceptions\ValueWasInvalid;
use Tempest\Validation\Rule;
use Tempest\Support\Arr;
use Tempest\Validation\Validator;

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

private bool $shouldRerender = true;

public function __construct(
private readonly Validator $validator,
) {}

public function render(Console $console, InteractiveConsoleComponent $component, array $validation = []): mixed
{
$clone = clone $this;
Expand Down Expand Up @@ -144,12 +148,12 @@ private function applyKey(InteractiveConsoleComponent $component, Console $conso
// If something's returned, we'll need to validate the result
$this->validationErrors = [];

$failingRule = $this->validate($return, $validation);
$validationError = $this->validate($return, $validation);

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

continue;
Expand Down Expand Up @@ -224,9 +228,17 @@ private function resolveHandlers(InteractiveConsoleComponent $component): array
/**
* @param \Tempest\Validation\Rule[] $validation
*/
private function validate(mixed $value, array $validation): ?Rule
private function validate(mixed $value, array $validation): ?string
{
return new Validator()->validateValue($value, $validation)[0] ?? null;
$failingRules = $this->validator->validateValue($value, $validation);

if ($failingRules === []) {
return null;
}

return $this->validator->getErrorMessage(
rule: Arr\first($failingRules),
);
}

public function isComponentSupported(Console $console, InteractiveConsoleComponent $component): bool
Expand Down
8 changes: 4 additions & 4 deletions packages/console/src/Middleware/InvalidCommandMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
use Tempest\Core\Priority;
use Tempest\Validation\Rules\IsBoolean;
use Tempest\Validation\Rules\IsEnum;
use Tempest\Validation\Rules\NotEmpty;
use Tempest\Validation\Rules\Numeric;
use Tempest\Validation\Rules\IsNotEmptyString;
use Tempest\Validation\Rules\IsNumeric;

use function Tempest\Support\str;

Expand Down Expand Up @@ -70,10 +70,10 @@ private function retry(Invocation $invocation, InvalidCommandException $exceptio
validation: array_filter([
$isEnum
? new IsEnum($argument->type)
: new NotEmpty(),
: new IsNotEmptyString(),
match ($argument->type) {
'bool' => new IsBoolean(),
'int' => new Numeric(),
'int' => new IsNumeric(),
default => null,
},
]),
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/PublishesFiles.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
use Tempest\Support\Json;
use Tempest\Support\Str\ImmutableString;
use Tempest\Validation\Rules\EndsWith;
use Tempest\Validation\Rules\NotEmpty;
use Tempest\Validation\Rules\IsNotEmptyString;
use Throwable;

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

return to_absolute_path(root_path(), $targetPath);
Expand Down
9 changes: 6 additions & 3 deletions packages/database/src/Builder/ModelInspector.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ final class ModelInspector

private(set) object|string $instance;

private Validator $validator {
get => get(Validator::class);
}

public function __construct(
private(set) object|string $model,
) {
Expand Down Expand Up @@ -344,7 +348,6 @@ public function validate(mixed ...$data): void
return;
}

$validator = new Validator();
$failingRules = [];

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

$failingRulesForProperty = $validator->validateValueForProperty(
$failingRulesForProperty = $this->validator->validateValueForProperty(
$property,
$value,
);
Expand All @@ -365,7 +368,7 @@ public function validate(mixed ...$data): void
}

if ($failingRules !== []) {
throw new ValidationFailed($this->reflector->getName(), $failingRules);
throw new ValidationFailed($failingRules, $this->reflector->getName());
}
}

Expand Down
4 changes: 2 additions & 2 deletions packages/database/src/Commands/MakeMigrationCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
use Tempest\Generation\Exceptions\FileGenerationFailedException;
use Tempest\Generation\Exceptions\FileGenerationWasAborted;
use Tempest\Validation\Rules\EndsWith;
use Tempest\Validation\Rules\NotEmpty;
use Tempest\Validation\Rules\IsNotEmptyString;

use function Tempest\Support\str;

Expand Down Expand Up @@ -74,7 +74,7 @@ private function generateRawFile(
->toString();

$targetPath = $this->promptTargetPath($suggestedPath, rules: [
new NotEmpty(),
new IsNotEmptyString(),
new EndsWith('.sql'),
]);
$shouldOverride = $this->askForOverride($targetPath);
Expand Down
4 changes: 2 additions & 2 deletions packages/database/src/Stubs/DatabaseModelStub.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@
namespace Tempest\Database\Stubs;

use Tempest\Database\IsDatabaseModel;
use Tempest\Validation\Rules\Length;
use Tempest\Validation\Rules\HasLength;

final class DatabaseModelStub
{
use IsDatabaseModel;

public function __construct(
#[Length(min: 1, max: 120)]
#[HasLength(min: 1, max: 120)]
public string $title,
) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
final readonly class PsrRequestToGenericRequestMapper implements Mapper
{
public function __construct(
private readonly Encrypter $encrypter,
private Encrypter $encrypter,
) {}

public function canMap(mixed $from, mixed $to): bool
Expand Down
8 changes: 6 additions & 2 deletions packages/http/src/Mappers/RequestToObjectMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@

final readonly class RequestToObjectMapper implements Mapper
{
public function __construct(
private Validator $validator,
) {}

public function canMap(mixed $from, mixed $to): bool
{
return $from instanceof Request;
Expand Down Expand Up @@ -53,10 +57,10 @@ public function map(mixed $from, mixed $to): array|object
];
}

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

if ($failingRules !== []) {
throw new ValidationFailed($from, $failingRules);
throw $this->validator->createValidationFailureException($failingRules, $from);
}

return map($data)->with(ArrayToObjectMapper::class)->to($to);
Expand Down
9 changes: 8 additions & 1 deletion packages/http/src/Responses/Invalid.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,22 @@
use Tempest\Http\Response;
use Tempest\Http\Session\Session;
use Tempest\Http\Status;
use Tempest\Intl\Translator;
use Tempest\Support\Json;
use Tempest\Validation\Rule;
use Tempest\Validation\Validator;

use function Tempest\get;
use function Tempest\Support\arr;

final class Invalid implements Response
{
use IsResponse;

public Validator $validator {
get => get(Validator::class);
}

public function __construct(
Request $request,
/** @var \Tempest\Validation\Rule[][] $failingRules */
Expand All @@ -36,7 +43,7 @@ public function __construct(
'x-validation',
Json\encode(
arr($failingRules)->map(fn (array $failingRulesForField) => arr($failingRulesForField)->map(
fn (Rule $rule) => $rule->message(),
fn (Rule $rule) => $this->validator->getErrorMessage($rule),
)->toArray())->toArray(),
),
);
Expand Down
4 changes: 2 additions & 2 deletions packages/http/src/Stubs/RequestStub.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@

use Tempest\Http\IsRequest;
use Tempest\Http\Request;
use Tempest\Validation\Rules\Length;
use Tempest\Validation\Rules\HasLength;

final class RequestStub implements Request
{
use IsRequest;

#[Length(min: 10, max: 120)]
#[HasLength(min: 10, max: 120)]
public string $title;
}
4 changes: 2 additions & 2 deletions packages/intl/src/Catalog/CatalogInitializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ public function initialize(Container $container): Catalog
Str\ends_with($path, ['.yaml', '.yml']) => Yaml::parse($contents),
};

foreach (Arr\undot($messages) as $key => $message) {
$catalog[$locale][$key] = $message;
foreach (Arr\dot($messages) as $key => $message) {
$catalog[$locale] = Arr\set_by_key($catalog[$locale], $key, $message);
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/intl/src/GenericTranslator.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public function translateForLocale(Locale $locale, string $key, mixed ...$argume
}

try {
return $this->formatter->format($message, ...$arguments);
return $this->formatter->format(mb_trim($message), ...$arguments);
} catch (\Throwable $exception) {
$this->eventBus?->dispatch(new TranslationFailure(
locale: $locale,
Expand Down
Loading
Loading