招待機能を実現するGem「devise_invitable」のソースコードを追ってみる

こんにちは!kossyです!




今回はRailsで認証機能を実装する際の定番Gemである「devise」のextensionで、招待機能を実現するGem「devise_invitable」のソースコードを追ってみたので、ブログに残してみたいと思います。




github.com




環境
Ruby 2.6.6
Rails 6.0.3
MacOS catalina





なお、今回の記事ではdevise_invitableの導入手順については説明を割愛します。また、deviseを利用しているUserモデルが定義されていることとします。

導入手順については以下の記事が参考になるかと思います。

qiita.com


Devise::InvitationsController 各種prepend_before_action

github.com



createアクションやupdateアクションのコードを見たいところですが、4つほど定義されているprepend_before_actionから先に読みます。

prepend_before_actionはCallbackメソッドのひとつで、before_actionよりも前に呼び出されます。

authenticate_inviter!

github.com

    def authenticate_inviter!
      send(:"authenticate_#{resource_name}!", force: true)
    end

resource_nameはdeviseを利用しているモデル名の文字列で、authenticate_user!メソッドをsendメソッドで呼び出す処理でした。

要はログインしているかどうか?をprepend_before_actionで確認しているということですね。


has_invitations_left?

github.com

    def has_invitations_left?
      unless current_inviter.nil? || current_inviter.has_invitations_left?
        self.resource = resource_class.new
        set_flash_message :alert, :no_invitations_remaining if is_flashing_format?
        respond_with_navigational(resource) { render :new }
      end
    end

current_inviterは現在ログイン中のユーザーのことですね。

current_inviter.has_invitations_left?メソッドの処理は以下です。

    # Return true if this user has invitations left to send
    def has_invitations_left?
      if self.class.invitation_limit.present?
        if invitation_limit
          return invitation_limit > 0
        else
          return self.class.invitation_limit > 0
        end
      else
        return true
      end
    end

各ユーザーが招待可能な人数のリミットを指定するinvitation_limitカラムが存在し、0より大きいかどうかの比較に、

Devise.configで設定された値があればそちらを優先し、なければカラムの値を使って比較して真偽値を返却します。

controller側のhas_invitations_left?に戻ります。

resource_classはdeviseを使っているモデルそのもので、今回の場合はUserモデルのインスタンスですね。

その後は内部でrespond_withメソッドを呼び出す respond_with_navigationalメソッドを用いてnewのviewをrenderしています。


require_no_authentication

github.com

  def require_no_authentication
    assert_is_devise_resource!
    return unless is_navigational_format?
    no_input = devise_mapping.no_input_strategies

    authenticated = if no_input.present?
      args = no_input.dup.push scope: resource_name
      warden.authenticate?(*args)
    else
      warden.authenticated?(resource_name)
    end

    if authenticated && resource = warden.user(resource_name)
      set_flash_message(:alert, 'already_authenticated', scope: 'devise.failure')
      redirect_to after_sign_in_path_for(resource)
    end
  end

assert_is_devise_resource!メソッドは、操作を行っているresourceがdeviseがmappingされたものであるかどうかを確認していました。

is_navigational_format?メソッドは、request_formatがDevise.navigational_formatsに含まれているかどうかを真偽値で返すようにしていました。

残りの処理は、既に認証済みの場合はフラッシュメッセージに「既に認証済みである」旨のメッセージを格納し、リダイレクト処理を行っていました。


resource_from_invitation_token

    def resource_from_invitation_token
      unless params[:invitation_token] && self.resource = resource_class.find_by_invitation_token(params[:invitation_token], true)
        set_flash_message(:alert, :invitation_token_invalid) if is_flashing_format?
        redirect_to after_sign_out_path_for(resource_name)
      end
    end

招待トークンがnilまたはdeviseを使っているモデルのテーブルを招待トークンで検索してレコードが見つからない場合は、
フラッシュメッセージに「招待トークンが間違っている」旨のメッセージを格納し、リダイレクト処理を行っていました。

before_actionを読むだけでもかなりのボリュームになりますね、、、


createアクションのソースコードを読む

ようやくcreateアクションのコードにたどり着きました、、、

  # POST /resource/invitation
  def create
    self.resource = invite_resource
    resource_invited = resource.errors.empty?

    yield resource if block_given?

    if resource_invited
      if is_flashing_format? && self.resource.invitation_sent_at
        set_flash_message :notice, :send_instructions, email: self.resource.email
      end
      if self.method(:after_invite_path_for).arity == 1
        respond_with resource, location: after_invite_path_for(current_inviter)
      else
        respond_with resource, location: after_invite_path_for(current_inviter, resource)
      end
    else
      respond_with_navigational(resource) { render :new }
    end
  end

invite_resourceメソッドを読んでみます。

    def invite_resource(&block)
      resource_class.invite!(invite_params, current_inviter, &block)
    end

    # invite_paramsのソースコードは下記

    def invite_params
      devise_parameter_sanitizer.sanitize(:invite)
    end

    # current_inviterメソッドは処理の内部でauthenticate_inviter!メソッドをコールしていました
    # https://github.com/scambra/devise_invitable/blob/db1f065c452e6102ff8802bb264329adc4714295/lib/devise_invitable/controllers/helpers.rb#L17

    def authenticate_inviter!
      send(:"authenticate_#{resource_name}!", force: true)
    end

invite!メソッドを見にいきましょう。

        def invite!(attributes = {}, invited_by = nil, options = {}, &block)
          attr_hash = ActiveSupport::HashWithIndifferentAccess.new(attributes.to_h)
          _invite(attr_hash, invited_by, options, &block).first
        end

_inviteメソッドが本丸っぽい。

        # Attempt to find a user by its email. If a record is not found,
        # create a new user and send an invitation to it. If the user is found,
        # return the user with an email already exists error.
        # If the user is found and still has a pending invitation, invitation
        # email is resent unless resend_invitation is set to false.
        # Attributes must contain the user's email, other attributes will be
        # set in the record
        def _invite(attributes = {}, invited_by = nil, options = {}, &block)
          invite_key_array = invite_key_fields
          attributes_hash = {}
          invite_key_array.each do |k,v|
            attribute = attributes.delete(k)
            attribute = attribute.to_s.strip if strip_whitespace_keys.include?(k)
            attributes_hash[k] = attribute
          end

          invitable = find_or_initialize_with_errors(invite_key_array, attributes_hash)
          invitable.assign_attributes(attributes)
          invitable.invited_by = invited_by
          unless invitable.password || invitable.encrypted_password.present?
            invitable.password = random_password
          end

          invitable.valid? if self.validate_on_invite
          if invitable.new_record?
            invitable.clear_errors_on_valid_keys if !self.validate_on_invite
          elsif invitable.invitation_taken? || !self.resend_invitation
            invite_key_array.each do |key|
              invitable.add_taken_error(key)
            end
          end

          yield invitable if block_given?
          mail = invitable.invite!(nil, options) if invitable.errors.empty?
          [invitable, mail]
        end

コメントアウト部分を訳してみます。

電子メールでユーザーを見つけようとします。 レコードが見つからない場合は、新しいユーザーを作成して招待状を送信します。 ユーザーが見つかった場合は、メールが既に存在するというエラーでユーザーを返します。

ユーザーが見つかり、まだ保留中の招待がある場合、resend_invitationがfalseに設定されていない限り、招待メールが再送信されます。 属性にはユーザーの電子メールが含まれている必要があり、他の属性はレコードに設定されます。

非常に丁寧なコメントアウトですね、、、自分もこのようなコメントアウトが英語で書けるようになりたいものです。もはやコードの説明は不要でしょう。(面倒だから読みたくないだけ)

ようやくinvite_resourceメソッドを読み終えました。残りの処理はエラーの有無に応じて、遷移先のページを切り替えているようでした。


大いに参考にさせていただいた記事

この場を借りて御礼を申し上げます。

Railsで、deviseとdevise_invitableをつかって招待機能を実装する - Qiita