From 2c365137e1b468a56d78d431abd71fe9f7f19c3c Mon Sep 17 00:00:00 2001 From: Marc Reichel Date: Mon, 12 Oct 2026 22:51:29 +0200 Subject: [PATCH] Add a Decides validation rule backed by laya decisions, with translated default messages --- README.md | 30 ++++++++ lang/de/validation.php | 5 ++ lang/en/validation.php | 5 ++ lang/es/validation.php | 5 ++ lang/fr/validation.php | 5 ++ lang/it/validation.php | 5 ++ lang/ja/validation.php | 5 ++ lang/nl/validation.php | 5 ++ lang/pl/validation.php | 5 ++ lang/pt/validation.php | 5 ++ lang/ru/validation.php | 5 ++ lang/tr/validation.php | 5 ++ lang/zh_CN/validation.php | 5 ++ src/Laravel/LayaServiceProvider.php | 2 + src/Laravel/Rules/Decides.php | 95 ++++++++++++++++++++++++ tests/DecidesRuleTest.php | 108 ++++++++++++++++++++++++++++ 16 files changed, 295 insertions(+) create mode 100644 lang/de/validation.php create mode 100644 lang/en/validation.php create mode 100644 lang/es/validation.php create mode 100644 lang/fr/validation.php create mode 100644 lang/it/validation.php create mode 100644 lang/ja/validation.php create mode 100644 lang/nl/validation.php create mode 100644 lang/pl/validation.php create mode 100644 lang/pt/validation.php create mode 100644 lang/ru/validation.php create mode 100644 lang/tr/validation.php create mode 100644 lang/zh_CN/validation.php create mode 100644 src/Laravel/Rules/Decides.php create mode 100644 tests/DecidesRuleTest.php diff --git a/README.md b/README.md index a3e5832..810a42b 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,26 @@ ClassifyTicket::dispatchSync($ticket); $laya->assertPredictedCount(1); ``` +#### Validation rule + +`Decides` is a validation rule backed by a decision. It resolves `Laya` from the container, so `Laya::fake()` works in tests. + +```php +use MarcReichel\Laya\Laravel\Rules\Decides; + +$request->validate([ + 'body' => ['required', 'string', Decides::that(Moderation::class, fn (Moderation $m) => ! $m->abusive && ! $m->spam) + ->message('Please keep reviews friendly and on topic.')], + + // Single yes/no question: not() passes on "no", yes() on "yes" + 'comment' => [Decides::not('Does the text contain insults, hate speech or threats?')], +]); +``` + +The callback returns `true` when the value passes. Non-strings fail without a request. The default message is `validation.laya` (`:attribute` available); the package ships English, German, French, Spanish, Italian, Dutch, Portuguese, Polish, Turkish, Russian, Japanese and Chinese (`zh_CN`), and your app's own `validation.laya` wins. If laya-serve is unreachable or busy, the validation fails; add `->passWhenUnavailable()` to let the value pass instead. Other errors are thrown. + +In tests, `Laya::fake(['answer' => true])` answers the `not()`/`yes()` question. + ## Asking questions ```php @@ -526,6 +546,16 @@ if ($moderation->abusive || $moderation->spam) { } ``` +### Validating input + +Reject abusive or spam reviews at the form, using the `Moderation` class from above (Laravel). + +```php +$request->validate([ + 'body' => ['required', 'string', Decides::that(Moderation::class, fn (Moderation $m) => ! $m->abusive && ! $m->spam)], +]); +``` + ### Scoring leads A score question's `score` is the expected level, a float, so leads with the same most likely level still sort cleanly. diff --git a/lang/de/validation.php b/lang/de/validation.php new file mode 100644 index 0000000..0c42cb8 --- /dev/null +++ b/lang/de/validation.php @@ -0,0 +1,5 @@ + ':attribute ist nicht zulässig.', +]; diff --git a/lang/en/validation.php b/lang/en/validation.php new file mode 100644 index 0000000..eb71c24 --- /dev/null +++ b/lang/en/validation.php @@ -0,0 +1,5 @@ + 'The :attribute is not acceptable.', +]; diff --git a/lang/es/validation.php b/lang/es/validation.php new file mode 100644 index 0000000..8435e1e --- /dev/null +++ b/lang/es/validation.php @@ -0,0 +1,5 @@ + 'El campo :attribute no es aceptable.', +]; diff --git a/lang/fr/validation.php b/lang/fr/validation.php new file mode 100644 index 0000000..17086a8 --- /dev/null +++ b/lang/fr/validation.php @@ -0,0 +1,5 @@ + 'Le champ :attribute n’est pas acceptable.', +]; diff --git a/lang/it/validation.php b/lang/it/validation.php new file mode 100644 index 0000000..25d78a3 --- /dev/null +++ b/lang/it/validation.php @@ -0,0 +1,5 @@ + 'Il campo :attribute non è accettabile.', +]; diff --git a/lang/ja/validation.php b/lang/ja/validation.php new file mode 100644 index 0000000..08e5900 --- /dev/null +++ b/lang/ja/validation.php @@ -0,0 +1,5 @@ + ':attribute は許可されていません。', +]; diff --git a/lang/nl/validation.php b/lang/nl/validation.php new file mode 100644 index 0000000..62bd439 --- /dev/null +++ b/lang/nl/validation.php @@ -0,0 +1,5 @@ + 'Het veld :attribute is niet toegestaan.', +]; diff --git a/lang/pl/validation.php b/lang/pl/validation.php new file mode 100644 index 0000000..970b0b9 --- /dev/null +++ b/lang/pl/validation.php @@ -0,0 +1,5 @@ + 'Pole :attribute jest niedozwolone.', +]; diff --git a/lang/pt/validation.php b/lang/pt/validation.php new file mode 100644 index 0000000..f01fdca --- /dev/null +++ b/lang/pt/validation.php @@ -0,0 +1,5 @@ + 'O campo :attribute não é aceitável.', +]; diff --git a/lang/ru/validation.php b/lang/ru/validation.php new file mode 100644 index 0000000..e47bfcd --- /dev/null +++ b/lang/ru/validation.php @@ -0,0 +1,5 @@ + 'Поле :attribute недопустимо.', +]; diff --git a/lang/tr/validation.php b/lang/tr/validation.php new file mode 100644 index 0000000..16fb6b9 --- /dev/null +++ b/lang/tr/validation.php @@ -0,0 +1,5 @@ + ':attribute alanı kabul edilemez.', +]; diff --git a/lang/zh_CN/validation.php b/lang/zh_CN/validation.php new file mode 100644 index 0000000..1a784b9 --- /dev/null +++ b/lang/zh_CN/validation.php @@ -0,0 +1,5 @@ + ':attribute 不可接受。', +]; diff --git a/src/Laravel/LayaServiceProvider.php b/src/Laravel/LayaServiceProvider.php index 24bab77..732951c 100644 --- a/src/Laravel/LayaServiceProvider.php +++ b/src/Laravel/LayaServiceProvider.php @@ -96,6 +96,8 @@ public function boot(): void __DIR__.'/../../config/laya.php' => $this->app->configPath('laya.php'), ], 'laya-config'); + $this->loadTranslationsFrom(__DIR__.'/../../lang', 'laya'); + $this->commands([HealthCommand::class, TryCommand::class, EvalCommand::class]); } } diff --git a/src/Laravel/Rules/Decides.php b/src/Laravel/Rules/Decides.php new file mode 100644 index 0000000..20e5dd4 --- /dev/null +++ b/src/Laravel/Rules/Decides.php @@ -0,0 +1,95 @@ + ['required', 'string', Decides::that(Moderation::class, fn (Moderation $m) => ! $m->abusive)], + * 'body' => [Decides::not('Does the text contain insults, hate speech or threats?')], + */ +final class Decides implements ValidationRule +{ + private ?string $message = null; + + /** @param Closure(Laya, string): bool $passes */ + private function __construct(private readonly Closure $passes, private bool $passWhenUnavailable = false) {} + + /** + * Passes when $passes returns true for the decision laya makes about the value. + * + * @template T of object + * + * @param class-string $class + * @param Closure(T): bool $passes + */ + public static function that(string $class, Closure $passes): self + { + return new self(fn (Laya $laya, string $value): bool => $passes($laya->decide($value, $class))); + } + + /** Passes when laya answers "yes" to the question. */ + public static function yes(string $question): self + { + return new self(fn (Laya $laya, string $value): bool => self::answer($laya, $value, $question)->yes()); + } + + /** Passes when laya answers "no" to the question. */ + public static function not(string $question): self + { + return new self(fn (Laya $laya, string $value): bool => self::answer($laya, $value, $question)->no()); + } + + public function message(string $message): self + { + $this->message = $message; + + return $this; + } + + /** Let the value pass when laya-serve is unreachable or busy, instead of failing the validation. */ + public function passWhenUnavailable(): self + { + $this->passWhenUnavailable = true; + + return $this; + } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (is_string($value) && $this->passes($value)) { + return; + } + + /** @var Translator $translator */ + $translator = Container::getInstance()->make('translator'); + + $fail($this->message ?? ($translator->has('validation.laya') ? 'validation.laya' : 'laya::validation.laya'))->translate(); + } + + private function passes(string $value): bool + { + try { + return ($this->passes)(Container::getInstance()->make(Laya::class), $value); + } catch (TransportException|ServerBusyException) { + return $this->passWhenUnavailable; + } + } + + private static function answer(Laya $laya, string $value, string $question): YesNoAnswer + { + return $laya->predict($value, ['answer' => Question::yesNo($question)])->yesNo('answer'); + } +} diff --git a/tests/DecidesRuleTest.php b/tests/DecidesRuleTest.php new file mode 100644 index 0000000..5705d4f --- /dev/null +++ b/tests/DecidesRuleTest.php @@ -0,0 +1,108 @@ +app->register(LayaServiceProvider::class); +}); + +function validates(mixed $value, Decides $rule, array $rules = []): Illuminate\Validation\Validator +{ + return Validator::make(['body' => $value], ['body' => [...$rules, $rule]]); +} + +it('passes or fails on the decision object', function (bool $churn, bool $passes) { + $laya = Laya::fake(['department' => 'billing', 'urgency' => 1, 'churn' => $churn]); + + $rule = Decides::that(Triage::class, fn (Triage $t) => ! $t->churn); + + expect(validates('Billed twice', $rule)->passes())->toBe($passes); + $laya->assertDecided(Triage::class); +})->with([[false, true], [true, false]]); + +it('answers yes/no shorthands', function () { + Laya::fake(['answer' => true]); + expect(validates('You idiot', Decides::not('Insulting?'))->passes())->toBeFalse() + ->and(validates('You idiot', Decides::yes('Insulting?'))->passes())->toBeTrue(); + + Laya::fake(['answer' => false]); + expect(validates('Nice', Decides::not('Insulting?'))->passes())->toBeTrue() + ->and(validates('Nice', Decides::yes('Insulting?'))->passes())->toBeFalse(); +}); + +it('fails with the default message, translated and with the attribute', function () { + Laya::fake(['answer' => true]); + + expect(validates('x', Decides::not('Insulting?'))->errors()->first('body'))->toBe('The body is not acceptable.'); + + app()->setLocale('de'); + expect(validates('x', Decides::not('Insulting?'))->errors()->first('body'))->toBe('body ist nicht zulässig.'); +}); + +it('prefers the app\'s validation.laya', function () { + Laya::fake(['answer' => true]); + app('translator')->addLines(['validation.laya' => 'Custom :attribute'], 'en'); + + expect(validates('x', Decides::not('Insulting?'))->errors()->first('body'))->toBe('Custom body'); +}); + +it('takes a custom message', function () { + Laya::fake(['answer' => true]); + + expect(validates('x', Decides::not('Insulting?')->message('Keep it friendly.'))->errors()->first('body'))->toBe('Keep it friendly.'); +}); + +it('fails non-strings without a request', function () { + $laya = Laya::fake(['answer' => false]); + + expect(validates(['a'], Decides::not('Insulting?'))->passes())->toBeFalse(); + $laya->assertNothingPredicted(); +}); + +it('fails or passes when laya is unavailable', function (int $status, bool $passWhenUnavailable) { + $rule = Decides::not('Insulting?'); + app()->instance(Laya::class, layaRespondingWith($status, 'busy')); + + expect(validates('x', $passWhenUnavailable ? $rule->passWhenUnavailable() : $rule)->passes())->toBe($passWhenUnavailable); +})->with([[503, false], [503, true]]); + +it('fails or passes when laya is unreachable', function (bool $passWhenUnavailable) { + app()->instance(Laya::class, new Laya('http://laya.local', httpClient: new class implements ClientInterface + { + public function sendRequest(RequestInterface $request): ResponseInterface + { + throw new class('down') extends RuntimeException implements ClientExceptionInterface {}; + } + })); + $rule = Decides::not('Insulting?'); + + expect(validates('x', $passWhenUnavailable ? $rule->passWhenUnavailable() : $rule)->passes())->toBe($passWhenUnavailable); +})->with([false, true]); + +it('lets other laya errors through', function () { + app()->instance(Laya::class, layaRespondingWith(500, 'boom')); + + validates('x', Decides::not('Insulting?')->passWhenUnavailable())->passes(); +})->throws(ServerException::class); + +it('ships a message with :attribute for every language', function () { + foreach (glob(__DIR__.'/../lang/*/validation.php') as $file) { + expect((require $file)['laya'])->toContain(':attribute'); + } +});