noggy’s blog

自分用の備忘録です。。。

13.3 マイクロポストの操作

データモデリングとマイクロポスト表示テンプレートが完成したので、次はWeb経由でそれらを作成するためのインターフェイスに取りかかる。

従来のRails開発の慣習と異なる箇所

Micropostsリソースへのインターフェイスは、主に「プロフィールページ」と「Homeページ」のコントローラを経由して実行されるので、Micropostsのコントローラにはneweditのようなアクションは不要。つまり、createdestroyがあれば十分。

ルーティング

よって、Micropostsリソースは次のようになる。

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'
  post    '/signup',  to: 'users#create'
  get     '/login',   to: 'sessions#new'
  post    '/login',   to: 'sessions#create'
  delete  '/logout',  to: 'sessions#destroy'
  resources :users                                                              
  resources :account_activations, only: [:edit]                                 
  resources :password_resets,     only: [:new, :create, :edit, :update]         
  resources :microposts,          only: [:create, :destroy]  #追加
end

13.3.1 マイクロポストのアクセス制御

Micropostsコントローラ内のアクセス制御から始める。
Micropostsは、関連付けられたユーザーを通してアクセスするので、createアクションやdestroyアクションを利用するユーザーは、ログイン済みでなければならない。

ログイン済みかどうかを確かめるテストでは、Usersコントローラ用のテストがそのまま役に立つ。つまり、

(1)正しいリクエストを各アクションに向けて発行
(2)マイクロポストの数が変化していないかどうか、
(3)リダイレクトされるかどうか

を確かめればよい。

require 'test_helper'

class MicropostsControllerTest < ActionDispatch::IntegrationTest

  def setup
    @micropost = microposts(:orange)
  end

  test "should redirect create when not logged in" do
    assert_no_difference "Micropost.count" do
      post microposts_path, params: { micropost: { content: "Lorem ipsum"} } #①
    end
    assert_redirected_to login_url #②
  end

  test "should redirect destroy when not logged in" do
    assert_no_difference 'Micropost.count' do
      delete micropost_path(@micropost)
    end
    assert_redirected_to login_url
  end
end

①:micropost_path(createアクション)にparamsハッシュのデータを持たせてPOST
②:login_url(ログイン画面)にリダイレクト

実装前のリファクタリング

上のテストにパスするコードを書くためには、少しアプリ側のコードをリファクタリングしておく。
というもの、beforeフィルターのlogged_in_userメソッドを使って、ログインを要求したことについて思い出すとわかる。あのときはUsersコントローラ内にこのメソッドがあったので、beforeフィルターで指定していたが、このメソッドはMicropostsコントローラでも必要。
そこで、各コントローラが継承するApplicationコントローラに移動する。

class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception
  include SessionsHelper                                                        

  private

    # ユーザーのログインを確認する #移動済
    def logged_in_user
      unless logged_in?
       store_location
       flash[:danger] = "Please log in."
       redirect_to login_url
      end
    end
end

コードが重複しないように、Usersコントローラからもlogged_in_userを削除する。

class UsersController < ApplicationController
  before_action :logged_in_user, only: [:index, :edit, :update, :destroy]       
  :
  private                                                                       

    def user_params 
      params.require(:user).permit(:name, :email, :password,
                                  :password_confirmation)
    end

    # 正しいユーザーがどうかを確認
    def correct_user
      @user = User.find(params[:id])
      redirect_to(root_url) unless current_user?(@user) 
    end
    
    # 管理者かどうかを確認
    def admin_user
      redirect_to(roo_url) unless current_user.admin?
    end
end

Micropostsコントローラにbeforeフィルターを追加

Micropostsコントローラからもlogged_in_userメソッドを呼び出せるようになった。
これにより、createアクションやdestroyアクションに対するアクセス制限が、beforeフィルターで簡単に実装できるようになった。

class MicropostsController < ApplicationController
  before_action :logged_in_user, only: [:create, :destroy] 

  def create
  end

  def destroy
  end
end

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

13.3.2 マイクロポストの作成

マイクロポストの作成フォームを、micropost/newページではなくホーム画面(つまりルートパス)にフォームに作る。これは、7章で、HTTP POSTリクエストをUsersコントローラのcreateアクションに発行するHTMLフォームを作成することで、ユーザーのサインアップを実装したときと似ている。違うのは、ホーム画面にフォームを作る点。モックアップは次の図。

f:id:nogicchi:20210823155354p:plain
マイクロポスト作成フォームのあるホーム画面のモックアップ


最後にホーム画面を実装したときは、[Sign up now!]ボタンが中央にあった。マイクロポスト作成フォームは、ログインしている特定のユーザーのコンテキストでのみ機能するので、ここでの1つの目標は、ユーザーのログイン状態に応じて、ホーム画面の表示を変更すること。

Micropostのcreateアクション

マイクロポストのcreateアクションもユーザー用アクションと似ている。違いは、新しいマイクロポストをbulidするためにUser/Micropost関連付けを使っている点。

micropost_paramsでStrong Parametersを使っていることにより、マイクロポストのcontent属性だけがWeb経由で変更可能になっている点に注目!(下の①)

class MicropostsController < ApplicationController
  before_action :logged_in_user, only: [:create, :destroy]                      

  def create
    @micropost = current_user.microposts.build(micropost_params)  #①   
    if @micropost.save
      flash[:success] = "Micropost created!"
      redirect_to root_url
    else
      render 'static_pages/home'
    end
  end

  def destroy
  end

  private

    def micropost_params
      params.require(:micropost).permit(:content)
    end
end
マイクロポスト作成フォーム

専用ページではなくstatic_pages/home.html.erbにフォームを作る。
さらに、サイト訪問者がログインしているかどうかに応じて異なるHTMLを提供するコードを使う。

#ココから追加
<% if logged_in? %> 
  <div class="row">
    <aside class="col-md-4">
      <section class="user_info">
        <%= render 'shared/user_info' %>
      </section>
      <section class="micropost_form">
        <%= render 'shared/micropost_form' %>
      </section>
    </aside>
  </div>
<% end %>
#ココまで
<div class="center jumbotron">
  <h1>Welcome to the Sample App</h1>

  <h2>
      This is the  home page for the
      <a href="https://railstutorial.jp">Ruby on Rails Tutorial</a>
      sample application
    </h2>
    
    <%= link_to "Sigh uo now!", signup_path, class: "btn btn-lg btn-primary" %>
  </div>
<% end %>

このコードのif-else文の分岐でコードを書きわけている点が少し汚いが、リファクタリングは演習。

いくつかのパーシャルを作る。まずはHomeページの新しいサイドバーから。

<%= link_to gravatar_for(current_user, size: 50), current_user %>
<h1><%= current_user.name %></h1>
<span><%= link_to "view my profile", current_user %></span>
<span><%= pluralize(current_user.microposts.count, "micropost") %></span>      

プロフィールサイドバーのときと同様、ユーザー情報にもそのユーザーが投稿したマイクロポストの総数が表示されていることに注目!ただし少し表示に違いがあり、プロフィールサイドバーでは、"Microposts"をラベルとし、「Microposts(1)」と表示することは問題ない。しかし、今回のように、"1 microposts"と表示してしまうと英語の文法上の誤りになってしまう。そこで、pluralizeメソッドを使って調整している。

次はマイクロポスト作成フォームを定義する。これはユーザー登録フォームに似ている。

<%= form_for(@micropost) do |f| %>
  <%= render 'shared/error_messages', object: f.object %>
  <div class="field">
    <%= f.text_area :content, placeholder: "Compose new micropost..." %>
  </div>
  <%= f.submit "Post", class: "btn btn-primary" %>
<% end %>

マイクロポスト作成フォームが動くようにする

2箇所の変更をする。

1つ目の変更
homeアクションにマイクロポストのインスタンス変数@micropostを追加。

class StaticPagesController < ApplicationController

  def home
    @micropost = current_user.microposts.build if logged_in? #追加 
  end
  :

もちろんcurrent_userメソッドはユーザーがログインしているときしか使えない。
したがって、@micropost変数もログインしているとき定義されるようになる。

2つ目の変更
エラーメッセージのパーシャルを再定義すること。
そうしないと_micropost_form.html.erbの次のコードが動かない

<%= render 'shared/error_messages', object: f.object %>

なぜなら、今のエラーメッセージパーシャル(7.20)は↓で、fは@userのままだから)

<% if @user.errors.any? %>                                                     
  <div id="error_explanation">
    <div class="alert alert-danger">
      The form contains <%= pluralize(@user.errors.count, "error") %>
    </div>
    <ul>
      <%= @user.errors.full_messages.each do |msg| %>                         
      <li><%= msg %></li>                                                       
      <% end %>
    </ul>
  </div>
<% end %>

上のパーシャルでは@user変数を直接参照していた。
今回は@microposts変数を参照したい!
@user変数でも@microposts変数でも参照できるようにしたい!
そこで、フォーム変数ff.objectとすることによって、関連付けられたオブジェクトにアクセスできるようにする。

くり返すと、

form_for(@user) do |f|

上のように、f.object@userとなる場合と、

form_for(@micropost) do |f|

f.object@micropostになる場合がある。
そこで、パーシャルにオブジェクトを渡すために、値がオブジェクトで、キーがパーシャルでの変数名と同じハッシュを利用する。これで、error_messagesパーシャルの2行目のコードが完成する。言い換えると、object: f.objecterror_messagesパーシャルの中でobjectという変数名を作成してくれるので、この変数を使ってエラーメッセージを更新すればよいということ。

<% if object.errors.any? %> #@userをobjectに変更      
  <div id="error_explanation">
    <div class="alert alert-danger">
      The form contains <%= pluralize(object.errors.count, "error") %> #@userをobjectに変更     
    </div>
    <ul>
      <%= object.errors.full_messages.each do |msg| %> #@userをobjectに変更                
      <li><%= msg %></li>                                                       
      <% end %>
    </ul>
  </div>
<% end %>

テストは失敗

error_messagesパーシャルが、他のビュー(ユーザー登録、パスワード再設定、ユーザー編集)で使われているため。第二引数をobject: f.objectと更新するとテストは成功。

以下変更した結果を示す。

<% provide(:title, "Sign up") %>

<h1>Sign up</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_for(@user) do |f| %>
      <%= render 'shared/error_messages', object: f.object %> #変更
    
      <%= f.label :name %>
      <%= f.text_field :name ,class: 'form-control'%>
      
      <%= f.label :email %>
      <%= f.text_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 "Create my account", class: "btn btn-primary" %>
    <% end %>
  </div>
</div>

<% provide(:title, "Edit user") %>
<h1>Update your profile</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_for(@user) do |f| %>
      <%= render 'shared/error_messages', object: f.object %> #変更
    
      <%= f.label :name %>
      <%= f.text_field :name ,class: 'form-control'%>
      
      <%= f.label :email %>
      <%= f.text_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="http://gravatar.com/emails">change</a> 
    </div>
  </div>
</div>

<% provide(:title, "Reset password") %>
<h1>Password reset</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_for(@user, url: password_reset_path(params[:id])) do |f| %>
      <%= render 'shared/error_messages', object: f.object %> #変更

      <%= hidden_field_tag :email, @user.email %>
    
      <%= 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 "Update password", class: "btn btn-primary" %>
    <% end %>
  </div>
</div>

テストは成功

13.3.3 フィードの原型

Homeページにマイクロポストを表示する部分を実装する。

正しく動作しているかどうかを確認したい場合、正しいエントリーを投稿後、プロフィールページに移動してポストを確認すればよい。が、これはかなり面倒な作業。ユーザー自身のポストを含むマイクロポストのフィードがないと不便。

すべてのユーザーがフィードを持つので、feedメソッドはUserモデルに作る。
フィードの原型では、現在ログインしているユーザーのマイクロポストをすべて取得する。
なお、14章で完全なフィードを実装するため、whereメソッドで実装↓

  :
  #試作feedの定義
  #完全な実装は14章を参照
  def feed
    Micropost.where("user_id = ?", id) 
  end

private

Micropost.where("user_id = ?", id)について

プレースホルダー(?のこと)を使った書き方。?に第二引数idが置き換わる。
これにより、SQLインジェクションと呼ばれるセキュリティホールを避けることができる。
feedアクションでやってることは、MIcropostテーブルからuser_ididのユーザーをすべて取得している(14章で完璧に実装する)。

pikawaka.com

また、上のコードは本質的には次のコードと同等

def feed
  microposts
end

このコードを使わなかったのは、14章での応用のため。

フィード機能の実装

次の流れで実装する。
(1)現在のユーザーのページ分割されたフィードに@feed_itemsインスタンス変数を追加し、
(2)次に、フィード用のパーシャルをHomeページに追加

Homeページに変更を加えるとき、ユーザーがログインしているかどうかを調べる後置if文が変化している点に注目!

@microopost = current_user.microposts.build if logged_in?

上のコードが、次のような前置if文に変わっている。

  if logged_in?
    @micropost = current_user.microposts.bulid
    @feed_items = current_user.feed.paginate(page: params[:page])
  end

(1行のときは後置if文、2行以上の時は前置if文を使うのがRubyの慣習)

  def home
    if logged_in?
      @micropost  = current_user.microposts.build   
      @feed_items = current_user.feed.paginate(page: params[:page])  
    end
  end
  :

<% if @feed_items.any? %>
  <ol class="microposts">
    <%= render @feed_items %>
  </ol>
  <%= will_paginate @feed_items %>
<% end %>

上のステータスフィードのパーシャルは、Micropostのパーシャルとは異なっている点に注目!

<%= render @feed_items %>

このとき、@feed_itemsの各要素がMicropostクラスを持っていたため、RailsはMicropostのパーシャルを呼び出すことができる。このように、Railsは対応する名前のパーシャルを、渡されたリソースのディレクトリ内から探しにいくことができる。

app/views/microposts/_micropost.html.erb

あとは、いつものようにフィードパーシャルを表示すればHomeページにフィードを追加できる。

<% if logged_in? %>
  <div class="row">
    <aside class="col-md-4">
      <section class="user_info">
        <%= render 'shared/user_info' %>
      </section>
      <section class="micropost_form">
        <%= render 'shared/micropost_form' %>
      </section>
    </aside>
    <div class="col-md-8"> #追加
      <h3>Micropost Feed</h3> #追加
      <%= render 'shared/feed' %> #追加
    </div>
  </div>
<% else %>
:
<% end %>

現状の問題

現時点では、新しいマイクロポストの作成は動作する。ただし、マイクロポストの投稿が失敗すると、Homeページは@feed_itemsインスタンス変数を期待しているため、現状では壊れてしまう。最も簡単な解決方法は、@feed_itemsに空の配列を渡しておくこと。

 :
  def create
    @micropost = current_user.microposts.build(micropost_params)  
    if @micropost.save
      flash[:success] = "Micropost created!"
      redirect_to root_url
    else
      @feed_items = [] #追加
      render 'static_pages/home'
    end
  end
  :
  private

   def micropost_params
     params.require(:micropost).permit(:content)
   end 

end

13.3.4 マイクロポストの削除

最後の機能として、マイクロポストリソースにポストを削除する機能を追加する。これはユーザー削除と同様に"delete"リンクで実現する。ユーザーの削除は管理者ユーザーのみが行えるよう制限されていたのに対し、今回は自分が投稿したマイクロポストに対してのみ削除リンクが動作するようにする。

最初のステップとして、マイクロポストのパーシャルに削除リンクを追加する。

<li id="micropost-<%= micropost.id %>">
  <%= link_to gravatar_for(micropost.user, size: 50), micropost.user %>
  <span class="user"><%= link_to micropost.user.name, micropost.user %></span>
  <span class="content"><%= micropost.content %></span>
  <span class="timestamp">
    Posted <%= time_ago_in_words(micropost.created_at) %> ago.
  <% if current_user?(micropost.user) %>
    <%= link_to "delete", micropost, method: :delete,
                                    data: { confirm: "You sure?" } %> #追加
  <% end %>
  </span>
</li>

次に、Micropostsコントローラのdestroyアクションを定義する。
これも、ユーザーにおける実装とだいたい同じ。大きな違いは、admin_userフィルターで@user変数を使うのではなく、関連付けを使ってマイクロポストを見つけようとしている点。これにより、あるユーザーが他のユーザーのマイクロポストを削除しようとすると、自動的に失敗するようになる。
具体的には、correct_userフィルター内でfindメソッドを呼び出すことで、現在のユーザーが削除対象のマイクロポストを保有しているかどうか確認する。

class MicropostsController < ApplicationController
  before_action :logged_in_user, only: [:create, :destroy]  
  before_action :correct_user,   only: :destroy #追加

  def create
    @micropost = current_user.microposts.build(micropost_params)  
    if @micropost.save
      flash[:success] = "Micropost created!"
      redirect_to root_url
    else
      @feed_items = []
      render 'static_pages/home'
    end
  end

  def destroy
    @micropost.destroy #追加
    flash[:success] = "Micropost deleted" 
    redirect_to request.referrer || root_url #①
  end

  private

    def micropost_params
      params.require(:micropost).permit(:content)
    end

    def correct_user
      @micropost = current_user.microposts.find_by(id: params[:id]) #追加
      redirect_to root_url if @micropost.nil?
    end
end

①:destroyメソッドでリダイレクトを使っている点に注目。ここではrequest.referrerというメソッドを使っている。このメソッドはフレンドリーフォワーディングのrequest.url変数と似ていて、1つ前のURLを返す。(今回の場合はHomeページになる)
このため、マイクロポストがHomeページから削除された場合でもプロフィールページから削除された場合でも、request.referrerを使うことでDELETEリクエストが発行されたページに戻すことができるので、非常に便利!!
ちなみに、元に戻すURLが見つからなかった場合でも(例えばテストではnilが返ってくる場合がある)、||演算子root_urlをデフォルトで設定しているため、エラーが出ずに/へと飛ぶ。

これらのコードにより、上から2番目のマイクロポストを削除してみると、うまく動くはず。