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

FactoryBot & Fakerの導入 (Rspecでの使い方)

初学者です。備忘録がてらまとめました。

  • Ruby2.6.5
  • Rails6.0.0

テストコードを効率的に書きたい!

テストコードが冗長になって読みにくい..そんな時に使えるFactoryBot!
例えばUserのログイン機能をテストしたいとします。

以下が使ってないverです。

RSpec.describe User, type: :model do
  describe 'ユーザー新規登録' do
    it 'nicknameが空では登録できない' do
      user = User.new(nickname: '', email: 'test@example', password: '000000', password_confirmation: '000000')
      user.valid?
      expect(user.errors.full_messages).to include("Nickname can't be blank")
    end

次にFactoruBot使用verです。

RSpec.describe User, type: :model do
  describe 'ユーザー新規登録' do
    it 'nicknameが空では登録できない' do
      user = FactoryBot.build(:user)  # Userのインスタンス生成
      user.nickname = ''  # nicknameの値を空にする
      user.valid?
      expect(user.errors.full_messages).to include "Nickname can't be blank"
    end

FactoruBotとは、インスタンスをまとめるGemのことです。別ファイルに書き込んだものを、書くテストコードで使い回しできるんです。

めっちゃ変わった!というわけではありませんが、目視でわかりますね。使ってないと横に伸びてます。

このコードだけだと、効果がわかりづらいのですが、テストコードはあら有る状況を書いていくもの..
FactoryBotを使わないと、チリも詰まればなんとやらと言いますが、まさしくそれで、可読性も下がってしまいます。

ですので、テストコードを書く際は積極的に使いましょう!

FactoryBotの導入

Gemの一種ですので、いつもの流れです。
が、記述する箇所に注意してください!
Gemfileのgroup :development, :test do ~ endの中です。
Rspecと同じ箇所ですね。

Gemfile

group :development, :test do
  # Call 'byebug' anywhere in the code to stop execution and get a debugger console
  gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
  gem 'rspec-rails', '~> 4.0.0'
  gem 'factory_bot_rails'   ←ここです
end

いつものbundle install!

ターミナル

% bundle install

インスタンスをまとめるファイルの作成

FactoryBotを書く、もしくはインスタンスをまとめるファイルを作成します。
※ここではUserモデルのテストコードで進めていきます。

spec/factories /users.rb ←これを作ります

具体的な書き方

生成したファイル内の記述例がこちらです。
Fakerを使用したほうが良いパターンもあります。詳しくはこの後に記述しています。

spec/factories/users.rb

FactoryBot.define do
  factory :user do
    nickname              {'test'}
    email                 {'test@example'}
    password              {'000000'}
    password_confirmation {password}
  end
end

この設定したインスタンスをテスト内で使う場合、 FactoryBot.build(:user) と書く必要があります!

さっき上で見たテストコードを使って修正します。

spec/models/user_spec.rb

RSpec.describe User, type: :model do
  describe 'ユーザー新規登録' do
    it 'nicknameが空では登録できない' do
      user = FactoryBot.build(:user)  # Userのインスタンス生成
      user.nickname = ''  # nicknameの値を空にする
      user.valid?
      expect(user.errors.full_messages).to include "Nickname can't be blank"
    end

これをさらに可読化させます。

RSpec.describe User, type: :model do
  before do               ←ここに注目!
    @user = FactoryBot.build(:user)
  end

  describe 'ユーザー新規登録' do
    it 'nicknameが空では登録できない' do
      @user.nickname = ''
      @user.valid?
      expect(@user.errors.full_messages).to include "Nickname can't be blank"
    end

@userとbeforeを活用することで、user = FactoryBot.build(:user)を繰り返し書かずに済みます。

ここで beforebuild が出てきたので簡単に触れましょう。

before

それぞれのテストコードを実行する前に、先に実行してくれます。コントローラーでもたまに使ってましたね!
今回はこのbeforeくんのおかげでちゃっちゃと記述できています。とっても効率的!

build

ActiveRecordのnewメソッドと同じように働きます。
build=組み立てる、築くって意味ですもんね!

テストコードがかけたら、下記のコマンドを実行します。

ターミナル

bundle exec rspec spec/models/user_spec.rb 

値をランダム生成するFaker

ここまでは、FactoryBot内の詳細を自分で記述していました。(email=test@example、nickname=testなど)
ですが、もしemailなどのバリデーションで一意性などの設定をしていた場合、このままだとテストが通らなくなってしまいます。

そんな時に使うのがFakerです!

Faker

ランダムな値を生成するGemです。メールアドレス、人名、パスワードなど、さまざまな意図に応じたランダムな値を生成してくれます。

Fakerの公式GitHub
Qiita・Fakerのチートシート

Fakerの導入

こちらもGemfileに記述し、bundle installしましょう!
今回もgroup :development, :test doに書いてくださいね!

Gemfile

group :development, :test do
  # Call 'byebug' anywhere in the code to stop execution and get a debugger console
  gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
  gem 'rspec-rails', '~> 4.0.0'
  gem 'factory_bot_rails'
  gem 'faker'
end

Fakerバージョンに記述を変更してみる

emailだけでもいいのですが、せっかくですから三つともFakerバージョンにしてみましょう!

spec/factories/users.rb

FactoryBot.define do
  factory :user do
    nickname              {Faker::Name.initials(number: 2)}
    email                 {Faker::Internet.free_email}
    password              {Faker::Internet.password(min_length: 6)}
    password_confirmation {password}
  end
end

テストしてみましょう。

ターミナル

% bundle exec rspec spec/models/user_spec.rb 

緑色で通れば成功です!
お疲れ様でした!

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

【Rails】Twitter APIを使ってトレンドを取得したい

はじめに

現在、TwitterAPIを使って、トレンド情報を取得する機能を実装しています。

まだまだ途中ですが、ひとまずこれまで気づいたことをまとめていきます。
使用するAPIはStandard v1.0です。

なお、本記事は初学者によるものです。
アドバイスなどございましたら、コメントいただけると幸いです!

環境

  • Ruby on Rails 6.0.0
  • mac os catalina
  • TwitterAPI standard v1.0

導入の手順

  1. Twitter developerへ登録
  2. 各API keyを取得
  3. gem Twitterを導入

Twitter developerへ登録

ぐぐると登録の仕方がたくさん出てくるので、深くは解説しません。
余談ですが、申請時に書く内容は全部日本語で記載しても通りました。

各API keyを取得

【概要】

APIを使用するために、認証をする必要があります。
認証には、以下4つのAPIキーが必要です。

  • consumer key (API key)
  • consumer secret (API secret)
  • access token
  • access secret

consumerは身分証のようなイメージです。
twitter developperに登録するとアプリごとに発行されます。

accessは通行券とか使用するパスポートみたいなイメージです。
使用の許可得てますよ!といったものです。

なお、これらのAPIキーは一度発行すると、同じものを再発行することはできないみたいです。

【取得方法】

①developperにログインし、Developper Portalを押します。
スクリーンショット 2021-02-25 21.55.07.png

②Dashbordからアプリを登録
③アプリ横の鍵マークをクリックし、Access token & secretを発行します。

gem Twitterを導入

gem Twitterを導入しAPIを叩いていきます。
このgemを導入してやられている方が多かったので、ひとまずgemを導入してやってみようと考えました。
導入方法と、トレンド情報の取得方法を記載していきます。

【導入方法】

①Gemfileに'twitter'を記入し、bundle install

gem 'twitter'
$ bundle install

②railsを再起動

【取得方法】

①まずは認証(gemのgithubを参考)

controller.rb
client = Twitter::REST::Client.new do |config|
      config.consumer_key        = ENV["TWITTER_API_KEY"]
      config.consumer_secret     = ENV["TWITTER_API_SECRET_KEY"]
      config.access_token        = ENV["TWITTER_ACCESS_TOKEN"]
      config.access_token_secret = ENV["TWITTER_ACCESS_SECRET_TOKEN"]
    end

APIkeyはgithubなどにアップするとまずいので、環境変数に設定します。
環境変数の設定方法はこちら

②trendsメソッドを使用してトレンド情報を取得

twitterの導入によりAPIを操作するメソッドが使用できます。
トレンド情報取得の場合はtrendsメソッドを使用します。

controller.rb
client = Twitter::REST::Client.new do |config|
      config.consumer_key        = ENV["TWITTER_API_KEY"]
      config.consumer_secret     = ENV["TWITTER_API_SECRET_KEY"]
      config.access_token        = ENV["TWITTER_ACCESS_TOKEN"]
      config.access_token_secret = ENV["TWITTER_ACCESS_SECRET_TOKEN"]
    end


@trends = client.trends

@trendsの戻り値はこんな感じです。

 @attrs=
  {:trends=>
    [{:name=>"#INDvENG",
      :url=>"http://twitter.com/search?q=%23INDvENG",
      :promoted_content=>nil,
      :query=>"%23INDvENG",
      :tweet_volume=>142604},
     {:name=>"#自分をつくりあげたゲーム4選",
      :url=>
     ...(略)

標準では、フォローしているアカウント、興味関心、位置情報から決まるようです。
個人的にはこのままだと、扱いづらいので日本のトレンド10件だけにしてみます。

controller.rb
    client = Twitter::REST::Client.new do |config|
      config.consumer_key        = ENV["TWITTER_API_KEY"]
      config.consumer_secret     = ENV["TWITTER_API_SECRET_KEY"]
      config.access_token        = ENV["TWITTER_ACCESS_TOKEN"]
      config.access_token_secret = ENV["TWITTER_ACCESS_SECRET_TOKEN"]
    end
    @trends = client.trends(id = 23424856).attrs[:trends].first(10)

gemの公式のドキュメントによると、trends(id = yahooの国ID)とすることで、その国のトレンドに限定して取得できるようです。
(これ見つけるのに苦戦しました)

ここから学んだこと

①英語のドキュメントでも、翻訳してトライするべし
最初めちゃくちゃ抵抗ありました。
でも、翻訳してみると、思ったよりわかるかも?
といった感じがありました。
何事もまずはトライ。

②gemもrubyファイルの塊
ということは、どこかにメソッドの定義が書いてある?

参考

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

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

Ruby on Rails の each文をマスターしよう!!(初投稿)

アジェンダ

  • この記事の対象者
  • 開発環境
  • シンプルなコード例
  • コード全体の流れ
  • よくある記述ミス
  • つまづきがちなエラー
  • ここで抑えるべき用語
  • 最後に

この記事の対象者

  • Railsを学び始めて1か月程度
  • each文って結局どのように動いてるのかわからない

開発環境

  • Rails5
  • windows10
  • vagrant

each文の最もシンプルなコード例

投稿した配列.
id: 1
title: タイトル
body: ボディ

id: 2
title: title
body: body

id: 3
title: たいとる
body: ぼでぃ

test.controller.rb
def index
  @books = Book.all
end
index.html.erb
<% @books.each.do |book| %>
  <%= book.id %><br>
  <%= book.title %><br>
  <%= book.body %>
  ------------
<% end %>
表示.
1
タイトル
ボディ
------------
2
title
body
------------
3
たいとる
ぼでぃ
------------


全体の流れ

  1. @booksに投稿をすべて格納する(Book.all)
  2. <% @books.each do |book| %>によって投稿をひとつひとつにわけて|book|に格納する
  3. book.title等で、ひとつひとつのレコードを順番に取り出す

※each do ~ endまでにあるものを投稿の数だけ繰り返す。
(上記コード例で「------------」が三回繰り返されている)

よくある記述ミス

  • <%= @books.each do |book| %>としてしまう
    • カラム名など余計なものも表示されてしまう(ここでは割愛するが、実際に自分でやってみるとよい)
  • @books をcontrollerで定義していない
    • viewファイルとcontrollerの該当アクションのインスタンス変数の数が同じかどうかを確認すること
  • <% end %>がない
    • syntax errorがでてしまう

つまづきがちなエラー

undefined method `each' for nil:NilClass

  • @books.each do の場合@booksに変数が入っていないことが多い
    これはeachだけでなくエラー文作成の際に起こるundefined method `errors' for nil:NilClassなどにもいえる。
    controllerを再度確認すること。

用語

  • ブロック変数 → |book|のこと。

最後に

each文は初学者のつまづきやすいポイントです。
ただ一回イメージをつかめればなんてことないもので、使えればとても便利なものなので必ず習得しましょう!

 また、初投稿のため抜け落ちている部分、間違っている部分等あるかもしれませんが、
その際はご指摘いただけると嬉しいです!
ご覧頂きありがとうございました。

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

productionモードでサーバーを立ち上げるとcssが反映されない。

producition用のデータベース作成

rails db:create RAILS_ENV=production

アセットのプリコンパイルを行います

rails assets:precompile RAILS_ENV=production

サーバー起動

rails s -e production -b 0.0.0.0

...cssが反映されない。。

解決策

config/enviroments/production.rbの

config.assets.compile = false
config.assets.compile = true

に変更したらうまくいきました。
アセットファイルがないときにファイルを探して自動コンパイルしてくれる設定のようです。

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

Bootstrapのhtml見本をhtml.erbで使うときの書き方変換いろいろ【超初心者】

よく忘れるので書き方
ボタンとフォームの書き方

ボタン
#html
<button type="button" class="btn btn-primary">投稿ボタン</button>

#html.erb form_withを使ってるとき
<%= f.submit '投稿ボタン', class: "btn btn-primary" %>
フォーム
#html
<div class="mb-3">
  <label for="content" class="form-label">投稿内容</label>
  <textarea class="form-control" id="content" rows="3" placeholder="薄くフォームに表示されるやつ"></textarea>
</div>

#html.erb form_withのフォーム
<div class="mb-3">
  <%= f.label :content, "投稿内容" %>
  <%= f.text_area :content, class: "form-control m-3", rows: 3, placeholder: "薄くフォームに表示されるやつ" %>
</div>

f.text_fieldだとフォームの大きさを変えられないので注意

順次忘れやすいやつを入力していきます

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

Rails Googleアカウントを利用したSNS認証

経緯

Googleアカウントを利用したSNS認証を実装したいと思い、記事を参考にしながら作業していたが、難航した為、補完した記事を書きたいと思いました。

前提

・devise機能は実装済とする。
・下記参考サイトをメインとし、不足及び不備の補完を行う。

参考サイト

https://qiita.com/akioneway94/items/35641ad30c2acb23b562

手順1 GoogleAPI登録

Googleplatformにログイン後以下の画像の通り操作

image.png

image.png

image.png

image.png

image.png
ナビゲーションメニューから「APIとサービス」を選択→「認証情報」をクリック→「同意画面を構成」をクリック

image.png

image.png
・必須項目(アプリ名、ユーザーサポートメール、メールアドレス)を入力して「保存して次へ」をクリック。

スコープ、テストユーザー、概要の画面は何もせず「保存して次へ」をクリック

image.png
・APIとサービスの認証情報をクリック→「認証情報を作成」をクリック→「OAuthクライアントID」をクリック

image.png

承認済みのリダイレクトURIを登録

・開発環境で使用したい場合
開発環境/users/auth/google_oauth2/callback
例:https://a072 ~ amazonaws.com/users/auth/google_oauth2/callback
・本番環境で使用したい場合
http://本番環境ドメイン/users/auth/google_oauth2/callback
※独自ドメインを取得しないと登録できません。

手順2 コードを記述

①devise.rbの編集

devise.rb
:
# ==> OmniAuth
# Add a new OmniAuth provider. Check the wiki for more information on setting
# up on your models and hooks.
# config.omniauth :github, 'APP_ID', 'APP_SECRET', scope: 'user,public_repo'
config.omniauth :google_oauth2, ENV['GOOGLE_CLIENT_ID'], ENV['GOOGLE_CLIENT_SECRET']
:

②Gemfileの編集※解説あり

Gemfile.
 gem 'devise', git: "https://github.com/heartcombo/devise.git", branch: "ca-omniauth-2"
:
 gem 'dotenv-rails'
 gem 'omniauth', '1.9.1'
 gem 'omniauth-google-oauth2' 

その後、bundle install

アプリケーションのルートディレクトリ直下に.envを作成

③.envと.ignoreの編集

image.png

env.
GOOGLE_CLIENT_ID='クライアントID'
GOOGLE_CLIENT_SECRET='クライアントシークレット'

上記のクライアントID、クライアントシークレットをコピーして.envファイルに貼り付け

ignore.
.env

.ignoreファイルに.envを記述

④routes.rbの編集

routes.rb
Rails.application.routes.draw do
  :
  devise_for :users, controllers: {
    sessions: 'users/sessions',
    passwords: 'users/passwords',
    registrations: 'users/registrations',
    omniauth_callbacks: "users/omniauth_callbacks" #この行を追加
  }
  :

⑤データベースにカラムを追加

$ rails g migration AddOuthColumnToUsers provider:string uid:string

その後rails db:migrate

⑥user.rbの編集

user.rb
class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable,
         # 以下を追加
         # google以外の認証をする場合は %i[twitter, facebook]
         :omniauthable, omniauth_providers: %i[google_oauth2]

  # クラスメソッドを作成します
  def self.from_omniauth(auth)
    where(provider: auth.provider, uid: auth.uid).first_or_create do |user|
      # deviseのuserカラムに name を追加している場合は以下のコメントアウトも追記します
      # user.name = auth.info.name
      user.email = auth.info.email
      user.password = Devise.friendly_token[0,20]
    end
  end
end

⑦user/omniauth_callbacks_controller.rbの編集

user/omniauth_callbacks_controller.rb
# frozen_string_literal: true

class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
  # callback for google
  def google_oauth2
    callback_for(:google)
  end

  def callback_for(provider)
    # 先ほどuser.rbで記述したメソッド(from_omniauth)をここで使っています
    # 'request.env["omniauth.auth"]'この中にgoogoleアカウントから取得したメールアドレスや、名前と言ったデータが含まれています
    @user = User.from_omniauth(request.env["omniauth.auth"])
    sign_in_and_redirect @user, event: :authentication
    set_flash_message(:notice, :success, kind: "#{provider}".capitalize) if is_navigational_format?
  end

  def failure
    redirect_to root_path
  end
end

⑧app/views/devise/shared/_links.html.erbの修正

app/views/devise/shared/_links.html.erb
#変更前
 :
    <%= link_to "Sign in with #{OmniAuth::Utils.camelize(provider)}", omniauth_authorize_path(resource_name, provider) %><br />
 :

#変更後    
 : 
  <%= link_to "Sign in with #{OmniAuth::Utils.camelize(provider)}", user_google_oauth2_omniauth_authorize_path %><br />
 :

※Gemfileの解説

2021年1月22日現在、ominiauthnの最新バージョンに対して、deviseが対応できていない。よって、ominiauthのバージョン1.~をinstallし、deviseは指定のURL及びbranchにて実装する。

エビデンスサイト

https://rubygems.org/gems/omniauth/versions?locale=ja
https://github.com/heartcombo/devise/pulls

                             以上。

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

rails + docker環境内で時間を日本時間に合わせる

docker-compose.yml

web: &web
    build: .
    # command: bundle exec rails s -p 3000 -b '0.0.0.0'
    volumes:
      - .:/app
      - gem_data:/usr/local/bundle
    ports:
      - "3000:3000"
    environment:
      WEBPACKER_DEV_SERVER_HOST: webpacker
      WEBPACKER_DEV_SERVER_PUBLIC: 0.0.0.0:3035
      TZ: Asia/Tokyo ← こいつを記載

application.rb

module App
  class Application < Rails::Application
    # Initialize configuration defaults for originally generated Rails version.
    config.load_defaults 6.0
    # config.active_record.default_timezone = :local
    config.i18n.default_locale = :ja # デフォルトのlocaleを日本語(:ja)にする


    config.time_zone = 'Asia/Tokyo' ← 追加
    config.active_record.default_timezone = :local ← 追加


    config.i18n.load_path += Dir[Rails.root.join('config', 'locales', '**', '*.{rb,yml}').to_s]

    initializer(:remove_action_mailbox_and_activestorage_routes, after: :add_routing_paths) { |app|
      app.routes_reloader.paths.delete_if {|path| path =~ /activestorage/}
      app.routes_reloader.paths.delete_if {|path| path =~ /actionmailbox/ }
    }
    # Settings in config/environments/* take precedence over those specified here.
    # Application configuration can go into files in config/initializers
    # -- all .rb files in that directory are automatically loaded after loading
    # the framework and any gems in your application.
    config.paths.add 'lib', eager_load: true
  end
end

これだけで日本時間に変更可能。
#dockerfileは書き換えたら docker-compose down、docker-compose upの手順踏まないと反映されません。

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

【Railsチュートリアル】第10章 ユーザーの更新・表示・削除 10.2まで

はじめに

これまで未実装だったedit、update、index、destroyアクションを加え、RESTアクションを完成させる。

10.1 ユーザーを更新する

PATCHリクエストに応答するupdateアクションを作成する。

10.1.1 編集フォーム

まず、Usersコントローラにeditアクションを追加して、それに対応するeditビューを実装する。
ユーザー編集ページのURLは/users/1/edit。ユーザーidはparams[:id]変数で取り出すことができる。

app/controllers/users_controller.rb
def edit
  @user = User.find(params[:id])
end

アクションを作成したら、ビューを作成する。
editビューは見た目はapp/views/users/new.html.erbと似ているが、HTMLソースに少し違いがある。

<form accept-charset="UTF-8" action="/users/1" class="edit_user"
      id="edit_user_1" method="post">
  <input name="_method" type="hidden" value="patch" />
    # 注目すべきは1つ上のコード↑
  .
  .
  .
</form>
<input name="_method" type="hidden" value="patch" />

URL/users/1PATCHでリクエストをするとユーザーを、Railsが既存のユーザーである(すでにDBに存在する)ということを区別し、更新をしてくれる。

演習 1

先ほど触れたように、target="_blank"で新しいページを開くときには、セキュリティ上の小さな問題があります。それは、リンク先のサイトがHTMLドキュメントのwindowオブジェクトを扱えてしまう、という点です。具体的には、フィッシング(Phising)サイトのような、悪意のあるコンテンツを導入させられてしまう可能性があります。Gravatarのような著名なサイトではこのような事態は起こらないと思いますが、念のため、このセキュリティ上のリスクも排除しておきましょう。対処方法は、リンク用のaタグのrel(relationship)属性に、"noopener"と設定するだけです。早速、リスト 10.2で使ったGravatarの編集ページへのリンクにこの設定をしてみましょう。

app/views/users/edit.html.erb
.
.
.
<div class="gravatar_edit">
      <%= gravatar_for @user %>
      <a href="https://gravatar.com/emails" target="_blank" rel="noopener">change</a>
    </div>
  </div>
</div>

演習 2

リスト 10.5のパーシャルを使って、new.html.erbビュー(リスト 10.6)とedit.html.erbビュー(リスト 10.7)をリファクタリングしてみましょう(コードの重複を取り除いてみましょう)。ヒント: 3.4.3で使ったprovideメソッドを使うと、重複を取り除けます3 。

リストに沿ってリファクタリングする。

10.1.2 編集の失敗

updateアクションを作成する。

def update
  @user = User.find(params[:id])
    # DBからparams[:id]でuserを検索し、@userに代入
    if @user.update(user_params)
      # 更新に成功した場合を扱う。
    else
      render 'edit'
        # falseの場合はeditビューに再レンダリング
  end
end

演習 1

編集フォームから有効でないユーザー名やメールアドレス、パスワードを使って送信した場合、編集に失敗することを確認してみましょう。

確認のみなので省略

10.1.3 編集失敗時のテスト

エラーを検知するための統合テストを実装する。

test/integration/users_edit_test.rb
require 'test_helper'

class UsersEditTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end

  test "unsuccessful edit" do
    # 編集が失敗するときのテスト
    get edit_user_path(@user)
      # 編集ページにアクセス
    assert_template 'users/edit'
      # editビューがレンダリングされるかどうか検証
    patch user_path(@user), params: { user: { name:  "", email: "foo@invalid", password: "foo", password_confirmation: "bar" } }
        # 無効な情報を送信
    assert_template 'users/edit'
      # editビューが再レンダリングされるか検証
      # updateアクションがfalseのときは「render 'edit'」が実行されるため
  end
end

演習 1

リスト 10.9のテストに1行追加し、正しい数のエラーメッセージが表示されているかテストしてみましょう。ヒント: 表 5.2で紹介したassert_selectを使ってalertクラスのdivタグを探しだし、「The form contains 4 errors.」というテキストを精査してみましょう。

test/integration/users_edit_test.rb
.
.
.
test "unsuccessful edit" do
    get edit_user_path(@user)
    assert_template 'users/edit'
    patch user_path(@user), params: { user: { name:  "", email: "foo@invalid", password: "foo", password_confirmation: "bar" } }
    assert_template 'users/edit'
    assert_select "div.alert", "The form contains 4 errors."
  end

10.1.4 TDDで編集を成功させる

編集の成功に対するテストを実装する。

test/integration/users_edit_test.rb
class UsersEditTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end
  .
  .
  .
  test "successful edit" do
    # 編集が成功するときのテスト
    get edit_user_path(@user)
      # 編集ページにアクセス
    assert_template 'users/edit'
      # editビューがレンダリングされるか検証
    name  = "Foo Bar"
      # nameを更新
    email = "foo@bar.com"
      # emailを更新
    patch user_path(@user), params: { user: { name:  name, email: email, password: "", password_confirmation: "" } }
      # 有効な情報を送信
    assert_not flash.empty?
      # flashメッセージが空かどうか
    assert_redirected_to @user
      # プロフィールページにリダイレクト
    @user.reload
      # リロードする
    assert_equal name,  @user.name
      # nameと@user.nameが同じかどうか
    assert_equal email, @user.email
      #emailと@user.emailが同じかどうか
  end
end

パスワードが空でも更新できるようにしているが、バリデーションがかかっているため、まだエラーになる。

app/models/user.rb
class User < ApplicationRecord
.
.
.
has_secure_password
validates :password, presence: true, length: { minimum: 6 }, allow_nil: true
**.
.
.**
end

has_secure_passwordがオブジェクト生成時に存在性を検証するため、新規ユーザー登録時に空のパスワードが有効になることは無い。

演習 1

実際に編集が成功するかどうか、有効な情報を送信して確かめてみましょう。

確認のみなので省略。

演習 2

もしGravatarと紐付いていない適当なメールアドレス(foobar@example.comなど)に変更した場合、プロフィール画像はどのように表示されるでしょうか? 実際に編集フォームからメールアドレスを変更して、確認してみましょう。

初期設定のアイコン?が表示される。

10.2 認可

ユーザーにログインを要求し、かつ自分以外のユーザー情報を変更できないように制御する。

10.2.1 ユーザーにログインを要求する

Usersコントローラの中でbeforeフィルターを使う。beforeフィルターは、before_actionメソッドを使って何らかの処理が実行される直前に特定のメソッドを実行する仕組みのこと。

app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:edit, :update]
    # editアクション、updateアクションが呼び出されたら、logged_in_userアクションを実行する。
  .
  .
  .
  private

    def user_params
      params.require(:user).permit(:name, :email, :password,
                                   :password_confirmation)
    end

    # beforeアクション

    # ログイン済みユーザーかどうか確認
    def logged_in_user
      unless logged_in?
        # ログインしているか?
        flash[:danger] = "Please log in."
          # falseのときはflashを表示
        redirect_to login_url
          # ログインページへリダイレクト
      end
    end
end
unless 条件式
  条件式が偽の時に実行する処理
end

unless文は条件式が偽の場合の処理を記述するのに使われる。

test/controllers/users_controller_test.rb
require 'test_helper'

class UsersControllerTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end
  .
  .
  .
  test "should redirect edit when not logged in" do
    get edit_user_path(@user)
      # editページにアクセス
    assert_not flash.empty?
      # flashが表示されていないか?
    assert_redirected_to login_url
      # ログインページにリダイレクトされたかどうか?
  end

  test "should redirect update when not logged in" do
    patch user_path(@user), params: { user: { name: @user.name,
                                              email: @user.email } }
      # @user情報を送信
    assert_not flash.empty?
      # flashが表示されていないか?
    assert_redirected_to login_url
      # ログインページにリダイレクトされたかどうか?
  end
end

演習 1

デフォルトのbeforeフィルターは、すべてのアクションに対して制限を加えます。今回のケースだと、ログインページやユーザー登録ページにも制限の範囲が及んでしまうはずです(結果としてテストも失敗するはずです)。リスト 10.15のonly:オプションをコメントアウトしてみて、テストスイートがそのエラーを検知できるかどうか(テストが失敗するかどうか)確かめてみましょう。

確認のみなので省略。

10.2.2 正しいユーザーを要求する

ユーザーが自分の情報だけを編集できるようにする。

test/controllers/users_controller_test.rb
require 'test_helper'

class UsersControllerTest < ActionDispatch::IntegrationTest

  def setup
    @user       = users(:michael)
    @other_user = users(:archer)
  end
  .
  .
  .
  test "should redirect edit when logged in as wrong user" do
    log_in_as(@other_user)
      # テストユーザー(:archer)としてログイン
    get edit_user_path(@user)
      # michaelのeditビューにアクセス
    assert flash.empty?
      # flashが表示されて
    assert_redirected_to root_url
      # root_urlにリダイレクトされる
  end

  test "should redirect update when logged in as wrong user" do
    log_in_as(@other_user)
      # テストユーザー(:archer)としてログイン
    patch user_path(@user), params: { user: { name: @user.name, email: @user.email } }
      # michaelの情報を更新しようとする
    assert flash.empty?
      # flashが表示されて
    assert_redirected_to root_url
      # root_urlにリダイレクトされる
  end
end

別のユーザーのプロフィールを編集しようとしたらリダイレクトさせたいので、correct_userというメソッドを作成し、beforeフィルターからこのメソッドを呼び出すようにする。

app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:edit, :update]
  before_action :correct_user,   only: [:edit, :update]
    # editアクション、updateアクションが呼び出されたらcorrect_userアクションを実行する
  .
  .
  .
  private

    def user_params
      params.require(:user).permit(:name, :email, :password,
                                   :password_confirmation)
    end

    # beforeアクション

    # ログイン済みユーザーかどうか確認
    def logged_in_user
      unless logged_in?
        flash[:danger] = "Please log in."
        redirect_to login_url
      end
    end

    # 正しいユーザーかどうか確認
    def correct_user
      @user = User.find(params[:id])
        # 受け取ったログイン情報を@userに代入
      redirect_to(root_url) unless current_user?(@user)
        # @userと現在ログインしているユーザーが違う場合はroot_urlにリダイレクトさせる
    end
end

演習 1

何故editアクションとupdateアクションを両方とも保護する必要があるのでしょうか? 考えてみてください。

他のユーザーの個人情報の表示、更新ができてしまうから。

演習 2

上記のアクションのうち、どちらがブラウザで簡単にテストできるアクションでしょうか?

editアクション。viewが定義されているから。

10.2.3 フレンドリーフォワーディング

リダイレクト先をユーザーがアクセスしたかったページにする。

編集ページにアクセスし、ログインした後に、(デフォルトのプロフィールページではなく)編集ページにリダイレクトされているかどうかをチェックするテスト。

test/integration/users_edit_test.rb
require 'test_helper'

class UsersEditTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end
  .
  .
  .
  test "successful edit with friendly forwarding" do
    # フレンドリーフォワーディングのテスト
    get edit_user_path(@user)
      # ログインがまだの状態で、editビューにアクセス
    log_in_as(@user)
      # テストユーザー(michael)でログイン
    assert_redirected_to edit_user_url(@user)
      # editビューにリダイレクト
    name  = "Foo Bar"
    email = "foo@bar.com"
    patch user_path(@user), params: { user: { name: name, email: email, password: "", password_confirmation: "" } }
    assert_not flash.empty?
    assert_redirected_to @user
    @user.reload
    assert_equal name,  @user.name
    assert_equal email, @user.email
  end
end

ユーザーを希望のページに転送するには、リクエスト時点のページをどこかに保存しておき、その場所にリダイレクトさせる必要がある。

app/helpers/sessions_helper.rb
module SessionsHelper
  .
  .
  .
  # 記憶したURL(もしくはデフォルト値)にリダイレクト
  def redirect_back_or(default)
    redirect_to(session[:forwarding_url] || default)
      # 送られてきたURLがnilでなければ左側を評価する。
    session.delete(:forwarding_url)
      # 転送用のURLを削除
  end

  # アクセスしようとしたURLを覚えておく
  def store_location
    session[:forwarding_url] = request.original_url if request.get?
      # もしGETリクエストが送られてきたらoriginal_urlをsession[:forwarding_url]に代入する
  end
end

演習 1

フレンドリーフォワーディングで、渡されたURLに初回のみ転送されていることを、テストを書いて確認してみましょう。次回以降のログインのときには、転送先のURLはデフォルト(プロフィール画面)に戻っている必要があります。ヒント: リスト 10.29のsession[:forwarding_url]が正しい値かどうか確認するテストを追加してみましょう。

test/integration/users_edit_test.rb
require 'test_helper'

class UsersEditTest < ActionDispatch::IntegrationTest
  .
  .
  .
  test "successful edit with friendly forwarding" do
    get edit_user_path(@user)
    assert_equal session[:forwarding_url], edit_user_url(@user)
    log_in_as(@user)
    assert_nil session[:forwarding_url]
    name  = "Foo Bar"
    email = "foo@bar.com"
    patch user_path(@user), params: { user: { name:  name,
                                              email: email,
                                              password:              "",
                                              password_confirmation: "" } }
    assert_not flash.empty?
    assert_redirected_to @user
    @user.reload
    assert_equal name,  @user.name
    assert_equal email, @user.email
  end
end

演習 2

7.1.3で紹介したdebuggerメソッドをSessionsコントローラのnewアクションに置いてみましょう。その後、ログアウトして /users/1/edit にアクセスしてみてください(デバッガーが途中で処理を止めるはずです)。ここでコンソールに移り、session[:forwarding_url]の値が正しいかどうか確認してみましょう。また、newアクションにアクセスしたときのrequest.get?の値も確認してみましょう(デバッガーを使っていると、ときどき予期せぬ箇所でターミナルが止まったり、おかしい挙動を見せたりします。熟練の開発者になった気になって(コラム 1.2)、落ち着いて対処してみましょう)。

[1, 10] in /home/vagrant/work/sample_app2/app/controllers/sessions_controller.rb
    1: class SessionsController < ApplicationController
    2: 
    3:   # GET /login
    4:   def new
    5:     debugger
=>  6:   end
    7: 
    8:   # POST /login
    9:   def create
   10:     @user = User.find_by(email: params[:session][:email].downcase)
(byebug) session[:forwarding_url]
"http://localhost:3000/users/1/edit"
(byebug) request.get?
true
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

第9章 発展的なログイン機構

はじめに

永続cookie(permanent cookies)を使って[remember me]を実装する。

9.1 Remember me 機能

ユーザーのログイン状態をブラウザを閉じた後でも有効にする[remember me]機能を実装していく。

9.1.1 記憶トークンと暗号化

Cookiesの場合

ブラウザ(cookie)に暗号化したパスワードとDBに入っているハッシュ化しているパスワードが一致するかRailsで認証する。
下記の方針で永続的セッションを作成する。
1. 記憶トークンを保存する場所を用意する。
2. 記憶トークンにはランダムな文字列を生成して用いる。
3. ブラウザのcookiesにトークンを保存するときには、有効期限を設定する。
4. トークンはハッシュ値に変換してからデータベースに保存する。
5. ブラウザのcookiesに保存するユーザーIDは暗号化しておく。
6. 永続ユーザーIDを含むcookiesを受け取ったら、そのIDでデータベースを検索し、記憶トークンのcookiesがデータベース内のハッシュ値と一致することを確認する。

db/migrate/[timestamp]_add_remember_digest_to_users.rb
class AddRememberDigestToUsers < ActiveRecord::Migration[6.0]
  def change
    add_column :users, :remember_digest, :string
  end
end

記憶トークン用のカラムを用意。string(文字列)のremember_digest属性を追加する。

app/models/user.rb
# ランダムなトークンを返す
def User.new_token
  SecureRandom.urlsafe_base64
end

A–Z、a–z、0–9、"-"、""のいずれかの文字(64種類)からなる長さ22のランダムな文字列を返すクラスメソッド`User.newtoken`を作成する。

マイグレーションは実行済みなので、Userモデルには既にremember_digest属性が追加されているが、remember_token属性はまだ追加されていない。attr_accessorを使って「仮想の」属性を作成する。

app/models/user.rb
class User < ApplicationRecord
  attr_accessor :remember_token

  .
  .
  .

  # 永続セッションのためにユーザーをデータベースに記憶する
  def remember
    self.remember_token = User.new_token
      # 自分自身のremember_tokenに新しいtokenを代入する。保存されない
    update_attribute(:remember_digest, User.digest(remember_token))
      # :remember_digestにremember_tokenをハッシュ化したものを保存する。
      # 頭のselfが省略されている
  end
end

user.remember_tokenメソッドを使ってトークンにアクセスできるようにし、かつ、トークンをDBに保存せずに 実装する。

演習 1

コンソールを開き、データベースにある最初のユーザーを変数userに代入してください。その後、そのuserオブジェクトからrememberメソッドがうまく動くかどうか確認してみましょう。また、remember_tokenとremember_digestの違いも確認してみてください。

>> user = User.first
   (1.9ms)  SELECT sqlite_version(*)
  User Load (1.3ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]
=> #<User id: 1, name: "Rails Tutorial", email: "example@railstutorial.org", created_at: "2021-02-13 18:10:29", updated_at: "2021-02-13 18:10:29", password_digest: [FILTERED], remember_digest: nil>

>> user.remember
   (0.1ms)  begin transaction
  User Update (8.2ms)  UPDATE "users" SET "updated_at" = ?, "remember_digest" = ? WHERE "users"."id" = ?  [["updated_at", "2021-02-23 08:22:41.315576"], ["remember_digest", "$2a$12$y6wygDQ4HwYF6smxv8E1Y.K6Nz.3tNhCHpJVEDKHOBFs9MBu/NuLe"], ["id", 1]]
   (14.1ms)  commit transaction
=> true

>> user.remember_token
=> "JTiObGhfDhCqI-mYx7jGrw"

>> user.remember_digest
=> "$2a$12$O6z7y1SMzDbXqIBa5OoYrOXv4nKB132fjA4WQ7MQxubweRSg/5nYa"

演習 2

リスト 9.3では、明示的にUserクラスを呼び出すことで、新しいトークンやダイジェスト用のクラスメソッドを定義しました。実際、User.new_tokenやUser.digestを使って呼び出せるようになったので、おそらく最も明確なクラスメソッドの定義方法であると言えるでしょう。しかし実は、より「Ruby的に正しい」クラスメソッドの定義方法が2通りあります。1つはややわかりにくく、もう1つは非常に混乱するでしょう。テストスイートを実行して、ややわかりにくいリスト 9.4の実装でも、非常に混乱しやすいリスト 9.5の実装でも、いずれも正しく動くことを確認してみてください。ヒント: selfは、通常の文脈ではUser「モデル」、つまりユーザーオブジェクトのインスタンスを指しますが、リスト 9.4やリスト 9.5の文脈では、selfはUser「クラス」を指すことにご注意ください。わかりにくさの原因の一部はこの点にあります。

確認だけなので省略

9.1.2 ログイン状態の保持

cookiesメソッドを使い、永続セッションを作成する。

value(値)とオプションのexpires(有効期限)が必要。有効期限は省略できる。

cookies[:remember_token] = { value:   remember_token,
                             expires: 20.years.from_now.utc }
cookies.permanent[:remember_token] = remember_token
 # Railsの20年で期限切れになるcookies設定
app/helpers/sessions_helper.rb
# ユーザーのセッションを永続的にする
  def remember(user)
    # 必ず引数を設定する
    user.remember
     # DBに書き込む
    cookies.permanent.signed[:user_id] = user.id
      # cookieに暗号化したuser.idを代入する(20年で期限切れになる)
    cookies.permanent[:remember_token] = user.remember_token
      # cookieにuser.remember_tokenを代入する(20年で期限切れになる)
  end

signed: 暗号化、復号化するときのメソッド

app/models/user.rb
def authenticated?(remember_token)
  BCrypt::Password.new(remember_digest).is_password?(remember_token)
    # ハッシュ値remember_digestと平文remember_tokenがあっているかBCryptがチェック
end
app/helpers/sessions_helper.rb
def current_user
  if (user_id = session[:user_id])
    # session[:user_id]をuser_idに代入してnilかどうか確認
    @current_user ||= User.find_by(id: user_id)
  elsif (user_id = cookies.signed[:user_id])
    # cookies.signed[:user_id]をuser_idに代入してnilかどうか確認
    user = User.find_by(id: user_id)
      # 
    if user && user.authenticated?(cookies[:remember_token])
      # nilかどうか確認(左側)して、引数にcookies内の:remember_tokenを引数にuser.authenticatedする
      log_in user
      @current_user = user
    end
  end
end

session[:user_id]cookies.signed[:user_id]もnilの場合はnilを返す。

演習 1

ブラウザのcookieを調べ、ログイン後のブラウザではremember_tokenと暗号化されたuser_idがあることを確認してみましょう。

確認のみなので省略

演習 2

コンソールを開き、リスト 9.6のauthenticated?メソッドがうまく動くかどうか確かめてみましょう。

確認のみなので省略

9.1.3 ユーザーを忘れる

ユーザーがログアウトできるようにする。

user.forgetメソッドでuser.rememberが取り消される(nilで更新する)

app/models/user.rb
# ユーザーのログイン情報を破棄する
def forget
  self.update_attribute(:remember_digest, nil)
    # nilで更新する。削除する。
end
app/helpers/sessions_helper.rb
# 永続的セッションを破棄する
def forget(user)
  user.forget
    # remember_digestを削除
  cookies.delete(:user_id)
    # user_idを削除
  cookies.delete(:remember_token)
    # remember_tokenを削除
end

# 現在のユーザーをログアウトする
def log_out
  forget(current_user)
    # cookie情報を削除
  session.delete(:user_id)
  @current_user = nil
end

演習 1

ログアウトした後に、ブラウザの対応するcookiesが削除されていることを確認してみましょう。

確認のみなので省略

9.1.4 2つの目立たないバグ

ユーザーがタブを複数開いているとき、複数のブラウザでログインしているときにそれぞれバグが発生する。

前者はユーザーがログイン中の場合にのみログアウトさせる必要があり、後者はremember_digestが存在しないときはfalseを返す処理をauthenticated?に追加する必要がある。

# 記憶トークンcookieに対応するユーザーを返す
def current_user
  if (user_id = session[:user_id])
    # log_outメソッドによってfalseになる
    @current_user ||= User.find_by(id: user_id)
  elsif (user_id = cookies.signed[:user_id])
    # log_outメソッドによってfalseになる
    user = User.find_by(id: user_id)
    if user && user.authenticated?(cookies[:remember_token])
      # 左側の条件式でエラーが出る
      log_in user
      @current_user = user
    end
  end
end

#current_userメソッドの評価はnil
test/models/user_test.rb
require 'test_helper'

class UserTest < ActiveSupport::TestCase

  def setup
    @user = User.new(name: "Example User", email: "user@example.com",
                     password: "foobar", password_confirmation: "foobar")
  end
  .
  .
  .
  test "authenticated? should return false for a user with nil digest" do
    assert_not @user.authenticated?('')
  end
end

演習 1

リスト 9.16で修正した行をコメントアウトし、2つのログイン済みのタブによるバグを実際に確かめてみましょう。まず片方のタブでログアウトし、その後、もう1つのタブで再度ログアウトを試してみてください。

確認のみなので省略

演習 2

リスト 9.19で修正した行をコメントアウトし、2つのログイン済みのブラウザによるバグを実際に確かめてみましょう。まず片方のブラウザでログアウトし、もう一方のブラウザを再起動してサンプルアプリケーションにアクセスしてみてください。

確認のみなので省略

演習 3

上のコードでコメントアウトした部分を元に戻し、テストスイートが red から green になることを確認しましょう。

確認のみなので省略

9.2 [Remember me]チェックボック

params[:session][:remember_me] == '1' ? remember(user) : forget(user)

もし、params[:session][:remember_me]が「1」だったら(チェックボックスにチェックが入っていたら)、ログイン情報を記憶するためにrememberメソッドを呼び出す。「1」でなかったら記憶しないのでforgetメソッドを呼び出す。

三項演算子

if boolean?
  (true)var = foo
else
  (false)var = bar
end

三項演算子を使うと下記のようになる。

var = boolean? ? (true) : (false)

演習 1

ブラウザでcookies情報を調べ、[remember me]をチェックしたときに意図した結果になっているかどうかを確認してみましょう。

確認のみなので省略。

演習 2

コンソールを開き、三項演算子を使った実例を考えてみてください(コラム 9.2)。

> def first(type)
>   type = "fire" ? "ヒトカゲ" : "フシギダネ"
> end  
=> :type
> first("fire")
=> "ヒトカゲ"

# ゼニガメごめんね

9.3 [Remember me]のテスト

test "login with remembering" do
    log_in_as(@user, remember_me: '1')
    assert_not_empty cookies[:remember_token]
  end

  test "login without remembering" do
    log_in_as(@user, remember_me: '1')
      # cookieを保存してログイン
      # remember me チェックする
    delete logout_path
      # ログアウトする
    log_in_as(@user, remember_me: '0')
      # cookieを削除してログイン
      # remember me チェックしない
    assert_empty cookies[:remember_token]
      # cookiesには情報が入ってないはず
  end

演習 1

リスト 9.25の統合テストでは、仮想のremember_token属性にアクセスできないと説明しましたが、実は、assignsという特殊なテストメソッドを使うとアクセスできるようになります。コントローラで定義したインスタンス変数にテストの内部からアクセスするには、テスト内部でassignsメソッドを使います。このメソッドにはインスタンス変数に対応するシンボルを渡します。例えばcreateアクションで@userというインスタンス変数が定義されていれば、テスト内部ではassigns(:user)と書くことでインスタンス変数にアクセスできます。本チュートリアルのアプリケーションの場合、Sessionsコントローラのcreateアクションでは、userを(インスタンス変数ではない)通常のローカル変数として定義しましたが、これをインスタンス変数に変えてしまえば、cookiesにユーザーの記憶トークンが正しく含まれているかどうかをテストできるようになります。このアイデアに従ってリスト 9.27とリスト 9.28の不足分を埋め(ヒントとして?やFILL_INを目印に置いてあります)、[remember me]チェックボックスのテストを改良してみてください。17

app/controllers/sessions_controller.rb
def create
    @user = User.find_by(email: params[:session][:email].downcase)
    # if user && user.authenticate(params[:session][:password])
    if @user && @user.authenticate(params[:session][:password])
      log_in @user
      params[:session][:remember_me] == '1' ? remember(@user) : forget(@user)
      redirect_to @user
    else
      # alert-danger => 赤色のフラッシュ
      flash.now[:danger] = 'Invalid email/password combination'
      render 'new'
      # GET /users/1 => show template
      #                 render 'new'(0回目)
    end
  end
test/integration/users_login_test.rb
test "login with remembering" do
    log_in_as(@user, remember_me: '1')
    assert_equal cookies[:remember_token], assigns(:user).remember_token
  end

9.3.2 [Remember me]をテストする

raiseメソッド: 例外(わざとバグ)を発生させるメソッド。きちんとテストがされているか確認する。

require 'test_helper'

class SessionsHelperTest < ActionView::TestCase

  def setup
    @user = users(:michael)
      # michaelを@userに代入
    remember(@user)
      # @userの情報をrememberに入れる
  end

  test "current_user returns right user when session is nil" do
    assert_equal @user, current_user
      # @userとcurrent_user(ログインしているuser)が一致しているかどうか
    assert is_logged_in?
      # ログインしているかどうか
  end

  test "current_user returns nil when remember digest is wrong" do
    @user.update_attribute(:remember_digest, User.digest(User.new_token))
      # @userのremember_digestを新しいものに書き換える
    assert_nil current_user
      # 新しいものに書き換えたのでcurrent_userはnilを返しているはず
  end
end

演習 3

リスト 9.33にあるauthenticated?の式を削除すると、リスト 9.31の2つ目のテストで失敗することを確かめてみましょう(このテストが正しい対象をテストしていることを確認してみましょう)。

確認のみなので省略。

さいごに

第9章からほぼ理解できないまま飛ばす箇所が出てきました。
Railsチュートリアルを最後まで進めたら戻ってこようと思います。

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

wicked_pdfでコンソールからPDFを作成したい

はじめに

wicked_pdfでコンソールからPDFを作成したいシチュエーションに遭遇したので、備忘録も兼ねてQiitaに記事を残します。

環境

Ruby 2.6.5
Rails 5.2.4
wicked_pdf 1.1.0

wicked_pdfとは?

RailsでPDF出力をしたい時に用いるGemで、HTMLを書くようにPDFを作成することができます。
PDFを作成するGemにはPrawnもありますが、PrawnはゴリゴリのDSLなので、自由度は高いですが学習コストがかかります。

wicked_pdf本家リポジトリはこちら
https://github.com/mileszs/wicked_pdf

前提

app/views/users/profile_pdf.pdf.slimというファイルがあり、中身が

h1 = @user.name
h3
  メールアドレス
p = @user.email
h3
  プロフィール
p = @user.profile

のようになっているとします。(雑でごめんなさい、、、)

Userモデルにはnameとemailとprofileがあるとします。

手順

$ rails c

controller = ActionController::Base.new # コントローラーのインスタンスを生成(ActionView::Baseのインスタンス生成に必要なため)
view = ActionView::Base.new(Rails.root.join('app', 'views'), {}, controller) # ビューインスタンスの生成。renderメソッドを呼ぶために使う
view.extend(ApplicationHelper) # viewファイルでHelperを使っている場合に使う。ApplicationHelper以外にもHelperファイルがある場合は、そちらもextendする

pdf = WickedPdf.new.pdf_from_string(
  view.render(
    template: "users/profile_pdf.pdf.slim", # 自動でviews/配下のファイルを探しに行くため、users/profile_pdf.slimでOK
    layout: 'layouts/base_pdf_layout.pdf.slim', # layoutファイルを使わない場合は指定の必要なし
    locals: { @user => User.first }, # slimファイル内で変数を使う場合に指定する。
    encoding: 'UTF-8'
  )
).force_encoding("UTF-8")

save_path = Rails.root.join('tmp', "#{User.first.name}_profile.pdf") # 保存するpathを作成

# Fileクラスを使って保存する処理
# 参考: https://github.com/mileszs/wicked_pdf#super-advanced-usage
File.open(save_path, 'wb') do |file|
  file << pdf
end

localsは@userのみを渡していますが、変数は複数渡すことができます。

force_encodingを行わない場合、文字化けが発生します。(何十分か溶かしました)

生成されたファイルがこちら

スクリーンショット 2021-02-24 16.48.53.png

まとめ

先ほど紹介したPrawnを用いる場合ですと、ActionControllerやActionViewに頼らずにコンソールからPDF出力ができるのですが、wicked_pdfでとなるとActionControllerやActionViewに頼らないと厳しいのかなと思いました。

「PDFを一括で出してほしい」のような依頼は意外とありますので、誰かの参考になればと思います。

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

Rubyについて

本日はRubyについて。。。
志望している会社でRubyに力を入れていらっしゃったので、復習というより知識をつけようと。

Rubyって何?

・プログラミング言語
・Webアプリ開発に特化
・DBと繋がりやすい
・幅広く使われている
・文法が覚えやすい

ってな感じ。

そもそもプログラムって実行しなきゃメモ用紙なのよねえ。=Excel的な。

Ruby on Railsって何?

最初、「どっちもRubyやん。違いが分からん」という感じでした。

結論は、「Rubyのフレームワークの一つ」ということ。。
フレームワークとは、要は「頻度の高いツールのハッピーセット」的なものだと考えてる。
それぞれを単品で細かく頼むより楽だし、早いし。
つまり、Ruby(?)のハッピーセットがRuby on Railsって名前で売ってある的な。わかりずらいか。

もちろんRubyのフレームワークは他にも「Sinatra」や「HANAMI」なんて可愛い名前のものもある。
それぞれ特徴があるが、一番使われているのが「Ruby on Rails」とされているんだと。

参考はこちら
https://eng-entrance.com/ruby-framework

まとめ

Railsっていう、Webアプリが作りやすくてみんな使ってる言語の一つで、ツールがあらかじめセットになってるものがRuby on Rails。

つまり、Railsハンバーガーのハッピーセット(おもちゃがRails)を頼んでるってことね。

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

[Rails6] Sprockets::Rails::Helper::AssetNotPrecompiled in エラーが出た

結論

app/assets/config/manifest.jsファイルにコードを一行追加したら解決しました。

app/assets/config/manifest.js
//= link_tree ../images
//= link_directory ../stylesheets .css

#以下を追記
//= link application.css

環境

  • macOS Catalina
  • Ruby 2.7.2
  • Rails 6.0.3

実際には下記の記事通りに環境構築をしています

[Docker] Ruby2.7.2 / Rails6.0.3 / MySQL8.0の開発環境構築できたメモ

参考

このサイトで「rails 6」でページ検索して出てきた解決案のひとつがうまくいきました!

Stack Overflow】Rails: Sprockets::Rails::Helper::AssetNotPrecompiled in development

他に試したこと

  • config/initializers/assets.rbファイルに追記

    1. Rails.application.config.assets.precompile += %w( application.css )この一行を追記
    2. rails assets:precompile コマンド実行後サーバー再起動
  • 画像を再取り込み?(SVGリンク切れ?)

いくつか試しましたが、Railsのバージョン違いなのか、これではエラー解消できませんでした。
(前提としては、タイトルのエラー文が出て、画像表示がうまく動作していなかったようです)

さいごに

Railsガイド - アセットパイプライン

原因はこれかな?
このあたりを読めば、仮説検証を考えながら解決できそう。

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

[Rails6]Sprockets::Rails::Helper::AssetNotPrecompiled in エラーが出た

結論

app/assets/config/manifest.jsファイルにコードを一行追加したら解決しました。

app/assets/config/manifest.js
//= link_tree ../images
//= link_directory ../stylesheets .css

#以下を追記
//= link application.css

環境

  • macOS Catalina
  • Ruby 2.7.2
  • Rails 6.0.3

実際には下記の記事通りに環境構築をしています

[Docker] Ruby2.7.2 / Rails6.0.3 / MySQL8.0の開発環境構築できたメモ

参考

このサイトで「rails 6」でページ検索して出てきた解決案のひとつがうまくいきました!

Stack Overflow】Rails: Sprockets::Rails::Helper::AssetNotPrecompiled in development

他に試したこと

  • config/initializers/assets.rbファイルに追記

    1. Rails.application.config.assets.precompile += %w( application.css )この一行を追記
    2. rails assets:precompile コマンド実行後サーバー再起動
  • 画像を再取り込み?(SVGリンク切れ?)

いくつか試しましたが、Railsのバージョン違いなのか、これではエラー解消できませんでした。
(前提としては、タイトルのエラー文が出て、画像表示がうまく動作していなかったようです)

さいごに

Railsガイド - アセットパイプライン

原因はこれかな?
このあたりを読めば、仮説検証を考えながら解決できそう。

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

form_withのlocal: trueって必要なん?これ何なん?(Ruby on Rails)

はじめに:私が犯した罪と償い

社内研修の一環で、プチIoTシステムを開発しています。
そんな中、自分のエラーの解消に大先輩3人の60分を溶かしてしまったので、せめてもの償いのために、学んだことをメモしておきます。

おかげで、

  1. Railsのメソッドform_withの引数local: trueが何の役に立っているのか
  2. アプリ作成時のネットワーク通信のこと

が痛いほどわかりました。

環境
Rails6.0.3, macOS Catalina 10.15.7

何のエラー出してたん?

フォームでバリデーションが通らなかった時にエラーメッセージをビューで表示したい。
ただそれだけのことでした。しかし仕込んだそのエラーメッセージが表示されません!

うまくいっていなかったビューはこちら。1行目にご注目。

new.html.erb
<%= form_with model: @supplier do |form| %>

  <% if form.object.errors.any? %>
    <div id="error_explanation">
      <div class="alert alert-danger">
        エラーが <%= form.object.errors.count %> 個あります。 
      </div>
      <ul>
      <% form.object.errors.full_messages.each do |msg| %>
        <li><%= msg %></li>
      <% end %>
      </ul>
    </div>
  <% end %>

  <div class="mb-3">
    <%= form.label :会社名, for: 'company', class: 'col-sm-2 col-form-label' %>
    <%= form.text_field :name, class: 'form-control', id: 'company', placeholder: '株式会社 喧嘩上等' %>
  </div>
  <div class="mb-3">
    <%= form.label :メールアドレス, for: 'email', class: 'col-sm-2 col-form-label' %>
    <%= form.text_field :email, class: 'form-control', id: 'email', placeholder: 'order@example.com' %>
    </div>

  <%= form.submit '送信', class: 'btn' %>
<% end %>

私は、Railsチュートリアル出身なのですが、そこではform_withlocal: trueという引数を渡していました。しかし、ネットでform_withの使い方を他で改めて調べてみたところ、この引数がなくても新規レコードの登録処理ができそうだったので、省いてみちゃえ!と出来心が全ての事の始まりでした。実際、これでデータベースまでの登録はきちんとできていて、なんだlocalパラメータいらないんじゃん。となっていました。

一方.....if form.object.errors.any?を入れても、エラー表示は一切できませんでした。

ちなみにコントローラはこんな感じ。問題なさそう。

supplier.controller.rb
class SuppliersController < ApplicationController

  def new
    @supplier = Supplier.new
  end

  def create
    @supplier = Supplier.new(supplier_params)
    if @supplier.save
      redirect_to suppliers_path
    else
      render 'new'
    end   
  end

end

binding.irbをビューやコントローラなど至る箇所に埋め込んでは、各変数に値が入っているかどうかをチェック。
form.objectには値もある。@supplierにしても同じ。errors.any?もtrueを返している。何故なんだ。タイポもなさそう。先輩たちの時間をどんどん溶かしてしまっています...。

解決:「クルクルしてない!」

クルクルしてない!
問題の原因が全くわからず途方にくれていた末に、ある先輩が。
フォームの送信ボタンを教えても画面がリロードしないことを変に思ったようで、そこからフォームの動きが怪しいとなりました。そこでlocal: trueを追記したのです。

new.html.erb
<%= form_with(model: @supplier, local: true) do |form| %>

以下略

学び1:local: trueとは?

form_withはRailsの比較的新しいメソッド。このメソッドではデフォルトがAjax通信で、非同期通信になってしまうのです。(これはRails 5.1〜6.0の仕様でRails 6.1からは同期通信がデフォルトに戻っているそうです。当記事はRails6.0.3です。→参考@jnchito さんありがとうございます!)それはつまり、必要な箇所だけページが更新され、その他の箇所はそのままになるという事。だからエラーメッセージのHTML部分が加えられなかったのです。
ここにlocal: trueと引数を渡す事で、これが通常のHTTPリクエストになり、ページ全体が返ってきてページがリロードされ、エラーメッセージも表示されるようになります。

学び2:アプリの開発でネットワークをチェックする

その後、ブラウザの開発者ツールでネットワークを確認しました。
スクリーンショット 2021-02-25 15.06.16.png
↑Typeがxhrになっとる。xhr=XMLHttpRequest。Ajaxのxの部分。つまりAjax通信になっているということ。

スクリーンショット 2021-02-25 15.06.04.png
↑suppliersが下の方に。他の要素がリロードされずにそのまま残っているということ。

local: trueにしておく(=通常のHTTP通信にする)とこのようなネットワーク動作になります。
suppliersがリストの一番先頭にいます。
ページ内の全ての要素が更新され、suppliersが一番最初に処理されたという事です。
スクリーンショット 2021-02-25 15.08.37.png

エンジニア中級者になるには、ネットワークのことを理解すること」と先輩。

最後に

エラーの解消の過程で、他にもbinding.irbの使い方、errorsやfull_messagesにどんなデータが格納されているのかなどいろんなことを勉強できました。

先輩方ありがとうございました!

@yuuu
@yukabeoka
@Junkins

宣伝ではないですが、周りがこんなエラーに親身に向き合ってくれる先輩ばかりです。
本当にFusicという会社でエンジニアキャリアを始められて幸せすぎる...。

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

【Rails】1日に記録できる数値の合計を制限するバリデーション

はじめに

投稿3回目です。
文章力が赤ちゃんレベルかつ遅筆なのでまだ慣れないです...
間違い等あればご指摘よろしくお願いします。

環境

  • Ruby 2.6.3
  • Rails 5.2.4

背景/目的

  • ポートフォリオで学習内容を記録するサイトを開発中。
  • 学習内容は1日に何回でも記録可能であり、項目には学習時間がある。
  • 学習時間の合計値が24時間/1日を超えないようにバリデーションを設定する。
  • 学習内容(learningsテーブル)の詳細は下記の通り。
カラム名 データ型 説明
date date 学習日
time float 学習時間

結論

app/models/learning.rb
class Learning < ApplicationRecord
  # 一つのユーザー(user)に対して、複数の学習記録(learning)が結びついている
  belongs_to :user

  # validateに定義したメソッドを設定
  validate :total_time_cannot_exceed_limit_time, on: :create
  validate :total_time_cannot_exceed_limit_time_for_edit, on: :update

  # 1日に記録できる学習時間の合計
  LIMIT_TIME_HOUR = 24

  # 入力された学習日に既に記録されている学習時間の合計値を取得する
  def one_day_time_sum(date)
    user.learnings.where(date: date).sum(:time)
  end

  # 入力された学習日に既に記録されている「編集対象の投稿以外の」学習時間の合計値を取得する
  def one_day_time_sum_unless_target_date(date)
    user.learnings.where(date: date).where.not(id: id).sum(:time)
  end

  # create時のカスタムバリデーション用のメソッドを定義
  def total_time_cannot_exceed_limit_time
    # [学習日が入力済]かつ[学習時間が入力済]かつ[学習日に既に記録された学習時間と入力した学習時間の合計が24時間を超える場合]
    if date.presence && time.presence && one_day_time_sum(date) + time > LIMIT_TIME_HOUR
      # エラーメッセージを表示する
      errors.add(:date, ":#{date.strftime("%Y年%m月%d日")}の学習時間の合計が#{LIMIT_TIME_HOUR}時間を超えています")
    end
  end

  # update時のカスタムバリデーション用のメソッドを定義
  def total_time_cannot_exceed_limit_time_for_edit
    # [学習日が入力済]かつ[学習時間が入力済]かつ[学習日に既に記録された「編集対象の投稿以外の」学習時間と入力した学習時間の合計が24時間を超える場合]
    if date.presence && time.presence && one_day_time_sum_unless_target_date(date) + time > LIMIT_TIME_HOUR
      errors.add(:date, ":#{date.strftime("%Y年%m月%d日")}の学習時間の合計が#{LIMIT_TIME_HOUR}時間を超えています")
    end
  end
end

createとupdateで処理を分けています。
コメントで処理の内容を記載しましたが、自分で読んでても正直よく分からないです。
例えば、下記のデータが既に保存されており、学習日2021-01-01・学習時間4の投稿を新たにする場合、

id 学習日 学習時間
1 2021-01-01 10.6
2 2021-01-01 11.4

入力された学習日2021-01-01に既に記録されている学習時間の合計値22時間を取得し、入力した学習時間4時間を足して、24時間と比較します。=> この場合26時間なのでエラーメッセージが表示されます。

これをupdateでも同じ処理にすると動作がおかしくなります。
例えば、id:1の学習時間を5時間に変更する場合、5 + 11.4 = 16.4時間になるのが理想です。
しかし、実際は10.6 + 11.4 + 5 = 27時間でNGになってしまいます。
user.learnings.where(date: date).sum(:time)だと編集前の学習時間も取得してしまうので、update時はwhere.not(id: id)を追記し、編集対象の投稿以外の学習時間の合計を取得するようにしています。

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

ここまで見て頂きありがとうございました。
ネーミングセンスがないのは許していただけると助かります?

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

Active Hashを使ってidを DBに保存する

ActiveHashを利用する

gem 'active_hash'

ActiveHashを導入する。

modelsディレクトリに新しいファイル作成後に、クラスを定義してActiveHash::Baseクラスを継承する。ActiveHashを用いるのに必要なもの。

class Genre < ActiveHash::Base
 self.data = [
   { id: 1, name: '----' },
   { id: 2, name: '経済' },
   { id: 3, name: '政治' },
   { id: 4, name: '地域' },
   { id: 5, name: '国際' },
   { id: 6, name: 'IT' },
   { id: 7, name: 'エンタメ' },
   { id: 8, name: 'スポーツ' },
   { id: 9, name: 'グルメ' },
   { id: 10, name: 'その他' }
 ]

 include ActiveHash::Associations
 has_many :examples
 end

ActiveHashを用いてアソシエーションの記述をするので、モジュールをincludeする。
また紐付いているモデルとのアソシエーションを記述する。

class Example < ApplicationRecord
 extend ActiveHash::Associations::ActiveRecordExtensions
 belongs_to :genre

 validates :genre_id, numericality: { other_than: 1 } 
end

同様に紐付いているモデル内に対応するアソシエーションを記述する。
こちらでもActiveHashを用いるのに使用するモジュールを取り込む。
また今回の場合はid1は {'---'}となったいるためDBに保存しないようにバリデーションをかけておく。

<%= f.collection_select(:genre_id, Genre.all, :id, :name, {}, {class:"genre-select"}) %>

プルダウンを生成するために上記の記述をする。

役割
:genre_id 保存先のカラム名
Genre.all 配列データ
:id 表示する際に参照するDBのカラム名
:name 実際に表示されるカラム名
{} オプションの指定
{class:"genre-select"} htmlオプション

扱うデータを簡単に保存できるために役立ちそうです。

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

Railsチュートリアル10章まとめ

この章でやること ユーザーの更新・表示・削除

  • Usersリソースのうち未実装だったedit、update、index、destroyアクションを加え、RESTアクションを完成させる
  • まず、ユーザーが自分のプロフィールを自分で更新できるようにする
  • 次に、すべてのユーザーを一覧できるようにする。サンプルデータとページネーション(pagination)も学ぶ
  • 最後に、ユーザーを削除し、データベースから完全に消去する機能を追加。ユーザーの削除はどのユーザーにも許可できるものではないので、管理ユーザーという特権クラスを作成し、このユーザーにのみ削除を許可するようにする

10.1 ユーザーを更新する

導入
ユーザー情報を編集するパターンは、新規ユーザーの作成と似ている
新規ユーザー用のビューを出力するnewアクションと同じようにして、ユーザーを編集するためのeditアクションを作成すればよい。
同様に、POSTリクエストに応答するcreateの代わりに、PATCHリクエストに応答するupdateアクションを作成する
最大の違いは、ユーザー登録は誰でも実行できるが、ユーザー情報を更新できるのはそのユーザー自身に限られる
beforeフィルターを使ってこのアクセス制御を実現していく

updating-usersトピックブランチを作成

$ git checkout -b updating-users

10.1.1 編集フォーム

Usersコントローラにeditアクションを追加して、それに対応するeditビューを実装
editアクションはデータベースからユーザーデータを読み込む

ユーザー編集ページの正しいURLが/users/1/edit。
ユーザーのidはparams[:id]変数で取り出すことができるため@user変数を定義

app/controllers/users_controller.rb
class UsersController < ApplicationController

  def edit
    @user = User.find(params[:id])
  end

end

対応するユーザーのeditビューを作成

$ touch app/views/users/edit.html.erb
```

app/views/users/edit.html.erb
<% provide(:title, "Edit user") %>
<h1>Update your profile</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_with(model: @user, local: true) do |f| %>
      <%= render 'shared/error_messages' %>

      <%= f.label :name %>
      <%= f.text_field :name, class: 'form-control' %>

      <%= f.label :email %>
      <%= f.email_field :email, class: 'form-control' %>

      <%= f.label :password %>
      <%= f.password_field :password, class: 'form-control' %>

      <%= f.label :password_confirmation, "Confirmation" %>
      <%= f.password_field :password_confirmation, class: 'form-control' %>

      <%= f.submit "Save changes", class: "btn btn-primary" %>
    <% end %>

    <div class="gravatar_edit">
      <%= gravatar_for @user %>
      <a href="https://gravatar.com/emails" target="_blank">change</a>
    </div>
  </div>
</div>

@user変数を使ったため、自動で既に編集フォームに名前やメールが入った状態になる

検証ツールでHTMLをみると

<form accept-charset="UTF-8" action="/users/1" class="edit_user"
      id="edit_user_1" method="post">
  <input name="_method" type="hidden" value="patch" />
  .
  .
  .
</form>

次の入力フィールドに隠し属性がある

<input name="_method" type="hidden" value="patch" />

WebブラウザはネイティブではPATCHリクエストを送信できない。
RailsはPOSTリクエストと隠しinputフィールドを利用してPATCHリクエストを偽造している。(hiddenを使っている)

railsのform_with(@user)のコードは新規作成newとeditで文章が変わらないが、Railsはどうやって新規ユーザー用のPOSTリクエストとユーザー編集用のPATCHリクエストを区別するのか?
その答えは、Railsは新規ユーザーか、既存のDBにいるユーザーか、
Active Recordのnew_record?論理値メソッドを使って区別できるから。

Rails Consoleで確認してみる。

$ rails console
>> User.new.new_record?
=> true
>> User.first.new_record?
=> false

※もう少し調べてみた

調査 
new_record?理論値メソッド
new_record?はDBにレコードが登録されて「いなければ」true 登録されていればfalse
→レコードが登録されていなければPOSTで送り、レコードが登録されていればPATCHを使う

Railsは、form_with(@user)を使ってフォームを構成すると、@user.new_record?がtrueの時にはPOSTリクエスト、falseのときにはPATCHリクエストを使う。

仕上げに、ナビゲーションバーにあるユーザー設定へのリンクを更新する。

Usersリソースの名前付きルートである、edit_user_pathと、current_userというヘルパーメソッドを使うと、実装が簡単。

<%= link_to "Settings", edit_user_path(current_user) %>

app/views/layouts/_header.html.erb
<header class="navbar navbar-fixed-top navbar-inverse">
  <div class="container">
    <%= link_to "sample app", root_path, id: "logo" %>
    <nav>
      <ul class="nav navbar-nav navbar-right">
        <li><%= link_to "Home", root_path %></li>
        <li><%= link_to "Help", help_path %></li>
        <% if logged_in? %>
          <li><%= link_to "Users", '#' %></li>
          <li class="dropdown">
            <a href="#" class="dropdown-toggle" data-toggle="dropdown">
              Account <b class="caret"></b>
            </a>
            <ul class="dropdown-menu">
              <li><%= link_to "Profile", current_user %></li>
              <li><%= link_to "Settings", edit_user_path(current_user) %></li> #passを定義
              <li class="divider"></li>
              <li>
                <%= link_to "Log out", logout_path, method: :delete %>
              </li>
            </ul>
          </li>
        <% else %>
          <li><%= link_to "Log in", login_path %></li>
        <% end %>
      </ul>
    </nav>
  </div>
</header>

調査 target_blankの調査 なぜ脆弱性があるのか

https://webegins.com/target-blank/
復習 provide メソッド
viewでprovideヘルパーを利用することで、ここのテンプレートからレイアウト側にタイトルを引き渡すことができる。

            view/home.html.erb
            <% provide :title, 'タイトルの例' %>
            レイアウト側からは、yield メソッドを利用することで呼び出せる。

            layouts/application.html.erb
            <title><%= yield(:title) || 'Rails入門' %></title>

演習

先ほど触れたように、target="_blank"で新しいページを開くときには、セキュリティ上の小さな問題があります。それは、リンク先のサイトがHTMLドキュメントのwindowオブジェクトを扱えてしまう、という点です。具体的には、フィッシング(Phising)サイトのような、悪意のあるコンテンツを導入させられてしまう可能性があります。Gravatarのような著名なサイトではこのような事態は起こらないと思いますが、念のため、このセキュリティ上のリスクも排除しておきましょう。対処方法は、リンク用のaタグのrel(relationship)属性に、"noopener"と設定するだけです。早速、リスト 10.2で使ったGravatarの編集ページへのリンクにこの設定をしてみましょう。
rel="noopener"を追加するだけ

リスト 10.5のパーシャルを使って、new.html.erbビュー(リスト 10.6)とedit.html.erbビュー(リスト 10.7)をリファクタリングしてみましょう(コードの重複を取り除いてみましょう)。ヒント: 3.4.3で使ったprovideメソッドを使うと、重複を取り除けます
→コピペするだけでOK

10.1.2 編集の失敗

createアクションと同じような構造で、updateアクションを作成する
editビューのフォームから送信されたparamsハッシュを受け取り、ユーザーを更新する。
無効な情報の場合は編集ページを描写する

app/controllers/users_controller.rb
class UsersController < ApplicationController


  def create
    @user = User.new(user_params)
    if @user.save
      log_in @user
      flash[:success] = "Welcome to the Sample App!"
      redirect_to @user
    else
      render 'new'
    end
  end

  def update  #createと構造同じ
    @user = User.find(params[:id])
    if @user.update(user_params)
      # 更新に成功した場合を扱う。
    else
      render 'edit'
    end
  end
end

updateへの呼び出しでuser_paramsを使っている
以前利用したStrong Parametersを使ってマスアサインメントの脆弱性を防止している(user_paramsはprivete以降に記述あり)

エラーに関しても、Userモデルのバリデーションとエラーメッセージのパーシャルが既にあるため、自動でエラーメッセージを表示してくれる

演習

編集フォームから有効でないユーザー名やメールアドレス、パスワードを使って送信した場合、編集に失敗することを確認してみましょう。
→確認

10.1.3 編集失敗時のテスト

テストのガイドラインに従って、エラーを検知するための統合テストを書く

まずは統合テストを生成

$ rails generate integration_test users_edit

最初は編集失敗時の簡単なテストを追加していく

  1. まず編集ページにアクセスし、editビューが描画されるかどうかをチェック
  2. 無効な情報を送信してみて、editビューが再描画されるかどうかをチェック

ここで、PATCHリクエストを送るためにpatchメソッドを使っていることに注意

test/integration/users_edit_test.rb
require 'test_helper'

class UsersEditTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end

  test "unsuccessful edit" do
    get edit_user_path(@user)  #@userの編集ページを取得
    assert_template 'users/edit'  #editページが表示されているか
    patch user_path(@user), params: { user: { name:  "",  #おかしいparamsをpatchへ送信
                                              email: "foo@invalid",
                                              password:              "foo",
                                              password_confirmation: "bar" } }

    assert_template 'users/edit'  #editビューが描写されているか
  end
end

これで、テストを実行すると green

演習

リスト 10.9のテストに1行追加し、正しい数のエラーメッセージが表示されているかテストしてみましょう。ヒント: 表 5.2で紹介したassert_selectを使ってalertクラスのdivタグを探しだし、「The form contains 4 errors.」というテキストを精査してみましょう。

 test "unsuccessful edit" do
    get edit_user_path(@user)  #@userの編集ページを取得
    assert_template 'users/edit'  #editページが表示されているか
    patch user_path(@user), params: { user: { name:  "",  #おかしいparamsをpatchへ送信
                                              email: "foo@invalid",
                                              password:              "foo",
                                              password_confirmation: "bar" } }

    assert_template 'users/edit'  #editビューが描写されているか
    assert_select "div.alert","The form contains 4 errors."
  end

10.1.4 TDDで編集を成功させる

ユーザーの編集フォームが動作するようにする。
プロフィール画像の編集は、Gravatarで画像のアップロードも既に動作するようになっている

快適にテストをするためには、アプリケーション用のコードを「実装する前に」統合テストを書いた方が便利
そのテストを「受け入れテスト(Acceptance Tests)」として呼ばれていて、ある機能の実装が完了し、受け入れ可能な状態になったかどうかを決めるテストとして知られている
同じ手法を使って(テスト駆動開発)ユーザーの編集機能を実装していく

テストの内容は上のテストを参考に書いていく

  1. ユーザー情報を更新する正しい振る舞いをテストで定義(今回は有効な情報を送信するように修正)。
  2. flashメッセージが空でないかどうかと、プロフィールページにリダイレクトされるかどうかをチェック
  3. データベース内のユーザー情報が正しく変更されたかどうかも検証

以下のテストコードになる

test/integration/users_edit_test.rb
require 'test_helper'

class UsersEditTest < ActionDispatch::IntegrationTest

test "successful edit" do
    get edit_user_path(@user) 
    assert_template 'users/edit'
    name  = "Foo Bar"
    email = "foo@bar.com"
    patch user_path(@user), params: { user: { name:  name,#有効な情報
                                              email: email,
                                              password:              "",
                                              password_confirmation: "" } }
    assert_not flash.empty?  #flashが空でないか
    assert_redirected_to @user #userビューページへ飛んでいるか
    @user.reload  #データテーブルを再度読み込む
    assert_equal name,  @user.name  #設定したnameとDBのnameが一致しているか
    assert_equal email, @user.email
end

テストがパスするために、updateアクションも変更

app/controllers/users_controller.rb
class UsersController < ApplicationController
  .
  def update
    @user = User.find(params[:id])
    if @user.update(user_params)
      flash[:success] = "Profile updated"
      redirect_to @user
    else
      render 'edit'
    end
  end
  .
end

1)テストコードのパスワードとパスワード確認が空であることに注目。
ユーザー名やメールアドレスを編集するときに毎回パスワードを入力するのは不便なので、(パスワードを変更する必要が無いときは)パスワードを入力せずに更新できると便利になる
2)また、@user.reloadを使って、データベースから最新のユーザー情報を読み込み直して、正しく更新されたかどうかを確認している点にも注目。(受け入れテストでは先にテストを書くので、効果的なユーザー体験について考えるようになる)

このテストはまだ red のまま。
なぜならパスワードの長さに対するバリデーションがあるので、パスワードやパスワード確認の欄を空にしているため引っかかってしまうから。
テストがパスするためにパスワードのバリデーションに対して、空だったときの例外処理を加える必要がある
allow_nil: trueオプションを使ってvalidatesに追加する
パスワードが空のままでも更新できるようになる

app/models/user.rb
class User < ApplicationRecord
  attr_accessor :remember_token
  before_save { self.email = email.downcase }
  validates :name, presence: true, length: { maximum: 50 }
  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
  validates :email, presence: true, length: { maximum: 255 },
                    format: { with: VALID_EMAIL_REGEX },
                    uniqueness: true
  has_secure_password
  validates :password, presence: true, length: { minimum: 6 }, allow_nil: true
  .
  .
  .
end

パスワードが空のままでも更新できるようになってしまうと、「新規ユーザー登録時に空のパスワードが有効になってしまうのか」と心配になるが、
has_secure_passwordでは(追加したバリデーションとは別に)オブジェクト生成時に存在性を検証するようになっているため、空のパスワード(nil)が新規ユーザー登録時に有効になることはない。
問題であった空のパスワードを入力すると存在性のバリデーションとhas_secure_passwordによるバリデーションがそれぞれ実行され、2つの同じエラーメッセージが表示されるというバグもこれで解決する

テストはパスするはず

演習

実際に編集が成功するかどうか、有効な情報を送信して確かめてみましょう。
もしGravatarと紐付いていない適当なメールアドレス(foobar@example.comなど)に変更した場合、プロフィール画像はどのように表示されるでしょうか? 実際に編集フォームからメールアドレスを変更して、確認してみましょう。
→OK Gravatarのデフォルトの画像が表示される

10.2 認可

ウェブアプリケーションでは、認証(authentication)はサイトのユーザーを識別することであり、認可(authorization)はそのユーザーが実行可能な操作を管理すること

今時点、どのユーザーでもあらゆるアクションにアクセスできるため、誰でも(ログインしていないユーザーでも)ユーザー情報を編集できてしまう。
ここでユーザーにログインを要求し、かつ自分以外のユーザー情報を変更できないように制御していく
(こういったセキュリティ上の制御機構をセキュリティモデルと呼ぶ)

10.2.1 ユーザーにログインを要求する

ログインしてないユーザがURLを直接触って編集できないようにするには、Usersコントローラの中でbeforeフィルターを使う。

app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:edit, :update]
  .
  .
  .

    # beforeアクション

    # ログイン済みユーザーかどうか確認
    def logged_in_user
      unless logged_in?#logged_inがfalseなら(logged_inはsessions_helperで定義ずみ)
        flash[:danger] = "Please log in."
        redirect_to login_url
      end
    end
end

デフォルトでは、beforeフィルターはコントローラ内のすべてのアクションに適用されるため、ここでは:onlyオプション(ハッシュ)を渡すことで、:editと:updateアクションだけにこのフィルタが適用されるように制限をかける

これで直接URLを触ってeditやupdateはできなくなったが、テストが古いのでテストは失敗する
原因は、ログインしていないユーザーのままのテストだから

log_in_asヘルパーを使ってテストでもログインする。

test/integration/users_edit_test.rb
require 'test_helper'

class UsersEditTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end

  test "unsuccessful edit" do
    log_in_as(@user) #@userとしてログイン
    get edit_user_path(@user)
    .
    .
    .
  end

  test "successful edit" do
    log_in_as(@user) #@userとしてログイン
    get edit_user_path(@user)
    .
    .
    .
  end
end

これでテストスイートがパスする
しかし、beforeフィルターの実装はまだ終わってない

なぜならbeforeフィルターをコメントアウトしてもテストが通ってしまうから

beforeフィルターは基本的にアクションごとに適用していくので、
Usersコントローラのテストもアクションごとに書いていく。

具体的には

①正しい種類のHTTPリクエストを使う
②editアクションとupdateアクションをそれぞれ実行させてみる
③flashにメッセージが代入されるかどうか検証
④ログイン画面にリダイレクトされたかどうか

HTTPリクエストは、 editにはget、updateにはpatchを書く

test/controllers/users_controller_test.rb
require 'test_helper'

class UsersControllerTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end
  .
  .
  .
  test "should redirect edit when not logged in" do
    get edit_user_path(@user)
    assert_not flash.empty?
    assert_redirected_to login_url
  end

  test "should redirect update when not logged in" do
    patch user_path(@user), params: { user: { name: @user.name,
                                              email: @user.email } }
    assert_not flash.empty?
    assert_redirected_to login_url
  end
end

コメントアウトしていた箇所を元に戻すと、テストがパスするはず

演習

デフォルトのbeforeフィルターは、すべてのアクションに対して制限を加えます。今回のケースだと、ログインページやユーザー登録ページにも制限の範囲が及んでしまうはずです(結果としてテストも失敗するはずです)。リスト 10.15のonly:オプションをコメントアウトしてみて、テストスイートがそのエラーを検知できるかどうか(テストが失敗するかどうか)確かめてみましょう。
→エラーになる

10.2.2 正しいユーザーを要求する

次はユーザーが自分の情報だけを編集できるようにする。

まずはユーザーの情報が互いに編集できないことを確認するために、サンプルユーザーをもう一人追加

test/fixtures/users.yml
michael:
  name: Michael Example
  email: michael@example.com
  password_digest: <%= User.digest('password') %>

archer:
  name: Sterling Archer
  email: duchess@example.gov
  password_digest: <%= User.digest('password') %>

log_in_asメソッドを使って、editアクションとupdateアクションをテストする。
このとき、既にログイン済みのユーザーを対象としているため、ログインページではなくルートURLにリダイレクトしている

test/controllers/users_controller_test.rb
require 'test_helper'

class UsersControllerTest < ActionDispatch::IntegrationTest

  def setup
    @user       = users(:michael)
    @other_user = users(:archer) 
  end
  .
  .
  .
 test "should redirect edit when logged in as wrong user" do
    log_in_as(@other_user) #違うユーザーとしてログイン
    get edit_user_path(@user)#@user_pathを取得
    assert flash.empty?#flashが空か確認
    assert_redirected_to root_url#Homeビューへ帰るか確認
  end

  test "should redirect update when logged in as wrong user" do
    log_in_as(@other_user)#違うユーザーでログイン
    patch user_path(@user), params: { user: { name: @user.name, #@userのparamsをcreateへ
                                              email: @user.email } }
    assert flash.empty?#フラッシュが空か
    assert_redirected_to root_url
  end
end

別のユーザーのプロフィールを編集しようとしたらリダイレクトさせたいので、correct_userというメソッドを作成し、beforeフィルターからこのメソッドを呼び出すようにする
beforeフィルターのcorrect_userで@user変数を定義しているため、editとupdateの各アクションから、@userへの代入文を削除している。

app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:edit, :update]
  before_action :correct_user,   only: [:edit, :update]
  .
  .
  .
  def edit
  end

  def update
    if @user.update(user_params)
      flash[:success] = "Profile updated"
      redirect_to @user
    else
      render 'edit'
    end
  end
  .
  .
  .
  private

    def user_params
      params.require(:user).permit(:name, :email, :password,
                                   :password_confirmation)
    end

    # beforeアクション

    # ログイン済みユーザーかどうか確認
    def logged_in_user
      unless logged_in?
        flash[:danger] = "Please log in."
        redirect_to login_url
      end
    end

    # 正しいユーザーかどうか確認
    def correct_user
      @user = User.find(params[:id])
      redirect_to(root_url) unless @user == current_user#後にリファクタリング
    end
end

今度はテストスイートがgreen になる

最後に、一般的な慣習に倣ってcurrent_user?という論理値を返すメソッドを実装。
correct_userというbefore filterの中で使えるようにしたいので、Sessionsヘルパーの中にcorrect_userメソッドを追加します。このメソッドを使うと今までの

unless @user == current_user
といった部分が、次のようになる
unless current_user?(@user)

app/helpers/sessions_helper.rb
module SessionsHelper

  # 渡されたユーザーがカレントユーザーであればtrueを返す
  def current_user?(user)
    user && user == current_user
  end

リファクタリングしたので、users_controller.rbを書き換える

app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:edit, :update]
  before_action :correct_user,   only: [:edit, :update]
  .
  .
  .
    # 正しいユーザーかどうか確認
    def correct_user
      @user = User.find(params[:id])
      redirect_to(root_url) unless current_user?(@user)
    end
end

演習

何故editアクションとupdateアクションを両方とも保護する必要があるのでしょうか? 考えてみてください。
→知らん人が勝手に他のユーザーを変えたら困るから
上記のアクションのうち、どちらがブラウザで簡単にテストできるアクションでしょうか?
→edit DB関係なくテストできる

10.2.3 フレンドリーフォワーディング

例えばユーザーの編集ページに行こうとして、ログインせよと弾かれた場合、ログイン後はその編集ページに飛ばしてあげるのが親切
そういう機能を追加していく
フレンドリーフォワーディングのテストは非常にシンプルで、ログインした後に編集ページへアクセスする、という順序を逆にしてあげるだけ

test/integration/users_edit_test.rb
require 'test_helper'

class UsersEditTest < ActionDispatch::IntegrationTest

 test "successful edit with friendly forwarding" do
    get edit_user_path(@user)#@userの編集ページを取得
    log_in_as(@user)#ログインしてなかったからできないので@userでログイン
    assert_redirected_to edit_user_url(@user)#editページへリダイレクトしたか
    name  = "Foo Bar"
    email = "foo@bar.com"
    patch user_path(@user), params: { user: { name:  name,#編集
                                              email: email,
                                              password:              "",
                                              password_confirmation: "" } }
    assert_not flash.empty?#flashが空でないか
    assert_redirected_to @user#ユーザーのshowページへリダイレクトしたか
    @user.reload#userのDBを再取得
    assert_equal name,  @user.name#編集した名前とDBの名前が一致しているか
    assert_equal email, @user.email
  end
end

フレンドリーフォワーディングを実装し、ユーザーを希望のページに転送するには、リクエスト時点のページをどこかに保存しておき、その場所にリダイレクトさせる。
この動作をstore_locationとredirect_back_orの2つのメソッドを使って実現していく

app/helpers/sessions_helper.rb
module SessionsHelper
  .
  .
  .
# 記憶したURL(もしくはデフォルト値)にリダイレクト
  def redirect_back_or(default)
    redirect_to(session[:forwarding_url] || default)
    session.delete(:forwarding_url)
    #リクエストされたURLが存在する場合はそこにリダイレクトし、ない場合はデフォルトのURLにリダイレクト
  end

  # アクセスしようとしたURLを覚えておく
  def store_location
    session[:forwarding_url] = request.original_url if request.get?
    #リクエストが送られたURLをsession変数の:forwarding_urlキーに格納
    #ただし、GETリクエストが送られたときだけ
  end
end

store_locationメソッドでは、 リクエストが送られたURLをsession変数の:forwarding_urlキーに格納。ただし、GETリクエストが送られたときだけ。
これによって、例えばログインしていないユーザーがフォームを使って送信した場合、転送先のURLを保存させないようにできる
滅多に起きないが起こり得る話で、例えばユーザがセッション用のcookieを手動で削除してフォームから送信するケースなどで、POSTや PATCH、DELETEリクエストを期待しているURLに対して、(リダイレクトを通して)GETリクエストが送られてしまい、場合によってはエラーが発生する。このため、if request.get?という条件文を使ってこのケースに対応

定義したstore_locationメソッドをbeforeフィルター(logged_in_user)をに加える

app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:edit, :update]
  before_action :correct_user,   only: [:edit, :update]
  .

    # beforeアクション

    # ログイン済みユーザーかどうか確認
    def logged_in_user
      unless logged_in?
        store_location
        flash[:danger] = "Please log in."
        redirect_to login_url
      end
    end

end

フォワーディング自体を実装するには、redirect_back_orメソッドを使い、
リクエストされたURLが存在する場合はそこにリダイレクトし、ない場合は何らかのデフォルトのURLにリダイレクトさせる
デフォルトのURLは、Sessionコントローラのcreateアクションに追加し、サインイン成功後にリダイレクト

redirect_back_orメソッドでは、次のようにor演算子||を使う

session[:forwarding_url] || default

このコードは、値がnilでなければsession[:forwarding_url]を評価し、そうでなければデフォルトのURLを使う
またsession.delete(:forwarding_url)という行を通して転送用のURLを削除している点にも注意。
削除しないと次回ログインしたときに保護されたページに転送されてしまい、ブラウザを閉じるまでこれが繰り返されてしまう

app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  .
  .
  .
  def create
    user = User.find_by(email: params[:session][:email].downcase)
    if user && user.authenticate(params[:session][:password])
      log_in user
      params[:session][:remember_me] == '1' ? remember(user) : forget(user)
      redirect_back_or user
    else
      flash.now[:danger] = 'Invalid email/password combination'
      render 'new'
    end
  end
  .
  .
  .
end

これでテストもパスし、認可も終わり

演習

フレンドリーフォワーディングで、渡されたURLに初回のみ転送されていることを、テストを書いて確認してみましょう。次回以降のログインのときには、転送先のURLはデフォルト(プロフィール画面)に戻っている必要があります。ヒント: リスト 10.29のsession[:forwarding_url]が正しい値かどうか確認するテストを追加してみましょう。

users_edit_test.rb
test "successful edit with friendly forwarding" do
    get edit_user_path(@user)                                                   # @userのユーザー編集ページを取得
    assert_equal session[:forwarding_url], edit_user_url(@user)                 # 渡されたURLに転送されているか確認
    log_in_as(@user)                                                            # @userでログイン
    assert_nil session[:forwarding_url]                                         # forwarding_urlの値がnilならtrue(deleteが効いてる)
    name  = "Foo Bar"                                                           # フォーム欄に値を入力する
    email = "foo@bar.com"                                                       
    patch user_path(@user), params: { user: { name: name,                       # 引数としてわざと失敗する値を持ったuserIDをpatchリクエストで送信(更新)する
                                              email: email,
                                              password:              "",
                                              password_confirmation: "" } }
    assert_not flash.empty?                                                     # エラー文が空じゃなければtrue
    assert_redirected_to @user                                                  # michaelのユーザーidページへ移動できたらtrue
    @user.reload
    assert_equal name,  @user.name                                              # DB内の名前と@userの名前が一致していていたらtrue
    assert_equal email, @user.email                                             # DB内のEmailと@userの名前が一致
  end

7.1.3で紹介したdebuggerメソッドをSessionsコントローラのnewアクションに置いてみましょう。その後、ログアウトして /users/1/edit にアクセスしてみてください(デバッガーが途中で処理を止めるはずです)。ここでコンソールに移り、session[:forwarding_url]の値が正しいかどうか確認してみましょう。また、newアクションにアクセスしたときのrequest.get?の値も確認してみましょう(デバッガーを使っていると、ときどき予期せぬ箇所でターミナルが止まったり、おかしい挙動を見せたりします。熟練の開発者になった気になって(コラム 1.2)、落ち着いて対処してみましょう)。
→bootstrapがうまく動かなかったので割愛

10.3 すべてのユーザーを表示する

ユーザーの一覧ページ(indexページ)を作る
ページネーションも学ぶ

10.3.1 ユーザーの一覧ページ

まずはセキュリティモデルから考える
ユーザーのshowページは、サイトを訪れたすべてのユーザーから見えるようにしておくが、
ユーザーのindexページはログインしたユーザーにしか見せないようにし、未登録のユーザーがデフォルトで表示できるページを制限していく

indexページを不正なアクセスから守るために、まずはindexアクションが正しくリダイレクトするか検証するテストを書いていく(テスト駆動開発)

test/controllers/users_controller_test.rb
require 'test_helper'

class UsersControllerTest < ActionDispatch::IntegrationTest

  def setup
    @user       = users(:michael)
    @other_user = users(:archer)
  end

  test "should get new" do
    get signup_path
    assert_response :success
  end

  test "should redirect index when not logged in" do
    get users_path  #indexページへのパスを取得
    assert_redirected_to login_url#ログインページまで戻るか
  end
  .
  .
  .
end

次に、beforeフィルターのlogged_in_userにindexアクションを追加して、このアクションを保護

app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:index, :edit, :update]#indexを追加
  before_action :correct_user,   only: [:edit, :update]

  def index
  end

  def show
    @user = User.find(params[:id])
  end
  .
  .
  .
end

今度はすべてのユーザーを表示するために、全ユーザーが格納された変数を作成し、順々に表示するindexビューを実装
User.allを使ってデータベース上の全ユーザーを取得し、ビューで使えるインスタンス変数@usersに代入させる(データの読み込みの問題はあるが後で直す)

app/controllers/users_controller.rb
  def index
    @users = User.all#全てのuserを取得
  end

eachメソッドを使って,ユーザーごとにliタグで囲むビューを作成する
それぞれの行をリストタグulで囲いながら、各ユーザーのGravatarと名前を表示

app/views/users/index.html.erb
<% provide(:title, 'All users') %>
<h1>All users</h1>

<ul class="users">
  <% @users.each do |user| %>
    <li>
      <%= gravatar_for user, size: 50 %>
      <%= link_to user.name, user %>
    </li>
  <% end %>
</ul>
app/helpers/users_helper.rb
module UsersHelper

  # 渡されたユーザーのGravatar画像を返す
  def gravatar_for(user, options = { size: 80 })
    size         = options[:size]
    gravatar_id  = Digest::MD5::hexdigest(user.email.downcase)
    gravatar_url = "https://secure.gravatar.com/avatar/#{gravatar_id}?s=#{size}"
    image_tag(gravatar_url, alt: user.name, class: "gravatar")
  end
end

CSSも修正

app/assets/stylesheets/custom.scss
.
.
.
/* Users index */

.users {
  list-style: none;
  margin: 0;
  li {
    overflow: auto;
    padding: 10px 0;
    border-bottom: 1px solid $gray-lighter;
  }
}

ヘッダーにユーザー一覧表示用のリンクを追加(users_path)

app/views/layouts/_header.html.erb
<header class="navbar navbar-fixed-top navbar-inverse">
  <div class="container">
    <%= link_to "sample app", root_path, id: "logo" %>
    <nav>
      <ul class="nav navbar-nav navbar-right">
        <li><%= link_to "Home", root_path %></li>
        <li><%= link_to "Help", help_path %></li>
        <% if logged_in? %>
          <li><%= link_to "Users", users_path %></li>#追加
          <li class="dropdown">
            <a href="#" class="dropdown-toggle" data-toggle="dropdown">
              Account <b class="caret"></b>
            </a>
            <ul class="dropdown-menu">
              <li><%= link_to "Profile", current_user %></li>
              <li><%= link_to "Settings", edit_user_path(current_user) %></li>
              <li class="divider"></li>
              <li>
                <%= link_to "Log out", logout_path, method: :delete %>
              </li>
            </ul>
          </li>
        <% else %>
          <li><%= link_to "Log in", login_path %></li>
        <% end %>
      </ul>
    </nav>
  </div>
</header>

これでテストgreenに

演習

レイアウトにあるすべてのリンクに対して統合テストを書いてみましょう。ログイン済みユーザーとそうでないユーザーのそれぞれに対して、正しい振る舞いを考えてください。ヒント: log_in_asヘルパーを使ってリスト 5.32にテストを追加してみましょう。

site_layout_test.rb
test "layout links login" do
    log_in_as(@user)
    get root_path
    assert_select "a[href=?]", root_path, count: 2
    assert_select "a[href=?]", help_path
    assert_select "a[href=?]", about_path
    assert_select "a[href=?]", contact_path
    assert_select "a[href=?]", signup_path
    assert_select "a[href=?]", users_path
    assert_select "a[href=?]", user_path(@user)
    assert_select "a[href=?]", edit_user_path(@user)
    assert_select "a[href=?]", logout_path
  end

10.3.2 サンプルのユーザー

Rubyを使ってユーザーを一気に増やす

まず、GemfileにFaker gemを追加
これは、実際にいそうなユーザー名を作成するgem
faker gemは開発環境以外では普通使わないが、今回は例外的に本番環境でも適用させる予定

source 'https://rubygems.org'

gem 'rails',                   '6.0.3'
gem 'bcrypt',                  '3.1.13'
gem 'faker',                   '2.1.2'
gem 'will_paginate',           '3.1.8'
gem 'bootstrap-will_paginate', '1.0.0'
$ bundle install

サンプルユーザーを生成するRubyスクリプト(Railsタスクとも呼ぶ)を追加

db/seeds.rb
# メインのサンプルユーザーを1人作成する
User.create!(name:  "Example User",
             email: "example@railstutorial.org",
             password:              "foobar",
             password_confirmation: "foobar")

# 追加のユーザーをまとめて生成する
99.times do |n|
  name  = Faker::Name.name
  email = "example-#{n+1}@railstutorial.org"
  password = "password"
  User.create!(name:  name,
               email: email,
               password:              password,
               password_confirmation: password)
end

これでExample Userという名前とメールアドレスを持つ1人のユーザと、それらしい名前とメールアドレスを持つ99人のユーザーを作成できる

データベースをリセットして、Railsタスクを実行(db:seed)

$ rails db:migrate:reset
$ rails db:seed

システムによっては数分かかる
(rails serverは止めたほうがいい)

終わるとユーザーが100人になってる

演習

試しに他人の編集ページにアクセスしてみて、10.2.2で実装したようにリダイレクトされるかどうかを確かめてみましょう。
→確認

10.3.3 ページネーション

今100このアカウントが1つのページに表示されてしまっているので、ページネーション(pagination)を実装して1つのページに30人だけユーザーを表示する

Railsには豊富なページネーションメソッドがあるが、最もシンプルかつ堅牢なwill_paginateメソッドを使っていく
まずはGemfileにwill_paginate gembootstrap-will_paginate gemを両方含め、Bootstrapのページネーションスタイルを使ってwill_paginateを構成する

source 'https://rubygems.org'

gem 'rails',                   '6.0.3'
gem 'bcrypt',                  '3.1.13'
gem 'faker',                   '2.1.2'
gem 'will_paginate',           '3.1.8'
gem 'bootstrap-will_paginate', '1.0.0'
.
$ bundle install

実行したら、新しいgemが正しく読み込まれるように、Webサーバーを再起動

indexビューにページネーションのコードを追加する
またindexアクションにあるUser.allを、ページネーションを理解できるオブジェクトに置き換える必要もある

app/views/users/index.html.erb
<% provide(:title, 'All users') %>
<h1>All users</h1>

<%= will_paginate %>

<ul class="users">
  <% @users.each do |user| %>
    <li>
      <%= gravatar_for user, size: 50 %>
      <%= link_to user.name, user %>
    </li>
  <% end %>
</ul>

<%= will_paginate %>

will_paginateメソッドは,usersビューのコードの中から@usersオブジェクトを自動的に見つけ出し、それから他のページにアクセスするためのページネーションリンクを作成してくれる。ただし、このままでは動かず、
will_paginateではpaginateメソッドを使った結果が必要のため

必要なpagineteメソッドの動きはこんな感じ

$ rails console
>> User.paginate(page: 1)
  User Load (1.5ms)  SELECT "users".* FROM "users" LIMIT 11 OFFSET 0
   (1.7ms)  SELECT COUNT(*) FROM "users"
=> #<ActiveRecord::Relation [#<User id: 1,...
>> User.paginate(page: 1).length
  User Load (3.0ms)  SELECT "users".* FROM "users" LIMIT ? OFFSET ?  [["LIMIT", 30],
  ["OFFSET", 0]]
=> 30

paginateでは、キーが:pageで値がページ番号のハッシュを引数に取る
User.paginateは、:pageパラメーターに基いて、データベースからひとかたまりのデータ(デフォルトでは30)を取り出す
つまり1ページ目は1から30のユーザー、2ページ目は31から60のユーザーといった具合にデータが取り出される。
pageがnilの場合、 paginateは単に最初のページを返す

indexアクション内のallをpaginateメソッドに置き換えると使えるようになる

app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:index, :edit, :update]
  .
  def index
    @users = User.paginate(page: params[:page])
  end

環境によってはRailsを再起動する必要があるが、これでページネーションが動くようになった

演習

Railsコンソールを開き、pageオプションにnilをセットして実行すると、1ページ目のユーザーが取得できることを確認してみましょう。
先ほどの演習課題で取得したpaginationオブジェクトは、何クラスでしょうか? また、User.allのクラスとどこが違うでしょうか? 比較してみてください。
→確認ずみ

>> user = User.all
>> page = User.paginate(page: 1)
>> user.class
=> User::ActiveRecord_Relation
>> page.class
=> User::ActiveRecord_Relation

10.3.4 ユーザー一覧のテスト

ページネーションに対する簡単なテストも書いておく

1 ログイン
2indexページにアクセス
3最初のページにユーザーがいることを確認
4ページネーションのリンクがあることを確認
といった順でテスト
3,4のステップでは、テスト用のデータベースに31人以上のユーザーがいる必要がある

そのためfixtureファイルに30人のユーザーを追加する(rubyもサポートされている)

test/fixtures/users.yml
michael:
  name: Michael Example
  email: michael@example.com
  password_digest: <%= User.digest('password') %>

archer:
  name: Sterling Archer
  email: duchess@example.gov
  password_digest: <%= User.digest('password') %>

lana:
  name: Lana Kane
  email: hands@example.gov
  password_digest: <%= User.digest('password') %>

malory:
  name: Malory Archer
  email: boss@example.gov
  password_digest: <%= User.digest('password') %>

<% 30.times do |n| %>
user_<%= n %>:
  name:  <%= "User #{n}" %>
  email: <%= "user-#{n}@example.com" %>
  password_digest: <%= User.digest('password') %>
<% end %>

indexページ用の統合テストを生成

$ rails generate integration_test users_index

テストでは、paginationクラスを持ったdivタグをチェックして、最初のページにユーザーがいることを確認

test/integration/users_index_test.rb
require 'test_helper'

class UsersIndexTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end

  test "index including pagination" do
    log_in_as(@user)
    get users_path
    assert_template 'users/index'
    assert_select 'div.pagination'
    User.paginate(page: 1).each do |user|
      assert_select 'a[href=?]', user_path(user), text: user.name
    end
  end
end

テストはパスし、ページネーションのテストは完了

演習

試しにリスト 10.45にあるページネーションのリンク(will_paginateの部分)を2つともコメントアウトしてみて、リスト 10.48のテストが red に変わるかどうか確かめてみましょう。
→確認
先ほどは2つともコメントアウトしましたが、1つだけコメントアウトした場合、テストが green のままであることを確認してみましょう。will_paginateのリンクが2つとも存在していることをテストしたい場合は、どのようなテストを追加すれば良いでしょうか? ヒント: 表 5.2を参考にして、数をカウントするテストを追加してみましょう。
assert_select 'div.pagination', count: 2でpaginationクラスのdivが2つ存在することを確認する

10.3.5 パーシャルのリファクタリング

リファクタリングしていく
まずは、呼び出し側のrender userをセット

app/views/users/index.html.erb
<% provide(:title, 'All users') %>
<h1>All users</h1>

<%= will_paginate %>

<ul class="users">
  <% @users.each do |user| %>
    <%= render user %>
  <% end %>
</ul>

<%= will_paginate %>

renderをパーシャル(ファイル名の文字列)に対してではなく、Userクラスのuser変数に対して実行している
この場合、Railsは自動的に_user.html.erbという名前のパーシャルを探しにいくので、このパーシャルを作成する

app/views/users/_user.html.erb
<li>
  <%= gravatar_for user, size: 50 %>
  <%= link_to user.name, user %>
</li>

さらに改良
renderを@users変数に対して直接実行する

app/views/users/index.html.erb
<% provide(:title, 'All users') %>
<h1>All users</h1>

<%= will_paginate %>

<ul class="users">#@userにし、eachを削除した
  <%= render @users %>
</ul>

<%= will_paginate %>

Railsは@users をUserオブジェクトのリストであると推測し、ユーザーのコレクションを与えて呼び出すと、Railsは自動的にユーザーのコレクションを列挙し、それぞれのユーザーを_user.html.erbパーシャルで出力するようになる

演習

リスト 10.52にあるrenderの行をコメントアウトし、テストの結果が red に変わることを確認してみましょう。
→確認

10.4 ユーザーを削除する

ユーザーの一覧ページはOK
残るはdestroyを実装
ユーザーを削除するためのリンクを追加しdestroyアクションも実装
その前に、削除を実行できる権限を持つ管理(admin)ユーザーのクラスを作成する
承認(authorization)においては、このような特権のセットをroleと呼ぶ

10.4.1 管理ユーザー

論理値をとるadmin属性をUserモデルに追加し、管理ユーザーを識別する
Userモデルに追加することで、自動的にadmin?メソッド(論理値を返す)も使えるようになる

$ rails generate migration add_admin_to_users admin:boolean

default: falseをマイグレーションファイルへ渡す
渡さなくてもnilになり、問題ないが、明示的にfalseにしておくことでrailsと開発者にわかりやすくなる

db/migrate/[timestamp]_add_admin_to_users.rb
class AddAdminToUsers < ActiveRecord::Migration[6.0]
  def change
    add_column :users, :admin, :boolean, default: false
  end
end
$ rails db:migrate

Railsコンソールで動作を確認

$ rails console --sandbox
>> user = User.first
>> user.admin? #adminメソッドも使えるように
=> false
>> user.toggle!(:admin)#toggle!メソッドはfalseからtrueに反転させる
=> true
>> user.admin?
=> true

最初のユーザーだけをデフォルトで管理者にする

db/seeds.rb
# メインのサンプルユーザーを1人作成する
User.create!(name:  "Example User",
             email: "example@railstutorial.org",
             password:              "foobar",
             password_confirmation: "foobar",
             admin: true)#true

# 追加のユーザーをまとめて生成する
99.times do |n|
  name  = Faker::Name.name
  email = "example-#{n+1}@railstutorial.org"
  password = "password"
  User.create!(name:  name,
               email: email,
               password:              password,
               password_confirmation: password)
end

データベースをリセット

$ rails db:migrate:reset
$ rails db:seed

Strong Parametersの復習
最初のユーザーに初期化ハッシュにadmin: trueを設定することでユーザーを管理者にしている

ここでは、荒れ狂うWeb世界にオブジェクトを晒すことの危険性を改めて強調している。

もし、任意のWebリクエストの初期化ハッシュをオブジェクトに渡せるとなると、攻撃者は次のようなPATCHリクエストを送信してくるかもしれない。

patch /users/17?admin=1
このリクエストは、17番目のユーザーを管理者に変えてしまう。
ユーザーのこの行為は少なくとも重大なセキュリティ違反となる可能性があるし、それだけでは済まされない。

このような危険があるからこそ、編集してもよい安全な属性だけを更新することが重要になる。

これを、Strong Parametersを使って対策

次のように、paramsハッシュに対してrequireとpermitを呼び出す。

 def user_params
    params.require(:user).permit(:name, :email, :password,
                                 :password_confirmation)

上のコードでは、許可された属性リストにadminが含まれていないことに注目。

これにより、任意のユーザーが自分自身にアプリケーションの管理者権限を与えることを防止できる。
この問題は重大であるため、編集可能になってはならない属性に対するテストを作成してみる。

演習

Web経由でadmin属性を変更できないことを確認してみましょう。具体的には、リスト 10.56に示したように、PATCHを直接ユーザーのURL(/users/:id)に送信するテストを作成してみてください。テストが正しい振る舞いをしているかどうか確信を得るために、まずはadminをuser_paramsメソッド内の許可されたパラメータ一覧に追加するところから始めてみましょう。最初のテストの結果は red になるはずです。最後の行では、更新済みのユーザー情報をデータベースから読み込めることを確認します

test/controllers/users_controller_test.rb
require 'test_helper'

class UsersControllerTest < ActionDispatch::IntegrationTest

test "should not allow the admin attribute to be edited via the web" do
    log_in_as(@other_user)  #違うユーザーてログイン
    assert_not @other_user.admin?  #違うユーザーにadmin属性がないことを期待
    patch user_path(@other_user), params: {
                                    user: { password:              "password",
                                            password_confirmation: "password",
                                            admin: true } }
    assert_not @other_user.reload.admin?
  end

10.4.2 destroyアクション

destroyアクションへのリンクを追加
ユーザーindexページの各ユーザーに削除用のリンクを追加し、続いて管理ユーザーへのアクセスを制限していく
これによって、現在のユーザーが管理者のときに限り [delete] リンクが表示される

app/views/users/_user.html.erb
<li>
  <%= gravatar_for user, size: 50 %>
  <%= link_to user.name, user %>
  <% if current_user.admin? && !current_user?(user) %>
    | <%= link_to "delete", user, method: :delete,
                                  data: { confirm: "You sure?" } %>
  <% end %>
</li>

method: :deleteに注意
また、各リンクをif文で囲い、管理者にだけ削除リンクが表示されるようにしている

ブラウザはネイティブではDELETEリクエストを送信できないので、RailsではJavaScriptを使って偽造
つまり、JavaScriptがオフになっているとユーザー削除のリンクも無効になる

削除リンクが動作するためには、destroyアクションを追加する
destroyアクションは
1該当するユーザーを見つけてActive Recordのdestroyメソッドを使って削除
2ユーザーindexに移動

app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:index, :edit, :update, :destroy]#loginしないとできないように
  before_action :correct_user,   only: [:edit, :update]
  .
  .
  .
  def destroy
    User.find(params[:id]).destroy
    flash[:success] = "User deleted"
    redirect_to users_url
  end

まだ終わっておらず、このままではコマンドラインでDELETEリクエストを直接発行するという方法でサイトの全ユーザーを削除してしまうことができてしまう
なのでdestroyアクションへのアクセスに制限をかける

beforeフィルターを使う

app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:index, :edit, :update, :destroy]
  before_action :correct_user,   only: [:edit, :update]
  before_action :admin_user,     only: :destroy
  .
  .
  .
  private
    .
    .
    .
    # 管理者かどうか確認
    def admin_user
      redirect_to(root_url) unless current_user.admin?#adminがfalseならrootに返す
    end
end

演習

管理者ユーザーとしてログインし、試しにサンプルユーザを2〜3人削除してみましょう。ユーザーを削除すると、Railsサーバーのログにはどのような情報が表示されるでしょうか?

10.4.3 ユーザー削除のテスト

ユーザー用fixtureファイルを修正し、今いるサンプルユーザーの一人を管理者にしてテストを書いていく

test/fixtures/users.yml
michael:
  name: Michael Example
  email: michael@example.com
  password_digest: <%= User.digest('password') %>
  admin: true

archer:
  name: Sterling Archer
  email: duchess@example.gov
  password_digest: <%= User.digest('password') %>

lana:
  name: Lana Kane
  email: hands@example.gov
  password_digest: <%= User.digest('password') %>

malory:
  name: Malory Archer
  email: boss@example.gov
  password_digest: <%= User.digest('password') %>

<% 30.times do |n| %>
user_<%= n %>:
  name:  <%= "User #{n}" %>
  email: <%= "user-#{n}@example.com" %>
  password_digest: <%= User.digest('password') %>
<% end %>

Usersコントローラをテストするために、アクション単位でアクセス制御をテストする
ログアウトのテストと同様に、削除をテストするために、DELETEリクエストを発行してdestroyアクションを直接動作させる
このとき2つのケースをチェック
1つは、ログインしていないユーザーであれば、ログイン画面にリダイレクトされること
1つは、ログイン済みではあっても管理者でなければ、ホーム画面にリダイレクトされること

test/controllers/users_controller_test.rb
require 'test_helper'

class UsersControllerTest < ActionDispatch::IntegrationTest

  def setup
    @user       = users(:michael)
    @other_user = users(:archer)
  end
  .
test "should redirect destroy when not logged in" do
    assert_no_difference 'User.count' do #User数が変わらないことを確認
      delete user_path(@user)    #ログインしてない状態で@userを削除
    end
    assert_redirected_to login_url
  end

  test "should redirect destroy when logged in as a non-admin" do
    log_in_as(@other_user)
    assert_no_difference 'User.count' do #User数が変わらないことを確認
      delete user_path(@user)  #adminがない状態で削除
    end
    assert_redirected_to root_url
  end
end

assert_no_differenceメソッドでユーザー数が変化しないことを確認

管理者ではないユーザーの振る舞いについて検証するが、管理者ユーザーの振る舞いと一緒に確認できるといい。
そこで、管理者であればユーザー一覧画面に削除リンクが表示される仕様を利用して、今回のテストを追加していくことにする。

これにより、後ほど追加する管理者の振る舞いについても簡単にテストが書けそう。

今回のテストで唯一の手の込んだ箇所は、管理者が削除リンクをクリックしたときに、
ユーザーが削除されたことを確認する部分。

assert_difference 'User.count', -1 do
  delete user_path(@other_user)
end

ユーザーが削除されたことを確認
DELETEリクエストを適切なURLに向けて発行し、User.countを使ってユーザー数が 1減ったかどうかを確認

まとめるとこうなる

test/integration/users_index_test.rb
require 'test_helper'

class UsersIndexTest < ActionDispatch::IntegrationTest

  def setup
    @admin     = users(:michael)
    @non_admin = users(:archer)
  end

test "index as admin including pagination and delete links" do
    log_in_as(@admin)   #adminがある状態でログイン
    get users_path       #indexを取得
    assert_template 'users/index'  #indexページが表示されるか
    assert_select 'div.pagination'  #pagenationがあるか
    first_page_of_users = User.paginate(page: 1)#1ページ目のユーザを代入
    first_page_of_users.each do |user|  #それぞれにリンクなどがあるか確認
      assert_select 'a[href=?]', user_path(user), text: user.name
      unless user == @admin  #admin以外のuserにdeleteがあるか確認
        assert_select 'a[href=?]', user_path(user), text: 'delete'
      end
    end
    assert_difference 'User.count', -1 do  #admin以外のユーザを削除したらUserの数が1減るか
      delete user_path(@non_admin)
    end
  end

  test "index as non-admin" do
    log_in_as(@non_admin) #non_adminとしてログイン
    get users_path  #indexページ取得
    assert_select 'a', text: 'delete', count: 0  #deleteが1つもないか確認
  end
end

各ユーザーの削除リンクをテストするときに、ユーザーが管理者であればスキップしている点にも注目

これで、削除に関するコードに対して、よくテストできている状態になった

演習

試しにリスト 10.59にある管理者ユーザーのbeforeフィルターをコメントアウトしてみて、テストの結果が red に変わることを確認してみましょう。
→確認

10.5 最後に

次の章に進む前に、すべての変更をmasterブランチにマージ

$ git add -A
$ git commit -m "Finish user edit, update, index, and destroy actions"
$ git checkout master
$ git merge updating-users
$ git push
アプリケーションを本番展開したり、サンプルデータを本番データとして作成することもできます(本番データベースをリセットするにはpg:resetタスクを使います)。

$ rails test
$ git push heroku
$ heroku pg:reset DATABASE
$ heroku run rails db:migrate
$ heroku run rails db:seed

※注意 ローカル環境でやっていると
git push後のtestで
paninateメソッドが定義されてないとかいうエラーが発生しました

ググっても
https://qiita.com/LotK/items/f49a1df5c9d9a510baa2
こういったエラーではなく、おかしかったのですが、
しばらく放置したら直りました。良くわかりません。
今までテストがパスしてたのに、いきなりエラーになった場合はしばらく放置するのも手かと

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

ダミーデータを条件付きで表示/非表示にする方法

はじめに

本記事は製品情報などのダミーデータを、DBにデータが保存されているかいないかで表示/非表示にする方法を解説するものです。
考えてみれば難しいことではないのですが、とても悩んで時間を使ってしまったので、今後のために残しておくことと、同じようなエラーに遭遇した方の助けになれば良いと思い、記事にしました。

環境

  • 言語:Ruby
  • フレームワーク:Rails
  • DB:MySQL

やりたかったこと

DBに保存済みのレコードをトップページに表示し、DBに何も保存されていなければダミーデータの製品を表示させる。

該当箇所のコード(コントローラー)

class ItemsController < ApplicationController
  before_action :authenticate_user!, only: [:new, :create]

  def index
    @items = Item.all.order('created_at DESC')
  end

該当箇所のコード(ビューファイル)

  <% if @items == nil %>
    #製品を表示させるための処理(今回は割愛)
  <% end %>

結果・・・・・
エラーは発生しないものの、ダミーデータが表示されない。。。
製品情報をDBに保存してみたところ、保存した製品自体は表示されるので、DBのデータを取得してトップページに反映させる処理自体は問題ない様子。

原因

if文の条件式にnilを指定しましたが、if文がDB内を「空」と判断してくれなかった様子

解決策①

if文の条件式部分を下記のコードに変更 (配列の要素を指定する)

<% if @items[0] == nil %>

nilに対し配列の1つ目、すなわち[0]を指定することにより、if文が「配列の1つ目が空」=「DBにデータは保存されていない」という判断をしてくれます。
これでDBにデータが保存されている時は保存されたデータを表示し、DBにデータが保存されていなければダミーデータを表示することができました。

解決策②

if文の条件式部分を下記のコードに変更 (empty?メソッドを用いる)

<% if @items.empty %>

emptyメソッドは要素が0の時にtrueを返すメソッドです。
これを条件式に指定することにより、DBに解決策①と同様の挙動をしてくれました。

さいごに

nilは条件指定で頻繁に使いますが、今回はそこに頭が集中するあまり他の方法論を検討していなかったのが時間を使い過ぎた原因と思っています。
一つの書き方が完答、というわけではありませんし、柔軟に考えるためにも様々な書き方を試してみるのも大事だなと感じました。

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

Rails環境にESlintとSaddlerでassets/javascriptsを自動でチェックさせる仕組みを作った話

こんにちは

Webアプリケーションでは実装初期にフロントエンド周りの設計やコーディングルールの取り決めをしなかったことによりレガシーとなってしまったJavaScript達に目を向けなければならない時があると思います。
今回はそんなレガシーなJavaScript達と向き合うために作った仕組みについて書いていきます。

肥大化してしまったレガシーJavaScript達を1ファイルずつ確認して直していくのは流石に大変なので、まずはレガシー化が進行しないようにする仕組みを作る必要がありました。

やったこと

  1. git diff から追加 / 変更したファイル名と行数を取得する
  2. 1で取得したファイルに対してEslintを実行する
  3. 2の結果から1で取得した行数の指摘だけ残すようにXMLを書き換える
  4. 3のXMLを使ってPull Requestに指摘内容をコメントする

確認環境

  • Rails:5.2.4
  • node:14.2.0
  • ESlint:6.8.0
  • Saddler:1.0.0
  • CircleCI

GitHubとCircleCIを連動させているのでルートの.circleci/にスクリプトを実装していきます。

.circleci/bin/run-eslint.js

#!/usr/bin/env node

import * as commander from 'commander';
import * as child_process from 'child_process';
import * as gitDiff from '../src/javascripts/gitDiff.js'
import * as lint from '../src/javascripts/lint.js'

const command = commander.default;
const exec = child_process.exec;

command
  .version('0.1.0')
  .option('-b, --base <branch name>', 'Base branch', 'origin/master')
  .option('-t, --target <branch name>', 'Target branch')
  .parse(process.argv);

const args = {
  baseBranch: command.base,
  targetBranch: command.target
}

/**
 * スクリプトのメイン処理
 *
 * @return void
 */
const main = () => {
  exec(gitDiff.getCommand(args), (err, stdout, stderr) => {
    if (err) {
      console.log(err);
      process.exit();
    }
    lint.run(args, gitDiff.getAffectedLines(stdout));
  });
}

main();

mainのスクリプトはこんな感じです。

このmainスクリプトがルートとなって各処理を実行していきます。

1. git diff から追加 / 変更したファイル名と行数を取得する

.circleci/src/javascripts/gitDiff.js

/**
 * git diffコマンド文字列を返す
 *
 * @args       {{baseBranch: string, targetBranch: string}}  run-eslint.jsに渡された引数群
 * @isNameOnly {boolean} --name-onlyオプションを使うかどうかのフラグ
 * @return {string} git diff コマンド文字列
 */
export const getCommand = (args, isNameOnly = false) => {
  const optionNameOnly = isNameOnly ? '--name-only ': '';
  const toBranch = args.targetBranch ? `...${args.targetBranch}`: '';

  return `git --no-pager diff --diff-filter=AM ${optionNameOnly}${args.baseBranch}${toBranch}`;
}

/**
 * git diffの結果から追加 / 変更のあった行番号を配列にして返す
 *
 * @stdout {boolean} git diff の出力結果
 * @return {Array} 差分ファイル毎の変更行配列
 */
export const getAffectedLines = (stdout) => {
  let path;
  let line;
  let affectedLines = {};
  stdout.split("\n").forEach(el => {
    if (el.startsWith('---')) {
      return;
    } else if (el.startsWith('+++')) {
      path = el.replace('+++ b/', '');
    } else if (el.startsWith('@@')) {
      var start = el.indexOf("-");
      var end = el.indexOf(",");
      line = el.slice(start + 1, end);
    } else {
      switch (el[0]) {
        case '+':
          if (affectedLines[path]) {
            affectedLines[path].push(line);
          } else {
            affectedLines[path] = [line];
          }
        case ' ':
          line++;
          break;
        case '-':
        default:
          break;
      }
    }
  })
  return affectedLines;
}

getAffectedLinesgit diff ***の結果から追加 / 変更したファイル名と行数を取得します。

2. 1で取得したファイルに対してEslintを実行する

.circleci/src/javascripts/lint.js

import * as child_process from 'child_process';
import * as xmldom from 'xmldom';
import * as xmlSerializer from 'xmlserializer';
import * as gitDiff from './gitDiff.js'

const exec = child_process.exec;
const DOMParser = xmldom.default.DOMParser;
const XMLSerializer = xmlSerializer.default;

const cwd = process.cwd();

/**
 * lintの結果から変更行配列に存在しない行を取り除いた結果を出力する
 *
 * @args          {{baseBranch: string, targetBranch: string}}  run-eslint.jsに渡された引数群
 * @affectedLines {Array} gitDiff.getAffectedLinesから取得した変更行配列
 * @return void
 */
export const run = (args, affectedLines) => {
  exec(getLintCommand(args), (err, stdout, stderr) => {
    let doc = new DOMParser().parseFromString(stdout);
    if (!stdout) return;
    const fileNodes = doc.documentElement.getElementsByTagName('file');
    Array.prototype.forEach.call(fileNodes, fileNode => {
      filterToLintResult(fileNode, affectedLines);
      if (!fileNode.childNodes.length) {
        doc.documentElement.removeChild(fileNode);
      }
    });
    console.log(XMLSerializer.serializeToString(doc.documentElement));
  });
}

/**
 * lintの結果から変更行配列に存在しない行を取り除く
 *
 * @fileNode      {Object} ESlintの結果から生成したXMLDOMDocument
 * @affectedLines {Array} gitDiff.getAffectedLinesから取得した変更行配列
 * @return void
 */
const filterToLintResult = (fileNode, affectedLines) => {
  let childCount = fileNode.childNodes.length;
  const fileName = fileNode.getAttribute('name').replace(cwd + '/', '');
  for (let i= 1; i <= childCount; ++i) {
    var childNode = fileNode.childNodes[i - 1];
    var lineNumber = Number(childNode.getAttribute('line'));
    if (!affectedLines[fileName].includes(lineNumber)) {
      fileNode.removeChild(childNode);
      i = 0;
      childCount = fileNode.childNodes.length;
    }
  }
}

/**
 * git diff コマンドにlintコマンドを付与したコマンド文字列を取得する
 *
 * @args {{baseBranch: string, targetBranch: string}}  run-eslint.jsに渡された引数群
 * @return {string}
 */
const getLintCommand = (args) => {
  return gitDiff.getCommand(args, true) + '| grep .js | grep -v .json | xargs ./node_modules/eslint/bin/eslint.js -f checkstyle';
}

filterToLintResultで変更行以外の結果をXMLから削除します。

eslintに適用するルールは通常通りルートに.eslintrc.jsonを置いてやれば読み込んでくれます。

.eslintrc.json

{
    "env": {
        "browser": true,
        "es6": false,
        "jquery": true
    },
    "extends": "eslint:recommended",
    "globals": {
        "Atomics": "readonly",
        "SharedArrayBuffer": "readonly"
    },
    "parserOptions": {
        "ecmaVersion": 2018
    },
    "plugins": [
        "jquery",
        "es5"
    ],
    "rules": {
        "indent": "error",
        "linebreak-style": [
            "error",
            "unix"
        ],
        "es5/no-arrow-functions": "error"
    }
}

今回導入するアプリケーションではjQueryのコードも多く残ってるのでjQueryプラグインを使ってます。

これをしないと$("#selector")みたいな記述がうまく読み込めないのです。

あとはIEでES6の書き方の一部が動かない物があるのでES5準拠でチェックするようにしています。

3のXMLを使ってPull Requestに指摘内容をコメントする

.circleci/bin/run-eslint.sh

#!/bin/bash
set -v
./.circleci/bin/run-eslint.js | saddler report --require saddler/reporter/github --reporter Saddler::Reporter::Github::PullRequestReviewComment
exit 0

あとはmainのスクリプトの実行結果をSaddlerに渡してあげればEslintの指摘をPull Requestにコメントしてくれます。

上記まででcircleCIで実行されるチェックスクリプトが用意できたので試し元々設置してあったsampleファイルに追記してcommitしてみます。
app/javascripts/test.js

const testFnc = () => {
  console.log('test');
};

// ここから追記
const testFnc2 = () => {
  console.log('test2');
};

するとcircleCIでビルドされてこのように追加した行のみGitHubコメントで指摘してくれます。

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

Railsのfind_eachやfind_in_batchesでorderにid以外を指定したい場合の解決方法

はじめに

大量のデータにアクセスして処理を行う場合、

  • メモリ不足で処理が中断されないよう、少しずつメモリに展開したい
  • 途中で処理が中断されても問題ないよう、一定件数ごとにコミットをしたい

と考えることがあると思います。

そんなときにRailsで役に立つのがfind_eachやfind_in_batchesですね。

ただしこの2つのメソッドには弱点があり、id(主キー/primary key)の昇順(ASC)でしかデータを扱うことができません

※Rails v6.1.0時点での情報です。

    # NOTE: Order can be ascending (:asc) or descending (:desc). It is automatically set to
    # ascending on the primary key ("id ASC").
    # This also means that this method only works when the primary key is
    # orderable (e.g. an integer or string).

https://github.com/rails/rails/blob/914caca2d31bd753f47f9168f2a375921d9e91cc/activerecord/lib/active_record/relation/batches.rb#L128:title

そこで、今回は自分が指定したorderで大量データを扱いたい場合の解決方法を紹介いたします。

解決方法

前提

Rankingモデルのrank(順位)順で処理をしたい、とします。

実装

@rank_offset = 0
@batch_size = 1000

def find_rankings_in_batches
  loop do
    rankings = Ranking.where('rank > ?', @rank_offset).order(rank: :asc).limit(@batch_size)
    break if rankings.blank?
    rankings.each do |ranking|
      yield(ranking)
    end
    @rank_offset = rankings.last.rank
  end
end

find_rankings_in_batches do |ranking|
  # ここに処理を書く
end

発行されたSQL

1ループ目

SELECT  `rankings`.* FROM `rankings` WHERE (rank > 0)  ORDER BY `rankings`.`rank` ASC LIMIT 1000

2ループ目

SELECT  `rankings`.* FROM `rankings` WHERE (rank > 1000)  ORDER BY `rankings`.`rank` ASC LIMIT 1000

rankの昇順かつbatch_size単位で取得できていることがわかります。

注意点

基本的には、orderに指定するカラムはUNIQUE制約が設定されているものにしてください。
batchの切れ目で同じ値が続く場合、処理されないレコードが存在してしまうためです。

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

RailsアプリをAWSで自動デプロイ【ステップ2 DBの構築】

はじめに

最近Rilsのアプリケーションをデプロイする機会がありましたので、その方法を忘れない内に書き込みます。
何記事に分けて説明してます。
この記事では、RDSの構築まで解説します。

現在のAWSの構成

スクリーンショット 2021-02-18 10.59.24.png

RDSの構築

RDSとは、AWSのフルマネージドなリレーショナルデータベースのサービスです。

スクリーンショット 2021-02-25 11.45.05.png

プライベートサブネットの作成

VPC:ステップ1で作成したVPCを指定します。
サブネット名:sample-privatesubnet-c
アベイラビリティーゾーン:アジアパシフィック (東京) / ap-northeast-1c
IPv4 CIDR ブロック:10.0.21.0/24

セキュリティーグループ作成

セキュリティグループ名:sample-infra-db
説明:sample-infra-db
VPC:ステップ1で作成したVPCを指定します。

インバウンドルール

タイプ プロトコル ポート範囲 ソース 説明
MYSQL/Aurora TCP 3306 ステップ1で作成したセキュリティーグループを指定

DBサブネットグループの作成

以下のリンクに移動します。
https://console.aws.amazon.com/rds/

スクリーンショット 2021-02-25 11.20.22.png

スクリーンショット 2021-02-25 11.21.30.png

名前:sample-infra-subnet-group
説明:sample-infra-subnet-group
VPC:ステップ1で作成したVPCを指定します。

ステップ1、2で作成したプライベートサブネットの追加を行います。

スクリーンショット 2021-02-18 11.38.40.png

DBパラメーターグループの作成

DBパラメーターグループとは、DBのパラメーターの設定をテンプレートとして登録できるサービス

スクリーンショット 2021-02-25 11.50.20.png

スクリーンショット 2021-02-25 11.21.44.png

パラメータグループファミリー:mysql8.0
グループ名:aws-infra-mysql80
説明:aws-infra-mysql80

DBオプショングループの作成

オプショングループとは、DBの機能的部分を設定できるサービス

スクリーンショット 2021-02-25 11.50.20のコピー.png

スクリーンショット 2021-02-25 11.21.54.png

名前:aws-infra-mysql80
説明:aws-infra-mysql80
エンジン:mysql
メジャーエンジンバージョン:8.0

RDSの作成

スクリーンショット 2021-02-25 11.50.20のコピー2.png

スクリーンショット 2021-02-25 11.22.15.png

エンジンのオプション

エンジンのタイプ:MySQL 
バージョン:MySQL8.0.21

テンプレート

開発テスト #今回はテスト用で作成するので、開発テスト用を選択しました。

設定

DB インスタンス識別子:sample-infra-web
認証情報の設定:
マスターユーザー名:root
パスワードの自動生成:✅

DB インスタンスサイズ

DB インスタンスクラス:バースト可能クラス
db.t3.micro

ストレージ

ストレージタイプ:汎用SSD
ストレージ割り当て:20
ストレージの自動スケーリング:無効

可用性と耐久性

マルチ AZ 配置:スタンバイインスタンスを作成しない

接続

Virtual Private Cloud (VPC):VPCを指定
サブネットグループ:ステップ2で作成したものを指定
パブリックアクセス可能:なし
VPC セキュリティグループ:既存の選択
既存の VPC セキュリティグループ:ステップ2で作成したセキュリテーグループを指定
アベイラビリティーゾーン:ap-northeast-1a
追加の接続設定:
データベースポート:3306

追加設定

最初のデータベース名:
DB パラメータグループ:aws-infra-mysql80
オプショングループ情報:aws-infra-mysql80
自動バックアップの有効化:✅
バックアップ保持期間:30日

バックアップウィンドウ:選択ウィンドウ
開始時間:21:00 #日本時間の午前6時に開始
期間:0.5

スナップショットにタグをコピー:✅
暗号を有効化:✅
マイナーバージョン自動アップグレード:✅

メンテナンスウィンドウ:選択ウィンドウ
開始日:日曜日
開始時間:20:00 
期間:0.5

削除保護:✅

ここまで設定が完了しましたら、作成を行います!

最後に

本記事では、RDSの構築までを解説しました。
次回は、アプリをデプロイするところまで解説しようと思います。

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

【Rails】seed_fuまとめ

何がうれしいか

  • seedデータの一部を変更した時、変更したファイルだけを読み込み、データの更新や追加ができる
    • デフォルトのseeds.rbの場合、既存のデータを削除してから再度読み込まなきゃいけない
  • 環境ごとにseedデータを分けやすくなる

インストール

Gemfile
gem 'seed-fu'
$ bundle install
$ mkdir db/fixtures # 必須
# 以下、環境ごとにseedファイルを分けたい場合に作成
$ mkdir db/fixtures/development
$ mkdir db/fixtures/production

データを読み込む

$ touch db/fixtures/development/01_user.rb #ファイル名は自由。アルファベット順に読み込まれる。

以下の2通りの書き方がある。データが多い時は2つ目の書き方の方が良さそう。

基本の書き方
User.seed do |s|
  s.id = 1
  s.name = '茂野吾郎'
  s.email = 'shigeno@example.com'
  s.password  = 'password'
end

User.seed do |s|
  s.id = 2
  s.name = '佐藤寿也'
  s.email = 'sato@example.com'
  s.password  = 'password'
end
この書き方でも良い
User.seed(
  {
    name: '茂野吾郎',
    email: 'shigeno@example.com',
    password: 'password'
  },
 {
    name: '佐藤寿也',
    email: 'sato@example.com',
    password: 'password'
  },
)
$ rails db:seed_fu

読み込むseedファイルを明示的に指定する

$ rails db:seed_fu

開発環境で上記のコマンドを叩くと、db/fixtures/development以下のファイルだけが読み込まれる。
他のディレクトリのファイルを読みたい場合などには以下のようにディレクトリを指定することができる。

$ rails db:seed_fu FIXTURE_PATH=db/fixtures/hogehoge

【注意】データの同一性はidで判断される

既存のデータが更新されたのか、新規データが追加されたのかは、idを元に判断されている。seedファイル内のデータにidを書かないと、既存のデータを更新したつもりでも新規データが追加されてしまう。

id以外の(ユニークな)データを基準にデータの同一性を判断させたい場合は、以下のように書く。

emailがユニークな場合
User.seed(:email) do |s|
  s.name = 'foo'
  s.email = 'foo@example.com'
  s.password  = 'password'
end

# or

User.seed(:email,
  {
    name: 'foo',
    email: 'foo@example.com',
    password: 'password'
  }
)

既存のDBからseedファイルを作成するrakeタスク

SeedFu::Writer.writeを使ってseedファイルを作ることができる

$ rails g task seed-fu-gen-user
lib.tasks/seed_fu_gen_user.rake
namespace :seed_fu_gen_user do
  desc 'usersテーブルのデータを元にseedファイルを生成'
  task create_seed_by_db: :environment do
    SeedFu::Writer.write('./db/fixtures/user_gen.rb', class_name: 'User') do |w|
      User.all.each do |x|
        w << x.as_json(except: %i[created_at updated_at])
      end
    end
  end
end
$ ./bin/rake seed_fu_gen_user:create_seed_by_db

※公式のREADMEを読むと、SeedFu::Writerにはもろもろ変更が入ったと注意書きがありますが、少なくともver2.3.9では上記の方法で動作確認ができました。

参考

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

AWSにデプロイ後、よく使うコマンド

EC2にSSHで接続し、git pullしてからよく使うコマンドまとめました。

  • 使用頻度が多いものを自分用に簡単にまとめました。

環境

rails 5.2.4
ruby 2.6.3
Cloud9
MySQL

AWSの構成

スクリーンショット 2021-02-25 8.15.49.png

EC2にSSHで接続する方法
$ ssh -i ~/.ssh/キー名.pem ec2-user@xx.xx.xx.xx

Nginxの再起動する方法
$ sudo systemctl restart nginx

アプリ(puma)起動する方法
$ rails s -e production

アプリ(puma)停止する方法
$ kill プロセスID
どちらも同じです
$ kill $(cat tmp/pids/puma.pid)
本番環境でbundle installする時のコマンド
$ bundle install --path vendor/bundle --without test development

CSS・JS変更時に使うコマンド
$ bundle exec rails assets:precompile RAILS_ENV=production

本番環境でrails db:migrateする時のコマンド
$ bundle exec rails db:migrate RAILS_ENV=production

seeds.rb編集した場合は、本番環境のデータベースを削除してからやり直す方がいいです。
本番環境のデータはなくなるので使う際はご自身で判断してください。

$ RAILS_ENV=production DISABLE_DATABASE_ENVIRONMENT_CHECK=1 bundle exec rails db:drop
$ mysql -u root -p -h エンドポイント
mysql> CREATE DATABASE 作成したいデータベース名;
$ bundle exec rails db:migrate RAILS_ENV=production
$ bundle exec rails db:seed RAILS_ENV=production
nginxエラーログ確認するコマンド
$ sudo tail -f /var/log/nginx/error.log

railsのエラーログ確認するコマンド
$ sudo tail -f log/production.log

全体の把握に時間がかかりたくさんのエラーを経験し心が折れそうになりました。
何度もEC2作り直ししましたのでAWSの知識もより深まりました。
今回は、S3を使っていないため今度はS3を使って構成することを考えてます?

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

Dockerのコンテナ立ち上げで上手くいかなかった時にやったこと

はじめに

初学者で、今回が初めての投稿になります。
Docker超入門 #5
(動画内容としましては イメージ作成→コンテナ作成→コンテナ起動 の順になります)
こちらの動画の内容に沿って学習した際に起こったエラー、そしてどう対処したかの記事になります

エラー内容

イメージ作成するとこまでは上手く行ったのですがコンテナ作成時にエラー表示が出ました。
結論、今回のエラーはイメージ内の誤字にありました。

やって見たこと

まずは作成はできていたものの、「イメージ作成」が上手く行っていないのではと思いファイル内の誤字脱字チェックに入りました。やはり2箇所誤字発見できました。そして今回はイメージファイルにミスがあったものの、イメージが作成出来てしまったのだとわかりました

対処の流れとしては
「①間違って起動したコンテナの停止→そのコンテナの削除→誤字含むイメージの削除
続いて
「②修正後のイメージ作成→コンテナ立ち上げ→コンテナ起動」で上手く行きました

コンテナの稼働状況チェック→停止→削除
$ docker ps

$ docker container stop  対象のコンテナ名

$ docker container rm  対象のコンテナ名  
イメージのチェック→削除
$ docker images

$ docker image rm 対象のイメージ名

最後に

もし記事内容に誤り等ございましたら是非ともご指摘ください。
同じような初学者の方の参考になれば幸いです

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

railsを使ってアプリを作る 登録編

データベースにデータを登録

ルーティングの作成

ルーティングに登録する時はpostで登録する
post "animals/create" => "animals#create"

postで登録する時は、フォームで入力した値をコントローラーで受け取る時に使うらしい

viewの作成

送りたい内容を
<%= form_tag "/animals/create" do %>

<% end %>
で囲む

コントローラーの作成

viewからのデータを取得する
view = Animal.new(name: params[:name])
view.save

登録が完了したら、別ページに遷移させる(リダイレクト)
redirect_to "/animals/index"

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

railsを使ってアプリを作る3

詳細ページ等のid付きのページの作成と表示

mvcの作成

urlが/details/1みたいにする場合は、ルーティングの書き方を
get "details/:id" => "details#show
にする

detailsコントローラーにshowメソッドを作成する

show.html.erbのファイルを作成する

idの取得と利用方法

コントローラーでparams[:id] で取得する
取得した値を
@id = params[:id]とし、viewで<%= @id %>と書いて使う

画面に置くリンクはこんな感じ
<% @details.each do |detail| %>
  <%=link_to (animal.content,"/animal/#{detail.id}")%>
<% end %>

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

ポートフォリオ制作録(Day1:環境構築~デプロイ)

はじめに

  • 今日は,railsの開発環境をvscodeで構築し,localhostとheroku上でhello worldが表示されるまで進めました

発生したエラーと解決策

1.herokuへデプロイ出来ない①

デプロイ時にweb pack not foundとエラーがでました。warningの文でnode.jsが古いよとあったので、node.jsをインストール(同時にnpmも)したら解決。

2.herokuへデプロイできない②

チュートリアルのままbundle installをしていたが、その際にインストールされるbundlerのバージョンがherokuに対応していないらしく,デプロイができなかった。bundleのバージョンをherokuに対応しているものにして再インストールして解決

3.application errorが出るよ問題

herokuにデプロイできたがapplicationエラーが発生。エラー文を読むと,postgreのバージョンが変だよとのことなので、指定し直したらできました

参考

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