プログラミング学習 備忘録

Railsを学習していく上での技術メモ。学んだことや解決したエラーなどを記録していきます。

【決断くん】※こだわりポイント※ 重みづけを用いた抽選機能を実装

今回実装したいこと

1、「最終確認」画面と「決断結果」画面の間に、「武士の情け」画面を挟む

2、「武士の情け」画面で選ばれた選択肢は、決断くんによって選ばれやすくなる(70%の確率で選ばれるようにする)

gyazo.com



実装方法

今回の作戦

1、new→confirm画面への遷移と同様に、hidden_fieldを使って、武士の情け(compassion)ページにタイトルと選択肢のパラメータを渡す。

2、compassionページには、お馴染みのhidden_fieldとf.radio_buttonでラジオボタンを設け、お題(:title)と5つの選択肢(:option_1~5)、そしてラジオボタンで選択された選択肢(:primary_option)を全て取得できるように。

3、createアクションとは別に、重みづけ専用のcompassion_createアクションを用意。 [option_1, option_2, …, primary_option]の形の配列にして、 gem ‘weighted_randomizer’を使って重みづけを行い抽選する。

1、「武士の情け(compassion)」ページへのルーティングとコントローラーを実装

# route.rb
  resources :choices, only: [:new, :create, :edit, :update, :index, :show] do
    collection do
      post :confirm, :compassion
      get :alert
    end

confirmと同様に、collectionを用いてネストさせる。
new→confirmと同様に、confirmからcompassionにパラメータを渡すのでリクエストはpostに。

# choices_controller
class ChoicesController < ApplicationController
  before_action :set_choice_instance, only: [:confirm, :create, :edit, :compassion, :compassion_create]
.
.
  def compassion; end
.
.
  private

  def set_choice_instance
    return if current_user.nil?

    @choice = current_user.choices.build(choice_params)
  end

ここは関してはnew→confirmへの遷移と一緒。

# choices/confirm.html.slim

  = form_with model: @choice, local: true do |f| 
    = f.hidden_field :title
    = f.hidden_field :option_1
    = f.hidden_field :option_2
    = f.hidden_field :option_3
    = f.hidden_field :option_4
    = f.hidden_field :option_5
    <br><br>
    = f.submit 'いざ!', class: 'ui secondary button'
    = f.submit '選択肢を考え直す', name: 'back', class: 'ui secondary button'
  
  = link_to root_path, class: 'ui secondary button'
    | #{t '.back'}

  = form_with model: @choice, url: compassion_choices_path, local: true do |f| 
    = f.hidden_field :title
    = f.hidden_field :option_1
    = f.hidden_field :option_2
    = f.hidden_field :option_3
    = f.hidden_field :option_4
    = f.hidden_field :option_5
    = f.submit '情けをかける', class: 'ui secondary button'

1つのフォームにまとめたかったのだが、form_withでsubmitボタンによって遷移先を複数に分割させることが出来ない?みたいなので2つのフォームに分割。 だいぶ冗長なので、partial化した方がいいかもしれない。

次からが本番

2、compassionページのビューを実装

# choices/compassion.html.slim

# 通常通りにpathを使ってurlを指定すると、createアクションが発動してしまうのでcontrollerとactionを明示的に渡す。
 = form_with model: @choice, url: {controller: 'choices', action: 'compassion_create'}, local: true do |f|

# 下の6行は、:titleおよび5つの選択肢をパラメータとして取得するためのものなのでhidden属性にする。
    = f.hidden_field :title
    = f.hidden_field :option_1
    = f.hidden_field :option_2
    = f.hidden_field :option_3
    = f.hidden_field :option_4
    = f.hidden_field :option_5
    <br><br>
    <div class='grouped fields'>

 # f.radio_buttonで各選択肢ごとにラジオボタンを設け、5つの選択肢から1つが選べるように。
    .field
      .ui.radio.checkbox
        = f.radio_button :primary_option, @choice.option_1, checked: true
        = f.label @choice.option_1
    <br>
    .field
      .ui.radio.checkbox
        = f.radio_button :primary_option, @choice.option_2
        = f.label @choice.option_2
    <br>
    - if @choice.option_3.present?
      .field
        .ui.radio.checkbox
          = f.radio_button :primary_option, @choice.option_3
          = f.label @choice.option_3
    <br>
    - if @choice.option_4.present?
      .field
        .ui.radio.checkbox
          = f.radio_button :primary_option, @choice.option_4
          = f.label @choice.option_4
    <br>
    - if @choice.option_5.present?
      .field
        .ui.radio.checkbox
          = f.radio_button :primary_option, @choice.option_5
          = f.label @choice.option_5
    <br>
    = f.submit 'いざ!', class: 'ui secondary button'

どうやって実装するか非常に悩んだが、5つの選択肢(:option_1~5)とラジオボタンで選択した選択肢(:primary_option)を配列にして、gemを使って重みづけを行うのが最適だと考えた。

Choicesテーブルとresultテーブルを分けて実装しておけばもっと簡単だったと後悔。テーブル設計ほどほどに実装始めてしまったのが仇となった。

これにより、submitボタンを押した際に以下のパラメータがchoices_controllerのcompassion_createアクションに渡されるようになる。

# 情けをかける画面で「ラーメン」を選択している
 Parameters: {"authenticity_token"=>"ANYnfC7SwwI97vg4wd6EcH/WyRbVjCou0+WkZF6UETpoI+TfVts+7d24eM+86SCb2p7JjdyK0kk5n+4+/nIRfQ==", "choice"=>{"title"=>"今日の晩ご飯", "option_1"=>"カレー", "option_2"=>"パスタ", "option_3"=>"ラーメン", "option_4"=>"", "option_5"=>"お寿司", "primary_option"=>"ラーメン"}, "commit"=>"いざ!"}

3、compassion_createアクションを定義する

class ChoicesController < ApplicationController
  before_action :set_choice_instance, only: [:confirm, :create, :edit, :compassion, :compassion_create]
.
.
  def compassion_create
    decide_with_compassion
    if params[:back]
      render :new
    elsif @choice.save
      flash[:success] = t 'choices.flash.new decide with compassion'
      redirect_to result_choice_path(@choice.id)
    end
  end

    private

  def set_choice_instance
    return if current_user.nil?

    @choice = current_user.choices.build(choice_params)
  end

      def decide_with_compassion

    queues = if @choice.option_5.present?
               { @choice.option_1.to_s => 7, @choice.option_2.to_s => 7, @choice.option_3.to_s => 7, @choice.option_4.to_s => 7,
                 @choice.option_5.to_s => 7, (params[:choice][:primary_option]).to_s => 65 }
             elsif @choice.option_4.present?
               { @choice.option_1.to_s => 10, @choice.option_2.to_s => 10, @choice.option_3.to_s => 10, @choice.option_4.to_s => 10,
                 (params[:choice][:primary_option]).to_s => 60 }
             elsif @choice.option_3.present?
               { @choice.option_1.to_s => 15, @choice.option_2.to_s => 15, @choice.option_3.to_s => 15,
                 (params[:choice][:primary_option]).to_s => 55 }
             else
               { @choice.option_1.to_s => 30, @choice.option_2.to_s => 30, (params[:choice][:primary_option]).to_s => 40 }
             end
    randomizer = WeightedRandomizer.new(queues)
    @choice.result = randomizer.sample
  end

見辛すぎて申し訳ない。
decide_with_compassion以外は通常のcreateアクション(ランダム抽選機能)とほぼ同じなので割愛。 今回は、if @choice.option_4.present?(選択肢が4つある場合)を例に簡単に解説する。

  def decide_with_compassion

#ハッシュの中で各選択肢を重みづけし、queuesに格納する
    queues = { @choice.option_1.to_s => 10, @choice.option_2.to_s => 10, @choice.option_3.to_s => 10, @choice.option_4.to_s => 10,
                 (params[:choice][:primary_option]).to_s => 60 }

# gem:WeightedRandomizerが自動的に、queuesを元にして重み付けされた配列を作ってくれる。
    randomizer = WeightedRandomizer.new(queues)

# 後はそれをsampleメソッドによって抽選し、@choice.resultに格納。
    @choice.result = randomizer.sample
  end

params[:choice][:primary_option]).to_s => 60が、70ではないのが腑に落ちないかもしれないが、@choice.option_1~4のうちのどれか1つがparams[:choice][:primary_option])と重複しているため、合計数を70にすることが重要。 コード自体は非常に冗長になってしまうが、これを応用すれば選択肢が何十個あっても好きな確率に設定可能。

今回お世話になったgem: Weighted Randomizerについては公式のGitHubをご参照ください。
GitHub - ryanlecompte/weighted_randomizer: Provides a common utility for weighted randomization


まとめ

実はこの機能、このサービスを作ることを決意した時からもっとも実装したかった機能です。
日常生活で、「こっちの商品の方がいいんだけど、こっちも少し捨てがたいな〜」って時が結構あるからそこを補いたいなと思っていました。
似たようなサービスはいくつかあるものの、この機能を実装しているサービスは1つもなかったですし、ここが決断くんのこだわりポイントです。

どうやってパラメータ渡そうかとか、渡した後はどうやって重み付けしようかとか、正直かなり悩みました。
テーブル設計の甘さと技術不足が祟り、コードは結構ぐちゃぐちゃになってしまいましたが自分の力だけで実装できて良かったし、自信に繋がりました。

もしもっといい実装方法あればコメントなどで教えていただけると幸いです。

RSpecでよく使ったマッチャまとめ

2ヶ月に渡り既存アプリの修正課題をやっていく上で、たくさんのテストを書きました。RSpecは今後の実装でも間違いなく使っていくことになるので、その際に辞書的に使えるように復習も兼ねて簡単にマッチャをまとめました。
※あくまでも自分用の備忘録なので雑です...



visit、fill_in、click_on、have_content

  describe '記事作成画面で画像ブロックを追加' do
    context '画像を選択せずにプレビューを閲覧' do
      it 'プレビューが正常に表示される' do
        visit new_admin_article_path
        fill_in 'タイトル', with: 'title'
        fill_in 'スラッグ', with: 'slag'
        fill_in '概要', with: 'body'
        click_on '登録する'
        click_on 'ブロックを追加する'
        click_on '画像'
        click_on 'プレビュー'
        expect(page).to have_content 'Blog'
        expect(page).to have_content 'title'
      end
    end
  end

reload, eq

  describe '編集画面で更新を押した際ステータスが自動的に変化する' do
    context 'ステータスが下書き以外' do
      it '公開日時が未来だと、ステータスが公開待ちに更新される' do
        visit edit_admin_article_path(article.uuid)
        fill_in '公開日', with: '2020-12-24 10:00'
        click_on '更新する'
        expect(page).to have_content '更新しました'
        expect(page).to have_content '公開待ち'
        article.reload
    #ステータスが変化するものは、reloadを挟まないと正しく判定されない

        expect(article.state).to eq 'publish_wait'
    # have_contentはhtmlにその記述があるかどうか、eqはオブジェクトの     中身の属性が等しいかを判定する

      end
    end
  end 

within, click_link

  describe 'タグ一覧画面' do
    it 'タグ のパンくずをクリックした時にタグ一覧画面に遷移すること' do
      visit edit_admin_tag_path(tag)
      within('.breadcrumb') do
        click_link 'タグ'
# withinでは、指定したセレクタの中身だけが判定される。今回だとclass: 'breadcrumb'内。click_linkはクリックするものがリンクの場合

      end
      expect(current_path).to eq(admin_tags_path)
# このように、urlとurlが等しいかどうかも判定することが可能

    end
  end

switch_to_window(windows.last), have_css, have_selector

  describe 'アイキャッチの横幅を変更' do
    context '横幅を100-700pxに指定した場合' do
      it 'プレビューで画像が正常に表示される' do
         eyecatch_width = rand(100..700)
         fill_in 'article[eyecatch_width]', with: eyecatch_width
         click_button '更新する'
         expect(page).to have_content('更新しました')
         click_link('プレビュー')
         switch_to_window(windows.last)
# リンクが別タブで開かれてしまう場合などに、別タブに移動してそのタブの要素をテストすることができる

         expect(page).to have_css('.eye_catch')
# 指定されたcssが存在するかどうか判定できる

         expect(current_path).to eq(admin_article_preview_path(article.uuid))
         expect(page).to have_selector("img[src$='eye_catch.jpg']")
# こちらは指定されたselectorが存在するかどうかの確認ができる。検証ツールを使うと各セレクターの名前がわかりやすい。

      end
    end
    end

have_http_status

  describe 'ライターの権限でログインした際に制限をかける' do
    context 'タグ編集画面のテスト' do
      let(:tag){ create :tag }
      it 'タグ編集画面にアクセスすると403エラーが表示される' do
        visit edit_admin_tag_path(tag)
        expect(page).to have_http_status(403)
# そのページのhttpステータスをテストすることができる。

      end
       end
     end

select ’○○’ from ‘○○’

describe '埋め込みタイプ' do
    before do 
      login_as(admin)
      article
      visit edit_admin_article_path(article.uuid)
      click_on 'ブロックを追加する'
      click_on '埋め込み'
      click_on '編集'
    end
    context 'twitterを選択し、URLを入力' do
      it 'ツイートが正常に表示される' do
        select 'Twitter', from: 'embed[embed_type]'
# 'embed[embed_type]'に登録されているものの中から 'Twitter'をセレクトする
        fill_in 'ID', with: 'https://twitter.com/2FnWaNQxNZRGcv0/status/1360535101948370945'
        page.all('.box-footer')[0].click_button('更新する')
#これは、class: 'box-footer'とついている要素を全て取り出して配列にし、その中の一番最初の要素([0])を選択している。

        click_on 'プレビュー'
        switch_to_window(windows.last)
        expect(current_path).to eq(admin_article_preview_path(article.uuid))
        expect(page).to have_selector("iframe[title='Twitter Tweet']")
      end
    end
  end

be_truthy, match

expect(mail.present?).to be_truthy, 'メールが送信されていません'
# be_truthyは、引数の中身がtrueかどうかを判定する。反対はbe_falsey

  describe '公開済記事の集計結果メールの送信' do
    context '昨日公開された記事がない' do
      fit '昨日公開された記事はありませんと本文に表示される' do
        check_sent_mail
        expect(mail.body).to match '昨日公開された記事はありません'
# matchは今回の例だとhave_contentと同じ効果。have_contentでも通る。ただmatchだと配列やハッシュも判定できるらしい。

      end
    end
    end

WheneverとActionMailerを両方使って定期通知メールを実装

表題の通り、Wheneverというgemとrailsの機能であるActionMailerを使って1日1回、記事の公開状況がメールで送られるように既存のアプリに修正を加えました。
wheneverとActionMailerについては以前扱ったことがあるので割愛し、解答例と違ったところや学んだことを記します。



自分が書いた最初のコード

class ArticleMailer < ApplicationMailer
  default from: 'from@example.com'

  def report_summary
    @articles = Article.all
    @yesterday_articles = @articles.yesterday_published
    mail(to: 'admin@example.com', subject: '公開済記事の集計結果')
  end


  def yesterday_published
    from = Date.yesterday.beginning_of_day
    to = Date.yesterday.end_of_day
    where(published_at: from...to)
  end
end

昨日公開された記事を取得するyesterday_publishedメソッドをArticleMailer内に定義し、report_summaryのコードを簡潔にしようとしたがうまく動かず。 結局下のようにベタ書きすることで実装した。

class ArticleMailer < ApplicationMailer
  default from: 'from@example.com'

  def report_summary
    from = Date.yesterday.beginning_of_day
    to = Date.yesterday.end_of_day
    @articles = Article.all
    @yesterday_articles = @articles.where(published_at: from...to)
    mail(to: 'admin@example.com', subject: '公開済記事の集計結果')
  end

解答例を確認したところ下のようになっていた。

class ArticleMailer < ApplicationMailer
    def report_summary
       @published_article_count = Article.published.count
     @articles_published_at_yesterday =                              Article.published_at_yesterday
      mail(to: 'admin@example.com', subject: '公開済記事の集計結果')
  end
end
# models/article.rb内
scope :published_at_yesterday, -> { where(published_at: 1.day.ago.all_day) }

1.day.ago.all_dayで昨日1日を絞り込んでいた。 Railsにおける時間を扱うメソッドについては、以下の記事が非常に参考になった。
RubyとRailsにおけるTime, Date, DateTime, TimeWithZoneの違い - Qiita

ActiveStorage 画像削除機能とカスタムバリデーションについて

今回はRailsのデフォルト機能であるActiveStorageを使って画像を扱う課題だったのですが、非常に難しく途中で断念してしまったので、解答例のコードを読み解いて行こうと思います。 Swiperを使った画像のスライド機能に関してはそこまでややこしくないので今回は省きます。



ActiveStorageを使った画像削除機能

    resource :site, only: %i[edit update] do
      resources :attachment, controller: 'site/attachments', only: %i[destroy]
    end

ルーティングは上記の通り。 resourcesをネストさせて、controllerを明示的に渡している。 これによりルーティングは下のようになり、:idを渡すことができるようになる。

 admin_site_attachment DELETE /admin/site/attachment/:id(.:format)                                                     admin/site/attachments#destroy

Controllerは下記のようにコーディング。

class Admin::Site::AttachmentsController < ApplicationController
  def destroy
    authorize(current_site)
# Active::Storage::Attachmentで紐づけられているデータを全て取得できる。
    image = ActiveStorage::Attachment.all.find(params[:id])
    image.purge
    redirect_to edit_admin_site_path
  end
end

ビューファイルはこのように。

      - if @site.favicon.attached?
        = image_tag @site.favicon_url('32x32')
        = link_to admin_site_attachment_path(@site.favicon.id), method: :delete
          i.fa.fa-trash
          | 削除
        br
        br

      = f.input :og_image, as: :file, hint: 'JPEG/PNG (1200x630)'

      - if @site.og_image.attached?
        = image_tag @site.og_image_url(:ogp), class: 'img-responsive'
        = link_to admin_site_attachment_path(@site.og_image.id), method: :delete
          i.fa.fa-trash
          | 削除
        br
        br

      = f.input :main_images, as: :file, hint: 'JPEG/PNG (1200x630)', input_html: { multiple: true }
# multiple: true をつけることによって、複数画像を一気にアップロードすることが可能
      - if @site.main_images.attached?
        - @site.main_images.each do |image|
          = image_tag image.variant(resize: "200x105"), class: 'main_images'
# active storageでは、variantメソッドによってファイルのサイズを指定できる
          = link_to admin_site_attachment_path(image.id), method: :delete
            i.fa.fa-trash
            | 削除

faviconもog_imageもmain_imagesも、保存された画像は全てActiveStorage::Attachmentに格納されるため、それぞれ一意性を持っているのでIDが重複されることはない。 なのでadmin/site/attachmentコントローラのdestroyアクションの使い回しが可能である。

カスタムバリデーションについて

こちらはコード自体が少しややこしく、理解するのにだいぶ時間がかかってしまった。

参考にしたURL: 【Rails】カスタムバリデータの使い方 - TASK NOTES

class AttachmentValidator < ActiveModel::EachValidator
  include ActiveSupport::NumberHelper

  def validate_each(record, attribute, value)
    return if value.blank? || !value.attached?

    has_error = false
# valueが空の場合はエラーを返さずにそこで処理を終わらせる

    if options[:maximum]
# Modelのバリデーションでmaximumオプション指定されているときは下の処理を行う
      if value.is_a?(ActiveStorage::Attached::Many)
# is_a?メソッドは、オブジェクトの中身(value)が引数で渡されたもの(ActiveStorage::Attached::Many)であればtrueを、それ以外ならばfalseを返す。

        value.each do |one_value|
# 今回はvalueが複数画像の場合はone_valueごとにバリデーションをかけている

          unless validate_maximum(record, attribute, one_value)
            has_error = true
# validate_maximumがfalseの場合はhas_error = trueを返す 
          end
        end
      else
        has_error = true unless validate_maximum(record, attribute, value)
#こちらはvalueが複数画像ではなかった場合の処理

    end

    if options[:content_type]
      if value.is_a?(ActiveStorage::Attached::Many)
        value.each do |one_value|
          unless validate_content_type(record, attribute, one_value)
            has_error = true
          end
        end
      else
        has_error = true unless validate_content_type(record, attribute, value)
    end

    record.send(attribute).purge if options[:purge] && has_error
# バリデーションでpurge: trueが指定されており、尚且つ上の分岐でhas_error = trueとなっている時に送られてきた画像データをpurgeする。

  end

  private

  def validate_maximum(record, attribute, value)
    if value.byte_size > options[:maximum]
# options[:maximum]で、Modelのバリデーションで定義されたmaximumのハッシュの値を取得できる。

      record.errors[attribute] << (options[:message] || "#{number_to_human_size(options[:maximum])}以下にしてください")
      false
    else
      true
    end
  end

  def validate_content_type(record, attribute, value)
    if value.content_type.match?(options[:content_type])
# こちらも同じくoption[:content_type]で、Modelのバリデーションで定義されたcontent_typeのハッシュの値(つまり指定されたファイル形式)をとってこれる。そしてmatch?メソッドでvalueのファイル形式と一致しているかチェック。

      true
    else
      record.errors[attribute] << (options[:message] || 'は対応できないファイル形式です')
      false
    end
  end
end

YoutubeとTwitterの埋め込み機能の修正と実装

今日は既存のアプリに表題の機能を施しました。
youtubetwitterに始まるSNSや動画サービスを閲覧したり、twitterfacebookを通してログインしたりするのは、もはや今のwebサービスに欠かせないものです。


見出しアイコンを動的にする

元々の自分のコードはこれで、htmlをベタ書きしている。

    elsif embed?
      if blockable.youtube?
        '<i class="fa fa-youtube-play"></i>'.html_safe
      else
        '<i class="fa fa-twitter"></i>'.html_safe
      end
    end

普通に記載するとエスケープ処理されてしまうので、html_safeメソッドでhtmlとして出力するようにしている。
参考url HTML特殊文字のエスケープ - Ruby on Rails入門

解答例のコードがこちら

  elsif embed?
      blockable.youtube? ? content_tag(:i, nil, class: 'fa fa-youtube-play') : content_tag(:i, nil, class: 'fa fa-twitter')
    end

まずは三項演算子を使って1行で記述している。 さらに、content_tagヘルパーを使用して:iclass: 'fa fa-twitterといった引数を渡してhtmlとして出力している。
参考url content_tag | Railsドキュメント

ブロック追加時のアイコンを横並べにする

自分のコード

.sns-icons         
  i.fa.fa-youtube-play
  i.fa.fa-twitter

.sns-icons {
  float: left;
    }    

下のコードでもいける

div
  i.fa.fa-youtube-play
  i.fa.fa-twitter

 i.fa.fa-youtube-play(style='display: inline')
 i.fa.fa-twitter(style='display: inline')

twitter埋め込みを実装

自分の実装したコード

# app/views/shared/_embed_twitter.html.slim内
.embed-twitter
<blockquote class="twitter-tweet"><a href="#{embed.identifier}"></a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

Slim表記にすると下のようなコードになる。

# app/views/shared/_embed_twitter.html.slim内
script async="" charset="utf-8" src="https://platform.twitter.com/widgets.js"
.embed-twitter
  blockquote.twitter-tweet
    a href="#{embed.identifier}"

参考url 【Twitter】埋め込み処理をAPIに投げずにローカルで行う - mizuff_diary

YouTubeの動画URLから、IDのみを抽出する

Rubyのメソッドを使って文字列をいじって抽出するので、これは色々な実装方法がある。 自分のコード

  def translate_url
    identifier.slice!(17, 28) if youtube?
  end

IDより前の文字列が共通&IDが11文字ということを利用して、slice!メソッドでIDの部分のみを切り取った。
しかしこれでは、動画が途中から再生されるような場合は対応できない。 そこで解答例のこのコードが有効

  def split_id_from_youtube_url
    # YoutubeならIDのみ抽出
    identifier.split('/').last if youtube?
      # lastで、切り取った複数の文字列の中からIDの部分に当たる最後の1つを選択してる。
  end

これなら 途中再生の場合も対応できる。

Punditを使った認可機能と403エラーについて

現職の都合でなかなか勉強の時間が取れず、ブログの更新もずっとストップしてしまってました。 色々落ち着いたので、今日からまた心機一転頑張っていきます。


今回はGem:Punditを使った既存の認可機能に修正を加えたので、備忘録として記録を残します。
PunditのGemについてのわかりやすい解説と、認可と認証の違いについては下記の記事を参考にさせていただきました。

Punditをなるべくやさしく解説する - Qiita

よくわかる認証と認可 | DevelopersIO


Punditの超基本的な使い方メモ

# app/policies/article_policy.rb

 class ArticlePolicy < ApplicationPolicy
   def update?
     user.admin? || user.editor?
   end
 
   def create?
     user.admin? || user.editor?
   end
# app/controllers/article_controller.rb

def create
    authorize(Article)

def update
    authorize(@article)

上記のように書くことで、update?アクションやcreate?アクションがtrueの時のみ(userがadminかeditorの時のみ)createアクションやupdateアクションが機能するようになる。

認可が降りていないページにアクセスした際に403エラーを表示させる

この機能を実装するためには、punditによる認可が降りていないページにアクセスした際のエラーログを補足し、403error :forbiddenとして処理する必要がある。
実装は至ってシンプル

#config/application.rb
config.action_dispatch.rescue_responses['Pundit::NotAuthorizedError'] = :forbidden

これだけで、publicディレクトリにある403エラーの静的ページが表示されるようになる。

開発環境で403エラー画面を表示させる方法

下記のコードにより、開発画面でも403エラーの画面が表示される

# development.rb
config.consider_all_requests_local = false
# trueだと、見慣れた開発画面のエラーが表示される。

起こったエラー

config.consider_all_requests_local = trueと記述しても、403エラーの画面が表示されてしまう。

コントローラーでrescue処理をしてしまっていたのが原因だった。
その場合は、if Rails.env.production?と記述をする必要がある。

【決断くん】Twitter認証機能&自動投稿機能の実装

現在作っているアプリ、決断くんにtwitter認証機能と自動投稿機能を実装してみました。
理由としては2つあります。

・決断くんのこだわりポイントの1つ、twitter自動投稿機能を実装するためには、twitter連携が不可欠だから。 ・twitterFacebookなどのAPIを扱えるようになりたかったから。
実装については公式のwikiと頼もしい先輩方のQiitaの記事を参考に行ったためあまり特筆することはないので、学んだことや少し詰まったところだけ備忘録としてまとめます。

参考にさせていただいたQiita記事
【Rails】SorceryでTwitter認証 - Qiita

【2019年版】RailsアプリからTwitterに更新内容を自動投稿!RailsとTwitterの連携機能を実装 - AUTOVICE



認証機能の実装

Userモデルのemail属性のnull: false制約を解除

Twitterにはemail登録をしていないユーザーもたくさんいるので、email属性がnull: falseのままだと登録段階でemailアドレスを引っ張ってこれずにバリデーションエラーが起こると予想される。

なので、email属性のnull: false制約を解除した。

#ChangeColumnNotnullマイグレーション内
class ChangeColumnToNotNull < ActiveRecord::Migration[6.0]
  def change
    change_column :users, :email, :string, null: true
  end
end


null: trueでもつけるのかな?とか思ってたらほんとにそうでびっくり。null: trueをつけてrails db:migrateしてやることでnull: falseを上書きして解除できた。
null: falseを後付けすることはあっても解除することはなかなかないので使う機会は限られるかもしれないが、覚えておこう。

エラー【OAuth::Unauthorized 400 bad request】

トップ画面からTwitterログイン画面に遷移する際に起こった。
Debuggerを使ったり色々な記事やwikiを見てみたが解決せず、結構時間を取られた。結論としては、credentials.yml.encを正しく読み込めていない or 記述できていなかったせいでエラーが起こっていたと判明。

そのため非推奨ではあるが、いったんsorcery.rbにkeyをベタ書きで記述するとうまく動いた。

#エラーがでたコード
  config.twitter.key = Rails.application.credentials.dig(:twitter, :key)
  config.twitter.secret = Rails.application.credentials.dig(:twitter, :secret_key)
#credentials.yml.enc内(vi)
twitter:
  key: '自分のAPI key'
  secret_key: '自分のAPI secret key'

Sorcery.rbのコードを下のように直書きにした。

#正常に動いたコード
  config.twitter.key = '自分のAPI key'
  config.twitter.secret = '自分のAPI secret key'

これについては明日原因究明して追記したいところ。

callback時にログインに失敗してしまう問題

#問題のコード
  def callback
    provider = params[:provider]
    if @user = login_from(provider)
      redirect_to root_path, success: "#{provider.titleize}でログインしました"
    else
      begin
        @user = create_from(provider)
        reset_session
        auto_login(@user)
        redirect_to root_path, success: "#{provider.titleize}でログインしました"
      rescue StandardError
        redirect_to root_path, danger: "#{provider.titleize}でのログインに失敗しました"
      end
    end
  end

なぜか必ずrescue StandardErrorに流れてしまっていた。
Debugger起動させて1つずつ処理を見ていくと、crypted_passwordが空になっているのが問題と判明。
(Twitter連携に取り掛かるまでは通常のログイン機能のままだったので当然だが、DBとモデル両方でpassword属性にnull: falseをかけていた。)
そこでemail同様、password属性のnull: falseを一旦外してみると見事ログイン成功!

正直、twitterで強制的に呟かれることで決断に強制力を持たせるのがこのアプリの醍醐味というかおもしろポイントなので、普通のログイン機能はもう削除しちゃっていいかな。。。

自動投稿機能の実装

Twitter developerへの登録や設定は全て終了していたので、こちらは意外なほどあっさりと実装が完了した。railsの偉大さを改めて実感。
実装できたのはdeveloperに登録しているtwitterアカウントへの呟き機能のみだった。ユーザーのアカウントに強制的に呟かせるには、個人のaccess_tokenとaccess_token_secretを取得する必要がある。一応できるらしいが、アカウントをのっとるようなものなので「twitterでシェア!」ボタンの実装だけに止めることに。一応developerアカウントに自動で呟く機能の実装方法は下記に残しておく。

choice_controllerのコード

自分の場合のコードはこんな感じに。

# choice_controller内
  def create
    options = []
    options.push(@choice.option_1, @choice.option_2, @choice.option_3, @choice.option_4, @choice.option_5)
    options.reject!(&:blank?)
    @choice.result = options.sample
    if params[:back]
      render :new
    elsif @choice.save
# \rは改行のコード
      @client.update("#{current_user.name}は、\r#{@choice.title}に対して、\r#{@choice.result}ことを決めた!!!!")
      flash[:success] = 'この決断をtwitterに投稿しました。必ず実行してください。'
      redirect_to "/choices/result/#{@choice.id}"
    end
  end
.
.
.
    private

  def twitter_client
    @client = Twitter::REST::Client.new do |config|
      config.consumer_key = "自分のAPI key"
      config.consumer_secret = "自分のAPI secret key"
      config.access_token = "自分のaccess token"
      config.access_token_secret ="自分のsecret access token"
    end
  end


エラー【NameError in ChoiceController#update uninitialized constant ChoiceController::Twitter

上のコードで実際に動かした際に出たエラー。updateがうまく動いていない。
原因はものすごく単純だった。 gem 'twitter'をインストールしていなかった。
凡ミスには注意。



今回の振り返り

とうとうこのアプリのこだわりポイントであるtwitter自動投稿機能を実装できました。
APIと聞くと自分の中では難しいイメージがあったので、無事に実装できて良かったです。
外部アプリケーションとの連携は、今やどのサービスにも導入されているので今回の経験はとても役に立つと思います。

また、最初にsorceryで通常のログイン機能を実装した後、twitter認証を追加したのですが、そのせいでカラムの変更やモデルの変更などに時間を裂くことになりました。
当たり前のことですが、コーディングに入る前に、実装する機能やDB設計をしっかりと確立させておかないと面倒なことになると実感しました。

2021/01/25 追記
コンテストまでにデプロイを目指していたが、時間が足りず、仕事のため予選会に出れなくなってしまいました。基本機能が完成したこのタイミングでいったん実装を終了し、新しい知識を身に付けるため中断していた課題に戻ることにしました。