1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Rails】Railsチュートリアル8章を読み終えて

Posted at

はじめに

こんにちは。アメリカで独学でエンジニアを目指している者です。
現在、Ruby on Rails Tutorialを使ってRailsを勉強しており、第8章まで読み終えたので、学んだことを忘れないように振り返りの記事を書いてみました。
読んだのはだいぶ前でしたが、理解度がかなり低かったので再度復習した際にこの記事を書いています


基本的なログイン機構

この章ではログインの仕組み

セッション

この章ではセッションについての説明と、具体的にSessionsControllerの設計を扱いました
sessionメソッドの具体的な説明は、以前に記事を書いているので割愛しますがこの章で作成したSessionsControllerはセッションを使用してログインとログアウトの仕組みを管理しようという意図でそういった名前にしたのかなと思いました
最初はsessionメソッドとSessionsControllerが出てきてどのように関係しているのか考えていましたが、見当違いでした。

ルーティングの設定

ルーティングを設定するために、rails generate controller Sessions newにてSessionsコントローラーを作成した後ルーティングを設定します
UsersControllerの際はRESTfulな設計にするために resource :usersとしましたが、今回はログインをするためのnew(GET),create(POST)とログインに必要なdestroy(DELETE)を設定しました

Rails.application.routes.draw do
 root "static_pages#home"
 get "/help", to:"static_pages#help"
 get "/about", to: "static_pages#about"
 get "/contact",to: "static_pages#contact"
 get "/signup", to:"users#new"
 get "/login", to: "sessions#new"
 post "/login", to:"sessions#create"
 delete "/logout", to: "sessions#destroy"
 resources :users
 end

ログインフォームの作成

Userの会員登録の際にも使用したform_withSessionsControllerのviewのnew.html.erbに定義しました。
form_withの引数がmodel:@userではなく、login_path, scope: :sessionに指定しています。
これはactionがlogin_pathつまりcreateアクションのPOSTリクエストであることを示しています
ちなみにscope: :sessionはHTTPリクエストとしてparamsに値を渡す際にsessionという名前を付けているという意味合いがあって、後に出てくるsessionメソッドとはまったく別物です

SessionsControllerのアクションの設定(create)

ここからは少しずつですがSessionsControllerのコントローラーを作成していきます。
必要なアクションはnew,create,destroyですが、newは主にviewsの表示に使用されているのだけなので現時点ではアクションの定義は不要です。

class SessionsController < ApplicationController
  def create
      user = User.find_by(email: params[:session][:email].downcase) # 入力されたemailと同じデータがDB内にあるか
      if user&.authenticate(params[:session][:password]) # そのuserのPWが正しいか
        reset_session #session id を更新してセッション固定を防止
        log_in user
        redirect_to user
      else
      # エラーメッセージを作成する
        flash.now[:danger] = 'Invalid email/password combination' 
        render 'new', status: :unprocessable_entity
      end
  end
end

createアクションの流れとしては

  1. DBからparamsのセッションのemailに該当するものがあるかを確認し、それをuserと定義
  2. userが存在し、Userモデルで設定したによって使用できるようになったauthenticate`メソッドを使用してPWが正しいか確認
  3. 2がtrueであれば、セッション固定を防ぐためにreset_sessionを使用しセッションをリセットする
  4. SessionHelperに定義したlog_inメソッド、引数をuserとして呼び出す。ここでセッションにsession[:user_id] = user.idとしてユーザーIDが登録される
  5. redirect_to userによってUser#showが呼び出されます。違うページに遷移する際にはredirect_toを使用します
  6. 2がfalseの場合、flashを使用します。flashだけだと次のリクエストまでエラーメッセージを表示してしまいますが、flash.nowなら今のリクエストまでしかメッセージを持たないのでエラーを表示させる場合、renderで再度そのページを表示させるケースが多い(新たにリクエストを送らない)ので、flash.nowを使用する
  7. Sessionsのnewアクションのviewを表示しています。この時:unprocessable_entityはリクエストが正しいけれど、意味的に誤りがある422ステータスコードを表示するようにしています

スマホ用のビジュアルの調整

スマホサイズか否かはブラウザの開発モードにて見ることができますが、スマホサイズになるとレイアウトが崩れます。
そこでハンバーガーメニューという三本線のよくあるメニューを導入しました

_header.html.erb
<header class="navbar navbar-fixed-top navbar-inverse">
    <div class="container">
    <%= link_to "sample app", root_path, id:"logo"%>
        <nav>
             <div class="navbar-header"> <!--スマホ用のハンバーガーメニューの設定 -->
                <button id="hamburger" type="button" class="navbar-toggle collapsed"> <!--collapsed によって閉じられていることを明示 -->
                    <span class="sr-only">Toggle navigation</span>
                    <span class="icon-bar"></span> <!--これでハンバーガーメニューの1本分 -->
                    <span class="icon-bar"></span>
                    <span class="icon-bar"></span><!--これが3つでハンバーガーメニューになる -->
                </button>
            </div>
            <ul id="navbar-menu" class="nav navbar-nav navbar-right collapse navbar-collapse"> <!--ハンバーガーメニューによって隠されるメニュー -->
                <li><%= link_to "Home", root_path %></li>
                <li><%= link_to "Help", help_path %></li>
                <% if logged_in? %> <!--ログイン状態なら表示されているメニュー -->
                        <li><%= link_to "Users",'#' %></li>
                        <li class= "dropdown"><!--Accountにドロップダウンがついている -->
                            <a href= "#" id= "account" class= "dropdown-toggle">
                                Account <b class= "caret"></b>
                            </a>
                            <ul id="dropdown-menu" class="dropdown-menu"><!--ドロップダウンによって隠されているメニュー -->
                                <li><%= link_to "Profile",current_user %></li>
                                <li><%= link_to "Settings", '#' %></li>
                                <li class="divider"></li>
                                <li>
                                    <%= link_to "Logout",logout_path, data:{ "turbo-method": :delete } %>
                                </li>
                            </ul>
                        </li>
                <% else %><!--ログインしていない場合に表示されているメニュー -->
                    <li><%= link_to "Log in", login_path %></li>
                <% end %>
            </ul>
        </nav>
    </div>
</header>

ログインの状態ならば、Home,Help,Accountが表示され、Accountにはドロップダウンがついておりそれを押すとProfile,Settings,Logoutリンクがあります。スマホで表示した場合は、<ul id="navbar-menu" class="nav navbar-nav navbar-right collapse navbar-collapse">のcollapseによって上記のメニューが隠れている状態です

ログインしていない場合のヘッダーは、Home,Help,Log inのみとなります

JavaScriptを反映させる

上記のHTML.ERBファイルへ実際にクリックするとJSが反映させるようにJSファイルを書く必要があります
そのまま書くと違う言語のため互換性がないので、importmapというライブラリを使用します
JSを使う前に

  1. Gemfileにgem "turbo-rails", "1.4.0"と記載(versionはRailsチュートリアルのものを記載している)
  2. app/assets/config/manifest.js(なければ新規作成)に以下のように記入する。
 //=link_tree../images
 //=link_directory ../stylesheets.css 
 //=link_tree../../javascript.js   //追加
 //=link_tree../../../vendor/javascript.js  //追加

ここで注意点ですが、もしvendor/javascript.jsがなければ追加しないと、ブラウザがJSを正常に読み込んでくれません。
作成する場所について、sample_appがルートディレクトリならその直下に作成してください(/vendor/javascript.js)
3. app/javascript/customディレクトリを作成。app/javascript/custom/menu.jsも作成して実装のメインとなるコードをmenu.jsに書いていく
4. config/importmap.rbに以下を記載

 pin "application",preload:true
 pin "@hotwired/turbo-rails", to:"turbo.min.js", preload: true
 pin "@hotwired/stimulus", to:"stimulus.min.js", preload: true
 pin "@hotwired/stimulus-loading",to: "stimulus-loading.js",preload:true
 pin_all_from "app/javascript/controllers",under:"controllers"
 pin_all_from "app/javascript/custom", under: "custom"

これも2の時同様でapp/javascript/controllersディレクトリが存在しない場合はこれもエラーが出ます。
私はまだこのディレクトリを使用していないので一旦はコメントアウトしています。
6. app/javascript/application.jsに以下を記載

 // Configure your import map in config/importmap.rb.
 // Read more: https://github.com/rails/importmap-rails
 import "@hotwired/turbo-rails"
 import "controllers"
 import "custom/menu"

application.scssの時同様ですが、application.jsがまとめて読み込むしようとなっているようです

ここまで設定して始めてコードを書きます

menu.js
//メニュー操作

//トグルリスナーを追加する
function addToggleListener(selected_id, menu_id, toggle_class) {
    let selected_element = document.querySelector(`#${selected_id}`);
    selected_element.addEventListener("click", function(event) {
        event.preventDefault();
        let menu = document.querySelector(`#${menu_id}`);
        menu.classList.toggle(toggle_class);
    });
}

// クリックをリッスンするトグルリスナーを追加する
document.addEventListener("turbo:load", function() {
    addToggleListener("hamburger", "navbar-menu", "collapse");
    addToggleListener("account", "dropdown-menu", "active");
});

DRY法則に乗っ取り演習でaddToggleListenerを作成しクリックされた場合に、以下のようになります

  1. ハンバーガーメニューが表示されるようになる
  2. ドロップダウンメニューが表示されるようになる
    menu.classList.toggleのようにtoggleメソッドを使用しているので、collapseのように最初に書いてあった場合はクリックすることでそれを削除することができます。

ログインのテスト

ここまで出来たらログインのテストを行います。
6章で出てきたfixtureのusers.ymlはテストの時に使用する変数を指定できるものなのでここで使用します。
しかし、テスト時にはhas_secure_passwordによるパスワードのハッシュ化は行われないので自分で作成する必要があります

user.rb
class User < ApplicationRecord 
  before_save{ email.downcase!}
  validates :name, presence: true, length: { maximum: 50 }
  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
  validates :email, presence: true, length: { maximum: 255},
                    format: { with: VALID_EMAIL_REGEX },
                    uniqueness: true
  has_secure_password
  validates :password, presence: true, length: { minimum: 6 }

  #渡された文字列のハッシュ値を返す
  def User.digest(string)
    cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost
    BCrypt::Password.create(string, cost:cost)
  end
end

これを以下のfixtureのusers.ymlに定義します

 michael:
  name: Michael Example
  email: michael@example.com
  password_digest: <%= User.digest('password') %>

ここでさっそくテストを書いていきますが、今回はloginのテストをメインで行っていたのでそれのみに触れます

require "test_helper"

class UsersLogin < ActionDispatch::IntegrationTest
  def setup
    @user = users(:michael)
  end
end

class InvalidPasswordTest < UsersLogin
  test "login path" do
    get login_path
    assert_template 'sessions/new'
  end
  test "login with valid email/invalid password" do
    post login_path, params: { session: { email: @user.email, password: "invalid" } }
    assert_not is_logged_in?
    assert_template 'sessions/new'
    assert_not flash.empty?
    get root_path
    assert flash.empty?
  end
end

class ValidLogin < UsersLogin
  def setup
    super
    post login_path, params: { session: { email: @user.email, password: 'password' } }
  end
end

class ValidLoginTest < ValidLogin
  test "valid login" do
    assert is_logged_in?
    assert_redirected_to @user
  end

  test "redirect after login" do
    follow_redirect!
    assert_template 'users/show'
    assert_select "a[href=?]", login_path, count: 0
    assert_select "a[href=?]", logout_path
    assert_select "a[href=?]", user_path(@user)
  end
end

class Logout < ValidLogin
  def setup
    super
    delete logout_path
  end
end

class LogoutTest < Logout
  test "successful logout"do
    assert_not is_logged_in?
    assert_response :see_other # 見慣れない
    assert_redirected_to root_url
  end

  test "redirect after logout" do
    follow_redirect!
    assert_select "a[href=?]", login_path
    assert_select "a[href=?]", logout_path, count: 0
    assert_select "a[href=?]", user_path(@user), count:0
  end
end

このテストはそれぞれ、ログインがうまくいくテスト、うまくいかないテスト、ログアウトがうまく行くテスト、行かないテストの計4つを検証しています。
この中でしいて気になるとすれば、見慣れないとコメントされている:see_otherというHTTPリクエストです。

sessions_controller.rb
  def destroy
    log_out
    redirect_to root_url, status: :see_other
  end

これはSessionsControllerでも定義されており、destroy アクションは DELETE リクエストによって呼ばれるため、リダイレクト先に移動した後でブラウザが再読み込み(リロード)された場合、DELETE リクエストが再度送信されるリスクがあります。
これを防ぐために、redirect_tostatus: :see_other(HTTP 303)を指定し、リダイレクト先では GET リクエストが実行されるようにしています。

まとめ

本当はログアウトについても書きたかったのですが、テストの説明をしていたらサクッと説明できたので今回は書きませんでした。
Railsチュートリアルは6章当たりから難しくなってきて8章もかなりやりごたえがあったなと思いました。
皆さん9章から挫折する人も多いと聞くので気を引き締めて9章望みたいと思います。

1
1
0

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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?