Skip to content
Open
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
23 changes: 23 additions & 0 deletions docs/2-features/03-validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,29 @@ $this->validator->validateValue('jon@doe.co', function (mixed $value) {
});
```

## Throwing validation failures manually

You may throw a {`Tempest\Validation\Exceptions\ValidationFailed`} exception with custom messages when validation depends on application logic instead of a validation rule.

```php app/PasskeyController.php
use Tempest\Validation\Exceptions\ValidationFailed;

throw ValidationFailed::withMessages([
'credential' => 'Passkey not valid',
]);
```

Multiple messages may be passed for the same field.

```php
throw ValidationFailed::withMessages([
'email' => [
'Email is already taken',
'Email domain is not allowed',
],
]);
```

## Accessing error messages

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.
Expand Down
28 changes: 28 additions & 0 deletions packages/validation/src/Exceptions/ValidationFailed.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Exception;
use Tempest\Validation\FailingRule;
use Tempest\Validation\Internal\MessageRule;

final class ValidationFailed extends Exception
{
Expand All @@ -27,4 +28,31 @@ public function __construct(
default => sprintf('Validation failed for %s.', is_object($subject) ? $subject::class : $subject),
});
}

/**
* @param array<string,string|list<string>> $messages
*/
public static function withMessages(array $messages, object|string|null $subject = null, ?string $targetClass = null): self
{
$failingRules = [];
$errorMessages = [];

foreach ($messages as $field => $messagesForField) {
$errorMessages[$field] = is_string($messagesForField) ? [$messagesForField] : $messagesForField;

foreach ($errorMessages[$field] as $message) {
$failingRules[$field][] = new FailingRule(
rule: new MessageRule($message),
field: $field,
);
}
}

return new self(
failingRules: $failingRules,
subject: $subject,
errorMessages: $errorMessages,
targetClass: $targetClass,
);
}
}
26 changes: 26 additions & 0 deletions packages/validation/src/Internal/MessageRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace Tempest\Validation\Internal;

use Tempest\Validation\HasErrorMessage;
use Tempest\Validation\Rule;

/** @internal */
final readonly class MessageRule implements Rule, HasErrorMessage
{
public function __construct(
private string $message,
) {}

public function isValid(mixed $value): bool
{
return false;
}

public function getErrorMessage(): string
{
return $this->message;
}
}
16 changes: 9 additions & 7 deletions packages/validation/src/Validator.php
Original file line number Diff line number Diff line change
Expand Up @@ -222,21 +222,23 @@ public function validateValues(iterable $values, array $rules): array
*/
public function getErrorMessage(Rule|FailingRule $rule, ?string $field = null): string
{
if (is_null($this->translator)) {
throw new TranslatorWasRequired();
$failingRule = $rule instanceof FailingRule ? $rule : null;

if ($failingRule instanceof FailingRule) {
$field ??= $failingRule->field;
$rule = $failingRule->rule;
}

if ($rule instanceof HasErrorMessage) {
return $rule->getErrorMessage();
}

$ruleTranslationKey = $this->getTranslationKey($rule);

if ($rule instanceof FailingRule) {
$field ??= $rule->field;
$rule = $rule->rule;
if (is_null($this->translator)) {
throw new TranslatorWasRequired();
}

$ruleTranslationKey = $this->getTranslationKey($failingRule ?? $rule);

$variables = [
'field' => $this->getFieldName($ruleTranslationKey, $field),
];
Expand Down
39 changes: 39 additions & 0 deletions packages/validation/tests/ValidatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,45 @@ public function test_closure_fails_with_string_response(): void
$this->assertSame('I expected b', $rule->getErrorMessage());
}

public function test_get_error_message_uses_custom_error_message_from_failing_rule(): void
{
$failingRules = $this->validator->validateValue('a', fn (mixed $_) => 'I expected b');

$this->assertSame('I expected b', $this->validator->getErrorMessage($failingRules[0]));
}

public function test_get_error_message_does_not_require_translator_for_custom_error_message(): void
{
$validator = new Validator();
$failingRules = $validator->validateValue('a', fn (mixed $_) => 'I expected b');

$this->assertSame('I expected b', $validator->getErrorMessage($failingRules[0]));
}

public function test_validation_failed_can_be_created_from_messages(): void
{
$validationFailed = ValidationFailed::withMessages([
'credential' => 'Passkey not valid',
'email' => [
'Email is already taken',
'Email domain is not allowed',
],
]);

$this->assertSame(
[
'credential' => ['Passkey not valid'],
'email' => ['Email is already taken', 'Email domain is not allowed'],
],
$validationFailed->errorMessages,
);
$this->assertCount(1, $validationFailed->failingRules['credential']);
$this->assertCount(2, $validationFailed->failingRules['email']);
$this->assertSame('Passkey not valid', $this->validator->getErrorMessage($validationFailed->failingRules['credential'][0]));
$this->assertSame('Email is already taken', $this->validator->getErrorMessage($validationFailed->failingRules['email'][0]));
$this->assertSame('Email domain is not allowed', $this->validator->getErrorMessage($validationFailed->failingRules['email'][1]));
}

public function test_closure_passes_with_null_response(): void
{
$validator = $this->validator;
Expand Down
13 changes: 13 additions & 0 deletions tests/Integration/Http/Exceptions/JsonExceptionRendererTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,19 @@ public function validation_failed(): void
$this->assertArrayHasKey('email', $response->body['errors']);
}

#[Test]
public function validation_failed_with_messages(): void
{
$response = $this->renderer->render(ValidationFailed::withMessages([
'credential' => 'Passkey not valid',
]));

$this->assertInstanceOf(Json::class, $response);
$this->assertSame(Status::UNPROCESSABLE_CONTENT, $response->status);
$this->assertSame('Passkey not valid', $response->body['message']);
$this->assertSame(['credential' => ['Passkey not valid']], $response->body['errors']);
}

#[Test]
public function access_denied_with_custom_message(): void
{
Expand Down
Loading