大ちゃんの駆け出し技術ブログ

RUNTEQ受講生のかわいいといわれるアウトプットブログ

【Gem】Banken

はじめに

こんにちは!大ちゃんの駆け出し技術ブログです。

2ヶ月ほど前にPunditの記事を書きましたが、今回はそれとよく似たgemのBankenについて書いていきたいと思います。

こちらのgemを作ってくださったのは日本人の方で英語のドキュメントだけでなく日本語のドキュメントも用意されています。

  • 日本語のドキュメント

github.com

日本人が作った日本語ドキュメントなので本当にわかりやすく導入することができました。

まずはインストール方法を先に紹介します。ただ、これは日本語ドキュメントがありますので、日本語ドキュメントを参照していただいた方が早いと思ってます。

インストール方法

上記ドキュメントに従ってインストールします。

Gemfileに追記しbundle install

# Gemfile
gem "banken"

# ターミナル
$ bundle
Fetching banken 1.0.3
  • アプリケーションコントローラでBankenをinclude
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  include Banken
  • ターミナル上でコマンドを使用してインストール

application_loyalty.rbというファイルが作成されます。

$ rails g banken:install
      create  app/loyalties/application_loyalty.rb

作成されたファイル

# app/loyalties/application_loyalty.rb
class ApplicationLoyalty
  attr_reader :user, :record

  def initialize(user, record)
    @user = user
    @record = record
  end

  def index?
    false
  end

  def show?
    false
  end

  def create?
    false
  end

  def new?
    create?
  end

  def update?
    false
  end

  def edit?
    update?
  end

  def destroy?
    false
  end
end

ここまでで基本インストールはおしまいです。

要件

今回自分がPFに使用した例を紹介します。

自分のPFではログイン後ユーザーがプロフィールを作成していない場合、まずはプロフィール新規作成画面に遷移します。ここでユーザーはプロフィール作成が終わるまで、プロフィール新規作成画からはどこのページにも遷移できないように制御したいです。

また、新規作成後はプロフィール新規作成画面に戻る事ができない仕様にしたいです。ユーザーとプロフィールのアソシエーションはhas_oneの関係(1 : 1)なので、ユーザーが1つのプロフィールを既に作成していれば、新規作成画面に遷移する必要がないからです。

# app/models/profile.rb
class Profile < ApplicationRecord
  belongs_to :user

# app/models/user.rb
class User < ApplicationRecord
    has_one :profile, dependent: :destroy

以下2つの項目を制御できるようにBankenを使用してみます。

  • 基本プロフィール未作成時にプロフィール一覧に遷移できない
  • プロフィール作成後はプロフィール作成画面に遷移できない

使用方法

  • Loyaltyクラスの作成

制御したいアクションがあるコントローラの名前と同名のLoyaltyクラスを作成します。以下公式ドキュメントを引用。


公式ドキュメントの例

Bankenではcontrollerに一対一で紐づく同名のloyaltyクラスを作成していきます。 今回はPostsControllerに対して制御を行うのでPostsLoyaltyクラスを作成する必要があります。

> rails g banken:loyalty posts
      create  app/loyalties/posts_loyalty.rb

今回はProfilesControllerの新規作成画面での遷移(newアクション)と一覧画面への遷移(indexアクション)に対して認可制御を行います。

rails g banken:loyalty profiles
      create  app/loyalties/profiles_loyalty.rb

新しくProfilesLoyaltyクラスが作成されました。しかし、中身はクラスが定義されているだけでメソッド等のロジックは記載されていません。

# app/loyalties/profiles_loyalty.rb
class ProfilesLoyalty < ApplicationLoyalty
end

基本プロフィール未作成時にプロフィール一覧に遷移できない

まずは新規作成画面からどこの画面にも遷移できないように制御します。

  • プロフィール一覧画面を表示させるindexアクションに対してauthorize!メソッドを設置

※ 今回はAPI化を行っているため、特にこちらのコントローラに記載はしておりません。

# app/controllers/profiles_controller.rb
class ProfilesController < ApplicationController

  def index
    authorize!
  end

authorize!メソッドについてですが、実行されたアクション名に紐づいたLoyaltyクラス内のアクション名?メソッドを実行します。今回はProfilesControllerindexアクションの中でauthorize!メソッドが実行されているので、コントローラに紐づいているProfilesLoyaltyindex?メソッドが実行されます。

  • ProfilesLoyaltyindex?に対してuser.profile.present?を記述

index?メソッドがまだ定義されていませんので定義します。?とあるようにこのLoyaltyクラス内のメソッドは真偽値を返さないといけません。そこで今回は要件に従うようににcurrent_userがプロフィールを所持しているかどうかに対してboolean値を返すようにします。

# app/loyalties/profiles_loyalty.rb
class ProfilesLoyalty < ApplicationLoyalty
  def index?
    user.profile.present?
  end
end

このメソッドの中にあるuserはどこからくるの?と思うかもしれませんが、実はこのusercurrent_userと同じです。Bankenの便利なところで上記のauthorize!メソッドは下記のことと同じことを実行します。

raise "not authorized" unless ProfilesLoyalty.new(current_user).index?

勝手に引数にcurrent_userを渡してくれていますね。なのでauthorize!メソッドで引数を渡さずとも、current_userは勝手に渡されており、それがindex?アクションの中でuserとして扱われています。

※ 上記のようにcurrent_userを必ず使用するため、current_userが使用できないとエラーが発生します。よって、必ずcurrent_userを使えるようにしておく必要があります。自分の場合、deviseを使用してcurrent_userを使えるようにしております。

  • application_controllerに例外処理を記載

上記のuser.profile.present?でfalseが返ると例外が発生します。例外をハンドリングするためにApplicationControllerにエラーハンドリングのロジックを書きます。

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  include Banken

  # エラーハンドリング =============
  rescue_from Banken::NotAuthorizedError, with: :user_not_authorized

  private

  def user_not_authorized(exception)
    redirect_to(request.referer || root_path)
  end

request.refererは遷移する前の元の画面のURLを取得します。プロフィール新規作成画面から遷移しようとしBankenで例外が生じると、プロフィール新規作成画面に戻るということになります。そして元の画面のURLがnilの場合はroot_pathに遷移するようにします。

ここで問題なのが、request.refererはボタンやリンクなどから元の画面のURLを取得するのですが、直接ブラウザにURLを打ち込まれて画面遷移した場合、request.refererの値はnilになってしまいます。よって、新規作成画面にいる状態でブラウザに/profilesを打ち込むと、root_pathにリダイレクトされていしまいます。しかし今回の実装の場合、プロフィール新規作成が終了するまでは他の画面に遷移する事はできないようにしたいので、root_pathにも遷移したくありません。

なのでroot_pathのアクションに対して直接ロジックを書きます。

def index
  if user_signed_in? && current_user.profile.nil?
    redirect_to new_profile_path
    end
end

ユーザーがuser_signed_in?していて尚且つプロフィールを持っていないのであれば、新規作成画面にリダイレクトします。少し冗長ですがこれで第一の要件を満たす事はできました。

プロフィール作成後はプロフィール作成画面に遷移できない

次の要件を満たすために先ほどと同じようにauthorize!メソッドを使用します。今回はnewアクションですね。

# app/controllers/profiles_controller.rb
class ProfilesController < ApplicationController

  def index
    authorize!
  end

 def new
    authorize!
 end

newアクションに対応するnew?メソッドをProfilesLoyaltyクラスに定義します。今回は新規作成済みの場合の制御となるのでuser.profile.present?とは逆のロジックを書けばいいと思います。

# app/loyalties/profiles_loyalty.rb
class ProfilesLoyalty < ApplicationLoyalty
  def index?
    user.profile.present?
  end

  def new?
      user.profile.nil?
  end
end

これで実装はできているのですが、先ほどと同じように直接ブラウザ上で新規作成画面のURLを打ち込むと、request.referernilになりますので、このままだとトップページにリダイレクトします。

それを制御するためには少々手荒な実装ですが、下記のように記述して解決しました。

def index
  if user_signed_in? && current_user.profile.nil?
    redirect_to new_profile_path
  elsif user_signed_in?
    redirect_to profiles_path
  end
end

user_signed_in?をさらに下に書く事で実装できました。これによりプロフィールは持っているけども、サインインしている状態のユーザーがプロフィール一覧画面にリダイレクトします。

user_signed_in? && current_user.profile.nil?の制御を必ず最初の条件に持っていかないとuser_signed_in?の制御が先に行われます。そのため、新規作成画面からトップページにリダイレクトした時にプロフィール一覧画面に再度また遷移し、そこでまた認可制御されるという無限ループになり画面が白くなります。

これで実装は完了です!

Punditとの違い

BankenはPunditの影響を強く受けているgemです。gemの英語ページでも下記のように説明されています。

Simple and lightweight authorization library for Rails inspired by Pundit

今回Bankenを使用し、また過去にPunditを使用したので2つのgemの違いがざっくりと見えましたので共有します。

コードの記述方法の違いを見ればわかりやすいかと思います。今回のロジックをPunditで行うと以下のようになります。以下はプロフィール一覧画面への制御です。

Punditの場合

def index
  authorize Profile
end

Bankenの場合

def index
  authorize!
end

違いとしては下記2点です。

Bankenと違う点

  1. authorize!ではなくauthorize(!マークがない)
  2. 引数がない場合クラスを引数にする(Profile)

1に関しては書き方の違いなので大した問題ではないのですが、2は大きな違いだと思います。Bankenの場合引数がなくても引数なしにauthorize!メソッドを使用できますが、Punditの場合、対応するモデルのクラス名を記述しないといけません。

こちらの理由ですが、これはPunditとBankenの紐づいている対象がモデルかコントローラかという違いになります。

Punditはコントローラ、モデル、そしてPolicyクラス(BankenのLoyalityクラスと同じ認可のメソッドが定義されるファイル)が1 : 1 : 1で結びついています。そして、authorizeメソッドは引数のインスタンス変数(@profileなど)からその変数のモデルクラスがあると推測し、そのモデルクラスに対応するPolicyクラスがあると判断します。ここがPunditとBankenの大きな違いです。

Punditの場合の認可のフローは下記のようなものです。まさにコントローラ、モデル、そしてPolicyクラスそれぞれが結びついていることがわかると思います。3つのファイルがあって初めて機能します。

def index # indexアクションを実行
  authorize @profile # 変数名からProfileクラスがあると推測
endclass Profile # Profileクラスを確認
endclass ProfilePolicy < ApplicationPolicy # モデルのクラスに対応するPolicyを確認
  def index? # コントローラで実行されたのはindexアクションなのでindex?メソッドを実行
    user.profile.present?
  end
end

そのため、authorizeメソッドで何も引数がない場合、Profileクラスがあると推測することができません。よって、モデルのクラス名を渡してあげることで、Punditにクラスがあると推測させる必要があるわけです。

def index # indexアクションを実行
  authorize Profile # クラス名からProfileクラスがあると推測
end

逆にBankenだとコントローラのみでしたね。モデルの有無は関係ありません。

class ProfilesController # コントローラ名からProfilesLoyaltyがあると推測
    def index # indexアクションを実行
      authorize! 
    endclass ProfilesLoyalty < ApplicationLoyalty # コントローラに対応するProfilesLoyaltyを確認
  def index? # コントローラで実行されたのはindexアクションなのでindex?メソッドを実行
    user.profile.present?
  end
end

もっと詳しく知りたいという方は下記のドキュメントを参照してください。

The difference between Banken and Pundit (Japanese)

github.com

本記事は以上になります。

参考記事

github.com

github.com