こんにちは!kossyです!
今回はRailsで認証機能を実装する際の定番Gemである「devise」のextensionで、招待機能を実現するGem「devise_invitable」のソースコードを追ってみたので、ブログに残してみたいと思います。
環境
Ruby 2.6.6
Rails 6.0.3
MacOS catalina
なお、今回の記事ではdevise_invitableの導入手順については説明を割愛します。また、deviseを利用しているUserモデルが定義されていることとします。
導入手順については以下の記事が参考になるかと思います。
Devise::InvitationsController 各種prepend_before_action
createアクションやupdateアクションのコードを見たいところですが、4つほど定義されているprepend_before_actionから先に読みます。
prepend_before_actionはCallbackメソッドのひとつで、before_actionよりも前に呼び出されます。
authenticate_inviter!
def authenticate_inviter! send(:"authenticate_#{resource_name}!", force: true) end
resource_nameはdeviseを利用しているモデル名の文字列で、authenticate_user!メソッドをsendメソッドで呼び出す処理でした。
要はログインしているかどうか?をprepend_before_actionで確認しているということですね。
has_invitations_left?
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
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メソッドを読み終えました。残りの処理はエラーの有無に応じて、遷移先のページを切り替えているようでした。