深いアソシエーションを組んだ時はdelegateを設定しよう

こんにちは!kossyです!




さて、今回はメソッドの移譲を実現するメソッド、delegateメソッドの使い方を
ブログに残してみたいと思います。




環境
Rails 5.2.2
Ruby 2.5.1
MacOS Mojave





ユースケース

例えば、
・Userクラス(name, email, profile_photo, password )
・Postクラス(title, content, user_id)
があったとして、
User has_many postsの関係とし、
特定のポストのユーザーのプロフィール写真を取得したいとします。

その場合、

@post.user.profile_photo

として値を取得すると思うのですが、
ここでdelegateメソッドを使うと、

@post.profile_photo

で値が取れるようになります。



実装手順

実装はとても簡単です。

class Post
  belongs_to :user
  delegate :profile_photo, to: :user
end

これで実装は終了です。
めちゃくちゃ簡単なのでぜひ試してみてください。

deviseのエラーメッセージ にbootstrapを当てたい

こんにちは!kossyです!




さて、今回はRailsの認証系機能を提供しているgem「devise」のエラーメッセージ に
cssフレームワークであるbootstrapを当てたい時の方法をブログに残してみたいと思います。





なお、devise及びbootstrapの導入方法については割愛してます。
このブログを読んでくださる方はエラーメッセージ の当て方について解を求めている方だと思いますので。



環境



devise_error_messages!をオーバーライド



bootstrapを適用したエラーメッセージ を表示するには、いくつかやり方がありますが、
ここでは、devise_error_messages!をオーバーライドする形を取りたいと思います。



app/helpers/application_helper.rb

module ApplicationHelper

  def devise_error_messages
    return "" if resource.errors.empty?
    errors_html = ""
    resource.errors.full_messages.each do |err_msg|
      errors_html += <<-EOF
        <div class="error_field alert alert-danger" role="alert">
          <p class="error_msg">#{err_msg}</p>
        </div>
      EOF
    end
    errors_html.html_safe
  end

end


html_safeはエスケープを行いたくない場合に使用するStringクラスのメソッドです。


ユーザー登録フォームの例はこんな感じです。(hamlで書いてます)

app/views/devise/registrations/new.html.haml

= form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f|
  = devise_error_messages
  .form-group
    = f.label :name, "名前"
    = f.text_field :name, autofocus: true, class: "form-control"
  .form-group
    = f.label :email, "メールアドレス"
    = f.email_field :email, autofocus: true, class: "form-control"
  .form-group
    = f.label :profile_photo, "アイコン"
    = f.file_field :profile_photo, autofocus: true, class: "form-control"
  .form-group
    = f.label :password, "パスワード"
    = f.password_field :password, autofocus: "off", class: "form-control"
  .form-group
    = f.label :password_confirmation, "パスワードの確認"
    = f.password_field :password_confirmation, autofocus: "off", class: "form-control"
  .actions
    = f.submit "登録する", class: "btn btn-primary w-100"

デフォルトでは

devise_error_messages!

ですが、オーバーライドしたメソッドを使うため、!を消してます。



参考にさせていただいたサイト
RubyonRails:deviseのエラーメッセージをカスタマイズする方法 - Madogiwa Blog
[Rails]ERBのエスケープを自在に扱おうぜ - Qiita

Railsアプリにdevise-two-factorとrqrcodeを使って2段階認証を導入した

こんにちは!kossyです!




最近はオリジナルアプリの実装に熱を入れ過ぎてブログの更新が疎かになっておりました、、、
アウトプットが大事だと頭でわかってはいるものの、
実行に移すのは大変ですね、、、





さて、今回はRailsアプリに2段階認証を導入する手順について、
ブログに残してみたいと思います。




環境
Rails 5.2.2
Ruby 2.5.1
devise 4.6.1
devise_two_factor 3.0.3
rqrcode 0.10.1
dotenv-rails 2.6.0
google_authenticator
macOs Mojave



[注意]
この解説記事内でgoogleの2段階認証アプリを使うので、実際に導入してみたい方は
お手持ちのスマートフォンGoogle Authenticatorを入れて下さい。
iOS: ‎「Google Authenticator」をApp Storeで
Android: Google 認証システムのインストール - Android - Google アカウント ヘルプ





Gemの導入

まずはGemの導入からです。

./Gemfile

# groupのブロック外ならどこに記述してもOKです

gem 'devise'
gem 'devise-two-factor'
gem 'rqrcode'
gem 'dotenv-rails'

# ターミナル
$ bundle install

それぞれ端的に解説すると、
deviseは言わずと知れた、認証の仕組みを数コマンドで提供してくれるGemです。
devise_two_factorを使うと、簡単に2段階認証の仕組みをRailsアプリに導入することができます。
rqrcodeは、QRコードの生成を行うgemです。
dotenv-rails環境変数の管理をしてくれるGemです。



deviseの導入

deviseの導入は細かい説明は省きます。
詳しく知りたい方は公式ドキュメントや、
[*Rails*] deviseの使い方(rails5版) - Qiita
こちらを参考にしてみてください。

$ rails g devise:install

$ rails g devise:views

$ rails g devise User

$ rails g devise:controllers users

$ rails db:migrate

上記のコマンドを順に実行してください。

次に、ルーティングを追加します。

config/routes.rb

Rails.application.routes.draw do
  devise_for :users, controllers: {
    registrations: 'users/registrations',
    sessions: 'users/sessions'
  }

end

これでdeviseの導入は終了です。

次にdevise_two_factorの設定を行います。




devise-two-factorを導入


ターミナルで以下のコマンドを実行します。

$ rails g devise_two_factor User TWO_FACTOR_ENCRYPTION_KEY

$ rails db:migrate

このコマンドを実行することで、以下の処理が実行されます。
・app/models/user.rbにdevise_two_factorの設定を追加
・:database_authenticatableを削除しようとする。 ※削除されない場合は手動で削除してください。
・config/initializers/devise.rbにwardenの設定を追加
・db/migrate下に二段階認証関連のカラムを追加するためのマイグレーションファイルを生成
追加されるカラムは以下の5種類です。

  • encrypted_otp_secret(string)
  • encrypted_otp_secret_iv(string)
  • encrypted_otp_secret_salt(string)
  • consumed_timestep(integer)
  • otp_required_for_login(boolean)

ワンタイムパスワードのパラメータを入れるカラムと、
ユーザーが2段階認証を有効にしているかどうかを判断するためのカラムを追加しています。





次に、環境変数の設定を追加します。



.envファイルの設定



アプリケーションフォルダ直下に.envファイルを作成し、
環境変数を設定します。

./ .env

# 例です
TWO_FACTOR_ENCRYPTION_KEY=hogehogehogehogehogehogehogehogehogehogehoge

このファイルは.gitignoreに追加して、gitの管理から外れるようにしましょう。

.gitignore

.env


次に、application_controller.rbにStrongParametersでotp_attempt(認証コード)を許可するように設定を加えます。


StrongParameterの設定

StrongParameterについては以下の記事が詳しかったです。
Rails初学者がつまずきやすい「ストロングパラメータの仕組み」

app/controllers/application_controller.rb



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

  before_action :configure_permitted_parameters, if: :devise_controller?

  protected

  def configure_permitted_parameters
    devise_parameter_sanitizer.permit(:sign_in, keys: [:otp_attempt])
  end

end

次項から、2段階認証の仕組みを実装していきます。



2段階認証を実装



Deviseを使っているRailsアプリに2段階認証を導入する - Qiita
こちらの記事のコードを参考に実装させていただきました。


app/controllers/users/sessions_controller.rbを編集し、通常のdeviseの認証の前に処理を挟むようにします。

class Users::SessionsController < Devise::SessionsController
  prepend_before_action :authenticate_with_two_factor, only: [:create]

  private
  def authenticate_with_two_factor
    # strong parameters
    user_params = params.require(:user).permit(:email, :password, :remember_me, :otp_attempt)

    if user_params[:email]
      user = User.find_by(email: user_params[:email])

    elsif user_params[:otp_attempt].present? && session[:otp_user_id]
      user = User.find(session[:otp_user_id])
    end
    self.resource = user

    return unless user && user.otp_required_for_login

    if user_params[:email]
      if user.valid_password?(user_params[:password])
        session[:otp_user_id] = user.id
        render 'devise/sessions/two_factor' and return
      end

    elsif user_params[:otp_attempt].present? && session[:otp_user_id]
      if user.validate_and_consume_otp!(user_params[:otp_attempt])
        session.delete(:otp_user_id)
        sign_in(user) and return
      else
        flash.now[:alert] = 'Invalid two-factor code.'
        render :two_factor and return
      end
    end
  end
end


次に、認証コードの入力画面を作成します。

app/views/devise/sessions/two_factor.html.erb


<h2>Log in</h2>

<%= form_for(resource, as: resource_name, url: session_path(resource_name), method: :post) do |f| %>
  <div class="field">
    <%= f.label :otp_attempt %><br />
    <%= f.text_field :otp_attempt %>
  </div>

  <div class="actions">
    <%= f.submit "Verify" %>
  </div>
<% end %>

ルーティングは下記のように実装します。

resource :two_factor_auth, only: [:new, :create, :destroy]


次に、2段階認証周りの機能を提供するtwo_factor_auths_controller.rbを作成します。

app/controllers/two_factor_auths_controller.rb



class TwoFactorAuthsController < ApplicationController

  # 2段階認証有効化確認
  def new
    unless current_user.otp_secret
      current_user.otp_secret = User.generate_otp_secret(32)
      current_user.save!
    end

    @qr_code = build_qr_code
  end

  # 2段階認証有効化
  def create
    if current_user.validate_and_consume_otp!(params[:otp_attempt])
      current_user.otp_required_for_login = true
      current_user.save!

      redirect_to root_path

    else
      @error = 'Invalid pin code'
      @qr_code = build_qr_code

      render 'new'
    end
  end

  # 2段階認証無効化
  def destroy
    current_user.update_attributes(
      otp_required_for_login:    false,
      encrypted_otp_secret:      nil,
      encrypted_otp_secret_iv:   nil,
      encrypted_otp_secret_salt: nil,
    )
    redirect_to root_path
  end

  private
  
  def build_qr_code
    label = "service_name"
    # issuerでGoogle Authenticator上の登録サービス名として表示
    issuer ="company_name"
    uri = current_user.otp_provisioning_uri(label, issuer: issuer)
    qrcode = RQRCode::QRCode.new(uri)
    qrcode.as_svg(
      offset: 0,
      color: '000',
      shape_rendering: 'crispEdges',
      module_size: 2
    )
  end
end






QRコード表示画面を作成

app/views/two_factor_auths/new.html.erb



<h1>Enable 2FA</h1>

<% if @error %>
  <div><%= @error %></div>
<% end %>

<div><%= raw @qr_code %></div>

<%= form_tag two_factor_auth_path do %>
  <div class="field">
    <%= label_tag :otp_attempt %><br />
    <%= text_field_tag :otp_attempt %>
  </div>

  <%= submit_tag :submit %>
<% end %>


認証機能の有効化・無効化画面の例

app/views/home/index.html.erb(ユーザープロフィール画面があればそこに表示するのがベスト?)


<div>
  <% if user_signed_in? %>
    <% if current_user.otp_required_for_login %>
      <%= button_to "Disable 2FA", two_factor_auth_path, method: :delete %>
    <% else %>
      <%= button_to "Enable 2FA", new_two_factor_auth_path, method: :get %>
    <% end %>
  <% end %>
</div>


これで一通りの実装は完了です。
それでは、ローカルで試してみます。(本ブログでは解説していないcontrollerとrootの設定をしています。)





localhost:3000/users/sign_upにアクセスし、必要情報を入力して、submitを押下します。
f:id:kossy-web-engineer:20190220220517p:plain




ユーザー登録に成功すると、enable 2FAのボタンが表示されるので、クリックします。
f:id:kossy-web-engineer:20190220220608p:plain




すると、QRコードが表示されるので、google認証のアプリでQRコードを読み取り、6桁の認証コードを入力します。
f:id:kossy-web-engineer:20190220220754p:plain




正しく認証をパスすると、Enable 2FAだったボタンが、Disable 2FAの表示に変わっていると思います。
f:id:kossy-web-engineer:20190220220854p:plain




これで2段階認証の導入は終了です。
思った通り動作するのはやっぱり嬉しいですね。




参考にさせていただいた記事
Deviseを使っているRailsアプリに2段階認証を導入する - Qiita
deviseとGoogle Authenticatorを用いてRailsシステムに「二段階認証」を導入した話 - LiBz Tech Blog
Railsの二段階認証Gem Devise-Two-FactorのDemoを触ってみた - Qiita
GitHub - tinfoil/devise-two-factor: Barebones two-factor authentication with Devise

冗長な検索ロジックはscopeにしちゃおう

こんにちは!kossyです!



さて、今回はRailsで独自にモデルのデータの絞り込みを定義できるscopeについて、
ブログに残してみたいと思います。




環境
Rails 5.1.6
Ruby 2.5.1
MacOS Mojave




実装方法



例えば、「公開が終了した映画の中で原作が漫画でないデータをidが古い順に取りたい」をモデルから絞り込みをするとします。
これをコードに落とし込むと、

@movies = Movie.where(status: :end).where_not(original_work: :comic).order(id)

ってな感じになるかと思うのですが、これをscopeで定義できまして、

class Movie < ApplicationRecord
  scope :not_comic_end_movie, -> { where(status: :end).where_not(original_work: :comic).order(id) } 
end

って感じで定義できます。scopeとして定義すると、

@movies = Movie.not_comic_end_movie

とするだけで、

  @movies = Movie.where(status: :end).where_not(original_work: :comic).order(id)

とした時と同じレコードを取得することができます。

同じような検索を何度も行うような時や、記述が冗長になってしまった時に有効な手段だと思います。

bundle installする時はパスの指定をしよう

こんにちは!kossyです!




さて、今回はbundle installする時の注意事項について、ブログに残してみたいと思います。




bundle installする時はpathを指定しよう

普段Gemfileを編集すると、
bundle installを実行すると思うのですが、

    • path vendor/bundleでパスの指定をしたほうが良いです。

理由は、


gemを任意のディレクトリにインストールし、gemをRailsプロジェクト毎に管理するためです。
ディレクトリを指定しないbundle installはシステム領域にインストールされるため、複数のrailsアプリを同一のマシン上で運用していると、問題が発生することがあります。
vendor/bundleは、アプリ専用の領域のため、他のアプリには影響がありません。

マシン上で、一つしかrailsアプリを動かさないのであれば、システム領域に入れてしまっても問題ないでしょう。

参考:
Ruby - bundle install するときになぜ vendor/bundle に入れるのか|teratail

とのことでした。


bundle install する時は余計なバグを生まないためにも
パスを指定するようにしましょう。

Rspec使う時のgenerator設定

こんにちは!kossyです!




さて、今回はrspec導入の際のgeneratorの設定についてブログに残してみたいと思います。




個人的には以下のような設定にすることが多いです。

config/application.rb


    config.generators do |g|
      g.template_engine  :haml
      g.test_framework   :rspec, fixture: false
      g.view_specs       false
      g.controller_specs false
      g.helper           false
      g.stylesheets      false
      g.javascripts      false

上記の設定ですと、
・viewはerbではなくhaml
・テストフレームワークRspecで、fixtureは生成されない
・viewのspecファイルは生成なし
・controllerもなし
・helperも
・scssファイルなし
・coffeeもなし

みたいな感じですね。

ちなみに、rails newする際に、-Tと入力して実行すると、
デフォルトで生成されるmini-testのスケルトンをスキップすることができます。

active_storageで画像を複数枚保存したい

こんにちは!kossyです!




さて、今回はRails5.2系から使えるようになった、active_storageで画像の複数枚アップロードの実装方法を
ブログに残してみたいと思います。





has_many_attachを使うだけ


active_storageの導入についてはこちらを参考にしてみてください。
開発環境でのActive_Storageの使い方 - web業界未経験からエンジニアになった人のブログ


画像を1枚添付するだけであれば、has_one_attached :image
みたいな感じで設定すれば事足りるのですが、
複数枚アップロードしたい場合は、has_many_attached :images
と記述します。




これで複数枚アップロードを実現できます。

ブラウザから返ってくるenumの値はシンボルじゃなくて文字列だった

こんにちは!kossyです!




さて、今回はenumで定義した値をブラウザからパラメータとして受け取る場合、シンボルの形式ではなく文字列で
返ってくるのに気づかずハマったので、備忘録としてブログに残してみたいと思います。





enumのステータスによって処理を分岐させたいというシチュエーションでハマりました、、、

コードは例としてこんなんです。

  enum status: { draft: 1, published: 2 }

hogehoge_controller.rb

if params[:status] == :draft
~~
else
~~
end


ここでbinding.pryで処理を止めてparamsをみます。
(省略しまくってすみません。エスパーしてください笑)

コンソール

[1] pry(#<DraftsController>)> params                                                                                                                                     
=> <ActionController::Parameters {"utf8"=>"", "authenticity_token"=>"Rl3FrY+0vYyOoCFv2mRhKYs2Gc3ptfCfMAicrzwECs0YpW8ZHOTGWQ6hHVjepT3K1fADkCZ/7nYHmEp6YusqFQ==", "article"=><ActionController::Parameters {"title"=>"test", "description"=>"test", "body"=>"test", "category_id"=>"1", "status"=>"draft"} permitted: false>, "commit"=>"SAVE", "controller"=>"drafts", "action"=>"create"} permitted: false>

statusのハッシュの値が"draft"と文字列で来ています。
文字列とシンボルを比較すれば当たり前ですがfalseが返ります。これで悶々としてました。

解決策は、

if params[:status].to_sym == :draft

とすればtrueを返すことができます。

コンソールで試してみましょう。

[1] pry(main)> string = "string"                                                                                                                                         
=> "string"
[2] pry(main)> symbol = :string                                                                                                                                          
=> :string
[3] pry(main)> string == symbol                                                                                                                                          
=> false
[4] pry(main)> _string = string.to_sym                                                                                                                                   
=> :string
[5] pry(main)> _string == symbol                                                                                                                                         
=> true

こんな感じですね。


ブラウザから送られてくるenumのパラメーターは文字列型で返ると覚えましょう。(自戒を込めて)

特定のテストケースを実行したい時のfocus: true

こんにちは!kossyです!




さて、今回は、特定のテストケースを実行したい時に便利な、
focus: trueオプションの使い方について、ブログに残してみたいと思います。




環境
Rails 5.1.6
Ruby 2.5.1
rspec 3.8.0
MacOS Mojave




まずはspec_helper.rbに設定

以下の記述を追記します。

/spec/spec_helper.rb

RSpec.configure do |config|

省略

  config.filter_run :focus

end


これでfocus: trueを使えるようになります。




後は実行したいブロックに記述するだけ


例えばこんなテストがあったとします。

  describe 'Validation of create User' do
    describe 'blank' do
      it '名前が空白だとエラーになる' do
        user = User.new(name: '', email: 'example@gmail.com', password: 'test1234', password_confirmation: 'test1234')
        user.valid?
        expect(user.errors[:name]).to include('を入力してください')
      end
      it 'メールアドレスが空白だとエラーになる' do
        user = User.new(name: 'テストユーザー', email: '', password: 'test1234', password_confirmation: 'test1234')
        user.valid?
        expect(user.errors[:email]).to include('を入力してください')
      end
      it 'パスワードが空白だとエラーになる' do
        user = User.new(name: 'テストユーザー', email: '')
        user.valid?
        expect(user.errors[:password]).to include('を入力してください')
      end
    end

では、一つ目のitブロックにfocus: trueを設定します。すると、

$ bundle exec rspec spec/models/user_spec.rb


User
  Validation of create User
    blank
      名前が空白だとエラーになる

Finished in 0.24428 seconds (files took 10.93 seconds to load)
1 example, 0 failures


一つ目のテストブロックのテストのみ実行されます。


describeのブロックにも設定することができます。

$ bundle exec rspec spec/models/user_spec.rb



  describe 'Validation of create User', focus: true do
    describe 'blank' do
      it '名前が空白だとエラーになる' do
        user = User.new(name: '', email: 'example@gmail.com', password: 'test1234', password_confirmation: 'test1234')
        user.valid?
        expect(user.errors[:name]).to include('を入力してください')
      end
      it 'メールアドレスが空白だとエラーになる' do
        user = User.new(name: 'テストユーザー', email: '', password: 'test1234', password_confirmation: 'test1234')
        user.valid?
        expect(user.errors[:email]).to include('を入力してください')
      end
      it 'パスワードが空白だとエラーになる' do
        user = User.new(name: 'テストユーザー', email: '')
        user.valid?
        expect(user.errors[:password]).to include('を入力してください')
      end
    end


以下はログ
User
  Validation of create User
    blank
      名前が空白だとエラーになる
      メールアドレスが空白だとエラーになる
      パスワードが空白だとエラーになる

Finished in 0.15704 seconds (files took 8.86 seconds to load)
3 examples, 0 failures

Validation of create Userのdescribeブロックのテストが全て実行されているのがわかります。




テストケースが増えてくると実行して終了するまで待つのが
とても面倒になってきます。

focus: trueはそんなシチュエーションで活躍間違いなしのオプションです。




参考にさせていただいた記事
RSpecで特定のテストケースのみを実行する方法 - TIM Labs
今日から使える! RSpec でテストの実行サンプル it を素早く絞り込み・スキップする方法 - Qiita

Rail5.2系でreferencesカラムを設定しようとした時のエラー

こんにちは!kossyです!





さて、今回は、Rails5.2系で外部キーを設定しようとした時に遭遇したエラーについて、
ブログに残してみたいと思います。



環境
Rails 5.2.2
Ruby 2.5.1






エラーの状況

2019****_create_category.rb

class CreateCategories < ActiveRecord::Migration[5.2]
  def change
    create_table :categories do |t|
      t.string :name, null: false
      t.text :content, null: false
      t.string :image, null: false

      t.timestamps
    end
  end
end


2019****_create_article.rb

class CreateArticles < ActiveRecord::Migration[5.2]
  def change
    create_table :articles do |t|
      t.string :title, null: false, index: true
      t.string :image
      t.text :description, null: false
      t.text :body, null: false
      t.integer :status, null: false
      t.references :user_id, foreign_key: true
      t.references :category_id, foreign_key: true

      t.timestamps
    end
  end
end

この内容でrails db:migrateを実行してみると、
以下のエラーが発生しました。

rails aborted!
StandardError: An error has occurred, all later migrations canceled:

Mysql2::Error: Table 'cms_development.articles' doesn't exist: SHOW FULL FIELDS FROM `articles`

Caused by:
ActiveRecord::StatementInvalid: Mysql2::Error: Table 'cms_development.articles' doesn't exist: SHOW FULL FIELDS FROM `articles`

Caused by:
Mysql2::Error: Table 'cms_development.articles' doesn't exist

Caused by:
Mysql2::Error: Cannot add foreign key constraint




対処(ベストプラクティスかは怪しい)



外部キーを追加できないと怒られていたので、該当部分をコメントアウト

class CreateArticles < ActiveRecord::Migration[5.2]
  def change
    create_table :articles do |t|
      t.string :title, null: false, index: true
      t.string :image
      t.text :description, null: false
      t.text :body, null: false
      t.integer :status, null: false
      # t.references :user_id, foreign_key: true
      # t.references :category_id, foreign_key: true

      t.timestamps
    end
  end
end

そして、rails db:migrateすると、ひとまず成功。

次に、

$ rails g migration AddReferencesToArticle user:references category:references


2019****_add_refereces_to_article.rb

class AddReferenceToArticle < ActiveRecord::Migration[5.2]
  def change
    add_reference :articles, :user, foreign_key: true
    add_reference :articles, :category, foreign_key: true
  end
end

この状態でrails db:migrateしたら、無事にreferenceカラムを追加できました。


これ本番環境でmigrationする時も起きるのでしょうか?
だとすれば、またそのうち対処法をブログに残すことになるかもしれません、、、笑


1/16 追記
t.references :user, foreign_key: true
t.references :category, foreign_key: true
とすればエラーは起きないみたいです、、、
もう同じミスはやらないと心に誓いました。