diff --git a/docs/2-features/03-validation.md b/docs/2-features/03-validation.md index ccf70eab1..62b3c4956 100644 --- a/docs/2-features/03-validation.md +++ b/docs/2-features/03-validation.md @@ -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', @@ -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 @@ -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' => 'jon@doe.co', '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('jon@doe.co', [new Email()]); +$this->validator->validateValue('jon@doe.co', [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('jon@doe.co', function (mixed $value) { +$this->validator->validateValue('jon@doe.co', 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('jon@doe.co', new Email()); +$failures = $this->validator->validateValue('jon@doe.co', 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. +``` diff --git a/packages/console/src/Components/InteractiveComponentRenderer.php b/packages/console/src/Components/InteractiveComponentRenderer.php index 92c65422a..4e117c7ca 100644 --- a/packages/console/src/Components/InteractiveComponentRenderer.php +++ b/packages/console/src/Components/InteractiveComponentRenderer.php @@ -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; @@ -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; @@ -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; @@ -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 diff --git a/packages/console/src/Middleware/InvalidCommandMiddleware.php b/packages/console/src/Middleware/InvalidCommandMiddleware.php index 9910f8fe2..bdebee5cd 100644 --- a/packages/console/src/Middleware/InvalidCommandMiddleware.php +++ b/packages/console/src/Middleware/InvalidCommandMiddleware.php @@ -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; @@ -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, }, ]), diff --git a/packages/core/src/PublishesFiles.php b/packages/core/src/PublishesFiles.php index 155232afc..c1a3c01ff 100644 --- a/packages/core/src/PublishesFiles.php +++ b/packages/core/src/PublishesFiles.php @@ -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; @@ -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 %s?', $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); diff --git a/packages/database/src/Builder/ModelInspector.php b/packages/database/src/Builder/ModelInspector.php index d824900d0..8fd3f9803 100644 --- a/packages/database/src/Builder/ModelInspector.php +++ b/packages/database/src/Builder/ModelInspector.php @@ -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, ) { @@ -344,7 +348,6 @@ public function validate(mixed ...$data): void return; } - $validator = new Validator(); $failingRules = []; foreach ($data as $key => $value) { @@ -354,7 +357,7 @@ public function validate(mixed ...$data): void continue; } - $failingRulesForProperty = $validator->validateValueForProperty( + $failingRulesForProperty = $this->validator->validateValueForProperty( $property, $value, ); @@ -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()); } } diff --git a/packages/database/src/Commands/MakeMigrationCommand.php b/packages/database/src/Commands/MakeMigrationCommand.php index 8f4672f34..91d56d7c7 100644 --- a/packages/database/src/Commands/MakeMigrationCommand.php +++ b/packages/database/src/Commands/MakeMigrationCommand.php @@ -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; @@ -74,7 +74,7 @@ private function generateRawFile( ->toString(); $targetPath = $this->promptTargetPath($suggestedPath, rules: [ - new NotEmpty(), + new IsNotEmptyString(), new EndsWith('.sql'), ]); $shouldOverride = $this->askForOverride($targetPath); diff --git a/packages/database/src/Stubs/DatabaseModelStub.php b/packages/database/src/Stubs/DatabaseModelStub.php index a6e37e357..88e723a35 100644 --- a/packages/database/src/Stubs/DatabaseModelStub.php +++ b/packages/database/src/Stubs/DatabaseModelStub.php @@ -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, ) {} } diff --git a/packages/http/src/Mappers/PsrRequestToGenericRequestMapper.php b/packages/http/src/Mappers/PsrRequestToGenericRequestMapper.php index 12f4ca66b..7bfd0d5cd 100644 --- a/packages/http/src/Mappers/PsrRequestToGenericRequestMapper.php +++ b/packages/http/src/Mappers/PsrRequestToGenericRequestMapper.php @@ -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 diff --git a/packages/http/src/Mappers/RequestToObjectMapper.php b/packages/http/src/Mappers/RequestToObjectMapper.php index 6b3dad6ee..6fc291d0a 100644 --- a/packages/http/src/Mappers/RequestToObjectMapper.php +++ b/packages/http/src/Mappers/RequestToObjectMapper.php @@ -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; @@ -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); diff --git a/packages/http/src/Responses/Invalid.php b/packages/http/src/Responses/Invalid.php index 295eaf611..15064994e 100644 --- a/packages/http/src/Responses/Invalid.php +++ b/packages/http/src/Responses/Invalid.php @@ -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 */ @@ -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(), ), ); diff --git a/packages/http/src/Stubs/RequestStub.php b/packages/http/src/Stubs/RequestStub.php index 6b598b8e6..a06270d48 100644 --- a/packages/http/src/Stubs/RequestStub.php +++ b/packages/http/src/Stubs/RequestStub.php @@ -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; } diff --git a/packages/intl/src/Catalog/CatalogInitializer.php b/packages/intl/src/Catalog/CatalogInitializer.php index 19fcb3139..504810ea2 100644 --- a/packages/intl/src/Catalog/CatalogInitializer.php +++ b/packages/intl/src/Catalog/CatalogInitializer.php @@ -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); } } } diff --git a/packages/intl/src/GenericTranslator.php b/packages/intl/src/GenericTranslator.php index 9ee18608d..338f5eb85 100644 --- a/packages/intl/src/GenericTranslator.php +++ b/packages/intl/src/GenericTranslator.php @@ -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, diff --git a/packages/intl/src/MessageFormat/Formatter/LocalVariable.php b/packages/intl/src/MessageFormat/Formatter/LocalVariable.php index bf2845b47..d4cfb9b5e 100644 --- a/packages/intl/src/MessageFormat/Formatter/LocalVariable.php +++ b/packages/intl/src/MessageFormat/Formatter/LocalVariable.php @@ -2,6 +2,7 @@ namespace Tempest\Intl\MessageFormat\Formatter; +use Tempest\Intl\MessageFormat\FormattingFunction; use Tempest\Intl\MessageFormat\SelectorFunction; final readonly class LocalVariable @@ -9,7 +10,8 @@ public function __construct( public string $identifier, public mixed $value, - public ?SelectorFunction $function = null, + public ?SelectorFunction $selector = null, + public ?FormattingFunction $formatter = null, public array $parameters = [], ) {} } diff --git a/packages/intl/src/MessageFormat/Formatter/MessageFormatter.php b/packages/intl/src/MessageFormat/Formatter/MessageFormatter.php index 13546bf60..444bd968a 100644 --- a/packages/intl/src/MessageFormat/Formatter/MessageFormatter.php +++ b/packages/intl/src/MessageFormat/Formatter/MessageFormatter.php @@ -82,14 +82,27 @@ private function formatMessage(MessageNode $message): string $variableName = $expression->variable->name->name; if (! array_key_exists($variableName, $this->variables)) { - throw new FormattingException("Required input variable `{$variableName}` not provided."); + if ($declaration->optional) { + $parameters = $this->evaluateOptions($expression->function?->options ?? []); + + $this->variables[$variableName] = new LocalVariable( + identifier: $variableName, + value: $parameters['default'], + selector: $this->getSelectorFunction((string) $expression->function?->identifier), + formatter: $this->getFormattingFunction((string) $expression->function?->identifier), + parameters: $parameters, + ); + } else { + throw new FormattingException("Required input variable `{$variableName}` not provided."); + } } if ($expression->function instanceof FunctionCall) { $this->variables[$variableName] = new LocalVariable( identifier: $variableName, value: $this->variables[$variableName]->value, - function: $this->getSelectorFunction((string) $expression->function->identifier), + selector: $this->getSelectorFunction((string) $expression->function->identifier), + formatter: $this->getFormattingFunction((string) $expression->function?->identifier), parameters: $this->evaluateOptions($expression->function->options), ); } @@ -99,7 +112,8 @@ function: $this->getSelectorFunction((string) $expression->function->identifier) $localVariables[$variableName] = new LocalVariable( identifier: $variableName, value: $this->evaluateExpression($declaration->expression)->value, - function: $this->getSelectorFunction($declaration->expression->function?->identifier), + selector: $this->getSelectorFunction($declaration->expression->function?->identifier), + formatter: $this->getFormattingFunction($declaration->expression->function?->identifier), parameters: $declaration->expression->attributes, ); } @@ -177,8 +191,8 @@ private function formatMatcher(Matcher $matcher): string $variantKey = $keyNode->value; $isMatch = false; - if ($variable->function) { - $isMatch = $variable->function->match($variantKey, $variable->value, $variable->parameters); + if ($variable->selector) { + $isMatch = $variable->selector->match($variantKey, $variable->value, $variable->parameters); } else { $isMatch = $variable->value === $variantKey; } @@ -248,6 +262,8 @@ private function formatPlaceholder(Placeholder $placeholder): string private function evaluateExpression(Expression $expression): FormattedValue { $value = null; + $formattingFunction = null; + $parameters = []; if ($expression instanceof LiteralExpression) { $value = $expression->literal->value; @@ -259,26 +275,31 @@ private function evaluateExpression(Expression $expression): FormattedValue } $value = $this->variables[$variableName]->value; + $formattingFunction = $this->variables[$variableName]->formatter; + $parameters = $this->variables[$variableName]->parameters; } elseif ($expression instanceof FunctionExpression) { $value = null; // Function-only expressions start with null } if ($expression->function !== null) { $functionName = (string) $expression->function->identifier; - $options = $this->evaluateOptions($expression->function->options); + $parameters = $this->evaluateOptions($expression->function->options); - if ($function = $this->getFormattingFunction($functionName)) { - return $function->format($value, $options); - } else { + if (is_null($formattingFunction = $this->getFormattingFunction($functionName))) { throw new FormattingException("Unknown function `{$functionName}`."); } } - $formatted = $value !== null ? ((string) $value) : ''; + if ($formattingFunction) { + return $formattingFunction->format($value, $parameters); + } - return new FormattedValue($value, $formatted); + return new FormattedValue($value, $value !== null ? ((string) $value) : ''); } + /** + * @param array