LoginSignup
42
26

More than 1 year has passed since last update.

【Laravel】 ポリシーをバリデーションに活用する

Last updated at Posted at 2019-12-21

【2021/10/15 追記】現在の自分の思想としてはこのアーキテクチャを推奨しておりません!

❓背景

Laravel のバリデーションですが,標準機能としては

  • 静的に判定可能なフォーマット系のバリデーション
  • 入力値同士の大小・前後関係のバリデーション
  • データベースの重複を見るユニークバリデーション
    (PresenceVerifierInterface によってデータベースパッケージとは疎結合な形で提供される)

の,3パターンぐらいしか用意されていません。ここには重要なものが欠けています…そう,認可を使ったバリデーションが無いのです。

「もしログイン中ユーザが○○だったら,このフィールドの編集を許可する」

愚直な方法を採るならクロージャ形式で自分で書くことが考えられます。しかし,せっかくならできるだけ宣言的に書きたいところです。この記事では,認可の仕組みをバリデーションに転用する方法を考えてみます。

💡認可バリデーションの導入

例えば,管理画面におけるユーザの登録・編集用のコントローラを考えてみましょう。すでに登録されている管理者が新たな管理者を登録したり,既存の管理者を編集したりするためのコントローラです。

ユーザモデルに関して,以下の3フィールドの存在を想定します。

  • role … 権限
    • admin … 全員に関する読み書きがすべてできる
    • write … 全員に関する読み取り,自分に関する書き込み,他者に関する一部の書き込みができる
    • read … 全員に関する読み取り,自分に関する書き込みができる
  • name … 氏名
  • memo … 運用上のメモ

認可処理の定義

作成に関する認可

  • role
    • 新規のユーザ登録は,自分が admin write である場合のみ行える
      (アクション自体を禁止する)
    • 自分と権限が同じか,それ以下の権限のユーザのみ発行することができる
      (アクションの内容をバリデーションする)
  • name
    • 論理的制約はなし
  • memo
    • 論理的制約はなし

認可が絡むものが2つありますが,両者はカッコ書きで書いたとおり,大きく性質が異なることに注意してください。

更新に関する認可

続いて,更新処理も同様にユースケースを想定します。

  • role
    • admin は,自分自身以外の権限を変更することができる
      (管理者不在になることを防ぐため)
    • write は,自分の権限を read降格させることのみ できる
    • read は,一切の変更ができない
  • name
    • admin は,全員の name フィールドを編集することができる
    • write read は,自分の name フィールドのみ編集できる
  • memo
    • admin write は,全員の memo フィールドを編集することができる
    • read は,自分の memo フィールドのみ編集できる

非常に複雑な要件ですが, BtoB アプリ作ってるとありそうですよね。
(実際に自分がこれに遭遇しました)

コントローラにベタ書き

まず最も愚直にコントローラにベタ書きする例を見てみましょう。

<?php

namespace App\Http\Controllers;

use App\Http\Resource\User as UserResource;
use App\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;

class UserController extends Controller
{
    public function store(Request $request): UserResource
    {
        if (!in_array($request->user()->role, ['admin', 'write'], true)) {
            throw new AccessDeniedHttpException('新規ユーザ発行には write 権限以上が必要です。');
        }

        $inputs = [
            'role' => $request->input('role'),
            'name' => $request->input('name'),
            'memo' => $request->input('memo'),
        ];

        $rules = [
            'role' => [
                'required',
                Rule::in(['admin', 'write', 'read']),
                function ($attribute, $value, $fail) use ($request) {
                    if ($request->user()->role !== 'admin' && $value === 'admin') {
                        $fail('admin 権限を持たないため,admin ユーザを発行できません。');
                    }
                },
            ],
            'name' => [
                'required',
                'string',
                'max:20',
            ],
            'memo' => [
                'string',
                'max:100',
            ],
        ];

        Validator::make($inputs, $rules)->validate();

        return new UserResource(User::create($inputs));
    }

    public function update(Request $request, User $user): UserResource
    {
        $inputs = [
            'role' => $request->input('role'),
            'name' => $request->input('name'),
            'memo' => $request->input('memo'),
        ];

        $rules = [
            'role' => [
                'required',
                Rule::in(['admin', 'write', 'read']),
                function ($attribute, $value, $fail) use ($request, $user) {
                    if ($request->user()->role === 'admin') {
                        if ($request->user()->is($user)) {
                            $fail('admin ユーザは,自分自身の権限を変更することはできません。');
                        }
                    } elseif ($request->user()->role === 'write') {
                        if ($request->user()->isNot($user)) {
                            $fail('write ユーザは,他者の権限を変更することはできません。');
                        } elseif ($value === 'admin') {
                            $fail('write ユーザは,自身の権限を昇格させることはできません。');
                        }
                    } else {
                        if ($request->user()->isNot($user)) {
                            $fail('read ユーザは,権限を変更することはできません。');
                        }
                    }
                },
            ],
            'name' => [
                'required',
                'string',
                'max:20',
                function ($attribute, $value, $fail) use ($request, $user) {
                    if ($request->user()->isNot($user) && $request->user()->role !== 'admin') {
                        $fail('他者の名前の編集には admin 権限が必要です。');
                    }
                },
            ],
            'memo' => [
                'string',
                'max:100',
                function ($attribute, $value, $fail) use ($request, $user) {
                    if ($request->user()->isNot($user) && !in_array($request->user()->role, ['admin', 'write'], true)) {
                        $fail('メモの編集には write 権限以上が必要です。');
                    }
                },
            ],
        ];

        Validator::make($inputs, $rules)->validate();

        $user->fill($inputs)->save();

        return new UserResource($user);
    }
}

これは…さすがにちょっと書きたくないですよね。

モデルクラスにルール定義を委譲

ルールをコントローラに直書きすると使い回しが効かないので,モデルに定義してみましょう。以下のようなフローに則って,バリデーションを分割します。

  1. フォーマットだけで判定できる静的バリーデーション を実行
  2. モデルに値を fill() する
  3. 現在の状態や他のフィールドと比較を行うインスタンスバリデーション を実行
    $this を使用可能

(これだけで1つの記事になるぐらい本当は濃い話になるのですが,ここではサラッと流します)

疑問

とはいっても,なぜ唐突にこの話がでてきたの?という疑問は沸くはずなので,軽く説明しておきます。

Q1.「静的バリデーションのみでいいのではないか?」

モデルの更新時,部分的なパラメータが送信されてきたときに,$request から取得できないフィールドと比較した相対バリデーションができないため問題があります。

$event = new Event();
$event->starts_at = '2020-01-01 00:00:00';

$inputs = [
    'ends_at' => $request->input('ends_at'),
];
$rules = [
    'ends_at' => ['reqiured', 'date', 'after:starts_at'],
];

Validator::make($inputs, $rules)->validate();

例えばこのように, starts_at が既にモデルに格納済みで,新たに ends_at のみリクエストでやってきた場合にそのままでは対応できません。送信されてきた場合とそうではない場合で処理を分岐することも可能ではありますが,コードが複雑化し,バグを生む要因になります。

そのため, $this を用いた 既に格納されている値とも比較できる バリデーションの導入には合理性があります。

Q2.「インスタンスバリデーションのみでいいのではないか?」

new \Carbon\Carbon('invalid')

のように Carbon に不正な日付時刻が入力されたとき,即座に例外がスローされるのが問題です。これはモデルで $dates $casts 等を利用して日付時刻のミューテータを定義している場合に発生する問題です。これを防ぐためには, fill() を呼ぶ前に前段でフォーマットのみのバリデーションが必要です。

Q3. それでもやっぱりモデルに書いちゃうのってどうなの?

コントローラやフォームリクエストに書くと,変更に強くなる代わりに再利用性が大きく下がる。モデルに書くと,再利用性は非常に高いが,その代償としてレールから外れたときの融通が効きづらくなってくる。一長一短だと思います。直近の業務では

  • すべてが入力されない属性の部分的な更新がある
     → モデルが有利
  • バリデーションの内容が認証ユーザの権限によって変化する
     → ややモデルが有利
  • バリデーションの内容が再利用のされるエンドポイントによって変化する
     → コントローラやフォームリクエストが有利
  • テーブルのフィールド数が約 90 個(!)ある
     → モデルが有利

という背景を考慮して,モデルを選択していました。アプリケーションの性質によってどちらが向いているか見極める必要があるでしょう。

ナイーブな実装

<?php

namespace App\Http\Controllers;

use App\Http\Resource\User as UserResource;
use App\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;

class UserController extends Controller
{
    public function store(Request $request): UserResource
    {
        if (!in_array($request->user()->role, ['admin', 'write'], true)) {
            throw new AccessDeniedHttpException('新規ユーザ発行には write 権限以上が必要です。');
        }

        $inputs = [
            'role' => $request->input('role'),
            'name' => $request->input('name'),
            'memo' => $request->input('memo'),
        ];

        // 静的バリデーションを実行
        Validator::make($inputs, User::staticValidationRules())->validate();

        // User インスタンスを生成して入力を埋める
        $user = new User($inputs);

        // インスタンスバリデーションを実行
        Validator::make($inputs, $user->instanceValidationRules())->validate();

        // 保存
        $user->save();

        return new UserResource($user);
    }

    public function update(Request $request, User $user): UserResource
    {
        $inputs = [
            'role' => $request->input('role'),
            'name' => $request->input('name'),
            'memo' => $request->input('memo'),
        ];

        // 静的バリデーションを実行
        Validator::make($inputs, User::staticValidationRules())->validate();

        // 入力を埋める
        $user->fill($inputs);

        // インスタンスバリデーションを実行(但し,実際に更新されるフィールドのみを対象にする)
        $dirty = $user->getDirty();
        $rules = array_intersect_key($user->instanceValidationRules(), $dirty);
        Validator::make($dirty, $rules)->validate();

        // 保存
        $user->save();

        return new UserResource($user);
    }
}
<?php

namespace App;

use Illuminate\Auth\Authenticatable;
use Illuminate\Contracts\Auth\Authenticatable as UserContract;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;

class User extends Model implements UserContract
{
    use Authenticatable;

    public static function staticValidationRules(): array
    {
        return [
            'role' => [
                'required',
                Rule::in(['admin', 'write', 'read']),
            ],
            'name' => [
                'required',
                'string',
                'max:20',
            ],
            'memo' => [
                'string',
                'max:100',
            ],
        ]);
    }

    public function instanceValidationRules(): array
    {
        return [
            'role' => [
                $this->exists
                ? function ($attribute, $value, $fail) {
                    if (Auth::user()->role === 'admin') {
                        if (Auth::user()->is($this)) {
                            $fail('admin ユーザは,自分自身の権限を変更することはできません。');
                        }
                    } elseif (Auth::user()->role === 'write') {
                        if (Auth::user()->isNot($this)) {
                            $fail('write ユーザは,他者の権限を変更することはできません。');
                        } elseif ($this->role === 'admin') {
                            $fail('write ユーザは,自身の権限を昇格させることはできません。');
                        }
                    } elseif (Auth::user()->role === 'read') {
                        if (Auth::user()->isNot($this)) {
                            $fail('read ユーザは,権限を変更することはできません。');
                        }
                    }
                }
                : function ($attribute, $value, $fail) {
                    if (Auth::user()->role !== 'admin' && $value === 'admin') {
                        $fail('admin 権限を持たないため,admin ユーザを発行できません。');
                    }
                },
            ],
            'name' => [
                $this->exists
                ? function ($attribute, $value, $fail) {
                    if (Auth::user()->isNot($this) && Auth::user()->role !== 'admin') {
                        $fail('他者の名前の編集には admin 権限が必要です。');
                    }
                }
                : function () {},
            ],
            'memo' => [
                $this->exists
                ? function ($attribute, $value, $fail) {
                    if (Auth::user()->isNot($this) && !in_array(Auth::user()->role, ['admin', 'write'], true)) {
                        $fail('メモの編集には write 権限以上が必要です。');
                    }
                }
                : function () {},
            ],
        ]);
    }
}

getDirty() の呼び出し等は隠蔽の余地があるものの,最初よりは見通しがだいぶよくなりました。もう少し整理してみましょう。コントローラは十分きれいになったので,ここからはモデルのリファクタリングに着手します。

ポリシークラスに認可処理を委譲

インスタンスバリデーションを行っているモデルのインスタンスを引数として,フィールドごとにポリシーのアビリティを定義してみましょう。以下のような命名規則に従って定義します。

{store|update}<フィールド名>of

例: storeRoleOf updateNameOf

<?php

namespace App\Policies;

use App\User;
use Illuminate\Auth\Access\HandlesAuthorization;
use Illuminate\Auth\Access\Response;

class UserPolicy
{
    use HandlesAuthorization;

    public function store(User $user): Response
    {
        return in_array($user->role, ['admin', 'write'], true)
            ? $this->allow()
            : $this->deny('新規ユーザ発行には write 権限以上が必要です。');
    }

    public function update(User $user): Response
    {
        return $this->allow();
    }

    public function storeRoleOf(User $user, User $target): Response
    {
        return $user->role !== 'admin' && $value === 'admin'
            ? $this->deny('admin を発行できるのは admin ユーザだけです。')
            : $this->store($user, $target);
    }

    public function updateRoleOf(User $user, User $target): Response
    {
        return $this->{__FUNCTION__ . 'By' . ucfirst($user->role)}($user, $target);
    }

    protected function updateRoleOfByAdmin(User $user, User $target): Response
    {
        return $user->is($target)
            ? $this->deny('admin ユーザは,自分自身の権限を変更することはできません。')
            : $this->allow();
    }

    protected function updateRoleOfByWrite(User $user, User $target): Response
    {
        if ($user->isNot($target)) {
            return $this->deny('write ユーザは,他者の権限を変更することはできません。');
        }
        if ($target->role === 'admin') {
            return $this->deny('write ユーザは,自身の権限を昇格させることはできません。');
        }
        return $this->allow();
    }

    protected function updateRoleOfByRead(User $user, User $target): Response
    {
        return $this->deny('read ユーザは,権限を変更することはできません。');
    }

    public function updateNameOf(User $user, User $target): Response
    {
        return $user->isNot($target) && $user->role !== 'admin')
            ? $this->deny('他者の名前の編集には admin 権限が必要です。')
            : $this->allow();
    }

    public function updateMemoOf(User $user, User $target): Response
    {
        return $user->isNot($target) && !in_array($user->role, ['admin', 'write'], true))
            ? $this->deny('メモの編集には write 権限以上が必要です。')
            : $this->allow();
    }
}
<?php

namespace App;

use Illuminate\Auth\Authenticatable;
use Illuminate\Contracts\Auth\Authenticatable as UserContract;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;

class User extends Model implements UserContract
{
    use Authenticatable;

    public static function staticValidationRules(): array
    {
        return [
            'role' => [
                'required',
                Rule::in(['admin', 'write', 'read']),
            ],
            'name' => [
                'required',
                'string',
                'max:20',
            ],
            'memo' => [
                'string',
                'max:100',
            ],
        ]);
    }

    public function instanceValidationRules(): array
    {
        return [
            'role' => [
                $this->exists
                ? function ($attribute, $value, $fail) {
                    $response = Gate::inspect('updateRoleOf', $this);
                    if ($response->denied()) {
                        $fail($response->message());
                    }
                }
                : function ($attribute, $value, $fail) {
                    $response = Gate::inspect('storeRoleOf', $this);
                    if ($response->denied()) {
                        $fail($response->message());
                    }
                },
            ],
            'name' => [
                $this->exists
                ? function ($attribute, $value, $fail) {
                    $response = Gate::inspect('updateNameOf', $this);
                    if ($response->denied()) {
                        $fail($response->message());
                    }
                }
                : function () {},
            ],
            'memo' => [
                $this->exists
                ? function ($attribute, $value, $fail) {
                    $response = Gate::inspect('updateMemoOf', $this);
                    if ($response->denied()) {
                        $fail($response->message());
                    }
                }
                : function () {},
            ],
        ]);
    }
}
<?php

namespace App\Http\Controllers;

use App\Http\Resource\User as UserResource;
use App\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;

class UserController extends Controller
{
    public function store(Request $request): UserResource
    {
        $this->authorize('store', User::class);

        $inputs = [
            'role' => $request->input('role'),
            'name' => $request->input('name'),
            'memo' => $request->input('memo'),
        ];

        // 静的バリデーションを実行
        Validator::make($inputs, User::staticValidationRules())->validate();

        // User インスタンスを生成して入力を埋める
        $user = new User($inputs);

        // インスタンスバリデーションを実行
        Validator::make($inputs, $user->instanceValidationRules())->validate();

        // 保存
        $user->save();

        return new UserResource($user);
    }

    public function update(Request $request, User $user): UserResource
    {
        $this->authorize('update', User::class);

        $inputs = [
            'role' => $request->input('role'),
            'name' => $request->input('name'),
            'memo' => $request->input('memo'),
        ];

        // 静的バリデーションを実行
        Validator::make($inputs, User::staticValidationRules())->validate();

        // 入力を埋める
        $user->fill($inputs);

        // インスタンスバリデーションを実行(但し,実際に更新されるフィールドのみを対象にする)
        $dirty = $user->getDirty();
        $rules = array_intersect_key($user->instanceValidationRules(), $dirty);
        Validator::make($dirty, $rules)->validate();

        // 保存
        $user->save();

        return new UserResource($user);
    }
}

ポリシークラスは極めて宣言的な実装になり,とてもすっきりしました。でもモデルはもう少し共通化できそうなにおいがしますね。

PolicyRule の作成

Gate::inspect() まわりの部分を共通化するための PolicyRule クラスを作成します。

  • passes() で属性名が入ってくるので,それをもとに自動でアビリティ名を推測できるようにします。
  • アビリティ引数には,デフォルトではバリデーション対象となっているモデルのインスタンスを渡すようにします。
<?php

namespace App\Rules;

use Illuminate\Contracts\Validation\Rule;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;

class PolicyRule implements Rule
{
    protected $target;
    protected $ability;
    protected $arguments;
    protected $response;

    public function __construct(Model $target, ?string $ability = null, ?array $arguments = null)
    {
        $this->target = $target;
        $this->ability = $ability;
        $this->arguments = $arguments;
    }

    public function passes($attribute, $value): bool
    {
        $this->response = Gate::inspect(
            $this->ability ?? $this->guessAbilityName($attribute),
            $this->arguments ?? $this->target
        );

        return $this->response->allowed();
    }

    public function message(): ?string
    {
        return optional($this->response)->message();
    }

    protected function guessAbilityName(string $attribute): string
    {
        return sprintf(
            '%s%sOf',
            $this->target->exists ? 'update' : 'store',
            Str::studly($attribute)
        );
    }
}

また,「何もしない」を型で明示的に表現できるように, NoopRule クラスも一緒に作っておきましょう。

<?php

namespace App\Rules;

use Illuminate\Contracts\Validation\Rule;

class NoopRule implements Rule
{
    public function passes($attribute, $value): bool
    {
        return true;
    }

    public function message(): ?string
    {
        return null;
    }
}

そしてこれらを簡単に利用するための,モデル用のトレイトを作成します。$this->exists による分岐はこのトレイトに任せます。

<?php

namespace App\Concerns;

use App\Rules\NoopRule;
use App\Rules\PolicyRule;
use App\Validation\Rule;

trait CreatesAuthorizationRules
{
    public function policyRule(?string $ability = null, ?array $arguments = null): PolicyRule
    {
        return new PolicyRule($this, $ability, $arguments);
    }

    public function policyRuleForStore(?string $ability = null, ?array $arguments = null): Rule
    {
        return $this->exists ? new NoopRule() : $this->policyRule($ability, $arguments);
    }

    public function policyRuleForUpdate(?string $ability = null, ?array $arguments = null): Rule
    {
        return $this->exists ? $this->policyRule($ability, $arguments) : new NoopRule();
    }
}

すると,モデルはここまでシンプルになります。

<?php

namespace App;

use Illuminate\Auth\Authenticatable;
use Illuminate\Contracts\Auth\Authenticatable as UserContract;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;

class User extends Model implements UserContract
{
    use Authenticatable;
    use Concerns\CreatesAuthorizationRules;

    public static function staticValidationRules(): array
    {
        return [
            'role' => [
                'required',
                Rule::in(['admin', 'write', 'read']),
            ],
            'name' => [
                'required',
                'string',
                'max:20',
            ],
            'memo' => [
                'string',
                'max:100',
            ],
        ]);
    }

    public function instanceValidationRules(): array
    {
        return [
            'role' => [
                $this->policyRule(),
            ],
            'name' => [
                $this->policyRuleForUpdate(),
            ],
            'memo' => [
                $this->policyRuleForUpdate(),
            ],
        ]);
    }
}

いかがでしょうか。これが求めていたゴールです。

💪2段階バリデーションの抽象化

コントローラの処理もいい感じにラップするクラスを作ってあげれば,更に可読性は向上するでしょう。この部分に関しても詳細に説明すると記事が肥大化するため,簡易的な実装例のコードだけを紹介しておきます。

ModelValidator として Validator のファクトリー兼ラッパーを定義します。

<?php

namespace App\Validation;

use Illuminate\Contracts\Validation\Validator as ValidatorContract;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Validator;

class ModelValidator
{
    public $model;
    public $inputs = [];
    public $fills = [];
    public $targets = [];

    // デフォルトでは,モデルに埋めた結果,差分が発生した属性だけをインスタンスバリデーションの対象にする
    public $includeRulesForCleanAttributes = false;

    public function __construct(Model $model)
    {
        $this->model = $model;
    }

    public function setInputs(array $inputs)
    {
        $this->inputs = $inputs;

        // デフォルトでは,入力された属性のみバリデーションする
        // そのため,入力が増減する可能性のある $request->all() $request->only() は
        // 使用してはならないことに注意する。
        // 必ず1つ1つ入力を $request->input() で受け取ること。
        $this->shouldValidate(array_keys($inputs));

        // デフォルトでは,入力をすべてモデルに埋める
        $this->shouldFill($inputs);

        return $this;
    }

    public function shouldValidate(array $targets)
    {
        $this->targets = $targets;
        return $this;
    }

    public function shouldFill(array $fills)
    {
        $this->fills = $fills;
        return $this;
    }

    public function includeRulesForCleanAttributes(bool $include = true)
    {
        $this->includeRulesForCleanAttributes = $include;
        return $this;
    }

    public function validate(): void
    {
        $this->newStaticValidator()->validate();
        $this->model->fill($this->fills);
        $this->newInstanceValidator()->validate();
    }

    public function newStaticValidator(): ValidatorContract
    {
        $className = get_class($this->model);

        return $this->newValidator(
            method_exists($className, 'staticValidationRules')
            ? $className::getStaticValidationRules($this)
            : []
        );
    }

    public function newInstanceValidator(): ValidatorContract
    {
        $rules = method_exists($this->model, 'instanceValidationRules')
            ? $this->model->getInstanceValidationRules($this)
            : [];

        if (!$this->includeRulesForCleanAttributes) {
            $rules = array_intersect_key($rules, $this->model->getDirty());
        }

        return $this->newValidator($rules);
    }

    public function newValidator(array $rules): ValidatorContract
    {
        return Validator::make($this->inputs, Arr::only($rules, $this->targets));
    }
}

これを使うと,コントローラは以下のようになります。

<?php

namespace App\Http\Controllers;

use App\Http\Resource\User as UserResource;
use App\User;
use App\Validation\ModelValidator;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;

class UserController extends Controller
{
    public function store(Request $request): UserResource
    {
        $this->authorize('store', User::class);

        $inputs = [
            'role' => $request->input('role'),
            'name' => $request->input('name'),
            'memo' => $request->input('memo'),
        ];

        $user = new User();
        (new ModelValidator($user))->setInputs($inputs)->validate();
        $user->save();

        return new UserResource($user);
    }

    public function update(Request $request, User $user): UserResource
    {
        $this->authorize('update', User::class);

        $inputs = [
            'role' => $request->input('role'),
            'name' => $request->input('name'),
            'memo' => $request->input('memo'),
        ];

        (new ModelValidator($user))->setInputs($inputs)->validate();
        $user->save();

        return new UserResource($user);
    }
}

完璧ですね。ここまで来ることができれば本当のゴールでしょう。

🌏i18n 対応の導入

実際には,バリデーションメッセージは日本語でそのまま書かれることは少ないでしょう。ここでは resouces/lang/{ja,en}/valdation.php に翻訳を記入し,プレースホルダとして

  • :attribute … 属性名
  • :input … 入力値

を置換する処理まで導入した翻訳を実装してみましょう。最終的に,ポリシークラスで以下のように使用できることを目標とします。

public function updateNameOf(User $user, User $target): Response
{
    return $user->isNot($target) && $user->role !== 'admin')
        ? $this->deny(__('validation.insufficient_permission'))->of($target, 'role')
        : $this->allow();
}
権限不足のため,:attributeに「:input」を指定することができません。
↓
権限不足のため,ロールに「管理者」を指定することができません。

Response クラスの拡張

Validator クラスが標準で翻訳機能を有しているため,この機能を流用します。

  • Validator::makeReplacements() メソッドを使用し,翻訳ファイルの定義を使用して :attribute:value を置換します。
  • 置換結果を利用して, Response インスタンスを再生成します。
<?php

namespace App\Auth\Access;

use Illuminate\Support\Facades\Validator;
use Illuminate\Auth\Access\Response as BaseResponse;
use Illuminate\Database\Eloquent\Model;

class Response extends BaseResponse
{
    public function of(Model $model, string $attribute): Response
    {
        // ルールは不定なので,ルールごとの replacer は
        // 使用しないという意図で _ という文字列を渡す
        $rule = '_';

        $message = $this->message !== null
            ? Validator::make([$attribute => $model->$attribute], [])
                ->makeReplacements($this->message, $attribute, $rule, [])
            : null;

        return new static($this->allowed, $message, $this->code);
    }
}

そして,標準の HandlesAuthorization の代替となるヘルパートレイトを作成すれば完了です。

<?php

namespace App\Policies;

use App\Auth\Access\Response;

trait HandlesAuthorization
{
    protected function allow(?string $message = null, $code = null): Response
    {
        return Response::allow($message, $code);
    }

    protected function deny(?string $message = null, $code = null): Response
    {
        return Response::deny($message, $code);
    }
}

あとは

  • validation.php にメッセージの翻訳を定義
  • validation.phpattributes.<フィールド名> に翻訳された :attribute 相当の値を定義
  • validation.phpvalues.<フィールド名>.<値> に翻訳された :input 相当の値を定義

をやって終わりのはずなんですが…


return [
    'insufficient_permission' => '権限不足のため,:attributeに「:input」を指定することができません。',
    'attributes' => [
        'role' => 'ロール',
    ],
    'values' => [
        'role' => ['admin' => '管理者', 'write' => '書き込み', 'read' => '読み取り'],
    ],
];

Validator の継承 (バグ対応)

実は,標準の Validator では,values を考慮した :input の置換をビルトインのルールでしかやってくれません!現時点では,以下のようになってしまいます。

権限不足のため,:attributeに「:input」を指定することができません。
↓
権限不足のため,ロールに「admin」を指定することができません。

この問題を修正するプルリクエストを Laravel フレームワーク本体の 7.x ブランチ向けに提出し,既にマージされています。残念ながら破壊的変更であるため, 6.x には適用されません。

6.x ではこれを解消するために,適当なサービスプロバイダで Validator::resolver() を使用して継承した Validator を生成するようにします。

<?php

namespace App\Providers;

use App\Validation\Validator as ValidatorImpl;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Validator::resolver(function (...$args) {
            return new ValidatorImpl(...$args);
        });
    }
}
<?php

namespace App\Validation;

use Illuminate\Validation\Validator as BaseValidator;

class Validator extends BaseValidator
{
    protected function replaceInputPlaceholder($message, $attribute)
    {
        $actualValue = $this->getValue($attribute);

        if (is_scalar($actualValue) || is_null($actualValue)) {
            // 標準だと :input がそのまま表示されるので Validator::$customValues に置き換える
            $message = str_replace(':input', $this->getDisplayableValue($attribute, $actualValue), $message);
        }

        return $message;
    }
}

これで,列挙値に関してもユーザに見やすい言語で表示することができるようになります!

✨リファクタリング内容の整理

最終的なリファクタリング内容を整理してみます。

コアコンポーネントの作成

  • Gate::inspect() を判定に使用する PolicyRule を作成
    • およびそれを宣言的に無効化するための,何もバリデーションしない NoopRule を作成
    • およびそれを各モデルで使うための HasAuthorizationRules トレイトを作成
  • 認可エラーメッセージ中のプレースホルダをバリデーションの機能を流用して付与できる拡張 Response を作成
    • およびそれを各ポリシーで使うための HandlesAuthorizations トレイトを作成
  • コントローラでの2段階バリデーション呼び出しを集約する ModelValidator を作成

ユースケースごとの対応

  • ポリシークラスに {store|update}<フィールド名>of の命名規則でバリデーションに関するアビリティを定義
  • モデルに2段階バリデーションルールを定義
    • 静的バリデーションは staticValidationRules()
    • インスタンスバリデーションは instanceValidationRules() (認可バリデーションはこちらに定義)
  • コントローラでは ModelValidator からバリデーションを実行する

😃最後に

バリデーションをモデルに書くか,それともコントローラかフォームリクエストに書くか。永遠の議題ですが,基本的なビジネスロジックがモデルベースになっていて,且つ DRY を優先して大きなメリットが得られるような場合には,モデルバリデーションを導入する価値はあるでしょう。

その際,今回の主題である「ポリシーのバリデーションへの活用」が権限判定の絡む複雑なバリデーションで力になってくれるはずです。また,もしモデルバリデーションを選択しなくても,ポリシーの書き方を少し変更すれば柔軟に対応することも可能ではあるでしょう。

昨日は @saya1001kirinn さんによる Laravelリレーション初心者向け!外部キーがデフォルトでないパターン!!! でした。勢いだけで内容が頭に入ってこない記事だったので少しマークダウンの整形をお手伝いさせていただきました(笑)

42
26
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
42
26