LoginSignup
39
40

More than 1 year has passed since last update.

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

Last updated at Posted at 2017-12-04

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

Laravel Advent Calendar 2017 5日目の記事になります。

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

前提知識

リレーション構成例

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

  • 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

図

【注意】

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

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

(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
commentable_id
CommentController@index
POST /comments commentable_type
commentable_id
CommentController@store
GET /comments/{comment} CommentController@show
PUT /comments/{comment} CommentController@update
DELETE /comments/{comment} CommentController@destroy

何も考えずに

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

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

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

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

ArticlePolicy::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
/communities/{community}/events/{event}/comments
CommentController@index
POST /communities/{community}/articles/{article}/comments
/communities/{community}/events/{event}/comments
CommentController@store
GET /communities/{community}/articles/{article}/comments/{comment}
/communities/{community}/events/{event}/comments/{comment}
CommentController@show
PUT /communities/{community}/articles/{article}/comments/{comment}
/communities/{community}/events/{event}/comments/{comment}
CommentController@update
DELETE /communities/{community}/articles/{article}/comments/{comment}
/communities/{community}/events/{event}/comments/{comment}
CommentController@destroy
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
/events/{event}/comments
CommentController@index
POST /articles/{article}/comments
/events/{event}/comments
CommentController@store
GET /articles/{article}/comments/{comment}
/events/{event}/comments/{comment}
CommentController@show
PUT /articles/{article}/comments/{comment}
/events/{event}/comments/{comment}
CommentController@update
DELETE /articles/{article}/comments/{comment}
/events/{event}/comments/{comment}
CommentController@destroy
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
/events/{event}/comments
CommentController@index
POST /articles/{article}/comments
/events/{event}/comments
CommentController@store
GET /comments/{comment} CommentController@show
PUT /comments/{comment}  CommentController@update
DELETE /comments/{comment} CommentController@destroy

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

Route::authorizedApiResource() マクロ

実装例

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';
    }
}

使用例

ルーティング

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

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つの変数として受け取ります

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

コントローラ

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

ArticleController::show()
public function show(Article $article)
{
    return $article;
}
ArticleController::index()
public function index(Request $request, Community $community)
{
    $q = $community->articles();
    if ($maxId = $request->input('max_id')) {
        $q->where('id', '<=', $maxId);
    }
    return $q->get();
}
CommentController::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();
}

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

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',
        ];
    }
}
ArticleController::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;
}
ArticleController::update()
public function update(ArticleValidation $request, Article $article)
{
    $article->fill($request->only(['title', 'body']))->save();
    return $article;
}

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


2日連続で占領してすいませんでした(どうしても書きたいネタが2つにしか絞れなかった)

6日目は @shinnoki さんからコントローラまわりのお話についてです。

39
40
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
39
40