【決断くん】※こだわりポイント※ 重みづけを用いた抽選機能を実装
今回実装したいこと
1、「最終確認」画面と「決断結果」画面の間に、「武士の情け」画面を挟む
2、「武士の情け」画面で選ばれた選択肢は、決断くんによって選ばれやすくなる(70%の確率で選ばれるようにする)
実装方法
今回の作戦
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の埋め込み機能の修正と実装
今日は既存のアプリに表題の機能を施しました。
youtubeとtwitterに始まるSNSや動画サービスを閲覧したり、twitterやfacebookを通してログインしたりするのは、もはや今の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ヘルパーを使用して:i
とclass: '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の超基本的な使い方メモ
# 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連携が不可欠だから。
・twitterやFacebookなどの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 追記
コンテストまでにデプロイを目指していたが、時間が足りず、仕事のため予選会に出れなくなってしまいました。基本機能が完成したこのタイミングでいったん実装を終了し、新しい知識を身に付けるため中断していた課題に戻ることにしました。