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
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions lang/de/validation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php

return [
'laya' => ':attribute ist nicht zulässig.',
];
5 changes: 5 additions & 0 deletions lang/en/validation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php

return [
'laya' => 'The :attribute is not acceptable.',
];
5 changes: 5 additions & 0 deletions lang/es/validation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php

return [
'laya' => 'El campo :attribute no es aceptable.',
];
5 changes: 5 additions & 0 deletions lang/fr/validation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php

return [
'laya' => 'Le champ :attribute n’est pas acceptable.',
];
5 changes: 5 additions & 0 deletions lang/it/validation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php

return [
'laya' => 'Il campo :attribute non è accettabile.',
];
5 changes: 5 additions & 0 deletions lang/ja/validation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php

return [
'laya' => ':attribute は許可されていません。',
];
5 changes: 5 additions & 0 deletions lang/nl/validation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php

return [
'laya' => 'Het veld :attribute is niet toegestaan.',
];
5 changes: 5 additions & 0 deletions lang/pl/validation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php

return [
'laya' => 'Pole :attribute jest niedozwolone.',
];
5 changes: 5 additions & 0 deletions lang/pt/validation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php

return [
'laya' => 'O campo :attribute não é aceitável.',
];
5 changes: 5 additions & 0 deletions lang/ru/validation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php

return [
'laya' => 'Поле :attribute недопустимо.',
];
5 changes: 5 additions & 0 deletions lang/tr/validation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php

return [
'laya' => ':attribute alanı kabul edilemez.',
];
5 changes: 5 additions & 0 deletions lang/zh_CN/validation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php

return [
'laya' => ':attribute 不可接受。',
];
2 changes: 2 additions & 0 deletions src/Laravel/LayaServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
}
}
95 changes: 95 additions & 0 deletions src/Laravel/Rules/Decides.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php

declare(strict_types=1);

namespace MarcReichel\Laya\Laravel\Rules;

use Closure;
use Illuminate\Container\Container;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Translation\Translator;
use MarcReichel\Laya\Answers\YesNoAnswer;
use MarcReichel\Laya\Exceptions\ServerBusyException;
use MarcReichel\Laya\Exceptions\TransportException;
use MarcReichel\Laya\Laya;
use MarcReichel\Laya\Question;

/**
* Validation rule backed by laya. Resolves Laya from the container at validation time, so Laya::fake() works.
*
* 'body' => ['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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{
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<T> $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');
}
}
108 changes: 108 additions & 0 deletions tests/DecidesRuleTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<?php

declare(strict_types=1);

use App\Decisions\Triage;
use Illuminate\Support\Facades\Validator;
use MarcReichel\Laya\Exceptions\ServerException;
use MarcReichel\Laya\Laravel\LayaServiceProvider;
use MarcReichel\Laya\Laravel\Rules\Decides;
use MarcReichel\Laya\Laya;
use Orchestra\Testbench\TestCase;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;

uses(TestCase::class);

require_once __DIR__.'/Fixtures/Decisions.php';

beforeEach(function () {
$this->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');
}
});
Loading