1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Railsチュートリアル第10章 ざっくり何やったか備忘録(前編)

Posted at

この章では、Usersリソース用のRESTアクションのうち、これまで未実装だった
editupdateindexdestroyアクションを加え、RESTアクションを完成させる。

##ユーザーの更新・表示・削除
まず、ユーザーが自分のプロフィールを自分で更新できるようにする。
8章で実装した認証用のコードを使うので、
認可モデル(Authorization Model)について学習。

次に、すべてのユーザーを一覧できるようにし、認証を要求しつつ
ページネーション(ページ分割機能)を導入する。

最後に、ユーザーを削除し、DBから完全に消去する機能を追加する。
ユーザーの削除はどのユーザーにも許可させる訳ではない。
管理ユーザーを作成し、このユーザだけ削除を許可するようにする。

##ユーザーを更新する
git checkout -b updating-users

ユーザー情報を編集するパターンは、新規ユーザーの作成と似ている。

  • 新規ユーザー用のビューを出力するnewアクションと同じように、ユーザーを編集するためのeditアクションを作成。
  • POSTリクエストに応答するcreateの代わりに、PATCHリクエストに応答するupdateアクションを作成。

最大の違いは、ユーザー登録は誰でも実行できるが、ユーザー情報を更新できるのは
そのユーザー自身に限られるということ。
第8章で実装した認証機構を使えば、before filterを使ってこのアクセス制御を実現できる。

###編集フォーム

下記モックアップを動かすには、Usersコントローラにeditアクションを追加して、
それに対応するeditビューを実装する必要がある。

まずはeditアクションの実装から始めるが、ここではDBから適切なユーザーデータを
読み込む必要がある。

edit_user_mockup_bootstrap.png

画像: 10章 図 10.1: ユーザー編集ページのモックアップ

ここで注意なのはユーザー編集ページの正しいURLが/users/1/editとなっている点。
ユーザーのidはparams[:id]変数で取り出すことができる。

app/controllers/users_controller.rb
  def edit
    @user = User.find(params[:id])
  end

次に、ユーザー編集ページに対応するビューを作成

app/views/users/edit.html.erb
<% provide(:title, "Edit user") %>
<h1>Update your profile</h1>

<div class="row">

  <div class="col-md-6 col-md-offset-3">
    <%= form_with(model: @user, local: true) do |f| %>
      <%= render 'shared/error_messages' %>

      <%= f.label :name %>
      <%= f.text_field :name, class: 'form-control' %>

      <%= f.label :email %>
      <%= f.email_field :email, class: 'form-control' %>

      <%= f.label :password %>
      <%= f.password_field :password, class: 'form-control' %>

      <%= f.label :password_confirmation, "Confirmation" %>
      <%= f.password_field :password_confirmation, class: 'form-control' %>

      <%= f.submit "Save changes", class: "btn btn-primary" %>
    <% end %>

    <div class="gravatar_edit">
      <%= gravatar_for @user %>
      <a href="https://gravatar.com/emails" target="_blank">change</a>
    </div>
  </div>
</div>

上のコードでは、newビューで使ったerror_messagesパーシャルを再利用している。
Gravatarへのリンクでtarget="_blank"が使われているが、これを使うとリンク先を新しいタブ(またはウィンドウ)で開くようになるので、別のWebサイトへリンクするときなどに便利。
ただしtarget="_blank"にはセキュリティ上の小さな問題もある。(後で解説)

@userインスタンス変数を使うと、編集ページがうまく描画されるようになる。
(Railsによって名前やメールアドレスのフィールドに値が自動的に入力されるようになる)
これらの値は、editアクションでDBから@user変数の属性情報から引き出されているため。

<form accept-charset="UTF-8" action="/users/1" class="edit_user"
      id="edit_user_1" method="post">
  <input name="_method" type="hidden" value="patch" />  #入力フィールドに隠し属性があることに注目。

ブラウザはそのままだとPATCHリクエストを送信できないので、
RailsはPOSTリクエストと隠しinputフィールドを利用してPATCHリクエストを「偽造」している。

patchの値がhidden属性によって、_methodという名前と共に、隠して送信される。

<微妙な点>
form_with(@user)のコードはnewビュー(new.html.erb)と完全に同じ。

Railsはどうやって新規ユーザー用のPOSTリクエストとユーザー編集用のPATCHリクエストを区別するのか?
-> Railsは、ユーザーが新規なのか、DBに存在する既存のユーザーであるかを、
Active Recordのnew_record?論理値メソッドを使って区別している。

$ rails console
>> User.new.new_record?
=> true
>> User.first.new_record?
=> false

Railsは、form_with(@user)を使ってフォームを構成すると、
@user.new_record?がtrueのときにはPOSTを、falseのときにはPATCHを使う。

仕上げに、ナビゲーションバーにあるユーザー設定へのリンクを更新。
edit_user_pathという名前付きルートと、
current_userというヘルパーメソッドを使うと、実装が簡単。
<%= link_to "Settings", edit_user_path(current_user) %>

これをheaderパーシャルに差し込み、レイアウトの “Settings” リンクを更新する。

app/views/layouts/_header.html.erb
            <ul class="dropdown-menu">
              <li><%= link_to "Profile", current_user %></li>
              <li><%= link_to "Settings", edit_user_path(current_user) %></li>
              <li class="divider"></li>
              <li>
                <%= link_to "Log out", logout_path, method: :delete %>
              </li>
            </ul>

###編集の失敗
ユーザー登録に失敗したときと似た方法で、編集に失敗した場合について扱う。

app/controllers/users_controller.rb
  def create
    @user = User.new(user_params)
    if @user.save
      log_in @user
      flash[:success] = "Welcome to the Sample App!"
      redirect_to @user
    else
      render 'new'
    end
  end

  def update
    @user = User.find(params[:id])
    if @user.update(user_params)
      # 更新に成功した場合を扱う。
    else
      render 'edit'
    end
  end

updateアクションの作成
updateを使って送信されたparamsハッシュに基いてユーザーを更新する。
無効な情報が送信された場合、更新の結果としてfalseが返され、elseに分岐して編集ページをレンダリングする。
この構造はcreateアクションの最初のバージョンと似ている。

updateへの呼び出しでuser_paramsを使っていることに注目
Strong Parametersを使ってマスアサインメントの脆弱性を防止している。

Userモデルのバリデーションとエラーメッセージのパーシャルが既にあるので、
無効な情報を送信すると役立つエラーメッセージが表示されるようになる。

###編集失敗時のテスト
編集失敗時の統合テストを生成する。
$ rails generate integration_test users_edit

最初は編集失敗時の簡単なテストを追加
①編集ページにアクセスし、editビューが描画されるかどうかをチェック。
②無効な情報を送信してみて、editビューが再描画されるかどうかをチェック。

ここで、PATCHリクエストを送るためにpatchメソッドを使っていることに注目
-> getやpost、deleteメソッドと同じように、HTTPリクエストを送信するためのメソッド。

test/integration/users_edit_test.rb
require 'test_helper'

class UsersEditTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end

  test "unsuccessful edit" do
    get edit_user_path(@user)
    assert_template 'users/edit'
    patch user_path(@user), params: { user: { name:  "",
                                              email: "foo@invalid",
                                              password:              "foo",
                                              password_confirmation: "bar" } }

    assert_template 'users/edit'
  end
end

テストは成功する。

###TDDで編集を成功させる
編集フォームが動作するようにする。
プロフィール画像の編集は、画像のアップロードをGravatarに任せてあるので、既に動作する。
changeボタンをクリックすれば、Gravatarを編集できる。

ここで、より快適にテストするためには、アプリ用のコードを実装する前に統合テストを書いた方が便利なので、
テスト駆動開発を使ってユーザーの編集機能を実装していく。

まずは、ユーザー情報を更新する正しい振る舞いをテストで定義。

次に、flashメッセージが空でないかどうかと、プロフィールページにリダイレクトされるかどうかをチェック。

また、DB内のユーザー情報が正しく変更されたかどうかも検証。

test/integration/users_edit_test.rb
  test "successful edit" do
    get edit_user_path(@user)
    assert_template 'users/edit'
    name  = "Foo Bar"
    email = "foo@bar.com"
    patch user_path(@user), params: { user: { name:  name,
                                              email: email,
                                              password:              "",
                                              password_confirmation: "" } }
    assert_not flash.empty?
    assert_redirected_to @user
    @user.reload
    assert_equal name,  @user.name
    assert_equal email, @user.email
  end
end

パスワードとパスワード確認が空であることに注目。
ユーザー名やメールアドレスを編集時に毎回パスワードを入力するのは不便。パスワードを入力せずに更新できると便利。

テスト駆動開発では先にテストを書くので、効果的なユーザー体験について考えるようになる。
テストが成功する必要のあるupdateアクションは、createアクションの最終的なフォームとほぼ同じ。

app/controllers/users_controller.rb
  def update
    @user = User.find(params[:id])
    if @user.update(user_params)
      flash[:success] = "Profile updated"
      redirect_to @user
    else
      render 'edit'
    end
  end

パスワードやパスワード確認の欄を空にしておくと、
パスワードの長さに対するバリデーションに引っかかってしまう恐れがあるため
このテストはまだ失敗する。

パスワードのバリデーションに対して、空だったときの例外処理を加える必要があり、
Userモデルのバリデーション(validates)に、allow_nil :trueというオプションを使う。

app/models/user.rb
class User < ApplicationRecord
  attr_accessor :remember_token
  before_save { self.email = email.downcase }
  validates :name, presence: true, length: { maximum: 50 }
  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[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 }, allow_nil: true

新規ユーザー登録時、空のパスワード(nil)は有効にはならない。
has_secure_passwordではオブジェクト生成時に存在性を検証するようになっているため、
空のパスワード(nil)が新規ユーザー登録時に有効になることはない。
(空のパスワードを入力すると存在性のバリデーションとhas_secure_passwordによるバリデーションがそれぞれ実行され、2つの同じエラーメッセージが表示されるというバグがあったが、これで解決する。)

テスト成功。

###認可

認証(authentication)はユーザーを識別することであり、
認可(authorization)はユーザーが実行可能な操作を管理すること。

第8章で認証システムを構築したことで、認可のためのシステムを実装する準備ができている。

editアクションとupdateアクションは、1点セキュリティ上の問題がある。
どのユーザーでもあらゆるアクションにアクセスできるため、誰でもユーザー情報を編集できてしまう。

今回はユーザーにログインを要求し、かつ自分以外のユーザー情報を変更できないように制御する。

また、ログインしていないユーザーが保護されたページにアクセスしようとした際のケースについて対処していく。

こういったケースはアプリケーションを使っていると普通に起こることなので、
ログインページに転送時に分かりやすいメッセージも表示させる。
許可されていないページに対してアクセスするログイン済みのユーザーがいたら、ルートURLにリダイレクトさせるようにする。

login_page_protected_mockup.png

画像: [10章 図 10.6: 保護されたページにアクセスしたときのページのモックアップ]

###ユーザーにログインを要求する
転送させる仕組みを実装したいときは、Usersコントローラの中でbeforeフィルターを使う。

beforeフィルターは、before_actionメソッドを使い、何らかの処理が実行される直前に特定のメソッドを実行する仕組み。

ユーザーにログインを要求するために、logged_in_userメソッドを定義して、
before_action:logged_in_userという形式で使う。

app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:edit, :update]

    # beforeアクション

    # ログイン済みユーザーかどうか確認
    def logged_in_user
      unless logged_in?
        flash[:danger] = "Please log in."
        redirect_to login_url
      end
    end
end

デフォルトでは、beforeフィルターはコントローラ内のすべてのアクションに適用される。
ここでは適切な:onlyオプション(ハッシュ)を渡すことで、:editと:updateアクションだけに
このフィルタが適用されるように制限をかけています。

beforeフィルターを使って実装した結果は、一度ログアウトしてユーザー編集ページ(/users/1/edit)にアクセスして確認できる。
テストはこの時点で失敗する。
原因は、editアクションやupdateアクションでログインを要求するようになったので、
ログインしていないユーザーだと失敗するようになったため。

このため、editアクションやupdateアクションをテストする前にログインしておく必要がある。
解決策はlog_in_asヘルパーを使うこと。

test/integration/users_edit_test.rb
  test "unsuccessful edit" do
    log_in_as(@user)
    get edit_user_path(@user)

  test "successful edit" do
    log_in_as(@user)
    get edit_user_path(@user)

これでテストは成功する。

次に、セキュリティモデルに関する実装を取り外してもテストが失敗する想定だが、
実際にコメントアウトして確かめると、テストが成功してしまう。

beforeフィルターをコメントアウトして巨大なセキュリティーホールが作られたら、テストスイートでそれを検出できるべきです。つまり、リスト 10.19のコードは red にならなければいけないのです。テストを書いて、この問題に対処しましょう。

beforeフィルターは基本的にアクションごとに適用していくので、Usersコントローラのテストもアクションごとに書く。
正しい種類のHTTPリクエストを使ってeditアクションとupdateアクションをそれぞれ実行
flashにメッセージが代入されたか
ログイン画面にリダイレクトされたかどうかを確認。

適切なリクエストは
edit -> GET
update -> PATCH
を割り当てる。

test/controllers/users_controller_test.rb
  def setup
    @user = users(:michael)
  end

  test "should redirect edit when not logged in" do
    get edit_user_path(@user)
    assert_not flash.empty?
    assert_redirected_to login_url
  end

  test "should redirect update when not logged in" do
    patch user_path(@user), params: { user: { name: @user.name,
                                              email: @user.email } }
    assert_not flash.empty?
    assert_redirected_to login_url
  end
end

beforeフィルターのコメントアウトを元に戻して、今度はテストが成功する。

###正しいユーザーを要求する
ユーザーが自分の情報だけを編集できるようにする必要がある。
ユーザーの情報が互いに編集できないことを確認するため、サンプルユーザーをもう一人追加する。

セキュリティモデルが正しく実装されている確信を持つために、テスト駆動開発を採用。
Usersコントローラのテストを補完するように、テストを追加するところから始めていく。

fixtureファイルに2人目のユーザーを追加する

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

archer:
  name: Sterling Archer
  email: duchess@example.gov
  password_digest: <%= User.digest('password') %>

次に、log_in_asメソッドを使い、editアクションとupdateアクションをテストする。
このとき、既にログイン済みのユーザーを対象としているため、ログインページではなく
ルートURLにリダイレクトしている点に注意。

test/controllers/users_controller_test.rb
 def setup
    @user       = users(:michael)
    @other_user = users(:archer)
 end

  test "should redirect edit when logged in as wrong user" do
    log_in_as(@other_user)
    get edit_user_path(@user)
    assert flash.empty?
    assert_redirected_to root_url
  end

  test "should redirect update when logged in as wrong user" do
    log_in_as(@other_user)
    patch user_path(@user), params: { user: { name: @user.name,
                                              email: @user.email } }
    assert flash.empty?
    assert_redirected_to root_url
  end

別のユーザーのプロフィールを編集しようとしたらリダイレクトさせたいので
correct_userというメソッドを作成し、beforeフィルターからこのメソッドを呼び出す。
beforeフィルターのcorrectuserで@user変数を定義し、editとupdateの各アクションから、
@userの代入文を削除している点にも注意。

app/controllers/users_controller.rb
  before_action :correct_user,   only: [:edit, :update]

    # 正しいユーザーかどうか確認
    def correct_user
      @user = User.find(params[:id])
      redirect_to(root_url) unless @user == current_user
    end

これでテストは成功する。

最後にリファクタリングとして、current_user?という論理値を返すメソッドを実装する。
correct_userというbefore filterの中で使えるようにしたいので、
Sessionsヘルパーの中にこのメソッドを追加する。
unless @user == current_user
unless current_user?(@user) にする。

app/helpers/sessions_helper.rb
  # 渡されたユーザーがカレントユーザーであればtrueを返す
  def current_user?(user)
    user && user == current_user
  end

先ほどのメソッドを使って比較演算していた行を置き換える

app/controllers/users_controller.rb
    # 正しいユーザーかどうか確認
    def correct_user
      @user = User.find(params[:id])
      redirect_to(root_url) unless current_user?(@user)
    end

###フレンドリーフォワーディング
後1つ小さなキズがある。保護されたページにアクセスしようとすると、問答無用で
自分のプロフィールページに移動させられてしまう。
理想は、ログインしていないユーザーが編集ページにアクセスしようとしていたなら、
ログイン後にはその編集ページにリダイレクトされるようにするのが望ましい動作。

リダイレクト先は、ユーザーが開こうとしていたページにしてあげる。

フレンドリーフォワーディングのテストはシンプルに書くことができる。
ログインした後に編集ページへアクセスする、という順序を逆にする。

<実際のテスト>

  • 編集ページにアクセス
  • ログイン
  • 編集ページ(デフォルトPFページではない)にリダイレクトされているかどうかをチェック
test/integration/users_edit_test.rb
  test "successful edit with friendly forwarding" do
    get edit_user_path(@user)
    log_in_as(@user)
    assert_redirected_to edit_user_url(@user)
    name  = "Foo Bar"
    email = "foo@bar.com"
    patch user_path(@user), params: { user: { name:  name,
                                              email: email,
                                              password:              "",
                                              password_confirmation: "" } }
    assert_not flash.empty?
    assert_redirected_to @user
    @user.reload
    assert_equal name,  @user.name
    assert_equal email, @user.email
  end

テストは失敗させた。
これでようやくフレンドリーフォワーディングを実装する準備ができた。
ユーザーを希望のページに転送するには、リクエスト時点のページをどこかに保存しておき、
その場所にリダイレクトさせる必要がある。
この動作をstore_locationredirect_back_orの2つのメソッドを使って実現してみる。
これらのメソッドはSessionsヘルパーで定義している。

フレンドリーフォワーディングの実装

app/helpers/sessions_helper.rb
  # 記憶したURL(もしくはデフォルト値)にリダイレクト
  def redirect_back_or(default)
    redirect_to(session[:forwarding_url] || default)
    session.delete(:forwarding_url)
  end

  # アクセスしようとしたURLを覚えておく
  def store_location
    session[:forwarding_url] = request.original_url if request.get?
  end

転送先のURLを保存する仕組みは、session変数を使う。

store_locationメソッドでは、 リクエストが送られたURLを
session変数の:forwarding_urlキーに格納している(GETリクエストが送られたときだけ)。
これで、ログインしていないユーザーがフォームを使って送信した場合に
転送先のURLを保存させないようにできる。

例えば、ユーザがセッション用のcookieを手動で削除してフォームから送信するケースが考えられる。
稀なケースだが起こり得る。

こういったケースに対処しておかないと、POST、PATCH、DELETEリクエストを期待しているURLに対して、
(リダイレクトを通して)GETリクエストが送られてしまい、場合によってはエラーが発生する。

先ほど定義したstore_locationメソッドを使い、beforeフィルター(logged_in_user)を修正する。

app/controllers/users_controller.rb
    # beforeアクション
    # ログイン済みユーザーかどうか確認
    def logged_in_user
      unless logged_in?
        store_location
        flash[:danger] = "Please log in."
        redirect_to login_url
      end
    end

フォワーディング自体を実装するには、redirect_back_orメソッドを使う。
リクエストされたURLが存在する場合はそこにリダイレクトし、ない場合は何らかの
デフォルトのURLにリダイレクトさせる。デフォルトのURLは、
Sessionコントローラのcreateアクションに追加し、サインイン成功後にリダイレクトさせる。
redirect_back_orメソッドでは、次のようにor演算子||を使う。

session[:forwarding_url] || default

値がnilでなければsession[:forwarding_url]を評価し、
そうでなければデフォルトのURLを使っている。
また、redirect_back_or(default)メソッドでは、session.delete(:forwarding_url)
という行を通して転送用のURLを削除している点にも注意。

これをやっておかないと、次回ログインしたときに保護されたページに転送されてしまい、
ブラウザを閉じるまでこれが繰り返されてしまう。
ちなみに、最初にredirect文を実行しても、セッションが削除される。

実は、明示的にreturn文やメソッド内の最終行が呼び出されない限り、リダイレクトは発生しないので、
redirect文の後にあるコードでも、そのコードは実行される。

app/controllers/sessions_controller.rb
  #フレンドリーフォワーディングを備えたcreateアクション 
  def create
    user = User.find_by(email: params[:session][:email].downcase)
    if user && user.authenticate(params[:session][:password])
      log_in user
      params[:session][:remember_me] == '1' ? remember(user) : forget(user)
      redirect_back_or user  # userの前のページもしくはdefaultにリダイレクト
    else
      flash.now[:danger] = 'Invalid email/password combination'
      render 'new'
    end
  end

これで、フレンドリーフォワーディング用統合テストは成功する。

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?