はじめに
こんにちは。アメリカで独学でエンジニアを目指している者です。
現在、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_with
をSessionsController
の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アクションの流れとしては
- DBからparamsのセッションのemailに該当するものがあるかを確認し、それをuserと定義
- userが存在し、Userモデルで設定した
によって使用できるようになった
authenticate`メソッドを使用してPWが正しいか確認 - 2がtrueであれば、セッション固定を防ぐために
reset_session
を使用しセッションをリセットする - SessionHelperに定義したlog_inメソッド、引数をuserとして呼び出す。ここでセッションに
session[:user_id] = user.id
としてユーザーIDが登録される -
redirect_to user
によってUser#show
が呼び出されます。違うページに遷移する際にはredirect_to
を使用します - 2がfalseの場合、flashを使用します。flashだけだと次のリクエストまでエラーメッセージを表示してしまいますが、flash.nowなら今のリクエストまでしかメッセージを持たないのでエラーを表示させる場合、renderで再度そのページを表示させるケースが多い(新たにリクエストを送らない)ので、flash.nowを使用する
- Sessionsのnewアクションのviewを表示しています。この時:unprocessable_entityはリクエストが正しいけれど、意味的に誤りがある422ステータスコードを表示するようにしています
スマホ用のビジュアルの調整
スマホサイズか否かはブラウザの開発モードにて見ることができますが、スマホサイズになるとレイアウトが崩れます。
そこでハンバーガーメニューという三本線のよくあるメニューを導入しました
<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を使う前に
- Gemfileに
gem "turbo-rails", "1.4.0"
と記載(versionはRailsチュートリアルのものを記載している) -
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
がまとめて読み込むしようとなっているようです
ここまで設定して始めてコードを書きます
//メニュー操作
//トグルリスナーを追加する
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
を作成しクリックされた場合に、以下のようになります
- ハンバーガーメニューが表示されるようになる
- ドロップダウンメニューが表示されるようになる
menu.classList.toggle
のようにtoggleメソッドを使用しているので、collapse
のように最初に書いてあった場合はクリックすることでそれを削除することができます。
ログインのテスト
ここまで出来たらログインのテストを行います。
6章で出てきたfixtureのusers.ymlはテストの時に使用する変数を指定できるものなのでここで使用します。
しかし、テスト時にはhas_secure_password
によるパスワードのハッシュ化は行われないので自分で作成する必要があります
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リクエストです。
def destroy
log_out
redirect_to root_url, status: :see_other
end
これはSessionsController
でも定義されており、destroy
アクションは DELETE
リクエストによって呼ばれるため、リダイレクト先に移動した後でブラウザが再読み込み(リロード)された場合、DELETE
リクエストが再度送信されるリスクがあります。
これを防ぐために、redirect_to
に status: :see_other(HTTP 303)
を指定し、リダイレクト先では GET リクエストが実行されるようにしています。
まとめ
本当はログアウトについても書きたかったのですが、テストの説明をしていたらサクッと説明できたので今回は書きませんでした。
Railsチュートリアルは6章当たりから難しくなってきて8章もかなりやりごたえがあったなと思いました。
皆さん9章から挫折する人も多いと聞くので気を引き締めて9章望みたいと思います。