こんな人におすすめ
- プログラミング初心者でポートフォリオの作り方が分からない
- Rails Tutorialをやってみたが理解することが難しい
前回:#14 ユーザ投稿表示, ページネーション編
次回:#16 線グラフ, Chartkick編
今回の流れ
- ルーティングと各アクションを整える
- 投稿フォームをつくる
- 画像を投稿できるようにする
- 投稿編集・削除機能をつくる
- 投稿時の不具合を解消する
- テストをつくる
この記事は、動画を観た時間を記録するアプリのポートフォリオです。
今回は投稿機能を実装します。
(投稿のためのモデルが必要なので未読の方は#14を参照下さい。)
1. ルーティングと各アクションを整える
投稿フォームはshow.html.erbと同じビューを使用します。
投稿編集と削除の際には別途ビューを生成します。
以上から必要なアクションはcreate、edit、update、destroyとなります。
よって、ここでの手順は以下の通りです。
- ルーティングを行う
- 作成済みのメソッドを移動する
- 各アクションを整える
ルーティングを行う
まずはルーティングを行いましょう。
Rails.application.routes.draw do
# 中略
resources :microposts, only: [:create, :edit, :update, :destroy]
end
作成済みのメソッドを移動する
各アクションを整える前に、1つ変更を加えます。
logged_in_userメソッドはMicropostsコントローラでも使用します。
先にこちらはApplicationコントローラに移動しましょう。
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つのアクションを整えましょう。
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. 投稿フォームをつくる
投稿フォームをつくりましょう。
パーシャルを生成し、そこに投稿フォームを作ります。
$ touch 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>
まず説明する点は、このような記述についてです。
<%= render 'shared/error_messages', object: form.object %>
renderにobjectオプションを使うことにより、error_messages.html.erbで、userやmicropostなどを引き渡すオブジェクト名としてobjectを使用できます。
エラーを表示する他のビューに対しても、同様に記述しましょう。
加えて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を使用する権限を与えます。
以下の手順を行いましょう。
- ルートユーザでAWSにログインする
- IAMを開く
- ポートフォリオ用ユーザを選択する
- 「アクセス権限の追加」を押す
- 「既存のポリシーを直接アタッチ」からS3を検索する
- 「AmazonS3FullAccess」をチェックし確認する
分からない場合はこちらをご覧ください↓
【AWS】【S3】作成手順 & アップロード手順 & アクセス権限設定手順
アクセスキーを作成する
続いて後述で必要になるアクセスキーを作成します。
このキーを作成することでRailsとS3を紐づけることが可能です。
以下の手順を行いましょう。
- ルートユーザでAWSにログインする
- IAMを開く
- ポートフォリオ用ユーザを選択する
- 「認証情報」タブから「アクセスキーの作成」を押す
- アクセスキーとシークレットアクセスキーをメモする
(一度しか表示されないので注意)
バケットを作成する
最後にポートフォリオ用ストレージとなるバケットを作成します。
以下の手順を行いましょう。
- IAMユーザでAWSにログインする
- S3を開く
- 「バケットを作成する」を押す
- バケット名を入力し東京リージョンを選択する
- 「次へ」を押し続け「バケットを作成」を押す
分からない場合はこちらをご覧ください↓
【AWS】【S3】作成手順 & アップロード手順 & アクセス権限設定手順
以上でS3(バケット)の作成は完了です。
Active Storageを用意する
次にActive Storageを用意しましょう。
ここでの手順は以下の通りです。
- Active Storageをインストールする
- モデル(今回はMicropostモデル)を編集する
Active Storageをインストールする
まずはインストールを行いDBをマイグレートします。
$ rails active_storage:install
$ rails db:migrate
モデル(今回はMicropostモデル)を編集する
モデルの編集はいたって簡単です。
app/models/micropost.rbに以下を加えるだけで成立します。
has_one_attached :picture
ここで宣言した名前(今回はpicture)がモデルのカラムとして機能します。
以上でActive Storageの用意は完了です。
Active StorageとS3を紐づける
最後にActive StorageとS3を紐づけましょう。
ここでの手順は以下の通りです。
- 必要なGemを追加する
- 設定を編集する
必要なGemを追加する
S3との紐づけにはaws-sdk-s3が必要です。
Gemfileに追加しましょう。
+ gem 'aws-sdk-s3'
設定を編集する
Active Storageの保存先をS3にするため:localから:amazonに変更します。
# 中略
# Store uploaded files on the local file system (see config/storage.yml for options)
config.active_storage.service = :amazon
# 中略
# Store uploaded files on the local file system (see config/storage.yml for options)
config.active_storage.service = :amazon
続いてstorage.ymlにてamazon:の部分をこのように変更します。
# 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を使用します。
$ EDITOR=vim rails credentials:edit
Vimは癖があるので、下記に簡単な操作を載せておきます。
- 入力開始:i
- 入力終了:esc
- 保存終了:ZZ
- 保存せず終了::q!
aws:
access_key_id: # ここにアクセスキーを入力
secret_access_key: # ここにシークレットアクセスキーを入力
以上で紐づけは完了です。
画像をリサイズする
このままではユーザが投稿する画像サイズに制限がありません。
画像のリサイズにはMiniMagickというGemを使用しましょう。
ここでの手順は以下の通りです。
- Gemを追加する
- ImageMagickをインストールする
- リサイズのメソッドを定義する
- ビューで表示する
Gemを追加する
+ gem 'mini_magick'
$ bundle install
ImageMagickをインストールする
MiniMagickの使用にはImageMagickのインストールが必要です。
こちらも加えて行いましょう。
$ sudo yum install -y ImageMagick
リサイズのメソッドを定義する
画像のリサイズを行うよう、モデルにメソッドを定義します。
def resize_picture
return self.picture.variant(resize: '100x100').processed
end
ビューで表示する
リサイズされた画像を表示するにはこのように記述します。
<% @microposts.each do |micropost| %>
<!-- 省略 -->
<% if micropost.picture.attached? %>
<div class="log-picture">
<%= image_tag micropost.resize_picture %>
</div>
<% end %>
<!-- 省略 -->
<% end %>
フォーム入力↓
ログ表示↓
以上で画像のリサイズは完了です。
4. 投稿編集・削除機能をつくる
編集と削除のためのアクションはすでに終えました。
よって、ここでの手順はビューをつくるのみです。
$ touch 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>
5. 投稿時の不具合を解消する
このままではフォームの時間が空の時、ログが「分」のみで出力されてしまいます。
それを避けるために簡単な条件を書き、ログを正しく出力しましょう。
<!-- 省略 -->
<% @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分」と表示↓
(少々デザインも変更しています)
6. テストをつくる
残るはテストのみです。
ここではModel specとRequest specのテストを行います。
Model specでのテスト
このテストでは以下を確認します。
- pictureのみ存在するMicropostモデルは有効か
- 5MBを超える画像は無効か
- 画像以外のファイルは無効か
その前に5MB以上の画像、画像以外のファイルを以下のフォルダに準備しましょう。
$ mkdir spec/fixtures/images
# 実際にファイルを追加する
その後、テストを記述します。
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でのテスト
このテストでは以下を確認します。
- ログインしていないユーザの投稿が無効か
- フォームが全て空欄のユーザ投稿が無効か
- ログインしているユーザの投稿が有効か
- ログインしていないユーザの投稿削除が無効か
- 他ユーザの投稿削除が無効か
- ログインしているユーザの投稿削除が有効か
- ログインしていないユーザの投稿編集が無効か
- 他ユーザの投稿編集が無効か
- フォームが全て空欄の投稿編集が無効か
- ログインしているユーザの投稿編集が有効か
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を使う手順は以下の通りです。
- 環境設定を行う
- FactoryBotを整える
- テストを書く
環境設定を行う
まずは、ダミーでアップロードを行うfixture_file_uploadメソッドを使うために、環境設定をしましょう。
# 中略
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を整えます。
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モデルを定義するだけです。
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でモデルテストを正しく行うには?