20210306のRailsに関する記事は27件です。

名前空間付きのモデルの関連付けでハマった話

背景

schoolモデルにcoachを持たせるのでcoachモデルのインスタンスを作成する際に「new」ではなく「build」に置き換えようとしたときの話。

前提

筆者

  • railsを初めて触って一ヶ月の初心者。
  • コードを書いているがなぜ動いているのかあまり分かっていない。
  • 間違いがあれば指摘していただけると幸いです。

開発環境

  • 端末:macbook pro
  • OS:macOS Big Sur 11.2.2
  • ruby:2.7.2
  • rails:6.1.3

schoolユーザーでログインしているアカウントのみcoachを作成できるように。

そのためにshoolsのnamespace内にcoachを入れようと考えました。なので、これらのモデルを作成する際にはscaffoldを使って以下のように作成。

rails g scaffold schools/coaches name:string

モデル同士を関連付ける。

school.rb
class School < ApplicationRecord
has_many :schools_coaches
end
schools/coach.rb
class Schools::coach < ApplicationRecord
belongs_to :school
end

newをbuildに置き換える。

coaches_controller.rb
def new
    @schools_coach = current_school.schools_coaches.build
end

これではうまく行きませんでした。
エラー uninitialized constant School::SchoolsCoach
と怒られてしまいます。

原因はテーブル名

どうやらテーブル名がschools_coachesとなっていたことが原因なようです。
has_many :schools_coachesとするとSchoolsCoachモデルを探しに行ってしまうことが原因のようです。

訂正

school.rb
class School < ApplicationRecord
has_many :coaches
end
coach.rb
class Coach < ApplicationRecord
belongs_to :school
end
coaches_controller.rb
def new
    @schools_coach = current_school.schools_coaches.build
end

モデル名を変更したことでviewも変更しなければならなかった。

_form.html.erb
<%= form_with(model: @schools_coach, url: schools_coaches_path) do |form| %>

modelのみ指定していたが、それだとform_withさんはCoachモデルをみて'coach/new'や'coach/1/edit'のパスだと勘違いしてしまうのでurlでパスを指定してあげる。
これでうまく動いた。

他の解決策

他の解決策もたくさんあるみたいです。
has_manyにclassnameを追記

まとめ

モデル名はできるだけnamespaceをつけない方がややこしくないかも。
つける場合はclassnameをつけるなどの対策が必要。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

AWSへデプロイ後に、暗号化したパスワードでログインできない現象について

AWSへデプロイ後に、bcryptで暗号化したパスワードでログインできない現象について

プログラミング初学者です。Ruby on Railsで作成したアプリをAWSのEC2インスタンスにデプロイした後で、作成したアカウントにログインできない現象が発生しました。パスワード周りのエラーの解消に丸一日かかってしまいましたので、また同じ現象で困らないためにもこの記事にまとめておきたいと思います。

発生した環境

Rubyバージョン:2.5.8
Railsバージョン:6.1.1
gem bcryptバージョン:3.1.16

発生した現象

デプロイ後に新規登録したアカウントに、始めはログインできていたのですが、しばらく経ってからログインしようとすると「メールアドレスかパスワードに誤りがあります」と表示されログインできなくなりました。

試したこと

ログを確認

$ vim log/production.log

いくつかのエラーログを確認しましたので、順に確認していきました。

Mysql2::Error

ActiveRecord::StatementInvalid (Mysql2::Error: Unknown column 'password_digest' in 'field list'):

bcryptでパスワードの暗号化を行なった際に、'password_digest'カラムをデータベースのusersテーブルに追加していました。mysqlにログインしたところ、確かに'password_digest'カラムは存在して値が保存されていました。

ArgumentError

ArgumentError (wrong number of arguments (given 0, expected 1)):
app/controllers/users_controller.rb:41:in `login'

値が入るはずの場所に値が入っていないことが確認できました。
該当の場所には下記コードがありました。

app/controllers/users_controller.rb
    if @user && @user.authenticate(params[:password])

どうやら入力したパスワードが正常に認識されていないことがわかりました。

NoMethodError

NoMethodError (undefined method `encrypted_password=' for #User:0x000000000572e6d8):

'encrypted_password'は何のことかとわからなかったので、検索するとDeviseを導入した時に追加されるという情報がありました。確認すると、確かにGemfileに「gem 'devise'」を追加していました。そこで、encrypted_passwordを利用したパスワードの暗号化に切り替えることにしました。

password_digestからencrypted_passwordへの切り替え

まずmysqlでusersテーブルのカラム名を変更します。

mysql> alter table users change column password_digest encrypted_password char(255);

user.rbを編集します。

app/models/user.rb
class User < ApplicationRecord
  has_secure_password #ここを削除します
  validates :name, {presence: true}
  validates :email, {presence: true, uniqueness: true}
  validates :image_name, {presence: true}
  validates :password, {presence: true} #ここも不要になるので削除します

  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable, :omniauthable
end

users_controller.rbを編集します。
エラーが発生していた、ログイン認証の行の認証メソッドを書き換えます。

app/controllers/users_controller.rb
    if @user && @user.valid_password?(params[:password]

パスワードを変更する部分が少々手こずりました。
params[:password]に保存されている新しいパスワードを手動で暗号化して更新しています。

app/controllers/users_controller.rb
    if params[:password]
      hashed_password = BCrypt::Password.create(params[:password])
      update_password_sql = "update users set encrypted_password = '#{hashed_password}' where id =#{@current_user.id};"
      ActiveRecord::Base.connection.execute(update_password_sql)
    end

コードの編集が終わったらサーバーを再起動します。
nginxを再起動します。

$ sudo service nginx restart

続いてunicornを再起動します。unicornで走っているスレッドを確認します。

$ ps -ef | grep unicorn | grep -v grep

3行の走っているスレッド番号が表示されます。
下は一行目の一例です。

ユーザー名    番号     1  0 11:16 ?        00:00:00 unicorn_rails master -c /var/www/アプリのディレクトリ/config/unicorn.conf.rb -D -E production
…

スレッドを停止します。

$ kill 番号

もう一度上記の確認コマンドを打ち込んで何も表示されなければ、スレッドが終了しています。
unicornを起動します。

$ bundle exec unicorn_rails -c /var/www/アプリのディレクトリ/config/unicorn.conf.rb -D -E production

確認コマンドを打ち込んでスレッド番号が表示されれば、再起動できています。

まとめ

丸一日かかってしまったので、思い出せる限り全部の工程をまとめました。

某プログラミング学習サイトでパスワードの暗号化を習い、簡単にできるものだと思っていたら思わぬ落とし穴がありました。Twitter連携などに使用するDeviseを導入することで、パスワード暗号化の機能が重複してエラーを発生するようです。簡単に習っただけでは実践には足りない、実際にエラーに遭遇することで腕を磨くものだと、今後の良い戒めになりました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

非同期通信(バックエンド)

非同期通信

リクエスト後にブラウザが再読み込みされず一部分のみが更新される通信方法。
待ち時間や画面の切り替わりがなくストレスが少なく操作できます。
SNSの既読機能だったり、いいね機能だったり多用されています。

Ajax

JavaScriptを使用して非同期通信を行う処理のこと
("Asynchronous JavaScript + XML"の略)

メモアプリを実装しながら投稿機能を非同期通信で、また既読機能をつけてみましょう。

新規アプリケーションを作成

console
% rails _6.0.0_ new first_app -d mysql

% rails db:create

ルーティングを設定

config/routes.rb
Rails.application.routes.draw do
 root to: 'posts#index'
post 'posts', to: 'posts#create'
end

postモデルを作成

% rails g model post

マイグレーションファイル作成

db/migrate/....
class CreatePosts < ActiveRecord::Migration[6.0]
  def change
    create_table :posts do |t|
      t.text :content
      t.boolean :checked
      t.timestamps
    end
  end
end

boolean型

trueまたはfalseの真理値を判断する型です。

既読機能実装時に「既読か未読か」をboolean型で管理します。

% rails db:migrate

postsコントローラーを作成

% rails g controller posts index

アクションを記述

class PostsController < ApplicationController

  def index
    @posts = Post.all.order(id: "DESC")
  end

 def create
   Post.create(content: params[:content])
   redirect_to action: :index

end

ビューを編集

<h1>AjaxApp</h1>
<%= form_with url:  "/posts", method: :post,id: "form" do |form| %>
  <%= form.text_field :content %>
  <%= form.submit '投稿する' , id: "submit" %>
<% end %>

<% @posts.each do |post| %>
<div class="post">
  <div class="post-date">
    投稿日時:<%= post.created_at %>
  </div>
  <div class="post-content">
    <%= post.content %>
  </div>
</div>
<% end %>

memo_appのappディレクトリにあるjavascriptディレクトリに
memo.jsとchecked.jsというファイルを作成

app/javascript/packs/application.js
require("@rails/ujs").start()
require("turbolinks").start()
require("@rails/activestorage").start()
require("channels")
require("../checked")
require("../memo")

非同期通信の実装方法

非同期通信時のレスポンス内容はHTMLではなくデータを返却します。
それをJavaScriptで受け取り、すでに表示されているHTMLを部分的にレンダリングするような仕組みです。

動きの図

エンドポイント

Ajaxでやり取りする際の、データ返却のアクションを実行するためのURLのことです。

Ajaxを実現するためには、コントローラーでのレスポンスを、HTMLではなくjsonなどのデータ形式で返却する必要がありデータを取得する時にアクセスするためのURLを、エンドポイントといいます。

既読機能の実装の際に、エンドポイントには「どのメモを既読にしたか」を判別するため「メモのid」というパラメーターを渡す必要があります。

URLパラメーター

サーバーに情報を送るために記載するURL末尾の文字列のことで、送信した情報は、今までparams[:id]などで取得し、使用してきました。
非同期通信では、このURLパラメーターを活用し、サーバーへデータを送ります。

queryパラメーター

queryパラメーターとは、

http://sample.jp/?fruit=orange

のように、「?」以降に情報をかくURLパラメーターです。
「?」以降の構造は、?<変数名>=<値>となっています。

このURLでparams[:fruit]とすると、orangeが戻り値となります。

エンドポイントを、queryパラメーターで記述

既読機能に必要なパラメーターは、「どのメモを既読したか」を判別するためのメモのidです。
メモのidを取得できるようにルーティングに設定します。
下記を追記しましょう。

config/routes.rb
Rails.application.routes.draw do
  root to: 'posts#index'
  post 'posts', to: 'posts#create'
  get 'posts', to: 'posts#checked'
end

queryパラメーターを使用した場合、/posts/?id=1とリクエストを行うと、params[:id]にてパラメーターを取得することができます。

pathパラメーター

http://tweets.jp/tweets/1

のように指定するURLパラメーターです。
queryパラメーターとの使い分けは、pathパラメーターで指定するのは「リソースを識別する場合」です。

既読機能のエンドポイントは、queryパラメーターで設定しました。しかし、今回のように渡す情報が一意の情報であればpathパラメーターの方が適しています。

routes.rb
Rails.application.routes.draw do
  root to: 'posts#index'
  post 'posts', to: 'posts#create'
  get 'posts/:id', to: 'posts#checked'
end

今回のように、postのidであれば'posts/:id'のように記載するpathパラメーターの方が認識もしやすく、記述も単純です。

データ形式

コンピューター上でデータをやり取りする際の形式のことです。プログラミングではJSONが多く使用されます。

XML

JSONと同じくデータをやり取りする際に使用する形式の1つです。

JSONはRubyのハッシュに似ていますが、XMLはHTMLに似ています。
JSONはJavaScriptObjectNotationの略であり、JavaScriptにおけるオブジェクトの表記です。そのため、データをJavaScriptのオブジェクト指向で取り扱う場合に相性が良いです。

text.json
{
  "name": "json表記",
  "format": {
    "json": "JavaScriptにおけるオブジェクトの表記",
    "xml": "HTMLに似ています"
  }
}
text.xml
<name>xml表記</name>
<format>
    <json>JavaScriptにおけるオブジェクトの表記</json>
    <xml>HTMLに似ています</xml>
</format>

Ruby on Railsは、デフォルトではHTMLをレスポンスとして返却する仕組みになっているので
JSONでデータを返却するにはコントローラーの記述を工夫する必要があります。

その際にrenderメソッドを使用します。

既読機能のcheckedというアクションを定義

app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def index
    @posts = Post.all.order(id: "DESC")
  end

  def create
    Post.create(content: params[:content])
    redirect_to action: :index
  end

  def checked
    post = Post.find(params[:id])
    if post.checked 
      post.update(checked: false)
    else
      post.update(checked: true)
    end

    item = Post.find(params[:id])
    render json: { post: item }
  end
end

①先ほど設定したURLパラメーターから、既読したメモのidが渡されるように設定するので、そのidを使用して該当するレコードを取得しています。

②if文で、post.checkedという既読であるか否かを判定するプロパティを指定し、既読であれば「既読を解除するためにfalseへ変更」し、既読でなければ「既読にするためtrueへ変更」します。
ActiveRecordのupdateというメソッドを使用して更新しています。

最後に、更新したレコードをitem = Post.find(params[:id])で取得し直し
render json:{ post: item }でJSON形式(データ)としてchecked.jsに返却しています。

バックエンド側の処理はいったんここまで次はフロントにいきます。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【発展】Active Storageで複数の画像を投稿しよう!(プレビュー機能)

Active Storage利用して複数の画像を投稿できる機能とJavaScriptを用いてプレビューできる機能を実装します。
今回も初心者向けにレシピ投稿アプリを例に作成していきます。

完成イメージ

e76d454dd2a3321f60786f9d0954754e.gif

※なお今回は前回の記事の発展的な内容になっております。
Active Storageの導入方法についてこちら

プレビュー機能についてはこちら

今回は上記の機能が実装されている前提で話を進めていきます。

Active Storage関連ファイルの修正

まずはActive Storage周りのファイルから修正していきます。

アソシエーションの修正

レコードとファイルを1対1の関係で紐づけるためにhas_one_attachedメソッドを使用していましたが、今回は1対多の関係に変更するのでhas_many_attachedメソッドに修正します。Railsガイド

また、imageを複数形のimagesに変更します。

app/models/recipe.rb
class Recipe < ApplicationRecord
  has_many_attached :images #ここを修正
end

投稿フォームの修正

file_fieldのimageをimagesに修正します。
また、name属性を追加し送信する際に必要な画像の配列を設定します。

app/views/recipes/new.html.erb
<%= form_with model: @recipe, local: true do |f| %>

#中略
    <div class="form-group">
      <label class="text-secondary">画像</label><br>
      <%= f.file_field :images, name: 'recipe[images][]' %> #ここを修正
    </div>

#以下略

<% end %>

コントローラーの修正

画像の配列を受け取れるようにストロングパラメーターを修正します。

app/controllers/recipes_controller.rb
class RecipesController < ApplicationController

#中略

  private
  def recipe_params
    params.require(:recipe).permit(:title, :text, :category_id, :time_required_id, images: []) #images: []に修正
  end
end

バリデーションの設定

今回、投稿できる画像を3枚までにしたいので、独自のバリデーションメソッドを作成していきます。Railsガイド

app/models/recipe.rb
class Recipe < ApplicationRecord
  has_many_attached :images 

#ここから追加
  validate :image_length #カスタムメソッドなので"validate"

  private

  def image_length
    if images.length >= 4
      errors.add(:images, "は3枚以内にしてください")
    end
  end
#ここまで追加
end

以上でActive Storage関連の修正は完了です。

preview.jsの修正

今回は以下のGIFのようにひとつ画像を選択すると新しく画像選択フォームが出現する仕様にします。
90c396247ee860b014d53f48d1c5f087.gif

まずは、前回作成したpreview.jsを確認してみましょう。

app/javascript/packs/preview.js
if (document.URL.match(/new/)){
  document.addEventListener('DOMContentLoaded', () => {
    const createImageHTML = (blob) => {
      const imageElement = document.getElementById('new-image');
      const blobImage = document.createElement('img');
      blobImage.setAttribute('class', 'new-img')
      blobImage.setAttribute('src', blob);

      imageElement.appendChild(blobImage);
    };

    document.getElementById('recipe_image').addEventListener('change', (e) => {
      const imageContent = document.querySelector('img');
      if (imageContent){
        imageContent.remove();
      }

      const file = e.target.files[0];
      const blob = window.URL.createObjectURL(file);
      createImageHTML(blob);
    });
  });
}

こちらを修正していきます。

今回は、以下のようなHTMLを作成することを前提にpreview.jsを編集していきます。

/recipes/new
<div id="new-image">
<!-- ここから -->
  <div class="image-element">
    <img class="new-img" src="xxxxxxx...">
    <input id="recipe_image_n" class="recipe-images" name="recipe[images][]" type="file">
  </div>
<!-- ここまでを複製していくイメージ -->
</div>

前回まではid="new-image"のdiv要素に直接プレビュー画像を挿入していましたが、今回は新たなdiv要素を作成しその中に画像を挿入していきます。

そして、classにimage-elementを設定しquerySelectorAllメソッドとlengthメソッドで作成された要素の数を定数imageElementNumに格納します。

app/javascript/packs/preview.js
if (document.URL.match(/new/)){
  document.addEventListener('DOMContentLoaded', () => {

    const ImageList = document.getElementById('new-image'); //追記

    const createImageHTML = (blob) => {

      const imageElement = document.createElement('div'); //new-imageからdivに変更

//ここから
      imageElement.setAttribute('class', "image-element")
      let imageElementNum = document.querySelectorAll('.image-element').length
//ここまで追記

      const blobImage = document.createElement('img');
      blobImage.setAttribute('class', 'new-img')
      blobImage.setAttribute('src', blob);

      imageElement.appendChild(blobImage);
      ImageList.appendChild(imageElement); //追記
      });
    };

    document.getElementById('recipe-images').addEventListener('change', (e) => { //recipe-imagesに修正

//ここから
     //const imageContent = document.querySelector('img');
      //if (imageContent){
        //imageContent.remove();
      //}
//ここまで削除

      const file = e.target.files[0];
      const blob = window.URL.createObjectURL(file);
      createImageHTML(blob);
    });
  });
}

次に新しく画像選択フォームを作成する記述をしていきます。
inputのidに先ほど宣言したimageElementNumを使いinput要素が何番目の要素かを判別します。

app/javascript/packs/preview.js
//中略
document.addEventListener('DOMContentLoaded', () => {
    const ImageList = document.getElementById('new-image');
    const createImageHTML = (blob) => {
      const imageElement = document.createElement('div');
      imageElement.setAttribute('class', "image-element")
      let imageElementNum = document.querySelectorAll('.image-element').length

      const blobImage = document.createElement('img');
      blobImage.setAttribute('class', 'new-img')
      blobImage.setAttribute('src', blob);

//ここから
      const inputHTML = document.createElement('input');
      inputHTML.setAttribute('id', `recipe_image_${imageElementNum}`);
      inputHTML.setAttribute('class', 'recipe-images');
      inputHTML.setAttribute('name', 'recipe[images][]');
      inputHTML.setAttribute('type', 'file');
//ここまで追記

      imageElement.appendChild(blobImage);
      imageElement.appendChild(inputHTML); //追記
      ImageList.appendChild(imageElement);

//以下略

これで、画像を選択すると新しく画像選択フォームが出現するようになりました。
2枚目以降にもイベント発火するよう処理を記述していきます。
1度目に発火するイベントとほとんど同じ記述になります。

app/javascript/packs/preview.js
//中略

    const createImageHTML = (blob) => {
      const imageElement = document.createElement('div');
      imageElement.setAttribute('class', "image-element")
      let imageElementNum = document.querySelectorAll('.image-element').length

      const blobImage = document.createElement('img');
      blobImage.setAttribute('class', 'new-img')
      blobImage.setAttribute('src', blob);

      const inputHTML = document.createElement('input');
      inputHTML.setAttribute('id', `recipe_image_${imageElementNum}`);
      inputHTML.setAttribute('class', 'recipe-images');
      inputHTML.setAttribute('name', 'recipe[images][]');
      inputHTML.setAttribute('type', 'file');

      imageElement.appendChild(blobImage);
      imageElement.appendChild(inputHTML);
      ImageList.appendChild(imageElement);

//ここから
      inputHTML.addEventListener('change', (e) => {
        const file = e.target.files[0];
        const blob = window.URL.createObjectURL(file);
        createImageHTML(blob);
      });
//ここまで追記
    };

//以下略

ここまでで複数の画像投稿に対応したプレビュー機能の実装が完了しました。
しかし、これでは1枚画像を選択するたびに新しく画像選択フォームが出現してしまうので何枚でも画像選択が可能になってしまいます。
9e0000ca5ed8bee348ff2752f8962bef.gif
モデルにバリデーションをかけているので投稿は保存されません。
61635f5521f4d56c6380ed39a77dea34.png

条件分岐で制限をかけよう

input要素の数える定数imageElementNumを使い、3枚選択すると新しく画像選択フォームが出現しないように条件分岐していきましょう。

app/javascript/packs/preview.js
//中略

      const inputHTML = document.createElement('input');
      inputHTML.setAttribute('id', `recipe_image_${imageElementNum}`);
      inputHTML.setAttribute('class', 'recipe-images');
      inputHTML.setAttribute('name', 'recipe[images][]');
      inputHTML.setAttribute('type', 'file');

      imageElement.appendChild(blobImage);
      if (imageElementNum < 2) {            //追記
        imageElement.appendChild(inputHTML);
      }                                     //追記
      ImageList.appendChild(imageElement);

      inputHTML.addEventListener('change', (e) => {
        const file = e.target.files[0];
        const blob = window.URL.createObjectURL(file);
        createImageHTML(blob);
      });
//以下略

以上で完成です。
32e67abef3b9fedea1327e930a9556a2.gif
以下、完成形のコードです。

app/javascript/packs/preview.js
if (document.URL.match(/new/)){
  document.addEventListener('DOMContentLoaded', () => {
    const ImageList = document.getElementById('new-image');
    const createImageHTML = (blob) => {
      const imageElement = document.createElement('div');
      imageElement.setAttribute('class', "image-element")
      let imageElementNum = document.querySelectorAll('.image-element').length

      const blobImage = document.createElement('img');
      blobImage.setAttribute('class', 'new-img')
      blobImage.setAttribute('src', blob);

      const inputHTML = document.createElement('input');
      inputHTML.setAttribute('id', `recipe_image_${imageElementNum}`);
      inputHTML.setAttribute('class', 'recipe-images');
      inputHTML.setAttribute('name', 'recipe[images][]');
      inputHTML.setAttribute('type', 'file');

      imageElement.appendChild(blobImage);
      if (imageElementNum < 2) {
        imageElement.appendChild(inputHTML);
      } 
      ImageList.appendChild(imageElement);

      inputHTML.addEventListener('change', (e) => {
        const file = e.target.files[0];
        const blob = window.URL.createObjectURL(file);
        createImageHTML(blob);
      });
    };

    document.getElementById('recipe-images').addEventListener('change', (e) => {

      const file = e.target.files[0];
      const blob = window.URL.createObjectURL(file);
      createImageHTML(blob);
    });
  });
}

次回は投稿された複数の画像をスライド形式で表示する実装を行っていきます。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【発展】Active Storageで複数の画像を投稿しよう! (プレビュー機能も実装)

Active Storage利用して複数の画像を投稿できる機能とJavaScriptを用いてプレビューできる機能を実装します。
今回も初心者向けにレシピ投稿アプリを例に作成していきます。

完成イメージ

e76d454dd2a3321f60786f9d0954754e.gif

※なお今回は前回の記事の発展的な内容になっております。
Active Storageの導入方法についてこちら

プレビュー機能についてはこちら

今回は上記の機能が実装されている前提で話を進めていきます。

Active Storage関連ファイルの修正

まずはActive Storage周りのファイルから修正していきます。

アソシエーションの修正

レコードとファイルを1対1の関係で紐づけるためにhas_one_attachedメソッドを使用していましたが、今回は1対多の関係に変更するのでhas_many_attachedメソッドに修正します。Railsガイド

また、imageを複数形のimagesに変更します。

app/models/recipe.rb
class Recipe < ApplicationRecord
  has_many_attached :images #ここを修正
end

投稿フォームの修正

file_fieldのimageをimagesに修正します。
また、name属性を追加し送信する際に必要な画像の配列を設定します。

app/views/recipes/new.html.erb
<%= form_with model: @recipe, local: true do |f| %>

#中略
    <div class="form-group">
      <label class="text-secondary">画像</label><br>
      <%= f.file_field :images, name: 'recipe[images][]' %> #ここを修正
    </div>

#以下略

<% end %>

コントローラーの修正

画像の配列を受け取れるようにストロングパラメーターを修正します。

app/controllers/recipes_controller.rb
class RecipesController < ApplicationController

#中略

  private
  def recipe_params
    params.require(:recipe).permit(:title, :text, :category_id, :time_required_id, images: []) #images: []に修正
  end
end

バリデーションの設定

今回、投稿できる画像を3枚までにしたいので、独自のバリデーションメソッドを作成していきます。Railsガイド

app/models/recipe.rb
class Recipe < ApplicationRecord
  has_many_attached :images 

#ここから追加
  validate :image_length #カスタムメソッドなので"validate"

  private

  def image_length
    if images.length >= 4
      errors.add(:images, "は3枚以内にしてください")
    end
  end
#ここまで追加
end

以上でActive Storage関連の修正は完了です。

preview.jsの修正

今回は以下のGIFのようにひとつ画像を選択すると新しく画像選択フォームが出現する仕様にします。
90c396247ee860b014d53f48d1c5f087.gif

まずは、前回作成したpreview.jsを確認してみましょう。

app/javascript/packs/preview.js
if (document.URL.match(/new/)){
  document.addEventListener('DOMContentLoaded', () => {
    const createImageHTML = (blob) => {
      const imageElement = document.getElementById('new-image');
      const blobImage = document.createElement('img');
      blobImage.setAttribute('class', 'new-img')
      blobImage.setAttribute('src', blob);

      imageElement.appendChild(blobImage);
    };

    document.getElementById('recipe_image').addEventListener('change', (e) => {
      const imageContent = document.querySelector('img');
      if (imageContent){
        imageContent.remove();
      }

      const file = e.target.files[0];
      const blob = window.URL.createObjectURL(file);
      createImageHTML(blob);
    });
  });
}

こちらを修正していきます。

今回は、以下のようなHTMLを生成することを前提にpreview.jsを編集していきます。

/recipes/new
<div id="new-image">
<!-- ここから -->
  <div class="image-element">
    <img class="new-img" src="xxxxxxx...">
    <input id="recipe_image_n" class="recipe-images" name="recipe[images][]" type="file">
  </div>
<!-- ここまでを複製していくイメージ -->
</div>

前回まではid="new-image"のdiv要素に直接プレビュー画像を挿入していましたが、今回は新たなdiv要素を作成しその中に画像を挿入していきます。

そして、classにimage-elementを設定しquerySelectorAllメソッドとlengthメソッドで作成された要素の数を定数imageElementNumに格納します。

app/javascript/packs/preview.js
if (document.URL.match(/new/)){
  document.addEventListener('DOMContentLoaded', () => {

    const ImageList = document.getElementById('new-image'); //追記

    const createImageHTML = (blob) => {

      const imageElement = document.createElement('div'); //new-imageからdivに変更

//ここから
      imageElement.setAttribute('class', "image-element")
      let imageElementNum = document.querySelectorAll('.image-element').length
//ここまで追記

      const blobImage = document.createElement('img');
      blobImage.setAttribute('class', 'new-img')
      blobImage.setAttribute('src', blob);

      imageElement.appendChild(blobImage);
      ImageList.appendChild(imageElement); //追記
      });
    };

    document.getElementById('recipe-images').addEventListener('change', (e) => { //recipe-imagesに修正

//ここから
     //const imageContent = document.querySelector('img');
      //if (imageContent){
        //imageContent.remove();
      //}
//ここまで削除

      const file = e.target.files[0];
      const blob = window.URL.createObjectURL(file);
      createImageHTML(blob);
    });
  });
}

次に新しく画像選択フォームを作成する記述をしていきます。

createElementメソッドでinput要素を生成し、setAttributeで属性を追加していきます。
そして、inputのidに先ほど宣言したimageElementNumを使いinput要素が何番目の要素かを判別します。

app/javascript/packs/preview.js
//中略
document.addEventListener('DOMContentLoaded', () => {
    const ImageList = document.getElementById('new-image');
    const createImageHTML = (blob) => {
      const imageElement = document.createElement('div');
      imageElement.setAttribute('class', "image-element")
      let imageElementNum = document.querySelectorAll('.image-element').length

      const blobImage = document.createElement('img');
      blobImage.setAttribute('class', 'new-img')
      blobImage.setAttribute('src', blob);

//ここから
      const inputHTML = document.createElement('input');
      inputHTML.setAttribute('id', `recipe_image_${imageElementNum}`);
      inputHTML.setAttribute('class', 'recipe-images');
      inputHTML.setAttribute('name', 'recipe[images][]');
      inputHTML.setAttribute('type', 'file');
//ここまで追記

      imageElement.appendChild(blobImage);
      imageElement.appendChild(inputHTML); //追記
      ImageList.appendChild(imageElement);

//以下略

これで、画像を選択すると新しく画像選択フォームが出現するようになりました。
2枚目以降にもイベント発火するよう処理を記述していきます。
1度目に発火するイベントとほとんど同じ記述になります。

app/javascript/packs/preview.js
//中略

    const createImageHTML = (blob) => {
      const imageElement = document.createElement('div');
      imageElement.setAttribute('class', "image-element")
      let imageElementNum = document.querySelectorAll('.image-element').length

      const blobImage = document.createElement('img');
      blobImage.setAttribute('class', 'new-img')
      blobImage.setAttribute('src', blob);

      const inputHTML = document.createElement('input');
      inputHTML.setAttribute('id', `recipe_image_${imageElementNum}`);
      inputHTML.setAttribute('class', 'recipe-images');
      inputHTML.setAttribute('name', 'recipe[images][]');
      inputHTML.setAttribute('type', 'file');

      imageElement.appendChild(blobImage);
      imageElement.appendChild(inputHTML);
      ImageList.appendChild(imageElement);

//ここから
      inputHTML.addEventListener('change', (e) => {
        const file = e.target.files[0];
        const blob = window.URL.createObjectURL(file);
        createImageHTML(blob);
      });
//ここまで追記
    };

//以下略

ここまでで複数の画像投稿に対応したプレビュー機能の実装が完了しました。

しかし、これでは1枚画像を選択するたびに新しく画像選択フォームが出現してしまうので何枚でも画像選択が可能になってしまいます。
9e0000ca5ed8bee348ff2752f8962bef.gif
モデルにバリデーションをかけているので投稿は保存されません。
61635f5521f4d56c6380ed39a77dea34.png

条件分岐で制限をかけよう

input要素の数える定数imageElementNumを使い、3枚選択すると新しく画像選択フォームが出現しないように条件分岐していきましょう。

app/javascript/packs/preview.js
//中略

      const inputHTML = document.createElement('input');
      inputHTML.setAttribute('id', `recipe_image_${imageElementNum}`);
      inputHTML.setAttribute('class', 'recipe-images');
      inputHTML.setAttribute('name', 'recipe[images][]');
      inputHTML.setAttribute('type', 'file');

      imageElement.appendChild(blobImage);
      if (imageElementNum < 2) {            //追記
        imageElement.appendChild(inputHTML);
      }                                     //追記
      ImageList.appendChild(imageElement);

      inputHTML.addEventListener('change', (e) => {
        const file = e.target.files[0];
        const blob = window.URL.createObjectURL(file);
        createImageHTML(blob);
      });
//以下略

以上で完成です。
32e67abef3b9fedea1327e930a9556a2.gif
以下、完成形のコードです。

app/javascript/packs/preview.js
if (document.URL.match(/new/)){
  document.addEventListener('DOMContentLoaded', () => {
    const ImageList = document.getElementById('new-image');
    const createImageHTML = (blob) => {
      const imageElement = document.createElement('div');
      imageElement.setAttribute('class', "image-element")
      let imageElementNum = document.querySelectorAll('.image-element').length

      const blobImage = document.createElement('img');
      blobImage.setAttribute('class', 'new-img')
      blobImage.setAttribute('src', blob);

      const inputHTML = document.createElement('input');
      inputHTML.setAttribute('id', `recipe_image_${imageElementNum}`);
      inputHTML.setAttribute('class', 'recipe-images');
      inputHTML.setAttribute('name', 'recipe[images][]');
      inputHTML.setAttribute('type', 'file');

      imageElement.appendChild(blobImage);
      if (imageElementNum < 2) {
        imageElement.appendChild(inputHTML);
      } 
      ImageList.appendChild(imageElement);

      inputHTML.addEventListener('change', (e) => {
        const file = e.target.files[0];
        const blob = window.URL.createObjectURL(file);
        createImageHTML(blob);
      });
    };

    document.getElementById('recipe-images').addEventListener('change', (e) => {

      const file = e.target.files[0];
      const blob = window.URL.createObjectURL(file);
      createImageHTML(blob);
    });
  });
}

次回は投稿された複数の画像をスライド形式で表示する実装を行っていきます。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

RailsのAPI開発で使える!JWTを理解して認証機能を実装する!

背景

以前、vue.js × Rails APIでのSPA開発に挑戦しました。その際、認証にJWTを使ったのですが、ネットの記事を参考に実装したために仕組みやコードを完全に理解できていませんでした。そのためJWTの認証について改めて記事にしてまとめたいと思いました。railsでvue,reactなどと連携させてSPA開発したい方は参考になるかと思います。

ポートフォリオに関する記事

【ポートフォリオ】Rails API × vue.js × AWS × Docker × CircleCi × terraformでポートフォリオを作成しました

目次

  1. JWTについて
  2. JWTで認証をする流れ
  3. 実際のrailsコード解説
  4. vue.jsとの連携


1. JWTについて

■JWTとはなにか

JWTとはJSON Web Tokenの略で、JSON形式で表されたトークンです。著名・暗号化によってセキュアな通信ができます。

■JWTの中身

JWTは下記のうようにピリオド区切りで3つの要素に分かれています。

{①ヘッダー要素}.{②ペイロード要素}.{③署名要素}
  • ①のヘッダー要素は、データの型やルールを指定します。
  • ②のペイロード要素には、属性情報が入ります。例えばuser_idやemailやtokenの有効期限などです。
  • ③の著名要素は、改ざんがされていないか確認するための情報です。

以上がざっくりとして説明です。アプリケーション実装のためには上記の大枠の把握だけで問題ないです。もっと詳しく知りたい場合は下記の参考記事を参照ください。
JWT(JSON Web Token)の「仕組み」と「注意点」


2. JWTで認証をする流れ

■図による説明

名称未設定.png

ざっくりですが説明です。

  • まずはフロント側からログインフォームからユーザー名・メールアドレス・パスワードなどログインに必要な情報を送ります。
  • その情報をバックエンドで受け取り、登録しているユーザー名とパスワードが一致していた場合に、tokenを発行してレスポンスします。発行されたtokenは秘密鍵で暗号化してJWTとして送られます。
  • そのtokenをlocalstorageに保存して、常に使える状態にします。vue.jsであればvuex, react.jsであればreduxに保存します。ログインしてないとできないリクエストは、このtokenをヘッダーにのせてリクエストします。
  • バックエンド側はヘッダー情報をもとに認証・認可を行い、リクエストを返します。
  • 帰ってきたリクエストをフロント側で表示等します。

上記の流れを理解すれば、バックエンド側の実装も理解しやすくなります。すなわちバックエンド側はjwtのtoken発行、そのルーティングとモデルの設定をすればよいのです。では早速具体的なコードを見ていきましょう。


実際のコードrails解説

■gemのインストール

gemfile
gem 'active_model_serializers'
gem 'jwt'
  • jwtに関してはそのままjwtというgemがあります。
  • active_model_serializersはレスポンスを簡単にそしてきれいにjson形式に整形してくれるgemです。railsでAPI開発をする際はわりかし頻繁に使われます。

■ルーティングの設定

config/routes.rb
Rails.application.routes.draw do
  root 'home#index'
  namespace :api do
    resources :users, only: %i[create]
    resource :session, only: %i[create destroy]
  end
end
  • 基本的にapi開発はapiやv1をurlにつけて管理することが多いです。そうすることでバージョンの更新がしやすくなるからです。
  • 今回はuserの登録はusers、usersの情報をもとに認証を行うのがsessionsの役割です。

ルーティングのnamespaceなどの指定に関しては下記記事を参照してください。
Railsのroutingにおけるscope / namespace / module の違い

■モデルの設定

xxx_create_users.rb
class CreateUsers < ActiveRecord::Migration[6.0]
  def change
    create_table :users do |t|
      t.string :name, null: false
      t.string :email, null: false
      t.string :password_digest, null: false

      t.timestamps
    end
    add_index :users, :email, unique: true
  end
end
models/user.rb
class User < ApplicationRecord
  has_secure_password
  validates :name, presence: true
  validates :email, presence: true, uniqueness: true
  validates :password_digest, presence: true
end
  • 特に難しいことはないですが、簡単に説明するとuser登録にはname,email,passwordが必要で、ログインにはemail,passwordが必要です。passwordはセキュリティの関係上,password_digestで暗号化して保存しています。

■コントローラの設定

app/contorollers/application_controller.rb
class ApplicationController < ActionController::Base
  protect_from_forgery with: :null_session
  class AuthenticationError < StandardError; end

  rescue_from ActiveRecord::RecordInvalid, with: :render_422
  rescue_from AuthenticationError, with: :not_authenticated

  def authenticate
    raise AuthenticationError unless current_user
  end

  def current_user
    @current_user ||= Jwt::UserAuthenticator.call(request.headers)
  end

  private

  def render_422(exception)
    render json: { error: { messages: exception.record.errors.full_messages } }, status: :unprocessable_entity
  end

  def not_authenticated
    render json: { error: { messages: ['please login'] } }, status: :unauthorized
  end
end
  • application controllerではエラーに関する処理を記載しています
  • 「protect_from_forgery with:」メソッドは自動でCSRF対策の設定です。「null_session」のオプションはTokenが一致しなかった場合にsessionを空にするというオプションです。
  • 「class AuthenticationError < StandardError; end」はStandardErrorは例外束ねているクラスです。それをAuthenticationErrorへ継承しています。
  • 「rescue_from」は例外の処理です。「rescue_from AuthenticationError, with: :not_authenticated」の意味はAuthenticationErrorが起こった場合に,not_authenticatedメソッドを実行するという意味です。
  • 「rescue_from ActiveRecord::RecordInvalid, with: :render_422」はActiveRecord::RecordInvalid(railsが用意しているバリデーションのエラー)が起こった場合に,render_422をするという意味です。
  • 「authenticate」は現在ログイン中のuserでなければエラーを発生させるメソッドです。raiseはエラーを発生させるメソッドで、currentuserでない場合はAuthenticationErrorを発生させます。
  • 「current_user」は現在ログイン中のuserかどうかを判定するメソッドです。Jwt::UserAuthenticator(この後説明するサービスファイル)で定義したcallメソッドを呼びます。引数にはリクエストのヘッダー情報を送ります。またuser情報が取得できた場合は@current_userに代入され、できない場合はfalseを返します。
  • render_422, not_anthentiatedはjson形式でエラーメッセージとstatusを返すメソッドです。


app/controllers/api/sessions_controller.rb
class Api::SessionsController < ApplicationController
  def create
    user = User.find_by(email: session_params[:email])

    if user&.authenticate(session_params[:password])
      token = Jwt::TokenProvider.call(user_id: user.id)
      render json: ActiveModelSerializers::SerializableResource.new(user, serializer: UserSerializer).as_json.deep_merge(user: { token: token })
    else
      render json: { error: { messages: ['mistake emal or password'] } }, status: :unauthorized
    end
  end

  private

  def session_params
    params.require(:session).permit(:email, :password)
  end
end 
  • 「user = User.find ・・・」でusersカラムから、リクエストされてきたemail情報をもとに特定のuserを見つけます。
  • 「user&.authenticate」はuserがnilでない場合にautheicateを実行するという意味です。(ぼっち演算子)
  • 「token = JWT」でTokenProviderというサービスファイルで定義したcallメソッドを呼び出し、引数であるuser_idをもとにトークンを取得しています。
  • 次に処理内容を「render json」でjson形式でレスポンスしているのですが、少し長いので分解して解説します。「ActiveModelSerializers::SerializableResource.new(user, serializer: UserSerializer)」の箇所は新しくserializerインスタンスを作成しています。普通であればserializerファイルを作っていればこんな記述をしなくてよいのですが,sessionの中身はuserモデルと同じなので、user情報とuserのserializerを引数にとり、その情報をもとにserializerインスタンスを作成しているのです。「.as_json」はserializerの形式を指定しています。「.deep_merge(user: { token: token })」はuserserializerにuserのもっているtoken情報を結合するよという意味です。「.merge」はコントローラ内でもよく使うと思うのですが、ハッシュの中にハッシュがあるような場合は「.deep_merge」を使う必要があります。
  • その他の「render json: {error~」や「sessionparams」の定義は難しくないので大丈夫だと思います。


app/controllers/api/users_controller.rb
class Api::UsersController < ApplicationController
  def create
    user = User.new(user_params)
    user.save!
    render json: user, serializer: UserSerializer
  end

  private

  def user_params
    params.require(:user).permit(:name, :email, :password, :password_confirmation)
  end
end
  • こちらも簡単ですので説明省略します。

■サービスの設定

app/services/jwt/user_authenticator.rb
module Jwt::UserAuthenticator
  extend self

  def call(request_headers)
    @request_headers = request_headers
    begin
      payload, = Jwt::TokenDecryptor.call(token)
      User.find(payload['user_id'])
    rescue StandardError
      nil
    end
  end

  private
  def token
    @request_headers['Authorization'].split(' ').last
  end
end
  • 「extend self」は、レシーバが「module Jwt::UserAuthenticator」そのものであり、module内で定義したメソッドが「Jwt::UserAuthenticator.メソッド名」として使えるようになるという意味です。
  • ちなみにここで定義されたcallメソッドはapplication.rbのcurrentuserメソッドで呼ばれました。引数としてrequestのヘッダー情報が渡されています。
  • 「begin ~ rescue ~ end」は例外処理です。begin内でエラーが起こりそうなアクション、resucueがエラーが起こったときのアクションです。「rescue StandardError」としてあるのは、特定のエラーを指定しています。つまりStandard errorが発生したときにnilを返す、今回でいうとcurrent_userがnilになります。ちなみにStandardErrorはプログラムで発生するよくあるエラーがまとまっているクラスです。明記しなくてもデフォルトで設定されるかも。。
  • 「payload,= Jwt::TokenDecryptor.call(token)」はサービスのTokenDecryptorファイルにあるcallメソッドを呼び出し、payloadに格納しています。また「payload,」は間違えではありません。「payload,_」と書くことも多いですが、このアンダーバーにヘッダー情報が格納されます。実際にこのコードではtokenを暗号化しているのですが、その際にpayloadの属性情報と一緒に、ヘッダー情報を返します。
  • 「User.find...」は取得したpayloadのuser_idの情報をもとにuserを探します。そしてapplicationコントローラのcurrent_userに代入されます。
  • プライベートメソッドのtokenには「request_headers['Authorization'].split(' ').last」と書いてあり、ヘッダーのauthrizationのtokenのみを取得しています。request_headersの中身は「Authorization: Bearer tokentokentoken...」となっているので、tokenだけを取得するには上記のようにします。


app/services/jwt/token_decryptor.rb
module Jwt::TokenDecryptor
  extend self

  def call(token)
    decrypt(token)
  end

  private

  def decrypt(token)
    JWT.decode(token, Rails.application.credentials.secret_key_base)
  rescue StandardError
    raise InvalidTokenError
  end
end
class InvalidTokenError < StandardError; end
  • プライペートメソッドのdecryptは、引数のtokenをもとに復号化しています。復号にはrailsのrailsの秘密鍵が必要になるので第2引数で指定しています。
  • つまりcallは復号化のメソッドです。「services/jwt/user_authenticator.rb」で特定のuserを探すために暗号化されたtokenを複合しているのです。
  • resucue以下は例外処理です。StandardErrorが起こった際にraiseでInvalidTokenErrorという自分で定義したエラーを起こしています。InvalidTokenErrorはStandardErrorを継承しています。


app/services/jwt/token_provider.rb
module Jwt::TokenProvider
  extend self

  def call(payload)
    issue_token(payload)
  end

  private
  def issue_token(payload)
    JWT.encode(payload, Rails.application.credentials.secret_key_base)
  end
end
  • 「issue_token」メソッドは、引数のpayload(今回でいうとuser_idのこと)をもとに暗号化しています。暗号化するにはrailsの秘密鍵が必要になるので第2引数で指定しています。
  • つまりcallは暗号化のメソッドです。これはsessionコントローラから呼ばれるメソッドですが、user_idをもとにtokenを暗号化してレスポンスします。


以上で終わりです。あとはpostmanなどで正常にリクエストできるか確かめるだけです。
ファイル構造に沿って流れを確認すると、ログイン時はログインリクエストが送られる→ルーティングでsessionコントローラに振り分けられる→リクエストのemailとpassword情報が正しければ暗号化してレスポンスするという流れです。またログイン中じゃないとできないアクションは、リクエストが送られる→ルーティングで該当のコントローラに振り分けられる→コントローラ内でcurrent_userが呼ばれる→リクエストを暗号化し特定のuserを探す→userがいた場合はcurrent_userに格納するという流れです。


4. vue.jsとの連携(補足)

■vuexでlocalhostに保存する。
vueの細かい動きは説明しませんが、vuexにjwtのtoken情報を保存するコードを説明します。localhostに保存するのがセキュリティ上どうなのかというところは正直わかりません。
vuexの基本に関しては前回記事を書いたのでそれを参照ください。
【Rails × VueでSPA開発】Vue Router・Vuexを学ぶ

:store.vue
import axios from 'axios'
const state = {
    currentUser: null,
};

const getters = {
    currentUser: state => state.currentUser,
};

const mutations = {
    SET_CURRENT_USER: (state, user) => {
        state.currentUser = user;
        localStorage.setItem('currentUser', JSON.stringify(user))
        axios.defaults.headers.common['Authorization'] = `Bearer ${user.token}`
    },
    CLEAR_CURRENT_USER: () => {
        state.currentUser = null
        localStorage.removeItem('currentUser')
        location.reload()
    }
};

const actions = {
    async login({ commit }, sessionParams) {
        const res = await axios.post(`/api/session`, sessionParams)
        commit("SET_CURRENT_USER", res.data.user);
    },

    logout({ commit }) {
        commit("CLEAR_CURRENT_USER");
    },
};

export default {
    namespaced: true,
    state,
    mutations,
    actions,
    getters
}; 
  • actionsでrailsのsessionsにリクエストを送り、レスポンスをresに格納します。そしてcommitしてmutationsのSET_CURRENT_USERに渡します。
  • そしてSET_CURRENT_USERでstateのcurrentuserに値を渡し、それを「localStorage.setItem('currentUser', JSON.stringify(user))」でlocalstorageに保存します。「axios.defaults.headers.common['Authorization'] = Bearer ${user.token}」でaxiosのデフォルトの通信にlocalstrageに保存した情報をのせて、認証します。
  • CLEAR_CURRENT_USERはログアウトのメソッドです。localstorageにあるユーザー情報を消せば、認証できなくなるのでログアウトという意味になります。


まとめ

いままでrailsの認証はdeviseに乗っかっていったので裏で何が起こっているかわからなかったですが、jwtを実装したことで裏の動きがわかるようになりました。Rails APIは他にもdevise_token_authやfirebaseAuthを利用して認証する方法が考えられますが、認証のベースがわかっていればなんとかなると思ってます。またjwtもまだ奥が深そうなので勉強したいと思います。とくにセキュリティに関しては無知ですので、、、


  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

rails webpacker:install node_modules/@rails/webpacker/node_modules/node-sass: Command failed.

この記事について

rails webpacker:installを実行しようとすると以下のエラーが発生(node-sass部分)。
解決できたので備忘録投稿

$ rails webpacker:install                                                                                                                                                  e

なんやかんや

warning "webpack-dev-server > webpack-dev-middleware@3.7.3" has unmet peer dependency "webpack@^4.0.0 || ^5.0.0".
[4/4] 🔨  Building fresh packages...
[-/2] ⠐ waiting...
error /Users/ketchamash/hello_rails/node_modules/@rails/webpacker/node_modules/node-sass: Command failed.
Exit code: 1

環境

Mac Catalina
ruby 2.6.3
rails 6.0.3

解決方法 & 参考記事

色々調べていると、nodejsのバージョンもしくはnode-sassバージョンに原因があることは分かった。どのバージョン同士でやったらええねん状態でしたが、こちらの記事でうまいこといきました。

nodejsとnode-sassのバージョンの対応表

nodejs -v node-sassのサポート -v
14 4.14+
13 4.13+, <5.0
12 4.12+
11 4.10+, <5.0
10 4.9+

実行コマンド

これでちゃんとインストールできやした〜。

nodebrewはNode.jsのバージョンを管理するためのツール

$ nodebrew install v14.15.0

$ nodebrew use v14.15.0

$ yarn add node-sass@4.14.0

$ rails webpacker:install

Webpacker successfully installed 🎉 🍰
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Rails] strftime vs I18n.localize vs to_s(:Time::DATE_FORMATS) どれを使うべきか?

結論

I18n.localizeがオススメ

理由

  • 日時表示専用のメソッドだから(= スコープが小さく、オーバーライドしやすいから )

strftimeがダメな理由

  • undefined method strftime for nil:NilClassエラーを回避するために無闇に&をつけるから
  • 日時を表示するのに長ったらしいメソッド名・時刻フォーマットを書くのはめんどくさいから

to_s(:Time::DATE_FORMATS)がダメな理由

  • スコープが広く、オーバーライドできない(すべきでない)から

to_snilを渡すと空文字を返す。

$ nil.to_s
=> ""

しかし、to_s(:Time::DATE_FORMATS)にするとArgumentErrorが発生する。

$ Time::DATE_FORMATS[:sample_format] = "%Y年%m月%d日"
=> "%Y年%m月%d日"

$ Time.now.to_s(:sample_format)
=> "2021年01月01日"

$ nil.to_s(:sample_format)
=> ArgumentError: wrong number of arguments (given 1, expected 0)

to_sで時刻フォーマットを指定できるが、nilを渡してもエラーを吐かないto_sの使いやすさがなくなった。
to_s&を毎回書くのは美しくないし、strftimeとやってる事が変わらなくなる。

I18n.localizenilが渡るとエラーが起きる。

config/locales/ja.yml
ja:
  time:
    formats:
      default: "%Y年%m月%d日"
$ I18n.l(Time.now)
=> "2021年01月01日"

$ I18n.l(nil)
=> I18n::ArgumentError: Object must be a Date, DateTime or Time object. nil given.

なので

  1. 諦めて永遠と&を使い続ける(論外)
  2. begin rescueでエラーをキャッチする(論外)
  3. オーバーライドしてnilをパスさせるか

どれかの対応が必要になる。
1は論外。美しくない。
2も論外。毎回begin resucueするのはダルい。美しくない。
必然的に3で対応することになるが、to_sは影響範囲が広く懸念が大きい。
なので、日時表示専用メソッドでスコープが小さく影響範囲が狭いI18n.localizeをオーバーライドするのがオススメ
オーバーライドの方法は、以下を参考にしてほしい。
http://hamasyou.com/blog/2014/02/19/rails-i18n-localize/

この記事を書こうと思った経緯と所感

I18n.localizeが日時表示用のメソッドならば、これ使えばいいんじゃね?」

って思ってました。
しかし、デフォルトではnilをパスできないと知って、他のメソッドを調べました。
その際に、to_sメソッドの引数に時刻フォーマットを設定すれば日時を表示できると知りました(to_sメソッド万能スギィィ!!!
しかし、

to_sでフォーマットを指定できるが、nilを渡してもエラーを吐かないto_sの使いやすさがなくなりました。。。

&でエラー回避できますが、strftimeとやってることは変わらないし、コードが美しくないと思いました。
このように、コードの保守性とメソッドスコープの観点からI18n.localizeをオーバーライドするのがベストだと考えました。

参考文献

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Rspec】requests spec・コントローラーのテストについて〜adminユーザー作成やeditアクションのテスト〜

requests spec・コントローラーのテストについての話

モデルと結合テストは問題なく終わり、コントローラーのテストについてあれこれ調べていたがなかなか自分に理解できるものがなく、困っていました。
公開されているGitHubのコードを見てもコントローラーのテストコードをやっている人が少なかったというのも一つの要因なのですが、試行錯誤してなんとか完成しました。
正直これでいいのかちょっとわからない部分もあるので、もし知見がある方がこの記事を見て下さったらコメント頂けると嬉しいです。

adminユーザーの生成で困ったこと

DBにadminカラムを作ってtruefalseかで判別しています。
FactoryBotでユーザーを生成するために書いたコードが下記のものです。

spec/factories/users.rb
FactoryBot.define do
  factory :user do
    nickname              { '野比のび太' }
    sequence(:email)      { |n| "tester#{n}@example.com" }
    password              { 'q11111' }
    password_confirmation { password }
    profile               { 'なんとかしてよドラえもん' }
    admin                 { false }    ⬅️一般ユーザー
  end

  factory :admin_user do
    nickname              { 'ドラえもん' }
    sequence(:email)      { |n| "tester#{n}@example.com" }
    password              { 'a11111' }
    password_confirmation { password }
    profile               { 'いつまでも子供じゃないんだよしっかりしろよ' }
    admin                 { true }     ⬅️adminユーザー
  end
end

そしてusers_spec.rbには以下のように記述。

spec/requests/users_spec.rb
RSpec.describe UsersController, type: :request do
  let(:user) { FactoryBot.create(:user) }
  let(:admin_user) { FactoryBot.create(:user, admin: true) }
 #let(:admin_user) { FactoryBot.create(:admin_user) }⬅️この書き方でもダメでした

これでadminユーザーが作成されるはずだと思っていたのですがbinding.pryで止めて中身を見るとadmin_userが作成されていませんでした。
色々書き方を変えてやってみたところ、letを使わずにbefore doにしたらうまくいきました。

spec/requests/users_spec.rb
RSpec.describe UsersController, type: :request do
  before do
    @user = FactoryBot.create(:user)
    @admin_user = FactoryBot.create(:user, admin: true)
   #@admin_user = FactoryBot.create(:admin_user)⬅️この書き方でもダメでした
  end

3c9fb0574c103d376e614707e02c1126.png

ひとまずこれで一般ユーザーとadminユーザーを分けることができるようになりました。

editアクションのテストで困ったこと

コントローラーのテストをする上でまず困ったのがeditアクションのテストでした。
authenticate_user!を使用しているためコントローラーのテスト実行時に、
「ログインしていないユーザー」とみなされてしまってリダイレクトされてしまうのです。
リクエストが正常のレスポンスではなくなってしまうと、テスト項目の
expect(response.status).to eq 200
ここの200が302になってしまいます。

また、編集できる時とできない時、両方のテストをしなければいけないため、コントローラーのテストコード上でログインをさせることができれば正常な返り値が得られると考えました。

deviseのメソッドをテストコード上でも使えるようにするには

テストコード上でログインさせるメソッドとして結合テストコード時に使っていたmoduleでやってみたところ、うまくいきませんでした。
調べた結果、コントローラーのテストではdeviseのメソッドを使用できるとのことだったのでそちらに切り替えました。

spec/rails_helper.rb
RSpec.configure do |config|
  #deviseのsign_inメソッドが使えるようになる
  config.include Devise::Test::IntegrationHelpers, type: :request
end

これでsign_inというメソッドが使用できるようになりました。
自分の場合は@userと定義しているのでそのまま使います。

spec/requests/users_spec.rb
require 'rails_helper'
RSpec.describe UsersController, type: :request do
  before do
    @user = FactoryBot.create(:user)
  end

  describe 'GET #edit' do
    context 'userがログインしているとき' do
      before do
        sign_in @user  ⬅️ここでログイン
      end
      it 'editアクションにリクエストすると正常にレスポンスが返ってくる' do
        get edit_user_path(@user)
        expect(response.status).to eq 200  ⬅️編集画面に遷移できている
      end
      it "editアクションにリクエストするとレスポンスに登録済みuserの名前が存在する" do 
        get edit_user_path(@user)
        expect(response.body).to include @user.nickname
      end
      it "editアクションにリクエストするとレスポンスに登録済みuserのプロフィールが存在する" do 
        get edit_user_path(@user)
        expect(response.body).to include @user.profile
      end
    end

    context 'userがログインしていないとき' do
      it 'editアクションにリクエストすると正常にレスポンスが返ってくる' do
        get edit_user_path(@user)
        expect(response.status).to eq 302  ⬅️トップページにリダイレクトしている
      end
    end
  end
end

これでうまくいきました。
admin_userにしたいときはbefore doで@admin_userを定義して、
sign_in @admin_userとすることで他のコントローラーでも問題なく処理ができました。
テストコードをやるとアプリケーションの仕様がよく理解できると言いますが、理由がよくわかりました。
細かいところに気がついたり、仕様そのものを一から見直す結果になって理解が深まったと実感できました。

参考にさせていただきました

@iwkmsy9618
[Rails]Request Specでのログインの実施

ありがとうございました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

gem ffi 1.14.2 エラーの解決方法 そのn番目

この記事について

bundle install実行時にffi 1.14.2のところでこけてしまう。

環境

  • Mac Catalina
  • ruby 2.6.3
  • rails 6.0.3

エラー内容

Fetching ffi 1.14.2
Installing ffi 1.14.2 with native extensions
Gem::Ext::BuildError: ERROR: Failed to build gem native extension.

current directory:
/Users/ketchamash/.rbenv/versions/2.6.3/lib/ruby/gems/2.6.0/gems/ffi-1.14.2/ext/ffi_c
/Users/ketchamash/.rbenv/versions/2.6.3/bin/ruby -I
/Users/ketchamash/.rbenv/versions/2.6.3/lib/ruby/2.6.0 -r ./siteconf20210306-53720-1m14ww7.rb
extconf.rb
checking for ffi_prep_closure_loc() in -lffi... yes
checking for ffi_prep_cif_var()... yes
checking for ffi_raw_call()... yes
checking for ffi_prep_raw_closure()... yes
creating extconf.h
creating Makefile

current directory:
/Users/ketchamash/.rbenv/versions/2.6.3/lib/ruby/gems/2.6.0/gems/ffi-1.14.2/ext/ffi_c
make "DESTDIR=" clean

current directory:
/Users/ketchamash/.rbenv/versions/2.6.3/lib/ruby/gems/2.6.0/gems/ffi-1.14.2/ext/ffi_c
make "DESTDIR="
compiling AbstractMemory.c
compiling ArrayType.c
compiling Buffer.c
compiling Call.c
compiling ClosurePool.c
compiling DynamicLibrary.c
compiling Function.c
Function.c:847:17: error: implicit declaration of function 'ffi_prep_closure_loc' is invalid in
C99 [-Werror,-Wimplicit-function-declaration]
ffiStatus = ffi_prep_closure_loc(closure->pcl, &fnInfo->ffi_cif, callback_invoke, closure,
code);
                ^
Function.c:847:17: note: did you mean 'ffi_prep_closure'?
/Library/Developer/CommandLineTools/SDKs/MacOSX10.14.sdk/usr/include/ffi/ffi.h:269:1: note:
'ffi_prep_closure' declared here
ffi_prep_closure(
^
1 error generated.
make: *** [Function.o] Error 1

make failed, exit code 2

なんやかんや

An error occurred while installing ffi (1.14.2), and Bundler cannot continue.
Make sure that `gem install ffi -v '1.14.2' --source 'https://rubygems.org/'` succeeds before
bundling.

In Gemfile:
  spring-watcher-listen was resolved to 2.0.1, which depends on
    listen was resolved to 3.4.1, which depends on
      rb-inotify was resolved to 0.10.1, which depends on
        ffi

解決のために試したこと

1.gem install ffi -v '1.14.2を実行

2.Qiitaで参考にさせて頂いた記事
自分の場合、こちらでは解決できませんでした。

解決できた参考ページ

gemのインストール時に--disable-system-libffiオプションをつけて実行するとインストールできた。

 gem install ffi -- --disable-system-libffi

解決できた理由?

libffiライブラリが上手く使うためのオプションなのかな?

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

RailsでQRコードを生成

前提条件

・ruby 2.6.6
・rails 6.0.3.4
・macOS Catalina バージョン10.15.7

概要

 webアプリ内で一般的な会員システムに搭載されているmyQRコードを作成したいと思い、実装したので忘れないようここに残すことにした。
 難しいことは特になく、比較的簡単に実装が完了した。

gemの導入

 webアプリ内にQRコードを生成させるためには
・gem 'rqrcode'
・gem 'chunky_png'
 の2つを導入する必要がある。

Gemfile
gem 'rqrcode'
gem 'chunky_png'
ターミナル
bundle

これで前準備は完了。
次にコードを書いていく。

QRコードを生成させる

 今回は個人的な理由だが、各ユーザーのマイページに設置したいため、user_helper.rbにコードを記述する。

app/helpers/users_helper.rb
module UsersHelper
#ここから
require 'chunky_png'

  def qrcode_tag(url, _options = {})
    qr = ::RQRCode::QRCode.new(url)
    ChunkyPNG::Image.from_datastream(qr.as_png.resize(250, 250).to_datastream).to_data_url
  end
#ここまで
end

 引数でurlを渡すことで、pngを生成する。
 また注意して欲しいのが、gemを導入しているときはターミナルでwebアプリを再起動してからじゃないと、エラーが起きてうまく作動しないので注意。(自分も忘れていて何でエラーが起きているかわからなかった。)

Viewに表示させる

 QRコード生成の準備ができたので、ViewでQRコードを表示させるコードを書いていく。自分の場合は何度もいうがマイページに設置したいので users/show.html.erb に記述していく。

users/show.html.erb
<%= image_tag qrcode_tag url %>

とは言っても表示させたい場所に上記のコードを記述するだけで完了。
urlの部分に、生成されるQRコードを読み取ったときに遷移させるページのURLを記述すれば、読み込んだときにそのページに遷移するQRコードが生成される。以上。

参考文献

・QRコードの生成・埋め込み
 https://qiita.com/koteko/items/d6d033997c544c47b718
https://github.com/whomwah/rqrcode
https://github.com/wvanbergen/chunky_png

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

RailsにAjaxでデータ保存する時に困ったこと

記事の目的

Railsでオリジナルアプリを作成中にAjaxでデータ保存出来ずに困ったこと、どのように解決したのかを忘れないために書いておきます。

開発環境

  • macOS Big Sur 11.1
  • VScode
  • Ruby 2.6.5
  • Rails 6.0.3.4
  • mysql 14.14
  • JavaScript
  • gem 3.0.3
  • heroku 7.47.12

オリジナル読書アプリ

読書アプリケーションを作りました。
https://github.com/hiro-mu/book_smart.git

困ったこと 

実装したのは読書アプリによくあるようなドラッグした部分の背景色が変わる機能です。
この機能の実装は
1. 文字列がドラッグされた場合にイベントが発生し、その文字列をデータベースに保存
2. データベースに存在する文字列が表示されている場合、replaceメソッドでブラウザ上のHTMLに背景色を変えるCSSを追加
と言う流れで行っています。
難しかったところはこの部分です。

highlight.js
XHR.open("GET", `/highlights/create?book_id=${bookId}&pagenum=${pageNum}&text=${selectedStr}`, true);

本来であればデータベースに変更を加えるリクエストなので、POSTを使うべきです。しかし、保守性の観点からRailsに外部からのデータベースを変更させるPOSTやPUTのリクエストは受け付けないという機能があるらしく、データベースに保存出来ませんでした。(厳密にはRailsはDELETE、PATCH、PUTのHTTPメソッドは全てPOSTとして扱うようです)
ようやく見つけた解決策は、HTTPメソッドはGETにしておいて、パラメータをURLに含めて送ってしまおうという手法でした・・・もっといい解決策があれば知りたいです。

学んだこと

CSRFなどの不正なリクエストのためにRailsに備わっているセキュリティ対策によって自身が苦しめられました。非常に勉強になりました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Rails】「simple_form」と「FormObject」を使って、複数テーブルの情報を同一フォームで保存

はじめに

現在Railsで写真投稿サービスの実装をしています。
一つのフォームで複数のテーブル情報を保存したいときに、accepts_nested_attributes_forというメソッドが用意されますが、あまり推奨されていないみたいです。(以下参考

そこで今回は、simple_formFormObjectを使ってフォームを作成しました。
自分のようなユースケースの記事がなかったので、参考になればと思い記事にします。

動作環境

Ruby '2.6.6'
Rails '6.0.3.5'
SimpleForm '5.1.0'
CarrierWave '2.1.1'
MiniMagick '4.11.0'

テーブル

スクリーンショット 2021-03-04 18.41.28.png

  • Post登録時にpost_images(子テーブル)post_categories(中間テーブル)の情報も同時に保存されるフォーム

モデル

app/models/post.rb
class Post < ApplicationRecord
  has_many :post_categories
  has_many :categories, through: :post_categories
  has_many :post_images, dependent: :destroy
  belongs_to :user
end

実装したコード

view(投稿フォーム)

app/views/posts/_form.html.slim
.box.box-primary
  = simple_form_for @form, url: posts_path do |f|
    .box-body
      = f.input :image, as: :file
      = f.input :caption

      = f.input :body, as: :text

      = f.input :categories, as: :check_boxes, collection: Category.all, include_hidden: false

    .box-footer
      = f.button :submit, '投稿する', class: %w[btn btn-primary]
  • simple_form_forの引数にはControllerで定義した変数をセットし、urlを指定
  • 複数選択できるcategoriesではinclude_hidden: falseを指定することで、配列に空のデータが入らないようにしている

controller

app/controllers/posts_controller.rb
  def new
    @form = PostsForm.new
  end

  def create
    @form = PostsForm.new(post_params)

    if @form.save
      redirect_to posts_path, success: t('defaults.message.created', item: Post.model_name.human)
    else
      flash.now['danger'] = t('defaults.message.not_created', item: Post.model_name.human)
      render :new
    end
  end

  private

  def post_params
    params.require(:posts_form).permit(
      :body,
      :image,
      :caption,
      { categories: [] }
    ).merge(user_id: current_user.id)
  end
  • アクションのnewcreateではFormObject名で変数を定義
  • ストロングパラメータのrequireキーもFormObject名で指定
  • 今回はcategoriesカラムのみ配列で受け取れる形になっている

FormObject

app/forms/posts_form.rb
class PostsForm
  include ActiveModel::Model
  include ActiveModel::Attributes
  extend CarrierWave::Mount

  mount_uploader :image, PostImageUploader

  attribute :body, :string
  attribute :image, :string
  attribute :caption, :string
  attribute :categories
  attribute :user_id, :integer

  validates :image, :categories, presence: :true

  def initialize(params = {})
    super(params)
  end

  def save
    return false if invalid?

    post = Post.new(post_params)

    # 画像の複数登録仕様にはなっていない
    post.post_images.build(post_images_params).save!

    categories.each do |category|
      post.post_categories.build(category_id: category).save!
    end

    post.save ? true : false
  end

  private

  def post_params
    {
      body: body,
      user_id: user_id
    }
  end

  def post_images_params
    {
      image: image,
      caption: caption
    }
  end
end
  • include ActiveModel::Attributesによってattributeの作成が簡単に
  • バリデーションもモデルではなくFormObjectに記述
  • initializeで値を初期化
  • コントローラ側からセットされたデータを、saveメソッド内で複数のテーブルに保存
  • 配列の受け取りには注意が必要で、each文で一つ一つ登録されるように記述

終わりに

たくさんのエラーに詰まりながら、最終的にこの記述で落ち着きました。
複雑なフォームも、できあがってみたらシンプルでスッキリできたと思います。
もし改善した方がいい点などあればコメントで教えてください。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Rails】「simple_form」と「FormObject」を使って、複数テーブルの情報を同時保存

はじめに

現在Railsで写真投稿サービスの実装をしています。
一つのフォームで複数のテーブル情報を保存したいときに、accepts_nested_attributes_forというメソッドが用意されますが、あまり推奨されていないみたいです。(以下参考

そこで今回は、simple_formFormObjectを使ってフォームを作成しました。
自分のようなユースケースの記事がなかったので、参考になればと思い記事にします。

動作環境

Ruby '2.6.6'
Rails '6.0.3.5'
SimpleForm '5.1.0'
CarrierWave '2.1.1'
MiniMagick '4.11.0'

テーブル

スクリーンショット 2021-03-04 18.41.28.png

  • Post登録時にpost_images(子テーブル)post_categories(中間テーブル)の情報も同時に保存されるフォーム

モデル

app/models/post.rb
class Post < ApplicationRecord
  has_many :post_categories
  has_many :categories, through: :post_categories
  has_many :post_images, dependent: :destroy
  belongs_to :user
end

実装したコード

view(投稿フォーム)

app/views/posts/_form.html.slim
.box.box-primary
  = simple_form_for @form, url: posts_path do |f|
    .box-body
      = f.input :image, as: :file
      = f.input :caption

      = f.input :body, as: :text

      = f.input :categories, as: :check_boxes, collection: Category.all, include_hidden: false

    .box-footer
      = f.button :submit, '投稿する', class: %w[btn btn-primary]
  • simple_form_forの引数にはControllerで定義した変数をセットし、urlを指定
  • 複数選択できるcategoriesではinclude_hidden: falseを指定することで、配列に空のデータが入らないようにしている

controller

app/controllers/posts_controller.rb
  def new
    @form = PostsForm.new
  end

  def create
    @form = PostsForm.new(post_params)

    if @form.save
      redirect_to posts_path, success: t('defaults.message.created', item: Post.model_name.human)
    else
      flash.now['danger'] = t('defaults.message.not_created', item: Post.model_name.human)
      render :new
    end
  end

  private

  def post_params
    params.require(:posts_form).permit(
      :body,
      :image,
      :caption,
      { categories: [] }
    ).merge(user_id: current_user.id)
  end
  • アクションのnewcreateではFormObject名で変数を定義
  • ストロングパラメータのrequireキーもFormObject名で指定
  • 今回はcategoriesカラムのみ配列で受け取れる形になっている

FormObject

app/forms/posts_form.rb
class PostsForm
  include ActiveModel::Model
  include ActiveModel::Attributes
  extend CarrierWave::Mount

  mount_uploader :image, PostImageUploader

  attribute :body, :string
  attribute :image, :string
  attribute :caption, :string
  attribute :categories
  attribute :user_id, :integer

  validates :image, :categories, presence: :true

  def initialize(params = {})
    super(params)
  end

  def save
    return false if invalid?

    post = Post.new(post_params)

    # 画像の複数登録仕様にはなっていない
    post.post_images.build(post_images_params).save!

    categories.each do |category|
      post.post_categories.build(category_id: category).save!
    end

    post.save ? true : false
  end

  private

  def post_params
    {
      body: body,
      user_id: user_id
    }
  end

  def post_images_params
    {
      image: image,
      caption: caption
    }
  end
end
  • include ActiveModel::Attributesによってattributeの作成が簡単に
  • バリデーションもモデルではなくFormObjectに記述
  • initializeで値を初期化
  • コントローラ側からセットされたデータを、saveメソッド内で複数のテーブルに保存
  • 配列の受け取りには注意が必要で、each文で一つ一つ登録されるように記述

終わりに

たくさんのエラーに詰まりながら、最終的にこの記述で落ち着きました。
複雑なフォームも、できあがってみたらシンプルでスッキリできたと思います。
もし改善した方がいい点などあればコメントで教えてください。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

rails マイグレーションファイルの削除について

マイグレーションファイルの削除

何回も削除しているが削除のたびに見直しているのでQiitaに残しておきます。

現状のマイグレーションファイルの状態を確認

rails db:migrate:status

上記コマンドでマイグレーションファイルの状態を確認
Alt text

up状態の場合は削除や編集はできないのでdown状態にします。
今回はロールバックを使用。

rails db:rollback

再度statusで確認してみると

Alt text

マイグレーションの削除

あとは手動でdownになっているマイグレーションファイルを削除するだけです。削除したら以下のコマンドを入力して完了になります。

rails db:migrate

upになっているマイグレーションファイルを削除するとめんどくさくなるそうです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Mac Ruby・Ruby on Rails環境設定

はじめに。

環境設定なんて新しい端末を買った時にしかしないのであまり必要に感じないかもしれないですが、一応ここに残しておきます。

必要な設定

  1. GoogleChromeのダウンロード
  2. VScodeのダウンロード
    1. 拡張機能
      1. Japanese Language Pack for Visual Studio Code
      2. HTML Snippets
      3. Ruby
      4. zenkaku
      5. Code Spell Checker
  3. Command Line Toolsのダウンロード
  4. Homebrewのダウンロード
  5. Rubyのダウンロード
  6. MySQLのインストール
  7. Railsのインストール
  8. Node.jsのインストール
  9. yarnのインストール

1.GoogleChromeのダウンロード

まずはGoogleChromeをダウンロードします。
Macであれば最初からSafariが入っていますがGoogleChromeの方が使っている人が多いのでダウンロードしていきましょう。

GoogleChromeのダウンロードはこちら

2.VScodeのダウンロード

VScodeとはテキストエディタのことであり、プログラムのコードをファイルに書く場所のことです。
今回VScodeをテキストエディタで使う理由として、拡張機能が豊富でありRuby on Railsのコードを書く上で最適であること、人気ランキングで1位をいう理由で使わせてもらいます。

VScodeのダウンロードはこちら

真ん中左側にDownloadの文字があるのでダウンロードを行ってください。
こちらはよく使うので忘れずにドックに追加しておきましょう。

2-1.拡張機能

次はダウンロードしたVScodeに拡張機能を追加していきます。
それぞれ追加する拡張機能の意味を軽く説明します。

1. Japanese Language Pack for Visual Studio Code

これは日本語表記に変えてくれる機能です。あって便利です。

2. HTML Snippets

HTMLタグ、CSSタグの入力を自動で補完してくれます。

3. Ruby

Rubyの構文をチェックしてくれて、間違っているところを教えてくれます。

4. zenkaku

半角スペースのところを全角スペースにしてしまっている場所を教えてくれます。
これは本当に便利!

5. Code Spell Checker

コードのスペルを確認してくれます。

拡張機能追加の仕方

  1. 起動したウィンドウ画面左側の、アイコンメニュー内にある四角のアイコンをクリックします

  2. 左上の「Search Extensions in Marketplace」にそれぞれの拡張したい拡張機能名を入力すると表示されます。

  3. それぞれの拡張したい拡張機能名を選択し、install(またはインストール)をクリックします。

  4. 最後に拡張機能がちゃんと反映されるようにVScodeを再起動して終わりです。(再起動しなくても大丈夫なやつもありますが念のため)

3.Command Line Toolsのダウンロード

Command Line Toolsとは、Webアプリケーションの開発に必要なソフトをダウンロードするために必要な機能です。

インストールしましょう。

ターミナル
% xcode-select --install

インストール ⇨ 同意 ⇨ 完了 でインストール完了です。

4.Homebrewのダウンロード

Homebrewとは、ソフト管理ツールです。
Macのモデルによってインストール方法が異なるのできおつけてください。

  1. Macのモデル確認方法
    1. 左上のりんごマークを押す ⇨ このマックについて
    2. Macの情報が出るのでIntelモデルかM1モデルかを確認

Intelモデル

ターミナル
% cd 
% /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install.sh)"

インストールに多少時間が掛かりますが気長に待ちましょう。
パスワードの入力を求められたらパスワードを打ちましょう。打っても文字が表示されませんがちゃんと打てていますので大丈夫です。

ターミナル
% Press RETURN to continue or any other key to about

この表示がでたらエンターキーを押しましょう。

ターミナル
% brew -v                                         #インストールされているかの確認
% brew update                                     #アップデート
%  sudo chown -R `whoami`:admin /usr/local/bin    #権限の確認(パスワード再入力)

これで終わりです。

5.Rubyのダウンロード

5-1. rbenv と ruby-buildのインストール

土台となる、rbenvとruby-buildを、事前にインストールしたHomebrewを用いてインストールします。

ターミナル
% brew install rbenv ruby-build   #rbenvとruby-buildのインストール
% echo 'eval "$(rbenv init -)"' >> ~/.zshrc  #どこからでも使えるようにするコマンド
% source ~/.zshrc                             #zshrc の変更反映

5-2. readlineのインストール

ターミナルのirb上で日本語入力を可能にする設定を行うためにインストールしましょう。

ターミナル
% brew install readline      #readlineのインストール
% brew link readline --force    #どこからでも使えるようにするコマンド

5-3. rbenvを利用してRubyをインストール

ターミナル
% RUBY_CONFIGURE_OPTS="--with-readline-dir=$(brew --prefix readline)"
% rbenv install 2.6.5    #2.6.5とは、Rubyのバージョンのことです
% rbenv global 2.6.5     #使用するバージョンの指定
% rbenv rehash           #変更の反映

6.MySQLのインストール

▷MySQLとはデータ管理をするツールです。様々な種類がありますが今回はMySQLをつかいます。

6-1. MySQLのインストール

ターミナル
% brew install mysql@5.6 

6-2. MySQLの自動起動設定
本来PC再起動のたびに起動し直す必要がありますが、面倒で手間がかかるので自動起動するようにしましょう。

ターミナル
% mkdir ~/Library/LaunchAgents 
% ln -sfv /usr/local/opt/mysql\@5.6/*.plist ~/Library/LaunchAgents
% launchctl load ~/Library/LaunchAgents/homebrew.mxcl.mysql\@5.6.plist  

6-3. MySQLコマンドをどこからでも実行できるようにする
どこからでもMySQLを操作するためのコマンドmysqlを実行できるようにしましょう。

ターミナル
% echo 'export PATH="/usr/local/opt/mysql@5.6/bin:$PATH"' >> ~/.zshrc   #mysqlのコマンドを実行できるようにする設定
% source ~/.zshrc      #設定を読み込むコマンド
% which mysql          #mysqlのコマンドが打てるか確認する  
% mysql.server status  #MySQLの状態を確認するコマンド

7.Ruby on Railsのインストール

RailsとはRubyのフレームワークのことです。一緒にGem(拡張機能)を管理するためのbundlerもインストールしましょう。

ターミナル
% gem install bundler --version='2.1.4'  #Rubyのgemを管理するbundlerをインストール
% gem install rails --version='6.0.0'    #railsのインストール
% rbenv rehash                           #rbenvに変更反映

8.Node.jsのインストール

Railsを動かすためにはNode.jsが必要です。Homebrewを用いてインストールしパスを指定しましょう。

ターミナル
% brew install node@14        #インストール
% echo 'export PATH="/usr/local/opt/node@14/bin:$PATH"' >> ~/.zshrc
% source ~/.zshrc

9.yarnのインストール

ターミナル
% brew install yarn

以上になります。何か問題があればコメント御願いします。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【随時更新】ポートフォリオ・SaaS開発の参考に!超本格的なRails製オープンソースたち!【初心者もおすすめ】

「プログラマーになってすごいサービスを作りたい!」

という動機でプログラミングを始めてみたものの、いざ勉強を始めてみると

「どんな技術を使って開発したらいいの?」
「どこまで作り込めばいいのだろう?」
「一応完成したけどまだ足りないところがあるのでは?」

と不安に感じたことは一度はありますよね?

そんな方に強くオススメしたいRailsアプリケーションのGitHubリポジトリたちを紹介します。

ここで紹介するRailsアプリケーションはオープンソースにもかかわらず、
実際にWebサービスとして運用されていたり、有料で提供されていたりするものもありますので、必ずサービス開発の参考になるはずです!

ぜひ、一読してWebサービス開発の参考にしてみてください!

対象読者

・プログラミング初学者さん
・個人サービスの開発に取り掛かる前の方
・サービス開発の参考資料を探している方
・よりよいサービス開発の参考にしたい方

GitHubリポジトリたち

ChatWoot

オープンソースのライブチャットソフトです。
ホームページやランディングページに埋め込むお問い合わせチャットや、それに対するメッセージの管理システムを提供しているサービスです。

Vue.js × Ruby on Railsで開発されていて、このコードを勉強してマネするだけでかなり本格的なサービスの開発に繋げられると思います!
初見、かなり難しそうに感じますが、Gemfileを見て「devise」など初学者さんでもわかるライブラリなどがたくさん使われていますので、そういった分かる部分から読み進めていってもいいかもしれません。

GitHubリポジトリURL
https://github.com/chatwoot/chatwoot

公式ページ
https://www.chatwoot.com/

dashboard-screen-b294bdd1d718312290ec49b6c2a13428.png
引用元:https://www.chatwoot.com/static/dashboard-screen-b294bdd1d718312290ec49b6c2a13428.png

widget-ghost-99d99a87a95d9c1731b79af6584218ef.png
引用元:https://www.chatwoot.com/static/widget-ghost-99d99a87a95d9c1731b79af6584218ef.png

使われている主な技術

  • Ruby on Rails
  • Vue.js
  • Docker
  • CircleCI など

GitLab

GitHubのようにコードを管理することができるサービス。
かなりざっくりとGitHubとの違いを挙げると、自社で用意したサーバーなどにGitHubと同じような環境を構築できるので、セキュリティや情報管理の観点からGitHubは使いたくない!という企業さんに使われることが多いサービスです。
※その他にもGitLabの良いところはたくさんありますが、割愛します!

こちらもVue.js × Ruby on Railsで開発されたサービスですが、chatwootと違い、Railsのapp/views内のHTMLファイルのコードも充実しているため、まだJSフレームワークでのフロントエンド開発は視野に入れていないプログラマーさんでも読みやすいところは多いのかな、と思います。
ただし、hamlで書かれているため、人によっては読みにくさを感じるかも…

GitHubリポジトリ
https://github.com/gitlabhq/gitlabhq/

公式ページ
https://about.gitlab.com/

solutions-create.png
引用元:https://about.gitlab.com/images/solutions/solutions-create.png

使われている主な技術

  • Ruby on Rails
  • Vue.js
  • Docker など

solidus

オープンソースのeコマース構築サービス。
他のリポジトリと比べるとディレクトリ構成が少し特殊かな、という印象です。
こちらはフロントエンドも含めてRuby on Railsで開発されているようですので、その点においては参考にしやすいリポジトリなのかな、と思います。

こちら(http://demo.solidus.io/) からでもページを見ることも可能です。

GitHubリポジトリ
https://github.com/solidusio/solidus

公式ページ
https://spreecommerce.org/

Screen Shot 2021-03-06 at 14.37.52.png

使われている技術

  • Ruby on Rails
  • CircleCI など

まとめ

こちらの内容は随時更新していきます!
他にも実際にサービスとして提供されている良いオープンソースコードがございましたら教えていただきますと幸いです!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Rails フォロー機能まとめ

記事作成理由

1:プログラミング初学者の私が見返すため。
2:私のような初学者の学習に少しでも役立てればと思ったため。

※誤った記述や知識があればお知らせいただけると幸いです。


開発環境

・ruby: 2.6.3
・rails: 5.2.4.5
・OS: macOS Catalina ver10.15.7
・Cloud9


前提条件

・Userモデル,usersテーブルがある

・Bootstrap導入済み
・deviseを使ってログイン機能実装済み


実装の流れ

1:Relationshipモデルの作成
2:中間テーブル(relationshipsテーブル)の作成
3:アソシエーションの設定
4:アクションの定義
5:Viewファイルに記述


1:Relationshipモデルの作成

ターミナル.
rails g model Relationship


2:中間テーブル(relationshipsテーブル)の作成

マイグレーションファイルにカラムを追加

db/migrate/○○○○_create_relationships.rb
class CreateRelationships < ActiveRecord::Migration[5.2]
  def change
    create_table :relationships do |t|

      t.references :follower, foreign_key: {to_table: :users}
      t.references :followed, foreign_key: {to_table: :users}

      t.timestamps

      t.index [:follower_id, :followed_id], unique: true
    end
  end
end
ターミナル.
rails db:migrate

マイグレーションファイルの反映忘れずに!

ーー解説ーー

relationshipsテーブルの中身はこのようになります。

カラム タイプ オプション
follower_id integer foreign_key: {to_table: :users}
followed_id integer foreign_key: {to_table: :users}

relationshipsテーブルは中間テーブルであるため、
followerカラムとfollowedカラムはt.referencesをつけて生成します。


(ポイント!)
今回生成したカラム (follower_id と followed_id) は自由に命名することができます。
他記事ではfollowing_idやfollows_idなど様々な命名がされています。自分が1番理解しやすいように命名しましょう。
(色々な記事を調べて、様々な命名を見ると頭が混乱します。僕だけかもしれませんが・・・)


また、follower、followedどちらも外部キーとして設定します。 foregin_key(外部キー)
ここでforegin_key :trueとしてしまうと、followersテーブルやfollowedsテーブルなど存在しないテーブルを参照してしまいエラーになります。
それを避けるために{to_table: :users}として、usersテーブルのidを参照するよう紐づかせています。


t.index [:follower_id, :followed_id], unique: true
これにより、2つのカラムの中身が同じ組み合わせでデータを保存することを防ぎます。
=同じユーザーを2回フォローできないようにしています。

中間テーブル概念についてはこちら2つの記事がとても参考になりました。
【Railsでフォロー機能を作ろう】
【初心者向け】丁寧すぎるRails『アソシエーション』チュートリアル


3:アソシエーションの設定

relationship.rb
class Relationship < ApplicationRecord
  belongs_to :follower, class_name: "User"
  belongs_to :followed, class_name: "User"

  validates :follower_id, presence: true
  validates :followed_id, presence: true
end


ーー解説ーー

■2,3行目
relationshipsテーブルでforeign_keyとしてfollower_id , followed_id を設定しているため、モデル名もFollower, Followedとします。

しかし、FollowerモデルもFollowedモデルも実在しない為、class_name: "User"とオプションを追記し、関連するモデル(本当のモデル名)を指定します。
このとき、"User"は文字列扱いになるため、ダブルクォーテーションで囲んでます。

■4,5行目
validatesの説明は割愛します。
(わからない方は「バリデーション」)でお調べください。

user.rb
class User < ApplicationRecord

  # フォローするユーザーから見た中間テーブル
  has_many :active_relationships,
                      class_name: "Relationship",
                      foreign_key: "follower_id",
                      dependent: :destroy

  # フォローされているユーザーから見た中間テーブル
  has_many :passive_relationships,
                      class_name: "Relationship",
                      foreign_key: "followed_id",
                      dependent: :destroy

  # 中間テーブルactive_relationshipsを通って、フォローされる側(followed)を集める処理をfollowingsと命名
  # フォローしているユーザーの情報がわかるようになる
  has_many :followings, through: :active_relationships, source: :followed

  # 中間テーブルpassive_relationshipsを通って、フォローする側(follower)を集める処理をfollowingsと命名
  # フォローされているユーザーの情報がわかるようになる
  has_many :followers, through: :passive_relationships, source: :follower

end


ーー解説ーー

ここが鬼門です。がんばっていきましょう。
4つのhas_manyがありますが、上から2つずつに分けて説明します。

まず中間テーブルの見方を2つに分けます。
1:フォローするユーザーから見た視点
2:フォローされるユーザーから見た視点

本来であれば has_many :relationships とすればいいのですが、
見方が2つあるため、2通りの定義をしなければなりません。

そのため、ここでは
フォローする側active_relationships と命名し、(行目)
フォローされる側passive_relationships と命名しています。(7行目)

※先ほどuser.rbで作成したモデル(Follower,Followed)と同様、これらも好きに命名することができます。
他記事を見て比較する時など、命名が異なるケースがあるため、注意しましょう。

そして、参照元のモデルはどちらもRelationshipモデルであるため、
class_name: "Relationship"とオプションを追加します。

しかし、このままではRelationshipsテーブルのどちら(follower , followed)を参照すればいいのかがわかりません。
そこで、下記のように指定してあげます
active_relationshipsは、foreign_key: "follower_id"を参照
passive_relationshipsは、foreign_key: "followed_id"を参照


dependent: :destroyは、とあるUserが削除された時にそれに紐づくrelationshipのデータも削除されるようにしています。

次に、こちらの文ですが、
has_many :followings, through: :active_relationships, source: :followed
has_many :followers, through: :passive_relationships, source: :follower

これらはコードのコメントに記載の通り、定義したテーブルを通って(through)、sourceで指定したモデルの参照元からデータを集めることができます。

これにより
@user.followings (userがフォローしている人の情報)

@user.followers(userがフォローされている人の情報=userをフォローしている人の情報)
といった表記が可能になります。

※くどいようですが、、followingsfollowersも好きに命名することができます。
他記事を見て比較する時など、命名が異なるケースがあるため、注意しましょう。

これでアソシエーションは完成です。

4:アクション定義

user.rb と relationships.controller.rbにアクションを定義します。

user.rb
#下記3つのアクションを追記
class User < ApplicationRecord      

  # フォローする
  def follow(user_id)
    active_relationships.create(followed_id: user_id)
  end

  # フォローを外す
  def unfollow(user_id)
    active_relationships.find_by(followed_id: user_id).destroy
  end

  # すでにフォローしているのか確認
  def following?(user)
    followings.include?(user)
  end
end
relationships_controller.rb
class RelationshipsController < ApplicationController
  before_action :authenticate_user!

  def create
    current_user.follow(params[:user_id])
    redirect_to request.referer
  end

  def destroy
    current_user.unfollow(params[:user_id])
    redirect_to request.referer
  end

  # フォローしている人一覧
  def follower
    user = User.find(params[:user_id])
    @users = user.followings
  end

  # フォローされている人一覧
  def followed
    user = User.find(params[:user_id])
    @users = user.followers
  end

end


5:Viewファイルに記述

ここからはあくまで例になります。
定義している変数などによって記述の仕方は変わります。
※一部抜粋

ーーフォロー数、フォロワー数表示、フォローボタンーー

users/index.html.erb
<tbody>
  <% @users.each do |user| %>
  <tr>
    <td><%= attachment_image_tag user, :profile_image, :fill, 40, 40, fallback: "no_image.jpg", size:"40x40" %></td>
    <td><%= user.name %></td>
    <td><%= "フォロー数: #{user.active_relationships.count}" %></td>
    <td><%= "フォロワー数: #{user.passive_relationships.count}" %></td>
    <td>
      <% if current_user.id != user.id %>
        <% if current_user.following?(user) %>
        <%= link_to 'フォローを外す', user_relationships_path(user.id), method: :DELETE %>
        <% else %>
        <%= link_to 'フォローする', user_relationships_path(user.id), method: :POST %>
        <% end %>
      <% end %>
    </td>
    <td><%= link_to "Show", user_path(user.id) %></td>
  </tr>
  <% end %>
</tbody>



ーーフォローしている人一覧ーー

relationships/follower.html.erb
<tbody>
  <% @users.each do |user| %>
    <tr>
      <td><%= attachment_image_tag user, :profile_image, :fill, 40, 40, fallback: "no_image.jpg" , size:"40x40" %></td>
      <td><%= user.name %></td>
      <td>フォロー数:<%= user.followings.count %></td>
      <td>フォロワー数:<%= user.followers.count %></td>
      <td><%= link_to "show", user_path(user.id) %> </td>
    </tr>
  <% end %>
</tbody>



ーーフォローされている人一覧ーー

relationships/followed.html.erb
<tbody>
  <% @users.each do |user| %>
    <tr>
      <td><%= attachment_image_tag user, :profile_image, :fill, 40, 40, fallback: "no_image.jpg" , size:"40x40" %></td>
      <td><%= user.name %></td>
      <td>フォロー数:<%= user.followings.count %></td>
      <td>フォロワー数:<%= user.followers.count %></td>
      <td><%= link_to "show", user_path(user.id) %> </td>
    </tr>
  <% end %>
</tbody>



以上です。
閲覧ありがとうございました!

誤字、脱字、誤り等ございましたら教えていただけると幸いです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

コールバックで before_save ではなく before_validation を使わないといけないパターン

仕様

  • Producer (製造者) は token という String の属性を持つ。
  • token は必須属性で、Producer の新規作成時に、ユニークかつランダムな英数字を自動的にセットしたい。

コード

app/models/producer.rb
require 'securerandom'

class Producer < ApplicationRecord
  validates :token, uniqueness: true

  # before_create :set_token # 駄目なパターン 
  before_validation :set_token, on: :create

  def set_token
    self.token = SecureRandom.alphanumeric(24)
    self.set_token unless self.valid?
  end
end

before_create で set_token を呼び出そうとした場合は、先に validation が走るので、 token の uniquenss 制約でエラーが発生する場合がある。
最初から全てのレコードに token が設定されていれば問題ないが、途中から導入した場合、set_token が呼び出される前の validation で nil が重複することになってしまう。

教訓

コールバック周りで想定と違う挙動が発生した場合は、心を落ち着けて Rails ガイドを見直すこと。

https://railsguides.jp/active_record_callbacks.html

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Ruby on Rails】結合テストコードについてまとめ(Capybara)

初学者です。
テストコードがなぜか大好きです。

今回は結合テストコードについてまとめます。
Capybaraという標準で導入されているGemを利用しています。

前提条件

  • FactoryBotを導入済みである

上記については以下の記事にまとめています。
【Ruby on Rails】FactoryBotとFakerについてまとめ

テストコードの種類

  • 単体テストコード

    モデルやコントローラーなど機能ごとに問題がないか確認します。
    例えばバリデーションがきちんと動作しているかなどです。

  • 結合テストコード

    ユーザーがブラウザで操作する一連の流れを再現して問題がないか確かめます。
    例えば、ユーザー登録で「名前とメールアドレスとパスワードを入力するとトップページに遷移して表示がユーザーの名前に変わっている」などの一連の動作を確認します。

  • 正常系

    ユーザーが開発者の意図する操作を行った時の挙動を確認するテストコードです。
    例えば、ユーザー登録で問題なく全てのデータが入力された場合などです。

  • 異常系

    ユーザーが開発者の意図しない操作を行った時の挙動を確認するテストコードです。
    例えば、ユーザー登録で正しい値が入っていないと登録できないかどうかなどです。

準備

今回はUserモデルの結合テストコードを例にしていきます。

まずは下記コマンドで結合テストコードを書くファイルを準備します。

ターミナル
rails g rspec:system users

spec/system配下にusers_spec.rbというファイルが生成されていればOKです。

テストコード

解説はあとにして先に記述例を載せます。

spec/system/users_spec.rb
require 'rails_helper'

RSpec.describe 'ユーザー新規登録', type: :system do
  before do
    @user = FactoryBot.build(:user)
  end
  context 'ユーザー新規登録ができるとき' do 
    it '正しく情報を入力すればユーザー新規登録ができてトップページに移動する' do
      # トップページに移動する
      visit root_path
      # トップページに新規登録ページへ遷移するボタンがあることを確認する
      expect(page).to have_content('新規登録')
      # 新規登録ページへ移動する
      visit new_user_registration_path
      # ユーザー情報を入力する
      fill_in 'name', with: @user.nickname
      fill_in 'Email', with: @user.email
      fill_in 'Password', with: @user.password
      fill_in 'Password confirmation', with: @user.password_confirmation
      # 登録ボタンを押すとユーザーモデルのカウントが1増えることを確認する
      expect{
        find('input[name="button"]').click
      }.to change { User.count }.by(1)
      # トップページへ遷移することを確認する
      expect(current_path).to eq(root_path)
      # カーソルを合わせるとログアウトボタンが表示されることを確認する
      expect(
        find('.user_nav').find('span').hover
      ).to have_content('ログアウト')
      # 新規登録ページへ遷移するボタンやログインページへ遷移するボタンが表示されていないことを確認する
      expect(page).to have_no_content('新規登録')
      expect(page).to have_no_content('ログイン')
    end
  end
  context 'ユーザー新規登録ができないとき' do
    it '誤った情報ではユーザー新規登録ができずに新規登録ページへ戻ってくる' do
      # トップページに移動する
      visit root_path
      # トップページに新規登録ページへ遷移するボタンがあることを確認する
      expect(page).to have_content('新規登録')
      # 新規登録ページへ移動する
      visit new_user_registration_path
      # ユーザー情報を入力する
      fill_in 'name', with: ''
      fill_in 'Email', with: ''
      fill_in 'Password', with: ''
      fill_in 'Password confirmation', with: ''
      # 登録ボタンを押してもユーザーモデルのカウントが増えないことを確認する
      expect{
        find('input[name="button"]').click
      }.to change { User.count }.by(0)
      # 新規登録ページへ戻されることを確認する
      expect(current_path).to eq('/users')
    end
  end
end

RSpec.describe 'ログイン', type: :system do
  before do
    @user = FactoryBot.create(:user)
  end
  context 'ログインができるとき' do
    it '保存されているユーザーの情報と合致すればログインができる' do
      # トップページに移動する
      visit root_path
      # トップページにログインページへ遷移するボタンがあることを確認する
      expect(page).to have_content('ログイン')
      # ログインページへ遷移する
      visit new_user_session_path
      # 正しいユーザー情報を入力する
      fill_in 'Email', with: @user.email
      fill_in 'Password', with: @user.password
      # ログインボタンを押す
      find('input[name="button"]').click
      # トップページへ遷移することを確認する
      expect(current_path).to eq(root_path)
      # カーソルを合わせるとログアウトボタンが表示されることを確認する
      expect(
        find('.nav_bar').find('span').hover
      ).to have_content('ログアウト')
      # サインアップページへ遷移するボタンやログインページへ遷移するボタンが表示されていないことを確認する
      expect(page).to have_no_content('新規登録')
      expect(page).to have_no_content('ログイン')
    end
  end
  context 'ログインができないとき' do
    it '保存されているユーザーの情報と合致しないとログインができない' do
      # トップページに移動する
      visit root_path
      # トップページにログインページへ遷移するボタンがあることを確認する
      expect(page).to have_content('ログイン')
      # ログインページへ遷移する
      visit new_user_session_path
      # ユーザー情報を入力する
      fill_in 'Email', with: ''
      fill_in 'Password', with: ''
      # ログインボタンを押す
      find('input[name="button"]').click
      # ログインページへ戻されることを確認する
      expect(current_path).to eq(new_user_session_path)
    end
  end
end

まずdescribe新規登録の場合とログインの場合で項目分けします。

次にcontextでそれぞれ正常系と異常系で項目分けします。

そのあと、それぞれの流れを書いていきます。

FactoryBotについては新規登録はbuildですが、ログインはユーザー情報があらかじめあることが前提なのでcreateにしています。

では内容について細かく解説していきます。

visit

トップページに移動するvisit root_pathとしています。
visitは遷移するという意味で利用します。

page

visitで訪れた先のページの情報が格納されています。例えばカーソルを合わせてはじめて見ることができる文字列はpageの中に含まれません。

have_content

トップページに新規登録ページへ遷移するボタンがあることを確認するexpect(page).to have_content('新規登録')としています。
visitで訪れたpageの中に該当の文字列があるかどうかを判断する時に利用します。

fill_in

ユーザー情報を入力するfill_inを利用しています。
fill_in フォームの名前, with: 入力する文字列と記述することでフォームへの入力を行うことができます。

find().click

find(クリックしたい要素).clickと記述することでクリックの動作ができます。

登録ボタンを押すとユーザーモデルのカウントが1増えることを確認する登録ボタンを押すの動作をfind('input[name="button"]').clickで表しています。
この場合はinput要素のname="button"を指定していることになります。

change

expect{ 動作 }.to change { モデル名.count }.by(1)登録ボタンを押すとユーザーモデルのカウントが1増えることを確認する
カウントが1増えるを表しています。

current_path

トップページへ遷移することを確認するexpect(current_path).to eq(root_path)としています。
current_path今いるページという意味です。

hover

find(要素).hoverで特定の要素にカーソルをあわせたときの動作を表すことができます。
カーソルを合わせるとログアウトボタンが表示されることを確認するexpect(find('.nav_bar').find('span').hover).to have_content('ログアウト')としています。
今回の場合は.nav_barの中のspan要素という感じで二重に探しています。

have_no_content

サインアップページへ遷移するボタンやログインページへ遷移するボタンが表示されていないことを確認するexpect(page).to have_no_content('新規登録')expect(page).to have_no_content('ログイン')としています。
存在しないことを確認するという意味です。

以上です。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Ruby言語とは

本日は、Ruby言語の特徴とメリットデメリットについて
記事を書きたいと思います。

特徴

Rubyは、1995年に公開されたプログラミング言語。
日本で開発され、日本初の国際規格で認定されたプログラミング言語。
開発者は、「まつもとゆきひろ」さん。
言語の名前の由来は宝石のルビーからきている。

・インタプリタ方式
実行エンジンがプログラムソースを解析して1行ずつ実行する方式である、インタプリタ方式を採用している。
なので、プログラム作成中に実行できるため、エラーが発見しやすい

・汎用性が高い
汎用性が高く、Web開発以外の現場でも使用されている。その原因は、Windows、Linux、Mac OS等いろいろな環境で動作可能なマルチプラットフォームであるため。
gemと呼ばれるライブラリ群が充実している点が挙げられる。このため、Webシステム以外でも幅広く活用できる。

メリット

メリットは、文法や書式はシンプルなものを使っているので、初心者でも勉強しやすい。
コードの記述する量も他の言語に比べると圧倒的に少ないため、開発効率も高い。
日本発のプログラミング言語というのもあり学習環境が充実している。
書籍なども多く販売されている。

デメリット

デメリットでは、処理速度が遅いこと。
インタプリタ方式を採用しているため、先ほど少し話たがコードを1行ずつ訳しながら
実行するため、プログラム全体を訳してから実行するコンパイル方式と比べると処理速度
は遅くなる。
コンパイル方式はコードが複雑なため、記述に時間がかかってしまう。

Ruby on Railsとは

RubyとRuby on Railsは混同されがちだが別物。
Ruby on Railsとは、Webアプリケーションフレームワークのひとつ。
フレームワークとは、最小コストでwebアプリケーションの開発ができる仕組みのこと。
つまり、Webアプリケーション開発で、「必要となる作業やリソースを事前に仮定し、
用意してある便利なもの」

■Ruby on Railsの理念
・DRY(Don`t Repeat Yourself)
同じコードを繰り返し書くことを避け、保守管理しやすい状態に保ちバグを減らす。

・CoC(Convention Over Configuration)
Ruby on Rails自体で規約を用意しているため、規約に則ったコードを書くことで記述量を少なくすることができ、スピーディーな開発が可能。

Rubyはプログラミング言語でRuby on Railsは開発を効率的に行えるフレームワークと覚えよう。
ちなみに、Ruby言語を使用し作られた有名なものは、Huluやクックパッドなどがある。

以上がRubyについての説明です。
今回は、自分が使用している言語について記事を書いてみたが他の言語についての
記事も書いていきたいなと思いました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Rails】seeds.rbでサンプルデータ作成時に画像も一緒に生成する(ActiveStorage使用)

環境

macOS: Big Sur Ver11.2.2
Rails: 6.0.0
Ruby: 2.6.5

前提

Railsで開発中しており、deviseを使ってユーザー登録関係を実装済み(Userモデル)。
さらに、UserモデルはActiveStorageにより画像を1枚添付できるようになっている(必ず添付しなければならない制約をつけてます)。
モデルのコードはこんな感じで、 has_one_attached と :image属性にpresence: trueが指定されているのがわかります。

class User < ApplicationRecord
  extend ActiveHash::Associations::ActiveRecordExtensions
  belongs_to :prefecture
  belongs_to :category
  has_one_attached :image

  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable

  # パスワードは半角英数混合で8文字
  validates :password, format: { with: /\A(?=.*?[a-z])(?=.*?\d)[a-z\d]{8}\z/i }
  with_options presence: true do
    validates :shop_name
    validates :address
    validates :business_hours
    validates :holiday
    validates :image
    validates :phone_number, format: { with: /\A0[1-9]\d{0,3}[-(]\d{1,4}[-)]\d{4}\z/ }
    with_options numericality: { other_than: 0 } do
      validates :category_id
      validates :prefecture_id
    end
  end
end


やりたいこと

ユーザーに紐づく画像をトップページに表示させたかったので、サンプルデータを20~30件くらい作って一気に表示させてみようと思いました。
新規登録されたユーザーの画像がトップページにズラッと並ぶ感じです。

試したこと①

seeds.rbに以下のとおり記述。

20.times do |n|
  user = User.create!(
    shop_name: "お店#{ n + 1 }",
    email: "seed#{ n + 1 }@seed.com",
    password: "pass1234",
    password_confirmation: "pass1234",
    category_id: rand(1..10),
    prefecture_id: rand(1..47),
    address: "仮町仮番地123-1",
    business_hours: "9時〜17時",
    holiday: "月曜",
    phone_number: "090-1234-5678",
    image: open("./db/fixtures/seed.jpg")
end

結果、「ファイルが見つからない、、」というエラー。

試したこと②

20.times do |n|
  user = User.create!(
    shop_name: "お店#{ n + 1 }",
    email: "seed#{ n + 1 }@seed.com",
    password: "pass1234",
    password_confirmation: "pass1234",
    category_id: rand(1..10),
    prefecture_id: rand(1..47),
    address: "仮町仮番地123-1",
    business_hours: "9時〜17時",
    holiday: "月曜",
    phone_number: "090-1234-5678",
  )
  user.image.attach(io: File.open(Rails.root.join('app/assets/images/test.jpg')),
                  filename: 'test.jpg')
end

結果、「imageがバリデーションに引っかかりました!」という指摘を受けました。

試したこと③

20.times do |n|
  user = User.create!(
    shop_name: "お店#{ n + 1 }",
    email: "seed#{ n + 1 }@seed.com",
    password: "pass1234",
    password_confirmation: "pass1234",
    category_id: rand(1..10),
    prefecture_id: rand(1..47),
    address: "仮町仮番地123-1",
    business_hours: "9時〜17時",
    holiday: "月曜",
    phone_number: "090-1234-5678",
    user.image.attach(io: File.open(Rails.root.join('app/assets/images/test.jpg')),
                  filename: 'test.jpg')
end

結果、「userに使っているimageというメソッドはないよ」と怒られました。

解決策

以下の記述方法を発見し、無事に画像データを持つユーザーのサンプルデータを作成できました。

20.times do |n|
  user = User.create!(
    shop_name: "お店#{ n + 1 }",
    email: "seed#{ n + 1 }@seed.com",
    password: "pass1234",
    password_confirmation: "pass1234",
    category_id: rand(1..10),
    prefecture_id: rand(1..47),
    address: "仮町仮番地123-1",
    business_hours: "9時〜17時",
    holiday: "月曜",
    phone_number: "090-1234-5678",
    image: ActiveStorage::Blob.create_and_upload!(io: File.open(Rails.root.join("db/fixtures/seed.jpg")), filename: "seed.jpg")
  )
end

最後に

ググって出てきた情報の多くは、画像をユーザーを作成した後からattachする場合のやり方で、自分のようにユーザー作成時点で画像が必須という設計とは前提が異なっていました。
なので3つの方法全てがエラーになったんですね。
ちなみに後から画像をattachする方法は、Railsガイドに書いてあります。

解決策のcreate_and_upload!メソッドはこちらに掲載されています。
あまり見たことがない公式リファレンス?だったので、これは初心者には厳しいな、、と言う印象でした。

この記事が誰かの役に立てば幸いです!

補足

seeds.rb ファイルを編集などして、再度サンプルデータを流すときは、以下のコマンドを実行すればデータベースの中身をリセットしてサンプルデータの流し込みまで一括でやってくれます。

% rails db:reset

参考リンク

seeds.rbの画像データの定義の方法がよく分からない

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Rails】データが作成された時間によってビューの表示を条件分岐する

環境

macOS: Big Sur Ver11.2.2
Rails: 6.0.0
Ruby: 2.6.5

やりたいこと

作成中のWebサービスのトップページに新着のお店を掲載しています。
そこで、3日以内に登録された店舗であれば「NEW!!」の表示をしたいなあと思い、条件分岐の方法を探りました。

結論

今回実装したのは、3日以内に登録された新規ユーザーであれば「NEW!!」の表示をするというもの。
結論、以下の記述で簡単に条件分岐できました。

<div class="contents-box">
  <div class="image-box">
    <%= image_tag user.image, class: "contents-image" %>
  </div>
  <h3 class="contents-title">
    <%= user.shop_name %>
  </h3>
  <p class="contents-price">
    <%= user.category.name %>
  </p>
  <%# お店の登録が3日以内であればnewマークを表示 %>
  <% if user.created_at > 3.days.ago %>
  <p><i class="fas fa-star"></i>NEW!!</p>
  <% end %>
</div>

実装した結果がこちら!
テストデータなので、全部NEW!!が表示されていますが、ご容赦ください。
8f9c95c9b443a9523e700afe9ca2afd5.png

以上!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Rakeタスクのテストを書いてみる

はじめに

開発環境にダミーデータを入れてテストする際に、「SQLのINSERT文を実行!」などはせず、rakeを使ってコマンドラインからデータを作成するようにしてみましたので、記録として残します。

rakeタスクファイルの作成

※ 今回はuserのインスタンスを作成するケースで進めていきます。

rails g task task_user

rakeファイルの編集

task_user.rb
namespace :task_users do
 desc "create user"
 task :create_user, ["name", "email", "password"]=> :environment do |task, args|
   user = User.new(
       name: args[:name],
       email: args[:email],
       password: args[:password]
   )

   user.save!
end

RSpecでテストを書く

※説明上rakeファイルの編集から記載してありますが、実際にコードを書いていく際はテストから書いた方がいいです。

task_users_spec.rb
require 'rails_helper'
require 'rake'

describe "TaskUsers" do
  before(:all) do
    @rake = Rake::Application.new
    Rake.application = @rake
    Rake.application.rake_require 'tasks/task_users'  #specディレクトリ内の/lib以下のパスを指定します
    Rake::Task.define_task(:environment)
  end

  before(:each) do
    @rake[task_name].reenable
  end

  context "create user" do
    let(:task_name) { 'task_user:create_user' }
    let(:name) { "kantai" }
    let(:email) { "hogehoge@.hoge.jp" }
    let(:password) { Faker::Alphanumeric.alphanumeric(number: 10) } # ランダムな数列を作成しています

    # matcherは、「Userのレコードが1増えているかどうか」という条件で設定しています。
    it "created user" do
      expect{ @rake[:task_name].invoke(name, email, password).to change(User, :count).by(1)
    end
end

コマンドで実行

rake 'task_users:create_user[name,email,password]'

おわりに

なかなかrakeを使ってコマンドからレコードを作成するケースは少ないかと思いますが、チームでドキュメントとして残しておくことで、レコード作成を属人化することなく誰でも作成しやすくできるのが良い点かなと思います。

参考

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ローカル環境でのGithub連携方法

はじめに

ローカル環境でのGithub連携方法についてまとめました。
railsアプリをGithubに連携させるためには、gitの知識も必要なため、gitを含めて説明します。

到達点

以下の2点を達成する
・gitが使えるようになる
・railsアプリをGithubに連携させる

流れ

① railsアプリを作成
② Github新規登録し、リポジトリを作成
③ gitコマンドを行い、連携させる

① railsアプリを作成

手前味噌ではありますが、私の記事を参照すると、ローカル環境でのrails環境構築ができます。
ローカル環境開発 Ruby on Railsの環境構築方法

  

② Github新規登録し、リポジトリを作成

Githubを新規登録し、新しいリポジトリを作成する。

以下の画像の赤枠の New もしくは New repository をクリックしてください。

スクリーンショット 2021-03-01 19.29.40.png

Repository nameは、自身のアプリ名(rails new アプリ名)を入力し、Create Repositoryをクリックしてください。

スクリーンショット 2021-03-01 19.24.22.png

③ gitコマンドを行い、連携させる

$ cd 自分のアプリ名
$ git init
$ git status   #作業ディレクトリでの現在の状態を表示する
$ git diff     #差分を見る

git addでファイルをstagedという状態にします
git add する前に変更した箇所とインデックスとの変更点を必ず確認しましょう
   

$ git add ..   #すべてのファイル・ディレクトリ
$ git diff     #差分を見る

git commitでローカルレポジトリにコミットします
git commit する前に差分を確認しましょう
  

$ git commit -m "Initialize repository"
$ git diff     #差分を見る

git commit -m"コミット名" でコミット名が作成できます。
git push する前にも差分を確認しましょう

$ git remote add origin http://Github.com/GitHubで作成したID/リポジトリ名.git
$ git push -u origin master

git pushで、ローカルレポジトリの内容がリモートに反映されます。
Githubのページからも確認しましょう。

参考記事

今日からはじめるGitHub 〜 初心者がGitをインストールして、プルリクできるようになるまでを解説

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Docker(Mac)でgRPC FUSE有効時にMySQL8コンテナが起動しない問題について調査報告と解決策

はじめに

Docker環境でRailsアプリケーションを開発する際にDocker Desktop for MacのgRPC FUSEを有効にした状態でコンテナを起動すると、MySQL8のコンテナが起動しない問題が発生した。

こんな感じ

db_1     | 2021-03-05T17:42:39.341701Z 1 [System] [MY-013576] [InnoDB] InnoDB initialization has started.
db_1     | 2021-03-05T17:42:40.003650Z 1 [System] [MY-013577] [InnoDB] InnoDB initialization has ended.
db_1     | 2021-03-05T17:42:40.011792Z 1 [ERROR] [MY-011087] [Server] Different lower_case_table_names settings for server ('2') and data dictionary ('0').
db_1     | 2021-03-05T17:42:40.012090Z 0 [ERROR] [MY-010020] [Server] Data Dictionary initialization failed.
db_1     | 2021-03-05T17:42:40.012378Z 0 [ERROR] [MY-010119] [Server] Aborting
db_1     | 2021-03-05T17:42:40.618413Z 0 [System] [MY-010910] [Server] /usr/sbin/mysqld: Shutdown complete (mysqld 8.0.23)  MySQL Community Server - GPL.

この問題はDocker 2.4.0から報告されている有名な問題らしく、gRPC FUSEをオフにすることで改善できる問題とのことだった。
参考:Mysql not starting in a docker container on MacOS after docker update

しかし、(本来ここについても調査すべきなのかもしれないがw)gRPC FUSEをオフにすることで、docker-composeを起動中にアプリケーションに編集を加えるとコンテナやアプリケーションがフリーズして再起動を必要とする…ということが起きてしまったため、
なんとかgRPC FUSEを有効にした状態でMySQL8コンテナを起動することはできないか、自分なりに調査をしてみました。




あくまでも「私はこのように調査したよ、このように対処したよ」という報告のような内容の記事ですので、ここに書いてある内容が正しいノウハウ、知識という認識で読まれるのはオススメしません。
この記事をきっかけに正しい知恵をいただいたり、もしくはこの問題についての議論が進むことを望みます。

実行環境

  • MacBook Pro 16inch 2019
  • macOS 11.2.2

  • Docker Desktop for Mac 3.1.0 ※gRPC FUSEを有効にする

docker-composeファイルの設定

docker-compose.yml
  db:
    image: mysql:8.0.23
    command: mysqld --default-authentication-plugin=mysql_native_password
    volumes:
      - ./mysql/mysql:/var/lib/mysql

いろいろわかったこと(原因は不明)

再ビルドしても起動しなかった

再度ビルドをしてみてクリーンな状態で起動してみてはどうか、と考えたが改善しなかった。
--no-cacheオプションも試しましたが関係ありませんでした。

別のログインユーザーは正常に起動した

私のMacBookにはプライベート用とそれ以外用とでユーザーアカウントをわけている。
今回、テストしてみたところ、同じRailsアプリケーション、同じdokker-composeファイルにもかかわらず、プライベート用のユーザーアカウントでは問題なくMySQL8コンテナを起動することができた。
インストールしているアプリケーションや開発環境の内容はほぼいっしょ(のはず)のため、もう一方のユーザーアカウントで起動しなかった理由はわからず…

インターン生のPCでも正常に起動した

私の勤務している会社で働いてくれているインターン生のMacBookでも正常に起動していた。
こちらのMacBookは最近使い始められたばかりで、特に設定やアプリのインストールがされているわけではないので、それが原因で起動した説…はあるかもしれない。。。

Dockerのバージョンを上げてみたが改善せず…

無駄でした…なお、バージョンを下げるのはなんだか気分が乗らなかったのでやめた。

docker-compose run db bashでコンテナに入ってみた => データベースが読み込まれていなかった

gRPC FUSEをオフの状態でMySQLコンテナに入り、MySQLを起動すると、
RailsのActiveRecordで作成したデータベーステーブルをshow databases;で見つけることができた。

しかし、gRPC FUSEを有効にしてから同様の処理をすると…そもそもMySQLにすらログインさせてもらえなかった。
つまり、データベースが読み込まれていない、ということだ。

なるほど…ということは、MySQLのデータを永続化させるために設定しているVolumesが怪しいのでは…?

MySQLコンテナのvolumesの記述を削除すると…起動した

MySQLデータを永続化させるためのvolumes設定を削除してみたところ、、、ついに、MySQLコンテナが正常に動作してくれた。
しかし、このままでは毎回コンテナ起動時にデータベースを作成しなければいけない。うーん、どうしたらいいものか。

そして見つけた、自分なりの改善策

MySQLのデータのマウント先を名前付きVolumesに設定 => 起動した!

Volumesが怪しいと思いいろいろ調べてみたところ、こちらの記事を見つけることができた。

Docker上のMySQLのデータをVolumeでホストのディレクトリにマウントすると権限周りで面倒なことになる
Dockerのvolumeでpermission deniedが発生した場合の解決法

Macでは発生しない問題(?)についての記事ではあったのですが、この記事を見て名前付きVolumesを設定したらどうなるだろう、と思った。

docker-composeファイルの設定のうち、volumesの設定を以下のように変更してみました。

docker-compose.yml
  db:
    image: mysql:8.0.23
    command: mysqld --default-authentication-plugin=mysql_native_password
    volumes:
      - mysql_data:/var/lib/mysql
volumes:
  mysql_data:  # 名前付きVolumesを定義

すると…




動いた!!!(´;ω;`)




データもしっかり永続化できているので、docker-compose downしてから再度コンテナを起動させても、問題なくデータベースを読み込むことができた。
改めて思うと、永続化データをプロジェクトディレクトリ内にマウントする必要性も特に感じないので、名前付きVolumesに設定で全然問題ないんですよね。次からこのやり方で統一していこうと思います。




。。。しかし、今回MySQLコンテナをgRPC FUSE有効時にも正常動作させるのにかなり苦労したが、名前付きVolumesを使わずとも正常に動作する環境があるのも事実…なので完全な問題解決、理解には至っていないのだ。。。

おわりに

とりあえずの対処法は発見できたものの、まだこの問題の根本的な解消には至っていないと考えています。Dockerに対しての理解が浅いために、この程度までの調査、分析しかできませんでした。
今年に入ってこの問題に関する記事が新規で上がっていないところを見ると、皆さんすでに解決済み?なのかもしれませんが、もしかしたら今も困っている方がいらっしゃるかもしれません。

もし、この問題の原因、解決法に詳しい方がいらっしゃいましたらコメント欄でご教授いただけますと幸いです。
この記事が、DockerのgRPC FUSE問題に苦しむ方の解決のきっかけにつながることを願います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Rails】git push herokuをしてもEverything up-to-dateと言われる【Heroku】【Git】

症状

git push herokuをやっても、herokuに変更が反映されませんでした。

git push heroku
Everything up-to-date

「Everything up-to-date」は変更するデータないよと言われているようです。

原因を調べると、gitも更新されていなかったことがわかりました。

解決策

コミットはしたものの変更をマージしていなかったことが分かりました。。
以下のコマンドを良しなに入力して解決しました。

初歩的なミスでしたが、解消に時間がかかったので書きました。

git merge ブランチ名
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む