Help us understand the problem. What is going on with this article?

Rails Tutorialの知識から【ポートフォリオ】を作って勉強する話 #15 投稿機能, Active Storage編

こんな人におすすめ

  • プログラミング初心者でポートフォリオの作り方が分からない
  • Rails Tutorialをやってみたが理解することが難しい

前回:#14 ユーザ投稿表示, ページネーション編
次回:#16 線グラフ, Chartkick編

今回の流れ

  1. ルーティングと各アクションを整える
  2. 投稿フォームをつくる
  3. 画像を投稿できるようにする
  4. 投稿編集・削除機能をつくる
  5. 投稿時の不具合を解消する
  6. テストをつくる

この記事は、動画を観た時間を記録するアプリのポートフォリオです。
今回は投稿機能を実装します。
(投稿のためのモデルが必要なので未読の方は#14を参照下さい。)

1. ルーティングと各アクションを整える

投稿フォームはshow.html.erbと同じビューを使用します。
投稿編集と削除の際には別途ビューを生成します。
以上から必要なアクションはcreate、edit、update、destroyとなります。
よって、ここでの手順は以下の通りです。

  • ルーティングを行う
  • 作成済みのメソッドを移動する
  • 各アクションを整える

ルーティングを行う

まずはルーティングを行いましょう。

config/routes.rb
Rails.application.routes.draw do
  # 中略
  resources :microposts, only: [:create, :edit, :update, :destroy]
end

作成済みのメソッドを移動する

各アクションを整える前に、1つ変更を加えます。
logged_in_userメソッドはMicropostsコントローラでも使用します。
先にこちらはApplicationコントローラに移動しましょう。

app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception
  include SessionsHelper

  private
    def logged_in_user
      unless logged_in?
        store_location
        flash[:warning] = 'ログインしてください'
        redirect_to login_url
      end
    end
end

各アクションを整える

create、edit、update、destroyといった4つのアクションを整えましょう。

app/controllers/microposts_controller.rb
class MicropostsController < ApplicationController
  before_action :logged_in_user, only: [:create, :edit, :update, :destroy]
  before_action :correct_user, only: :destroy

  def create
    @user = current_user
    @micropost = current_user.microposts.build(micropost_params) if logged_in?
    @microposts = @user.microposts.page(params[:page]).per(10)

    if @micropost.save
      redirect_to current_user
    else
      render 'users/show'
    end
  end

  def edit
    @micropost = current_user.microposts.find_by(id: params[:id]) || nil
    if @micropost.nil?
      flash[:warning] = "編集権限がありません"
      redirect_to root_url
    end
  end

  def update
    @micropost = current_user.microposts.find_by(id: params[:id])
    @micropost.update_attributes(micropost_params)
    if @micropost.save
      flash[:success] = "編集が完了しました"
      redirect_to current_user
    else
      render 'microposts/edit'
    end
  end

  def destroy
    @micropost.destroy
    flash[:success] = "ログが削除されました"
    redirect_to current_user
  end

  private
    def micropost_params
      params.require(:micropost).permit(:memo, :time, :picture)
    end

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

このポートフォリオはTutorialと異なり、user_pathにフォームを置いています。
この場合、フォーム入力失敗時のパスがmicroposts_pathとなります。

その結果、フォーム入力失敗時に/user/show.html.erbをrenderするため、Micropostsコントローラのcreateアクションにuserとmicroposts変数を渡す必要があります。

2. 投稿フォームをつくる

投稿フォームをつくりましょう。
パーシャルを生成し、そこに投稿フォームを作ります。

bash
$ touch app/views/layouts/_micropost_form.html.erb
app/views/layouts/_micropost_form.html.erb
<div class="container micropost-container">
  <h1>Form</h1>
  <%= form_with(model: @micropost, url: microposts_path, local: true) do |form| %>
    <%= render 'shared/error_messages', object: form.object %>
    <div class="form-group">
      <%= form.number_field :time, class: 'form-control', placeholder: "時間(分)を入力してください" %>
    </div>
    <div class="form-group">
      <%= form.text_area :memo, class: 'form-control', placeholder: "メモを加えてください" %>
    </div>
    <div class="form-group">
      <%= form.file_field :picture %>
    </div>
    <div class="form-group">
      <%= form.submit "記録する", class: 'btn btn-info btn-lg form-submit' %>
    </div>
  <% end %>
</div>

lantern_form.png

まず説明する点は、このような記述についてです。

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

renderにobjectオプションを使うことにより、error_messages.html.erbで、userやmicropostなどを引き渡すオブジェクト名としてobjectを使用できます。
エラーを表示する他のビューに対しても、同様に記述しましょう。

加えてerror_messages.html.erbはこのように変更しましょう。

app/views/shared/_error_messages.html.erb
<% if object.errors.any? %>
  <div id="error_explanation">
    <div class="alert alert-danger alert-form-extend" role="alert">
      <%= object.errors.count %>個のエラーがあります
    </div>
    <ul>
      <% object.errors.full_messages.each do |msg| %>
        <li><%= msg %></li>
      <% end %>
    </ul>
  </div>
<% end %>

参考になりました↓
form_for の f.object って何だ?(Rails)

続いてこの部分ですが、現状では動作しません。

<%= form.file_field :picture %>

こちらは下記の画像投稿を可能にすることで動作します。

3. 画像を投稿できるようにする

画像投稿にはRails5.2から対応のActive Storageを使用します。
また画像をアップロードするストレージとしてS3を使用します。
まずはActive Storageについて理解しましょう。

Active Storageを理解する

Active Storageとはファイルをアップロードする機能のことです。
以下の記事が分かりやすいですので一読してみましょう。
【Rails 5.2】Active Storageの使い方

加えて今回はアップロード時の保存先にS3を使用します。
S3とはAWSが提供するストレージのことです。
S3の使用にはGemの追加と設定の編集が必要です。

以上を踏まえてActive Storageを使用するにはこのような手順が必要です。

  • S3(バケット)を作成する
  • Active Storageを用意する
  • Active StorageとS3を紐づける

S3(バケット)を作成する

まずはS3(バケット)を作成しましょう。
ここでの手順は以下の通りです。

  • IAMユーザに権限を与える
  • アクセスキーを作成する
  • バケットを作成する

IAMユーザに権限を与える

ポートフォリオ用のユーザにS3を使用する権限を与えます。
以下の手順を行いましょう。

  1. ルートユーザでAWSにログインする
  2. IAMを開く
  3. ポートフォリオ用ユーザを選択する
  4. 「アクセス権限の追加」を押す
  5. 「既存のポリシーを直接アタッチ」からS3を検索する
  6. 「AmazonS3FullAccess」をチェックし確認する

分からない場合はこちらをご覧ください↓
【AWS】【S3】作成手順 & アップロード手順 & アクセス権限設定手順

アクセスキーを作成する

続いて後述で必要になるアクセスキーを作成します。
このキーを作成することでRailsとS3を紐づけることが可能です。
以下の手順を行いましょう。

  1. ルートユーザでAWSにログインする
  2. IAMを開く
  3. ポートフォリオ用ユーザを選択する
  4. 「認証情報」タブから「アクセスキーの作成」を押す
  5. アクセスキーとシークレットアクセスキーをメモする (一度しか表示されないので注意)

バケットを作成する

最後にポートフォリオ用ストレージとなるバケットを作成します。
以下の手順を行いましょう。

  1. IAMユーザでAWSにログインする
  2. S3を開く
  3. 「バケットを作成する」を押す
  4. バケット名を入力し東京リージョンを選択する
  5. 「次へ」を押し続け「バケットを作成」を押す

分からない場合はこちらをご覧ください↓
【AWS】【S3】作成手順 & アップロード手順 & アクセス権限設定手順
以上でS3(バケット)の作成は完了です。

Active Storageを用意する

次にActive Storageを用意しましょう。
ここでの手順は以下の通りです。

  • Active Storageをインストールする
  • モデル(今回はMicropostモデル)を編集する

Active Storageをインストールする

まずはインストールを行いDBをマイグレートします。

bash
$ rails active_storage:install
$ rails db:migrate

モデル(今回はMicropostモデル)を編集する

モデルの編集はいたって簡単です。
app/models/micropost.rbに以下を加えるだけで成立します。

app/models/micropost.rb
has_one_attached :picture

ここで宣言した名前(今回はpicture)がモデルのカラムとして機能します。
以上でActive Storageの用意は完了です。

Active StorageとS3を紐づける

最後にActive StorageとS3を紐づけましょう。
ここでの手順は以下の通りです。

  • 必要なGemを追加する
  • 設定を編集する

必要なGemを追加する

S3との紐づけにはaws-sdk-s3が必要です。
Gemfileに追加しましょう。

gemfile
+ gem 'aws-sdk-s3'

設定を編集する

Active Storageの保存先をS3にするため:localから:amazonに変更します。

config/environments/development.rb
  # 中略
  # Store uploaded files on the local file system (see config/storage.yml for options)
  config.active_storage.service = :amazon
config/environments/production.rb
  # 中略
  # Store uploaded files on the local file system (see config/storage.yml for options)
  config.active_storage.service = :amazon

続いてstorage.ymlにてamazon:の部分をこのように変更します。

config/storage.yml
# Use rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
amazon:
  service: S3
  access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
  secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
  region: ap-northeast-1
  bucket: #S3で作成したバケット名(今回はlantern-lantern-s3)

最後に、先ほどメモしたアクセスキーとシークレットアクセスキーを入力します。
この2つはセキュリティ上Rails Credentialsを使用します。
この機能により直接キーを入力することを回避し暗号化します。

それではRails Credentialsを編集しましょう。
エディターにはVimを使用します。

bash
$ EDITOR=vim rails credentials:edit

Vimは癖があるので、下記に簡単な操作を載せておきます。

  • 入力開始:i
  • 入力終了:esc
  • 保存終了:ZZ
  • 保存せず終了::q!
aws:
 access_key_id: # ここにアクセスキーを入力
 secret_access_key: # ここにシークレットアクセスキーを入力

以上で紐づけは完了です。

画像をリサイズする

このままではユーザが投稿する画像サイズに制限がありません。
画像のリサイズにはMiniMagickというGemを使用しましょう。
ここでの手順は以下の通りです。

  • Gemを追加する
  • ImageMagickをインストールする
  • リサイズのメソッドを定義する
  • ビューで表示する

Gemを追加する

gemfile
+ gem 'mini_magick'
bash
$ bundle install

ImageMagickをインストールする

MiniMagickの使用にはImageMagickのインストールが必要です。
こちらも加えて行いましょう。

bash
$ sudo yum install -y ImageMagick

リサイズのメソッドを定義する

画像のリサイズを行うよう、モデルにメソッドを定義します。

app/models/micropost.rb
def resize_picture
  return self.picture.variant(resize: '100x100').processed
end

ビューで表示する

リサイズされた画像を表示するにはこのように記述します。

app/views/layouts/_log.html.erb
<% @microposts.each do |micropost| %>
<!-- 省略 -->
  <% if micropost.picture.attached? %>
  <div class="log-picture">
    <%= image_tag micropost.resize_picture %>
  </div>
  <% end %>
<!-- 省略 -->
<% end %>

フォーム入力↓
lantern_input_form.png
ログ表示↓
lantern_output_form.png
以上で画像のリサイズは完了です。

4. 投稿編集・削除機能をつくる

編集と削除のためのアクションはすでに終えました。
よって、ここでの手順はビューをつくるのみです。

bash
$ touch app/views/microposts/edit.html.erb
app/views/microposts/edit.html.erb
<% provide(:title, 'メモ編集') %>
<div class="container micropost-edit-container">
  <div class="edit-titles">
    <%= image_tag 'edit_02.png', class: 'edit-img' %>
    <h1 class="title edit-micropost-title">メモ編集</h1>
  </div>
  <%= form_with(model: @micropost, url: micropost_path, local: true) do |form| %>
    <%= render 'shared/error_messages', object: form.object %>
    <div class="form-group">
      <%= form.label :time, '時間' %>
      <%= form.number_field :time, class: 'form-control', placeholder: @micropost.time %>
    </div>
    <div class="form-group">
      <%= form.label :memo, 'メモ' %>
      <%= form.text_area :memo, class: 'form-control', placeholder: @micropost.memo %>
    </div>
    <div class="form-group">
      <%= form.label :picture, '画像' %>
      <%= form.file_field :picture, class: 'form-control-file', placeholder: @micropost.picture %>
    </div>
    <div class="row">
      <div class="col">
        <div class="form-group">
          <%= link_to "削除", micropost_path, method: :delete, data: { confirm: "本当に削除しますか?" }, class: 'btn btn-lg btn-danger btn-edit-user' %>
        </div>
      </div>
      <div class="col">
        <div class="form-group">
          <%= form.submit "編集", class: 'btn btn-info btn-lg btn-edit-user' %>
        </div>
      </div>
    </div>
    <div class="form-group">
      <%= link_to "戻る", current_user, class: 'btn btn-lg btn-edit-user btn-back' %>
    </div>
  <% end %>
</div>

メモ編集画面↓
lantern_edit_micropost.png

5. 投稿時の不具合を解消する

このままではフォームの時間が空の時、ログが「分」のみで出力されてしまいます。
それを避けるために簡単な条件を書き、ログを正しく出力しましょう。

app/views/layouts/_log.html.erb
<!-- 省略 -->
<% @microposts.each do |micropost| %>
    <li id ="micropost-<%= micropost.id %>">
      <span class="row log-list">
        <span class="col-2 log-timestamp d-none d-md-inline-block log-timestamp-block">
          <span class="log-timestamp"><%= time_ago_in_words(micropost.created_at) %></span>
        </span>
        <span class="col-md-10 col-log-memos">
          <div class="log-time-and-edit">
            <div class="row">
              <span class="log-time col-3">
                <% if micropost.time.nil? %>
                  0分
                <% else %>
                  <%= micropost.time %><% end %>
              </span>
              <span class="col-7 log-timestamp log-timestamp-inline"><%= time_ago_in_words(micropost.created_at) %></span>
              <span class="log-edit col-2"><%= link_to image_tag('edit.png', class: "log-edit-image"), edit_micropost_path(micropost) %></span>
            </div>
          </div>
          <% if micropost.memo.present? %>
            <div class="log-memo"><%= micropost.memo %></div>
          <% end %>
          <% if micropost.picture.attached? %>
            <div class="log-picture"><%= image_tag micropost.resize_picture %></div>
          <% end %>
        </span>
      </span>
    </li>
<% end %>
<!-- 省略 -->

時間が空の場合「0分」と表示↓
(少々デザインも変更しています)
lantern_log_correction.png

6. テストをつくる

残るはテストのみです。
ここではModel specとRequest specのテストを行います。

Model specでのテスト

このテストでは以下を確認します。

  • pictureのみ存在するMicropostモデルは有効か
  • 5MBを超える画像は無効か
  • 画像以外のファイルは無効か

その前に5MB以上の画像、画像以外のファイルを以下のフォルダに準備しましょう。

bash
$ mkdir spec/fixtures/images
# 実際にファイルを追加する

その後、テストを記述します。

spec/models/micropost_spec.rb
require 'rails_helper'

RSpec.describe Micropost, type: :model do

  let(:user) { create(:user) }
  let(:micropost) { user.microposts.build(time: 240, memo: "Lorem ipsum", user_id: user.id) }

  # 中略

  describe "picture" do
    it "should be valid if all columns are nil except picture" do
      micropost.update_attributes(time: nil, memo: nil, user_id: user.id)
      expect(micropost).to be_invalid
      micropost.picture.attach(io: File.open(Rails.root.join('spec', 'fixtures', 'images', 'test.jpg')), filename: 'test.jpg', content_type: 'image/jpg')
      expect(micropost).to be_valid
    end

    it "should not be over than 5MB" do
      micropost.picture.attach(io: File.open(Rails.root.join('spec', 'fixtures', 'images', 'test_5mb.jpg')), filename: 'test_5mb.jpg', content_type: 'image/jpg')
      expect(micropost).to be_invalid
    end

    it "should be only images file" do
      micropost.picture.attach(io: File.open(Rails.root.join('spec', 'fixtures', 'images', 'test.pdf')), filename: 'test.pdf', content_type: 'application/pdf')
      expect(micropost).to be_invalid
    end
  end
end

参考にさせていただきました↓
Active Storageの簡単なバリデーションの実装とテスト
ActiveStorageでattachできるものについて調べてみた
Ruby IOクラスについて学ぶ
Railsのファイルパスの操作のメモ

Request specでのテスト

このテストでは以下を確認します。

  • ログインしていないユーザの投稿が無効か
  • フォームが全て空欄のユーザ投稿が無効か
  • ログインしているユーザの投稿が有効か
  • ログインしていないユーザの投稿削除が無効か
  • 他ユーザの投稿削除が無効か
  • ログインしているユーザの投稿削除が有効か
  • ログインしていないユーザの投稿編集が無効か
  • 他ユーザの投稿編集が無効か
  • フォームが全て空欄の投稿編集が無効か
  • ログインしているユーザの投稿編集が有効か
spec/requests/microposts_spec.rb
require 'rails_helper'

RSpec.describe "Microposts", type: :request do

  let(:user) { create(:user) }
  let(:other_user) { create(:other_user) }

  def post_valid_information
    post microposts_path, params: { micropost: { memo: "aaa" } }
  end

  def post_invalid_information
    post microposts_path, params: { micropost: { memo: nil } }
  end

  def patch_valid_information
    patch micropost_path, params: { micropost: { memo: "bbb" } }
  end

  def patch_invalid_information
    patch micropost_path, params: { micropost: { memo: nil } }
  end

  describe "POST /microposts" do
    it "does not add a micropost when not logged in" do
      expect{ post_valid_information }.not_to change(Micropost, :count)
      follow_redirect!
      expect(request.fullpath).to eq '/login'
    end

    it "does not add a micropost when the form has no information" do
      log_in_as(user)
      get user_path(user)
      expect{ post_invalid_information }.not_to change(Micropost, :count)
    end

    it "succeeds to add a micropost" do
      log_in_as(user)
      get user_path(user)
      expect(request.fullpath).to eq '/users/1'
      expect{ post_valid_information }.to change(Micropost, :count).by(1)
      follow_redirect!
      expect(request.fullpath).to eq '/users/1'
    end
  end

  describe "DELETE /micropost" do
    it "does not destroy a micropost when not logged in" do
      delete micropost_path(1)
      follow_redirect!
      expect(request.fullpath).to eq '/login'
    end

    it "does not destroy a micropost when other users logged in" do
      log_in_as(user)
      get user_path(user)
      post_valid_information
      follow_redirect!
      delete logout_path
      log_in_as(other_user)
      get user_path(other_user)
      expect(request.fullpath).to eq '/users/2'
      post_valid_information
      expect{ delete micropost_path(1) }.not_to change(Micropost, :count)
      expect{ delete micropost_path(2) }.to change(Micropost, :count).by(-1)
    end

    it "succeeds to destroy a micropost" do
      log_in_as(user)
      get user_path(user)
      expect{ post_valid_information }.to change(Micropost, :count).by(1)
      follow_redirect!
      expect{ delete micropost_path(1) }.to change(Micropost, :count).by(-1)
      follow_redirect!
      expect(request.fullpath).to eq '/users/1'
      expect(flash[:success]).to be_truthy
    end
  end

  describe "GET /microposts/:id/edit" do
    it "does not edit a micropost when not logged in" do
      log_in_as(user)
      get user_path(user)
      post_valid_information
      follow_redirect!
      delete logout_path
      follow_redirect!
      get edit_micropost_path(1)
      follow_redirect!
      expect(request.fullpath).to eq '/login'
    end

    it "does not edit a micropost when other users logged in" do
      log_in_as(user)
      get user_path(user)
      post_valid_information
      follow_redirect!
      delete logout_path
      follow_redirect!
      log_in_as(other_user)
      get edit_micropost_path(1)
      follow_redirect!
      expect(request.fullpath).to eq '/'
    end

    it "does not edit a micropost when the form has no information" do
      log_in_as(user)
      get user_path(user)
      post_valid_information
      follow_redirect!
      get edit_micropost_path(1)
      expect(request.fullpath).to eq '/microposts/1/edit'
      patch_invalid_information
      expect(request.fullpath).to eq '/microposts/1'
    end

    it "succeeds to edit a micropost" do
      log_in_as(user)
      get user_path(user)
      post_valid_information
      follow_redirect!
      get edit_micropost_path(1)
      expect(request.fullpath).to eq '/microposts/1/edit'
      patch_valid_information
      follow_redirect!
      expect(request.fullpath).to eq '/users/1'
    end
  end
end

以上でテストは終了です。
お疲れさまでした。

備考:Active Storageを伴うテストにFactoryBotを使いたい

Micropostモデルを検証する際、FactoryBotを使ったテストを行いたかったのですが、断念しました。一応調べた分を共有します。

Active Storageを伴うFactoryBotを使う手順は以下の通りです。

  1. 環境設定を行う
  2. FactoryBotを整える
  3. テストを書く

環境設定を行う

まずは、ダミーでアップロードを行うfixture_file_uploadメソッドを使うために、環境設定をしましょう。

spec/rails_helper.rb
# 中略
RSpec.configure do |config|
  # 中略
  # factoryBot内での呼び出し
  FactoryBot::SyntaxRunner.class_eval do
    include ActionDispatch::TestProcess
  end
  # fixtureのパス指定(テスト時のパスをfixtures以下から省略できる)
  config.fixture_path = "#{::Rails.root}/spec/fixtures"
# 中略
end

参考にさせていただきました↓
parperclipでファイルアップロードをRspecでテスト w/ factory_girl
Factory Bot trait for attaching Active Storange has_attached

FactoryBotを整える

続いてFactoryBotを整えます。

spec/factories/microposts.rb
FactoryBot.define do
  factory :micropost do
    trait :memo_1 do
      time { 240 }
      memo { "I just ate an orange!" }
      user_id { 1 }
      created_at { 10.minutes.ago }
      picture { fixture_file_upload('/images/test.jpg', 'image/jpg') }
    end
    # 中略
    association :user
  end
end

参考にさせていただきました↓
Factory Bot trait for attaching Active Storange has_attached

テストを書く

残るはletでMicropostモデルを定義するだけです。

spec/models/micropost_spec.rb
require 'rails_helper'

RSpec.describe Micropost, type: :model do
  let(:micropost) { create(:micropost, :memo_1) }
  # 中略
end

しかしテスト時に画像を追加・削除する際、Active Storageで生成される他の2つのモデルとの関連づけがないためエラーが発生します。

# どれもエラーが発生する
micropost.picture = nil
micropost.save!

micropost.update_attribute(:picture, nil)

micropost.picture.attach(nil)
ActiveRecord::RecordNotSaved:
       Failed to save the new associated picture_attachment.

強引に2つのクラスを生成するという方法もあるのですが、断念しました。
シンプルにテストを通す方法をご存知の方は、ご教示お願いします。

参考にさせていただきました↓
ruby on rails 画像 fixture_file_uploadに{file}が存在しないというエラーがあります
ruby on rails rails_blob_path ActiveStorageでモデルテストを正しく行うには?


前回:#14 ユーザ投稿表示, ページネーション編
次回:#16 線グラフ, Chartkick編

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away