【Laravel】Route Model Binding と Policy だけで認可処理を完結させるための Resourceful ルーティングの工夫

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

[Laravel Advent Calendar 2017](https://qiita.com/advent-calendar/2017/laravel) 5日目の記事になります。

昨日の @mpyw さんの記事に引き続き，本日は @mpyw が担当させていただきます。

# 前提知識

- **Gate** および **Policy** という認可のための仕組みがある
  - [認可 5.5 Laravel](https://readouble.com/laravel/5.5/ja/authorization.html)
  - [Laravel5.1.11で追加されたGateを試してみる(その1) - Qiita](https://qiita.com/inaka_phper/items/653820a75d1a9c716f0e)
- **Route Model Binding** という URL から判断して暗黙的に Eloquent モデルのインスタンスをコントローラおよびポリシーのメソッドに DI する仕組みがある
  - [ルーティング 5.5 Laravel](https://readouble.com/laravel/5.5/ja/routing)
  - [Laravelのルーティングチートシート - Qiita](https://qiita.com/fagai/items/a1bf55b6249aee03a624)
- `Route::apiResource()` メソッドを使うと Resourceful なルーティングを一括で定義できる
  - [コントローラ 5.5 Laravel](https://readouble.com/laravel/5.5/ja/controllers.html)
  - [Laravelのルーティングチートシート - Qiita](https://qiita.com/fagai/items/a1bf55b6249aee03a624)

# リレーション構成例

例として，以下のようなモデルを考えてください。

- `User` … コミュニティ
- `Community` … コミュニティ
- `Article` … 記事
- `Event` … イベント
- `Comment` … コメント

ここで，以下のようなリレーションを考えます。

- `User` belongs to many `Community`
  - `Community` belongs to many `User`
- `Article` belongs to `Community`
  - `Community` has many `Article`
- `Event` belongs to `Community`
  - `Community` has many `Event`
- `Comment` morph to commentable  
  - `Article` morph many `Comment`
  - `Event` morph many `Comment`

![図](https://qiita-image-store.s3.amazonaws.com/0/25060/f553c218-b3ce-0697-c90b-52a88eb09a19.png)

【注意】

- モデル定義のコードは省略します
- 実運用での利便性を考えて完全な正規化は捨てている部分があります

# ルーティングパターンを考えてみよう

## (A) 常に自身のエンティティのみをルートに含む

※ 「外部パラメータ」はGETリクエストならクエリーストリング，POSTリクエストならリクエストボディでデータを渡すことを意味します。

| メソッド | パス | 外部パラメータ | アクション |
|:----|:----|:----|:----|
| GET | `/communities` | | `CommunityController@index` |
| POST | `/communities` | | `CommunityController@store` |
| GET | `/communities/{community}` | | `CommunityController@show` |
| PUT | `/communities/{community}` | | `CommunityController@update` |
| DELETE | `/communities/{community}` | | `CommunityController@destroy` |

| メソッド | パス | 外部パラメータ | アクション |
|:----|:----|:----|:----|
| GET | `/articles` | community_id | `ArticleController@index` |
| POST | `/articles` | community_id | `ArticleController@store` |
| GET | `/articles/{article}` | | `ArticleController@show` |
| PUT | `/articles/{article}` | | `ArticleController@update` |
| DELETE | `/articles/{article}` | | `ArticleController@destroy` |

| メソッド | パス | 外部パラメータ | アクション |
|:----|:----|:----|:----|
| GET | `/events` | community_id | `EventController@index` |
| POST | `/events` | community_id | `EventController@store` |
| GET | `/events/{event}` | | `EventController@show` |
| PUT | `/events/{event}` | | `EventController@update` |
| DELETE | `/events/{event}` | | `EventController@destroy` |

| メソッド | パス | 外部パラメータ | アクション |
|:----|:----|:----|:----|
| GET | `/comments` | commentable_type<br>commentable_id | `CommentController@index` |
| POST | `/comments` | commentable_type<br>commentable_id | `CommentController@store` |
| GET | `/comments/{comment}` | | `CommentController@show` |
| PUT | `/comments/{comment}` | | `CommentController@update` |
| DELETE | `/comments/{comment}` | | `CommentController@destroy` |

何も考えずに

```php
Route::apiResources([
    'communities' => 'CommunityController',
    'articles' => 'CommunityController',
    'events' => 'EventController',
    'comments' => 'CommentController',
]);
```

とかやるとこうなっちゃいますね。一見ルーティングがシンプルに収まっていていいようにも思いますが，「外部パラメータ」という謎の概念が付きまとってきます。これ，ポリシーを適用したいときにめちゃくちゃ困るんですよね。例えば「記事を見れるのはコミュニティ内のユーザ限定」という実装であると仮定します。

```php:ArticlePolicy&#58;&#58;show()
public function show(User $user, Article $article)
{
    // ユーザがコミュニティに所属していることを検証する
    return $user->communities()->wherePivot('community_id', $article->community_id)->exists();
}
```

`show`のようにルートモデルバインディングでモデルが入ってきている場合はいいんです。ではそれが無い`index`はどうなるのか？

```php:ArticlePolicy&#58;&#58;index()
public function index(User $user)
{
    // ユーザがコミュニティに所属していることを検証する
    return $user->communities()->wherePivot('community_id', Request::input('community_id'))->exists();
}
```

このように，外部パラメータを使ってしまうと`Request`ファサードを使わない限り，ポリシーの中で判定するのは困難になります。また，ポリシーは可能な限りシンプルで，**宣言的**であるべきであり，外部入力に依存するファサードを中で使うこと自体がもはやご法度です。使い回しが効かなくなりますからね。

## (B) 常に自身のエンティティに加えて祖先のエンティティもすべてルートに含む

| メソッド | パス | アクション |
|:----|:----|:----|
| GET | `/communities` | `CommunityController@index` |
| POST | `/communities` | `CommunityController@store` |
| GET | `/communities/{community}` | `CommunityController@show` |
| PUT | `/communities/{community}` | `CommunityController@update` |
| DELETE | `/communities/{community}` | `CommunityController@destroy` |

| メソッド | パス | アクション |
|:----|:----|:----|
| GET | `/communities/{community}/articles` | `ArticleController@index` |
| POST | `/communities/{community}/articles` | `ArticleController@store` |
| GET | `/communities/{community}/articles/{article}` | `ArticleController@show` |
| PUT | `/communities/{community}/articles/{article}` | `ArticleController@update` |
| DELETE | `/communities/{community}/articles/{article}` | `ArticleController@destroy` |

| メソッド | パス | アクション |
|:----|:----|:----|
| GET | `/communities/{community}/events` | `EventController@index` |
| POST | `/communities/{community}/events` | `EventController@store` |
| GET | `/communities/{community}/events/{event}` | `EventController@show` |
| PUT | `/communities/{community}/events/{event}` | `EventController@update` |
| DELETE | `/communities/{community}/events/{event}` | `EventController@destroy` |

| メソッド | パス | アクション |
|:----|:----|:----|
| GET | `/communities/{community}/articles/{article}/comments`<br>`/communities/{community}/events/{event}/comments` | `CommentController@index` |
| POST | `/communities/{community}/articles/{article}/comments`<br>`/communities/{community}/events/{event}/comments` | `CommentController@store` |
| GET | `/communities/{community}/articles/{article}/comments/{comment}`<br>`/communities/{community}/events/{event}/comments/{comment}` | `CommentController@show` |
| PUT | `/communities/{community}/articles/{article}/comments/{comment}`<br>`/communities/{community}/events/{event}/comments/{comment}` | `CommentController@update` |
| DELETE | `/communities/{community}/articles/{article}/comments/{comment}`<br>`/communities/{community}/events/{event}/comments/{comment}` | `CommentController@destroy` |

```php
Route::apiResource('communities', 'CommunityController');
Route::prefix('communities/{community}')->group(function () {
    Route::apiResources([
        'articles' => 'ArticleController',
        'events' => 'EventController',
    ]);
    Route::prefix('articles/{article}')->apiResource('Comment', 'CommentController');
    Route::prefix('events/{event}')->apiResource('Comment', 'CommentController');
});
```

…はい，もう見るからに辛そうですね（笑）
さすがにこれは無いので，次の (C) を検討しましょう。

## (C) 常に自身のエンティティに加えて直属の親エンティティをルートに含む

| メソッド | パス | アクション |
|:----|:----|:----|
| GET | `/communities` | `CommunityController@index` |
| POST | `/communities` | `CommunityController@store` |
| GET | `/communities/{community}` | `CommunityController@show` |
| PUT | `/communities/{community}` | `CommunityController@update` |
| DELETE | `/communities/{community}` | `CommunityController@destroy` |

| メソッド | パス | アクション |
|:----|:----|:----|
| GET | `/communities/{community}/articles` | `ArticleController@index` |
| POST | `/communities/{community}/articles` | `ArticleController@store` |
| GET | `/communities/{community}/articles/{article}` | `ArticleController@show` |
| PUT | `/communities/{community}/articles/{article}` | `ArticleController@update` |
| DELETE | `/communities/{community}/articles/{article}` | `ArticleController@destroy` |

| メソッド | パス | アクション |
|:----|:----|:----|
| GET | `/communities/{community}/events` | `EventController@index` |
| POST | `/communities/{community}/events` | `EventController@store` |
| GET | `/communities/{community}/events/{event}` | `EventController@show` |
| PUT | `/communities/{community}/events/{event}` | `EventController@update` |
| DELETE | `/communities/{community}/events/{event}` | `EventController@destroy` |

| メソッド | パス | アクション |
|:----|:----|:----|
| GET | `/articles/{article}/comments`<br>`/events/{event}/comments` | `CommentController@index` |
| POST | `/articles/{article}/comments`<br>`/events/{event}/comments` | `CommentController@store` |
| GET | `/articles/{article}/comments/{comment}`<br>`/events/{event}/comments/{comment}` | `CommentController@show` |
| PUT | `/articles/{article}/comments/{comment}`<br>`/events/{event}/comments/{comment}` | `CommentController@update` |
| DELETE | `/articles/{article}/comments/{comment}`<br>`/events/{event}/comments/{comment}` | `CommentController@destroy` |

```php
Route::apiResource('communities', 'CommunityController');
Route::prefix('communities/{community}')->group(function () {
    Route::apiResources([
        'articles' => 'ArticleController',
        'events' => 'EventController',
    ]);
});
Route::prefix('articles/{article}')->apiResource('Comment', 'CommentController');
Route::prefix('events/{event}')->apiResource('Comment', 'CommentController');
```

(B) に比べると悪くはないんですが，大きな欠点が1つあります。例えば `CommentController@show`　を実行したいとき， **コメントIDだけで一意にエンティティを特定できるにも関わらず，記事IDまたはイベントIDも必要**となっている点です。API設計としてはまだ間違っている感じがしますね。

## (D) 自身のエンティティが未作成の場合にのみ直属の親エンティティをルートに含む

で，考え抜いて導き出した答えがこちら。当たり前といえば当たり前なんですが。

| メソッド | パス | アクション |
|:----|:----|:----|
| GET | `/communities` | `CommunityController@index` |
| POST | `/communities` | `CommunityController@store` |
| GET | `/communities/{community}` | `CommunityController@show` |
| PUT | `/communities/{community}` | `CommunityController@update` |
| DELETE | `/communities/{community}` | `CommunityController@destroy` |

| メソッド | パス | アクション |
|:----|:----|:----|
| GET | `/communities/{community}/articles` | `ArticleController@index` |
| POST | `/communities/{community}/articles` | `ArticleController@store` |
| GET | `/articles/{article}` | `ArticleController@show` |
| PUT | `/articles/{article}` | `ArticleController@update` |
| DELETE | `/articles/{article}` | `ArticleController@destroy` |

| メソッド | パス | アクション |
|:----|:----|:----|
| GET | `/communities/{community}/events` | `EventController@index` |
| POST | `/communities/{community}/events` | `EventController@store` |
| GET | `/events/{event}` | `EventController@show` |
| PUT | `/events/{event}` | `EventController@update` |
| DELETE | `/events/{event}` | `EventController@destroy` |

| メソッド | パス | アクション |
|:----|:----|:----|
| GET | `/articles/{article}/comments`<br>`/events/{event}/comments` | `CommentController@index` |
| POST | `/articles/{article}/comments`<br>`/events/{event}/comments` | `CommentController@store` |
| GET | `/comments/{comment}` | `CommentController@show` |
| PUT | `/comments/{comment}`　| `CommentController@update` |
| DELETE | `/comments/{comment}` | `CommentController@destroy` |

ただこれ，Laravelの標準機能だと書きづらいので，自分で`Route`ファサードにマクロを生やして使っています。以下に実装例を紹介します。この実装では，ルーティングを定義すると同時に，ポリシーを適用するところまで一気に完結させています。

# `Route::authorizedApiResource()` マクロ

## 実装例

```php:app/Providers/AuthorizedApiResourceMacroServiceProvider.php
<?php

declare(strict_types=1);

namespace App\Providers;

use App;
use Illuminate\Routing\Route;
use Illuminate\Support\Str;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Route as RouteFacade;
use Illuminate\Routing\Router;

class AuthorizedApiResourceMacroServiceProvider extends ServiceProvider
{
    /**
     * @var array
     */
    protected static $abilityMap = [
        'independent' => [
            'show' => 'GET',
            'update' => 'PUT',
            'destroy' => 'DELETE',
        ],
        'dependent' => [
            'index' => 'GET',
            'store' => 'POST',
        ],
    ];

    /**
     * Route ファサードにマクロを定義します。
     */
    public function boot(): void
    {
        RouteFacade::macro('authorizedApiResource', function (string $className, $parentClassNames = null): Router {
            return AuthorizedApiResourceMacroServiceProvider::authorizedApiResource($className, $parentClassNames);
        });
    }

    /**
     * リソースルーティングを定義します。
     *
     * @param  string            $className
     * @param  null|array|string $parentClassNames
     * @return Router
     */
    public static function authorizedApiResource(string $className, $parentClassNames = null): Router
    {
        foreach (static::$abilityMap['independent'] as $ability => $method) {
            static::registerRoute($ability, $method, $className, null, true);
        }
        foreach (static::$abilityMap['dependent'] as $ability => $method) {
            if ($parentClassNames === null) {
                static::registerRoute($ability, $method, $className, null, false);
            } else {
                foreach ((array)$parentClassNames as $i => $parentClassName) {
                    static::registerRoute($ability, $method, $className, $parentClassName, false);
                }
            }
        }
        return app('router');
    }

    /**
     * 1つのルーティングを定義します。
     *
     * @param  string      $ability
     * @param  string      $method
     * @param  string      $className
     * @param  null|string $parentClassName
     * @param  bool        $resourceAlreadyExists
     * @return Route
     */
    protected static function registerRoute(string $ability, string $method, string $className, ?string $parentClassName, bool $resourceAlreadyExists): Route
    {
        return RouteFacade::match(
            $method,
            static::compileUri($className, $parentClassName, $resourceAlreadyExists),
            static::controllerName($className) . '@' . $ability
        )->middleware(
            static::compileMiddleware($ability, $className, $parentClassName, $resourceAlreadyExists)
        )->name(
            static::compileName($ability, $className, $parentClassName)
        );
    }

    /**
     * URIを定義します。
     *
     * 親リソースがある例: communities/{communities}/articles ← ArticleController::index(Request $request, Community $community)
     * 親リソースがない例: articles/{article}                 ← ArticleController::show(Request $request, Article $article)
     *
     * @param  string      $className
     * @param  null|string $parentClassName
     * @param  bool        $resourceAlreadyExists
     * @return string
     */
    protected static function compileUri(string $className, ?string $parentClassName, bool $resourceAlreadyExists): string
    {
        $segments = [];
        if ($parentClassName !== null) {
            $segments[] = static::parameterName($parentClassName);
            $segments[] = '{' . static::placeholderName($parentClassName) . '}';
        }
        $segments[] = static::parameterName($className);
        if ($resourceAlreadyExists) {
            $segments[] = '{' . static::placeholderName($className) . '}';
        }
        return implode('/', $segments);
    }

    /**
     * 認可のミドルウェアを定義します。
     *
     * 親リソースがある例: can:index,\App\Article,community ← ArticlePolicy::index(User $user, Community $community)
     * 親リソースがない例: can:show,article                 ← ArticlePolicy::show(User $user, Article $article)
     *
     * @param  string      $ability
     * @param  string      $className
     * @param  null|string $parentClassName
     * @param  bool        $resourceAlreadyExists
     * @return string
     */
    protected static function compileMiddleware(string $ability, string $className, ?string $parentClassName, bool $resourceAlreadyExists): string
    {
        $segments = [
            $ability,
            $resourceAlreadyExists ? static::placeholderName($className) : $className,
        ];
        if ($parentClassName !== null) {
            $segments[] = static::placeholderName($parentClassName);
        }
        return 'can:' . implode(',', $segments);
    }

    /**
     * ルーティング名称を定義します。
     *
     * 親リソースがある例: communities.articles.index
     * 親リソースがない例: articles.show
     *
     * @param  string      $ability
     * @param  string      $className
     * @param  null|string $parentClassName
     * @return string
     */
    protected static function compileName(string $ability, string $className, ?string $parentClassName): string
    {
        if ($parentClassName !== null) {
            $segments[] = static::parameterName($parentClassName);
        }
        $segments[] = static::parameterName($className);
        $segments[] = $ability;
        return implode('.', $segments);
    }

    /**
     * モデル名からプレースホルダ名に変換します。
     *
     * @param  string $className
     * @return string
     */
    protected static function placeholderName(string $className): string
    {
        return Str::snake(class_basename($className));
    }

    /**
     * モデル名からパラメータ名に変換します。
     *
     * @param  string $className
     * @return string
     */
    protected static function parameterName(string $className): string
    {
        return Str::snake(Str::plural(class_basename($className)));
    }

    /**
     * モデル名からコントローラ名に変換します。
     * （プレフィックスは RouteServiceProvider の $namespace で定義されているため，クラス名部分だけを返せばいい）
     *
     * @param  string $className
     * @return string
     */
    protected static function controllerName(string $className): string
    {
        return Str::studly(class_basename($className)) . 'Controller';
    }
}
```

## 使用例

### ルーティング

以下のようにルーティングをとても簡単に記述できます。

```php
Route::authorizedApiResource(Community::class);
Route::authorizedApiResource(Article::class, Community::class);
Route::authorizedApiResource(Event::class, Community::class);
Route::authorizedApiResource(Comment:class, [Article::class, Event::class]);
```

### ポリシー

取りうる親エンティティが複数ある場合は，**共通の1つの変数として受け取ります**。

```php:ArticlePolicy&#58;&#58;show()
public function show(User $user, Article $article)
{
    // ユーザがコミュニティに所属していることを検証する
    return $user->communities()->wherePivot('community_id', $article->community_id)->exists();
}
```

```php:ArticlePolicy&#58;&#58;index()
public function index(User $user, Community $community)
{
    // ユーザがコミュニティに所属していることを検証する
    return $user->communities()->wherePivot('community_id', $community->id)->exists();
}
```

```php:CommentPolicy&#58;&#58;index()
public function index(User $user, $commentable)
{
    // ユーザがコミュニティに所属していることを検証する
    return $user->communities()->wherePivot('community_id', $commentable->community_id)->exists();
}
```

### コントローラ

取りうる親エンティティが複数ある場合は，**型指定した引数を`null`許容して列挙します**。

```php:ArticleController&#58;&#58;show()
public function show(Article $article)
{
    return $article;
}
```

```php:ArticleController&#58;&#58;index()
public function index(Request $request, Community $community)
{
    $q = $community->articles();
    if ($maxId = $request->input('max_id')) {
        $q->where('id', '<=', $maxId);
    }
    return $q->get();
}
```

```php:CommentController&#58;&#58;index()
public function index(Request $request, Article $article = null, Event $event = null)
{
    $commentable = $article ?: $event;
    $q = $commentable->comments();
    if ($maxId = $request->input('max_id')) {
        $q->where('id', '<=', $maxId);
    }
    return $q->get();
}
```

もしポリシーの範疇に収まらない追加のバリデーションが必要な場合，フォームリクエストバリデーションを使うと良いでしょう。コントローラはスカスカで美しいまま維持できます。

- [バリデーション 5.5 Laravel](https://readouble.com/laravel/5.5/ja/validation.html)
- [Laravel5.2でFormRequestを使ってvalidateしてみる。 - Qiita](https://qiita.com/ohtsuka_shouta/items/b360c20a0eef8d8a44e0)

```php:ArticleValidation
class ArticleValidation extends Request
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        return [
            'title' => 'required|max:30',
            'body' => 'required',
        ];
    }
}
```

```php:ArticleController&#58;&#58;store()
public function store(ArticleValidation $request, Community $community)
{
    $article = Article::make($request->only(['title', 'body']))
        ->community()->associate($community)
        ->user()->associate(Auth::user());
    $article->save();
    return $article;
}
```

```php:ArticleController&#58;&#58;update()
public function update(ArticleValidation $request, Article $article)
{
    $article->fill($request->only(['title', 'body']))->save();
    return $article;
}
```

これでルーティングのお悩みも解決！

----

<del>2日連続で占領してすいませんでした（どうしても書きたいネタが2つにしか絞れなかった）</del>
6日目は @shinnoki さんからコントローラまわりのお話についてです。