RailsのPathヘルパーを誤用してdeviseにハマった

はじめに

タイトル通りです。 deviseを使って認証機能を実装する際に、Pathヘルパーを誤用していたため、devise周りの機能がバグってしまいました。

この記事では、deviseでバグが発生した原因とその修正方法をまとめました。 また、何故deviseで期待した挙動にならないのかを理解するために、deviseのコードを追ってみました。

目次

バグの内容

サインアウトのリンクをクリック後に以下のバグが発生しました。

  • リダイレクトしない。
  • サインアウト後に通知メッセージが表示されない。
    • 英語の場合、en.devise.sessions.signed_outキーの訳文が該当メッセージとなる。

再現方法

今回発生した現象を再現するために、devise周りの設定方法について記載しました。

まず、バージョン情報は以下になります。

次にルーティングの設定です。config/routes.rbにて以下のように設定をします。

Rails.application.routes.draw do
  devise_for :users
  # その他ルーティングの設定は省略。
end

サインアウトのprefix、メソッド、URIパターンは以下のようになります。

prefix メソッド URIパターン アクション
destroy_user_session DELETE /users/sign_out(.:format) devise/sessions#destroy

Controllerの設定は不要です。
最後にViewの設定になります。任意のViewファイルで以下のようにPathヘルパーを使ってサインアウトのリンクを作ります。

<%= link_to 'サインアウト', destroy_user_session_path(current_user), method: :delete %>

current_userはdeviseが用意したヘルパーメソッドです。サインイン中のユーザーのモデルオブジェクトを返します。

バグ発生の原因と対処方法

バグ発生の原因は、タイトルにある通り、Pathヘルパーの使い方に誤りがあるためです。 該当箇所は再現方法で説明した「サインアウトのリンクを作成するコード」です。 Pathヘルパーのdestroy_user_session_pathのパラメータにcurrent_userを指定したことが誤りになります。

destroy_user_sessionURIパターン/user/sign_out(.:format):formatは、パラメータとなります。丸括弧()は必須ではないパラメータを指します。パラメータ名がformatなので、ここに入れるべき値は、htmlxmlなどのMimeTypeのサブタイプが当てはまります。

しかし今回のケースでは、Pathヘルパーの引数にcurrent_userを指定してます。 実際のURLは/user/sign_out.2のように、:format箇所が ログインユーザーのidとなります。フォーマットがdeviseの想定しない値であるため、今回のバグが発生したのです。

バグの対処方法は簡単です。 Pathヘルパーのパラメータに不適切な値を指定したので、これを修正することでバグは解消できます。URIパターンの丸括弧のパラメータは必須ではないため、下記のように、Pathヘルパーの引数を削除します。(引数なしでもリクエストでのフォーマットのMimeTypeのサブタイプはhtmlになります。)

<%= link_to 'サインアウト', destroy_user_session_path, method: :delete %>

サインアウト後にリダイレクトされない原因

根本的な原因は上述しましたが、deviseで期待した挙動にならない原因をコードリーディングで調査したいと思います。

サインアウト後のアクションは、devise/sessions#destroyになるので、app/controllers/devise/sessions_controller.rbdestroyメソッドを見ます。(deviseのファイルのパスは、bundle info deviseで確認が出来ます。)
最終行にrespond_to_on_destroyメソッドを呼び出しているので、このメソッドがリダイレクト処理を実行しています。

  def respond_to_on_destroy
    # We actually need to hardcode this as Rails default responder doesn't
    # support returning empty response on GET request
    respond_to do |format|
      format.all { head :no_content }
      format.any(*navigational_formats) { redirect_to after_sign_out_path_for(resource_name) }
    end
  end

respond_toメソッドでは、クライアントの要求するフォーマットごとに処理を記述しています。 ブロック処理にはformat.all(引数なし)とformat.any(*navigational_formats)があります。format.allformat.anyは異なる機能のメソッドに見えますが、これらはエイリアスメソッドになります。 ですので、2つの処理が異なる点は引数の有無です。 引数の有無による挙動の違いについては、Rails APIActionController::MimeResponds#respond_toで以下のように説明があります。

respond_to | Rails API

  • 引数有りの場合

    respond_to also allows you to specify a common block for different formats by using any:

  • 引数無しの場合

    any can also be used with no arguments, in which case it will be used for any format requested by the user:

つまり、引数有りの場合は、指定したフォーマットを対象に共通のブロック処理を実行します。また、引数がない場合は、任意のフォーマットを対象に共通のブロック処理を実行します。

respond_to_on_destroyでは、navigational_formatsに該当しないフォーマットの場合は、リクエスト時のページから遷移しない204 No Contentをレスポンスステータスとしています。 このことから、サインアウト後にリダイレクトしない原因は、destroy_user_session_path(current_user)のフォーマットがnavigational_formatsに該当しないことだと予測できます。

では次に、navigational_formatsがどのフォーマットであるのか調べます。 app/controllers/devise_controller.rbに該当するメソッドがあります。

  def navigational_formats
    @navigational_formats ||= Devise.navigational_formats.select { |format| Mime::EXTENSION_LOOKUP[format.to_s] }
  end

配列のクラス変数で繰り返し処理を行っています。Mime::EXTENSION_LOOKUPに含まれているフォーマットを抽出した要素がnavigational_formatsになるようです。

Mime::EXTENSION_LOOKUPは、Railsで扱うフォーマットになります。 クラス変数のDevise.navigational_formatsは、lib/devise.rbで定義しています。

  # Which formats should be treated as navigational.
  mattr_accessor :navigational_formats
  @@navigational_formats = ["*/*", :html]

navigational_formatsは、Railsプロジェクトで設定可能な項目になります。 つまりlib/devise.rbでは、初期設定としてワイルドカードhtmlnavigational_formatsとして定義されています。

以上のことから、調査内容をまとめます。

  1. destroy_user_session_path(current_user)のリクエストフォーマットはログイン中のユーザーidである。
  2. リクエストのフォーマットがnavigational_formatsに該当する場合は、リダイレクトする。
  3. 2.以外の場合は、204 No Contentをレスポンスステータスを返す。つまり、画面遷移をしない。
  4. navigational_formatsは、今回のdeviseの設定では*/*html
  5. 今回のケースでリダイレクトしない原因は、リクエストフォーマットがnavigational_formatsに含まれず、2.が実行されるため。

サインアウト後に通知メッセージが表示されない原因

こちらの原因もコードリーディングで調査したいと思います。

destroyメソッドには、set_flash_message!を呼び出しています。これが、通知メッセージを出力する処理となります。

  # Sets flash message if is_flashing_format? equals true
  def set_flash_message!(key, kind, options = {})
    if is_flashing_format?
      set_flash_message(key, kind, options)
    end
  end

デバッグすると、is_flashing_format?の値が偽となり処理が終了します。
is_flashing_format?を見てみます。

# Check if flash messages should be emitted. Default is to do it on
# navigational formats
def is_flashing_format?
  request.respond_to?(:flash) && is_navigational_format?
end

最初の式の値は真となりましたが、2つ目の式の値は偽となりました。 ここでもnavigational_formatが出ました。is_navigational_format?は、リクエストのフォーマットがDevise.navigatinal_formatsに含まれているかを判定します。

def is_navigational_format?
  Devise.navigational_formats.include?(request_format)
end

Devise.navigational_formatsはリダイレクトの原因調査の際に確認しました。 つまり、通知メッセージが表示されない原因は、リダイレクトしない原因と同様にリクエストフォーマットがnavigational_formatsに含まれないためです。

まとめ

RailsのPathヘルパーを使用する際に、フォーマットのパラメータにdeviseが意図しない値を指定したことが原因でdevise周りの機能でバグが発生しました。
今回のバグの調査をしたことで、提供されるAPIメソッドの使い方と挙動を正確に理解することが重要であることを学びました。