プログラミング備忘録

プログラミングの学習状況をメモしています

55,56日目

今日の学習

Ruby on Rails

Railsチュートリアル 第10章

昨日はフレンドリーフォワーディングまでを学習した。

今日は引き続きここから学習していく。

フレンドリーフォワーディング

昨日書いたメソッドの部分を実現させる。

まず、記憶したURLにリダイレクトするメソッドredirect_back_orと、アクセスしようとしたURLを記憶するメソッドstore_locationを作成する。

app/helpers/sessions_helper.rb

# アクセスしようとしたURLを記憶
def store_location
  session[:forwarding_url] = request.original_url if request.get?
end

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

転送先のURLを保存するために、session変数を使用している。

request.original_urlでリクエスト先が取得できる。

store_locationメソッドで、リクエストが送られたURLをsession変数の:forwarding_urlキーに格納している。

if request.get?をつけることで、GETリクエストが送られてきたとき限定にしてある。

ここで格納したものをredirect_back_orメソッドで利用し、リダイレクトするようにしてある。

最後はsession.delete(:forwarding_url)で、転送用URLを削除する。

こうしておかないと、次回ログインしたときに保存されたページに転送されてしまい、ブラウザを閉じるまでこれが繰り返されてしまう。転送は一回だけでいいはずなので、ここで削除する。

ここで作成したstore_locationメソッドは、users_controller.rbのログイン済みユーザーかどうか確認するbeforeアクションlogged_in_userに加える。

redirect_back_orメソッドは、userを引数にしてsessions_controller.rbcreateアクションに加える。

Railsテストのデフォルトリクエストホスト名

演習で、フレンドリーフォワーディングがしっかり機能しているかどうか、初回のみの転送になっているかのテストを行ったが、エラーが起きる。

        --- expected
        +++ actual
        @@ -1 +1 @@
        -"http://www.example.com/users/762146111/edit"
        +"/users/762146111/edit"
        test/integration/users_edit_test.rb:22:in `block in <class:UsersEditTest>'

上のURLが期待されるURLで、下が実際のURL。http://www.example.comという部分がないよと言われている。

第10章Railsチュートリアル演習問題と解答まとめ - エンジニアになりたい肉体労働者

http://www.example.comが、Railsテストでのリクエストホスト名のデフォルトのようだ。

session[:forwarding_url]に、アクセスした`edit_user_path(@user)が格納してあるか調べるためのものに、上のデフォルトURLを繋げればテストが通った。

session[:forwarding_url], "http://www.example.com" + edit_user_path(@user)
すべてのユーザーを表示する

indexアクションを使用して、すべてのユーザーを一覧表示する。

ここでは、データベースにサンプルデータを追加する方法や、ページネーションのやり方も学ぶ。

ユーザー一覧ページ

ユーザーの個人ページshowは、ログインの有無に関わらず閲覧できる状態だが、一覧ページindexはログインしたユーザーにしか見せない様にする。

そのため、logged_in_userindexアクションを追加して、ログインしている状態限定にする。

indexアクションには、ひとまず@users = User.allと記述しておく。

ユーザー一覧のビューは、ユーザーごとにliタグで囲み、eachメソッドを使って作成する。

各ユーザーのGravatarと名前を表示する。

<ul>
  <% @users.each do |user| %>
    <li>
      <%= gravatar_for user, size: 50 %>
      <%= link_to user.name, user %>
    </li>
  <% end %>
</ul>

以前作成したgravatar_forアクションを利用して、画像のサイズをオプション引数で指定している。

サンプルユーザーを増やす

現在、サンプルユーザーは1名だけなので、Fakergemを利用してサンプルユーザーを増やす。

本来は開発環境以外では使わないgemだが、Railsチュートリアルでは本番環境でも適応させる予定らしく、全ての環境で使える状態で導入した。

db/seeds.rbファイルに、Fakerを利用したサンプルユーザーを生成するコードを書く。

99.times do |n|
  name = Faker::Name.name
  email = "example-#{n+1}@railstutorial.org"
  password = "password"
  User.create!(name: name,
               email: email,
               password: password,
               password_confirmation: password)
end

rails db:seedを行い、データを投入。

ページネーション

初期データと、先ほど作成したデータを合わせて100件のサンプルデータが存在する。

今のままでは、一つのページに大量のユーザーが表示されてしまうことになる。

そのため、ページネーションを導入して、一つのページに表示させるユーザーの数を制限していく。Railsチュートリアルでは、30人だけを表示させるようにしていくようだ。

今回利用するページネーションメソッドはwill_paginate

以前ページネーションについて学習したときは、kaminariというgemを利用したが、今回はwill_paginatebootstrap-will_paginategemを利用する。

ページネーションを動作させるために、二つの手順が必要になる

  1. indexビューにページネーションを行うためのコードを書く
  2. indexアクションのUser.allを置き換える

まずは1だが、先ほどのgemで利用できるようになったwill_paginateメソッドを追加する。

<%= will_paginate %>

<ul class="users">
  <%= @users.each do |user| %>
    <li>
      <%= gravatar_for user, size: 50 %>
      <%= link_to user.name, user %>
    </li>
  <% end %>
</ul>

<%= will_paginate %>

先ほど作成したindexビューのコードを、will_paginateメソッドで囲む。こうすることで、@usersオブジェクトを自動的に見つけ出して、ページネーションリンクを作成してくれる。

次の2で、ビューファイルのwill_paginateメソッドがしっかり機能するようにする。

gem追加で利用できるようになったpaginateメソッドでは、キーが:pageで値がページ番号のハッシュを引数に取る。

User.paginate(page: 1)
#=> デフォルトではデータベースから30データ分を取り出す

このメソッドを使って、indexアクションのUser.allallを置き換える。

def index
  @users = User.paginate(page: params[:page])
end

パラメータにparams[:page]を利用しているが、will_paginateによって自動的に生成される。

これで、will_paginateがページネーションを自動で生成してくれる。

f:id:hasegawa_note:20210629234205p:plain

ちゃんと他のページにも飛べるようになっている。

will_paginateは上下に配置したので、下の方も確認してみると...。

f:id:hasegawa_note:20210629234417p:plain

ええ...。

サンプルユーザーの情報が列挙されてしまっている...。

index.html.erbに書き損じがあるのか確認したが、特に問題はない。

結論から言うと、確認する際に、indexビューのリファクタリングを行ったら表示されなくなった。釈然としない。

<ul class="users">
  <%= @users.each do |user| %>
# このliタグの部分をrenderに置き換える
    <li>
      <%= gravatar_for user, size: 50 %>
      <%= link_to user.name, user %>
    </li>
  <% end %>
</ul>

<ul class="users">
  <%= @users.each do |user| %>
    <%= render user %>
  <% end %>
</ul>

each文の中のuser変数に対して実行している。Railsは自動的に_user.html.erbという名前のファイルを探してくれるため、同じ名前のファイルを作成して、先ほどの部分を貼り付ける。

<li>
  <%= gravatar_for user, size: 50 %>
  <%= link_to user.name, user %>
</li>

さらにindexページを簡略化することができる。renderを@users変数に対して直接実行する。

<ul class="users">
  <%= render @users %>
</ul>

Railsは、@usersをUserオブジェクトのリストだと推測し、自動的にUserのコレクションを列挙し、それぞれを_user.html.erbパーシャルで出力する。賢すぎる。

ユーザーの削除

残るアクションはdestroyのみ。削除を行うリンクや、destroyアクションの実装をする。

その前に、削除を実行できる権限を持つ管理(admin)ユーザーのクラスを作成する。

承認(authorization)において、このような特権のセットをroleと呼ぶ。

管理ユーザー

ユーザーが特権を持つ管理ユーザーなのかどうかを識別するために、論理値を取るadmin属性をUserモデルに追加する。

属性の型をbooleanとすることで、自動的に論理値を返すadmin?メソッドが使えるようになり、これを使って管理ユーザーか否かをチェックできる。

rails g migration add_admin_to_users admin:boolean

上を実行し、作成されたマイグレーションファイルにあるadd_columnに、default: falseという引数を追加する。

  def change
    add_column :users, :admin, :boolean, default: false
  end

もしもfalseを与えない場合は、adminの値はデフォルトではnilとなる。これはfalseと同じ意味になるので、引数を与える意味はないが、明記することにより分かりやすくなる。

booleanについて調べてみると、null: falseの引数も追加して、NOT NULL制約も入れておくべきという意見を多数見かけた。

コンソールで確認してみる。

f:id:hasegawa_note:20210630004705p:plain

userの情報にadmin: falseが加わっており、admin?メソッドにもちゃんと反応してくれる。

toggle!メソッドは、インスタンスに保存されているbooleanの値を反転させて、データベースに保存する。

このメソッドを利用し、引数を:adminにすることによって、adminの値をfalseからtrueに変更している。

【Rails】toggleとtoggle!の使い方 - Qiita

Strong Parametersとadmin

Strong Parametersでは、編集してもよい安全な属性だけを更新できるように設定してある。今の状態だと、以下の四つのみが更新可能になっている。

  • name
  • email
  • password
  • password_confirmation

もしもこの中にadminを加えてしまったらどうなるだろうか。

PATCHリクエストで、admin: trueという情報を受け取ると、編集可能なadmintrueに更新されてしまい、非常に危険になる。そのため、絶対に加えないこと。

Railsチュートリアルでは、admin属性のテストを演習で行った。

destroyアクション

ユーザー一覧のindexページで、各ユーザーの削除用リンクを表示する。

if文を使って、ログイン中のユーザーが管理者のときのみdeleteリンクが表示されるようにする。

先ほど作成した、_user.html.erbに記述していく。

<li>
  <%= gravatar_for user, size: 50 %>
  <%= link_to user.name, user %>
  <% if current_user.admin? && !current_user?(user) %>
    | <%= link_to "delete", user, method: :delete,
                                  data: { confirm: "You sure?" } %>
  <% end %>
</li>

if文で、current_userが管理者で、かつ削除するuserがcurrent_userではないかどうかを見ている。管理者が自分自身を削除できないようにするためだ。

DELETEリクエストを発行するリンクは、method: :deleteによって生成されている。

ビューファイルには削除リンクの用意ができたので、destroyアクションを追加していく。また、このアクションもログインしている必要があるため、logged_in_userフィルターに追加する。

def destroy
  User.find(params[:id]).destroy
  flash[:success] = "User deleted"
  redirect_to users_url
end

今のままでは、DELETEリクエストを送られてしまうとユーザーが削除されてしまう。

そのため、管理者だけがユーザーを削除できるようにする必要があり、destroyアクションにアクセス制御を行わなければならない。以下のようにして、befroreアクションを設定する。

before_action :admin_user, only: :destroy
private

def admin_user
  redirect_to(root_url) unless current_user.admin?
end
ユーザー削除のテスト

ユーザーを削除するといった重要な操作については、動作のテストをしっかり行うべきだと書かれている。そのため、テストで行ったことを書き記しておく。

まずはfixtureファイルに追加されているユーザーの一人にadmin: trueと書くことで、管理者にする。

作成済みのtest/controllers/users_controller_test.rbで行う。

destroyアクションを行った時に確認したいことは以下の二つ。

  • ログインしていないユーザーであれば、ログイン画面にリダイレクトされる
  • ログイン済みで、管理者でない場合はホーム画面にリダイレクトされる
# ログインしていない場合
test "should redirect destroy when not logged in" do
# ユーザー数が変化しないかどうかを確認
  assert_no_difference 'User.count' do
# deleteリクエストを発行
    delete user_path(@user)
  end
# ログイン画面へリダイレクト
  assert_redirected_to login_url
end

#ログイン済みで管理者ではない場合
test "should redirect destroy when logged in as a non-admin" do
  log_in_as(@other_user)
  assert_no_difference 'User.count' do
    delete user_path(@user)
  end
  assert_redirected_to root_url
end

次は、管理者でログインしている場合は削除リンクが表示されることを利用して、削除リンクが存在するかどうかを調べるテストも実施する。

また、実際にユーザーが削除できるかどうかもテストをする。

ページネーションを設定した時、ページネーションを含めたUserIndexのテストを作成したので、その部分に追加する形でテストを書いていく。

test/integration/users_index_test.rb

def setup
  @admin = users(:adminにしたユーザー)
  @non_admin = users(:adminではないユーザー)
end

# ページネーションのテスト部分は省略
test "index as admin including delete links" do
  first_page_of_users = User.paginate(page: 1)
  first_page_of_users.each do |user|
    assert_select 'a[href=?]', user_path(user), text: user.name
# adminユーザーは選択しない
    unless user == @admin 
# deleteリンクがあるかを確認
      assert_select 'a[href=?], user_path(user), text: 'delete'
    end
  end
# deleteリクエストでユーザーの数が一つ減るかを確認
  assert_difference 'User.count', -1 do
    delete user_path(@non_admin)
  end

test "index as non-admin" do
  log_in_as(@non_admin)
  get users_path
# deleteリンクがないことを確認
  assert_select 'a', text: 'delete', count: 0
end

Heroku

Railsチュートリアルで作成したものはHerokuにデプロイしているが、サンプルデータを本番データとして作成することもできるようだ。

本番データベースをリセットするには、pg:resetタスクを使う。

heroku pg:reset DATABASE

マイグレーションをしたい場合は以下のコマンド。

heroku run rails db:migrate
heroku run rails db:seed

今日のやらかし

テストでcurrent_userを指定してテスト失敗

レイアウトにあるリンクに対して統合テストを書くという演習があった。

このリンクが正しく機能しているかどうかテストをしたい。

<li><%= link_to "Profile", current_user %></li>
<li><%= link_to "Setting", edit_user_path(current_user) %></li>

setupで@userには正しいユーザーを代入済み。

test do
  log_in_as(@user)
  get root_path
  assert_select "a[href=?]", current_user
  assert_select "a[href=?]", edit_user_path(current_user)
end

このような書き方でいけるかと思ったが、エラーが出て失敗。

NameError: undefined local variable or method `current_user'

メソッドを使わずに書かなければならない。

test do
  log_in_as(@user)
  get root_path
  assert_select "a[href=?]", user_path(@user)
  assert_select "a[href=?]", edit_user_path(@user)
end