20191202のRailsに関する記事は30件です。

Capybaraで壊れにくいテストを書くために気を付けていること

この記事はSmartHR Advent Calendar 2019 2日目の記事です。

SmartHRではRuby on Railsを広く採用しています。アプリケーションを長期的にメンテナンスしていくためにテストは欠かせません。特にReact.jsなどを用いた複雑なUIにおいては、単なるAPIのテストやモデルのテストだけではなく「実際にブラウザを操作して、ユーザーが期待する結果を得られるかどうか」をテストすることが重要です。

Rubyではこのようなブラウザを操作するテストを書くために、Capybaraという便利なフレームワークがあり、比較的簡単にテストを書き始めることができます。ただ、この手のテストは保守が大変であったり、手間の大きさからテストが追加されなくなったり、ということがよくあります。本記事では、私がこれまでの経験から学んだ、壊れにくいテストを書くためのTipsを紹介します。

なお、特に説明のない限り、ここではCapybara + RSpec + Selenium + Chrome (Headless)の環境を想定しています。

sleepしない

出オチっぽいですが、非常に重要です。非同期なリクエストの結果を待っているときや、時間差でレンダリングされる画面など「いい感じに少し待って」と言いたくなる状況は確かにあります。

そういった場合に、単にsleep 5などとしてしまうと

  • テストを実行する環境によって適切な待ち時間が異なるため、落ちたり落ちなかったりするテストが生まれやすい
  • テストが落ちたときに、適当に待ち時間を伸ばされることが多く、テストの実行時間が伸びがち

などの問題があります。このような場合では、何を待っているかを短いスパンで定期的にタイムアウトまで待つようなヘルパーメソッドを定義して、それを利用するようにしましょう。

it "何かのアクションの結果、Successメッセージが帰ってくる" do
  click_button "何かのアクション"
  finally do
    expect(page).to have_content "Success!"
  end
end

def finally(timeout: Capybara.default_max_wait_time)
  start = Time.now

  begin
    yield
    return
  rescue RSpec::Expectations::ExpectationNotMetError, Capybara::ElementNotFound
    raise if Time.now > start + timeout
    sleep 0.1
    retry
  end
end

XPathやclassに依存しない

Capybaraではhave_buttonfill_inなど基本的なHTMLの要素に対するヘルパーが定義されているため、素直な画面に対しては比較的読みやすいテストを書くことができます。

しかし、現実には画面が複雑な構造になっていることが多く、これらのヘルパーだけでは力不足であることはよくあります。そういったときに、XPathやclassに依存したテストを書いてしまうこともきっとよくあるでしょう。

find('form active-form button').click
expect(page).to have_xpath '//*[@id="form"]/div[2]'

しかし、これでは後からテストだけ見たときに、何をテストしているのかわからなくなってしまいます。例えば、あなたが画面を大幅に弄った後に、「よーし、テスト直すかー」とこのテストを見たら... きっとこのテストごと消えてしまうことになるでしょう。

このような悲劇を産まないためにも、テストは後から読めるように、XPathやclassに依存しないことをおすすめしています。とはいえ、素直にヘルパーが利用できない画面というのは当然ありますから、話はそんな簡単ではありません。こういった場合にはデータ属性を利用し、さらにそれを指定するヘルパーメソッドを生やすと良い感じになります。

click_form_button
expect_to_have_error_message
def click_form_button
  find(spec_selector('active-form-button')).click
end

def expect_to_have_error_message
  expect(page).to have spec_selector('active-form-error-message')
end

def spec_selector(name)
  "[data-spec='#{name}']"
end

データ属性は他の用途に利用されることがなく、自由に目印をつけられるので、テストを見たときに何を指しているかわかりやすく、HTML側の編集時にも目を引く良い方法です。適切な単位でデータ属性を割り当てていれば「なぜかわからないけどdivをひとつズラしたらテストが通らなくなった」といった問題も起きにくくなるでしょう。

withinを活用する

この記事をテストすると仮定して、「本文の書き出しに"Capybara"というリンクが含まれていること」をテストするとします。

expect(page).to have_link "Capybara", href: "https://github.com/teamcapybara/capybara"

これでもテストは通りますが、これでは「本文の書き出しの中に」という重要な条件が抜けてしまっています。例えば、末尾の参考文献に"Capybara"を含むリンクを追加した途端、本当にテストしたかった書き出しのリンクが消えても、テストが通る状態になってしまいます。

他にも「保存」ボタンをクリックしたい状況があるとして、同じ画面中にいくつも「保存」ボタンがあると、単にclick_button "保存"では、Ambiguous matchを引き起こしてしまいます。match: :firstallしてアクセスする方法もありますが、あまり良い方法ではありませんよね。

click_button "保存", match: :first # firstって何?
all('button', text: "保存")[1].click # うーん...

こういった場合では、withinによるスコープの絞り込みが役に立ちます。

it "書き出しにリンクが含まれる" do 
  within_introduction do
    expect(page).to have_link "Capybara", href: "https://github.com/teamcapybara/capybara"
  end
end

it "ヘッダーの保存ボタンをクリック" do
  within_header do
    click_button "保存"
  end
end

def within_introduction
  within(spec_selector("introduction")) { yield }
end

def within_header
  within(spec_selector("header")) { yield }
end

データ属性を使ったヘルパーメソッドの定義と合わせると、随分と読みやすく感じるはずです。

ユーザーの目に見えないもの(気にしないもの)をテストしない

これは書き方というか、心構えの問題だと思うのですが、基本的にデータベースの中身だったり、DOMの構造など「ユーザーが意識しないもの」はテストするべきではない、と考えています。例えば、こんなテストです。

visit edit_user_path(user)
fill_in "名前", with: "新しい名前"
click_button "保存"

expect(user).to have_attribute(name: "新しい名前")

もちろん、こういったテストを書かざるを得ない状況というのもあると思うのですが、可能な限りユーザーの体験をテストしたいので、ユーザーが知ることができないデータベースの値をテストするのは望ましくないでしょう。実際にユーザーが更新済みの値を見ることができる画面でテストするべきです。

visit edit_user_path(user)
fill_in "名前", with: "新しい名前"
click_button "保存"

expect(page).to have_current_path user_path(user)
expect(page).to have_content "新しい名前"

ボタンクリックなどの操作も同様です。ユーザーは「divタグの3番目の中のボタンをクリックするぞ!」とクリックすることはありませんよね。withinなどと組み合わせて「新着メニューの中にあるボタンをクリックする」というように表現すると、後から見た時に読みやすくなります。

# Bad
all('button', text: "詳細")[2].click

# Good
within_new_menu do
  click_button "詳細"
end

ブラウザのサイズを大きくする

当たり前のことだからしれませんが、あまり言及されている印象がないので書いておきます。ブラウザのサイズは大きければ大きいほどいいです。

Capybara.register_driver(:chrome_headless) do |app|
  options = Selenium::WebDriver::Chrome::Options.new(args: [
    "window-size=3000,3000",
    "headless",
    "disable-gpu",
  ])
  Capybara::Selenium::Driver.new(app, browser: chrome, options: options)
end

ブラウザのサイズが小さい場合、別の要素が被ってくることによって、テストが落ちるなどの問題が起きることがあります。もちろん、ブラウザサイズが小さい画面でテストをしたい状況もあるので、必ずしもこの手が使えるわけではないのですが、特に理由がないならば、ある程度大きく設定しておくことをおすすめします。

簡単にブラウザを起動できる環境を用意する

CIでテストを回すことを考えると、ヘッドレスモードでChromeを動かすことになると思いますが、開発中やテストを書いている段階では、実際にブラウザが立ち上がって操作しているところを見れる方がテンションもあがりますし、問題を特定しやすくなります。

個人的には、環境変数でdriverを簡単に切り替えられるようにしておくと、さっとブラウザを起動してテストが落ちた原因を探ることができるので便利です。

Capybara.configure do
  config.default_driver = ENV['FOREGROUND'] ? :chrome : :chrome_headless
end

おわりに

ブラウザを操作するテストはコストが高く、メンテが難しい、という意見をよく聞きますが、個人的にはコツを抑えて書けば、もっとうまくできるのではないかと思っています。

SmartHRもまだ十分と言える状況ではありませんが、QAチームとも協力しながら、うまくテストを増やしていきたいところです。

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

【Rails】ユーザーの削除と権限【Rails Tutorial 10章まとめ】

管理ユーザー

admin属性

ユーザーを削除する機能を追加するが、この機能は特別な権限を持ったユーザーのみが行えるようにする。
まずはUserモデルにadmin属性を追加する。

$ rails generate migration add_admin_to_users admin:boolean

マイグレーションファイルのadd_columnにdefault: falseオプションを与えて、admin属性のデフォルト値をfalseにする。

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

サンプルユーザーの一人のadmin属性値をtrueにする。

db/seeds.rb
User.create!(name:  "Example User",
             email: "example@railstutorial.org",
             password:              "foobar",
             password_confirmation: "foobar",
             admin: 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

admin属性のテスト

admin属性を変更されるとセキュリティ上問題になるので、ユーザーの新規登録と編集ではユーザー情報を送信する場合にStrong Parametersという形式を用いていた。

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

これが機能しているかどうかのテストを書く。

test/controllers/users_controller_test.rb
  test "should not allow the admin attribute to be edited via the web" do
    log_in_as(@other_user)
    assert_not @other_user.admin?
    patch user_path(@other_user), params: { user: { password:              '',
                                                    password_confirmation: '',
                                                    admin: true } }
    assert_not @other_user.admin?
  end

admin?メソッドはadmin属性を追加したことで生成されるメソッドで、Userオブジェクトに使うとadminの値に応じて論理値を返す。

ユーザーの削除

削除用リンク

ユーザー一覧ページに、管理者にのみ表示される削除用リンクを表示する。

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>

リンク表示の条件式に!current_user?(user)を付けることで、管理ユーザー自身は削除できないようになっている。
link_toにmethod: :deleteを追加することで、DELETEリクエストを送信できるようになっている。
また、data: { confirm: "You sure?" }を付けると削除前に確認用のポップアップが表示される。

destroyアクション

ユーザーを削除するdestroyアクションを書く。

app/controllers/users_controller.rb
  before_action :logged_in_user, only: [:index, :edit, :update, :destroy]
  before_action :correct_user,   only: [:edit, :update]
  .
  .
  .
  def destroy
    User.find(params[:id]).destroy
    flash[:success] = "User deleted"
    redirect_to users_url
  end

destroyメソッドでUserオブジェクトを削除する。
その後、フラッシュメッセージを表示してユーザー一覧ページにリダイレクトする。
また、logged_in_userを使ってログインユーザー以外はアクセスできないようにしておく。

admin_userフィルター

管理者以外に削除用リンクを表示しないようにするだけでは、セキュリティ上の問題がある。
そこで、destroyアクションには管理者のみがリクエストを送れるようにするadmin_userメソッドを用意する。

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?
    end
end

destroyアクションにDELETEリクエストを送っても、現在のユーザーが管理者でなければリダイレクトする。

ユーザー削除のテスト

管理者権限によるユーザー削除の制限のテスト

テスト用ユーザーの一人を管理者にしておく。

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

ユーザー削除に関するテストは次の2点である。
ユーザーを削除しようとした時、
①ログインしていないユーザーの場合はログイン画面にリダイレクトする。
②ログインしているが管理者でないユーザーの場合はホーム画面にリダイレクトする。

test/controllers/users_controller_test.rb
  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
      delete user_path(@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
      delete user_path(@user)
    end
    assert_redirected_to root_url
  end

ユーザーが削除されていないことをassert_no_differenceで確認している。

ユーザー削除のテスト

ユーザー一覧画面のテストに管理者によるユーザー削除を含める。

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)
    get users_path
    assert_template 'users/index'
    assert_select 'div.pagination'
    first_page_of_users = User.paginate(page: 1)
    first_page_of_users.each do |user|
      assert_select 'a[href=?]', user_path(user), text: user.name
      unless user == @admin
        assert_select 'a[href=?]', user_path(user), text: 'delete'
      end
    end
    assert_difference 'User.count', -1 do
      delete user_path(@non_admin)
    end
  end

  test "index as non-admin" do
    log_in_as(@non_admin)
    get users_path
    assert_select 'a', text: 'delete', count: 0
  end
end

管理者であれば、ユーザー一覧画面にはユーザー削除用リンクが表示されていることをassert_selectで確認している。
二つ目のテストではこれの逆である。
また、unless user == @adminとすることで、管理者以外の削除リンクだけが表示されていることを確認している。

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

rails独学でポートフォリオを作成中

こんにちは!現在転職活動中のバスケンです。
初めての投稿です!

railsの独学を初めて3か月が経ちました。
この3か月間の私の学習経過は下記になります。

1、progateでhtml,css,ruby,ruby on rails,githubを1周
2、ruby on rails チュートリアル2週
3、railsでオリジナルのポートフォリオを作成途中

今日は3にて私が作成したポートフォリオの概要を説明します。

ポートフォリオ「KuiShare」の概要

私は人の後悔を聞いたとき似たような後悔が自分に起きないように気をつけるよにします。

このことから、みんなとたくさんの後悔が共有できればおのずと注意深くなり先々で自分に起きうる後悔を減らすことができるのではないかと考えました。

みんなと気軽に後悔を共有することができるのが今回作成した「KuiShare」です。

「KuiShare」のURL「https://kuishare.herokuapp.com/」

「KuiShare」でできること

・後悔したことを投稿/編集/削除
・後悔を共有したいユーザーをフォローする機能
★人の後悔にコメントをする機能
★人の後悔に『ドンマイ』(instagramでいういいね!)をつける機能
・プロフィールの編集機能

利用している技術
・rails
・heroku
・S3(画像置き場)

★印をつけている機能はこれから追加しようとしている機能です。

今回は初投稿でしたのでただ自分の学習経過を記しただけになってしまい申し訳ございません!!
明日からはちゃんとした技術ブログを投稿していきたいと思います!

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

【Rails】ユーザー一覧の表示とページネーション【Rails Tutorial 10章まとめ】

ユーザー一覧ページ

indexアクションの認可

ユーザー一覧を表示するindexアクションとビューはログインユーザーにのみ表示したいので、logged_in_userを設定する。

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

  def index
    @users = User.all
  end
  .
  .
  .
end

indexアクションには@users変数に全てのUserオブジェクトを入れておく。

テストを書く。

test/controllers/users_controller_test.rb
  test "should redirect index when not logged in" do
    get users_path
    assert_redirected_to login_url
  end

indexビュー

ユーザー一覧ページ用のindexビューを作成する。

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/views/layouts/_header.html.erb
 <% if logged_in? %>
          <li><%= link_to "Users", users_path %></li>
          <li class="dropdown">
  .
  .
  .

サンプルユーザーの生成

ユーザー一覧に表示するサンプルユーザーを手作業で作っていると手間なので、fakerジェムを使ってユーザーを大量生成する。

Gemfileにfakerジェムを追加する。

Gemfile
gem 'faker', '1.7.3'

seedファイルにユーザーを生成するRailsスクリプト(Railsタスク)を書く。

db/seeds.rb
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

create!メソッドはcreateメソッドと違い、ユーザーが無効な場合でも例外を返すため、余計なエラーの現にならずに済む。

データベースのリセットと、Railsタスクの実行を行う。

rails db:migrate:reset
rails db:seed

エラーを吐く場合はローカルサーバーを止めたりしてみる。
(自分の場合はseedファイルにデフォルトで入っていたコメントを残していたらエラーを吐いた。)

ページネーション

will_paginateメソッド

一つのページに表示するユーザーを30人までとして、それ以降はページを切り替えて表示するページネーションを実装する。
まずGemfileにwill_paginateジェムとbootstrap-will_paginateジェムを追加する。

Gemfile
gem 'will_paginate',           '3.1.6'
gem 'bootstrap-will_paginate', '1.0.0'

ページネーションを実装するには、まずindexビューのユーザー表示部分をwill_paginateメソッドで挟む。

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 %>

次に、indexアクションの@users = User.allをUser.paginate(page: params[:page])に変える。

app/controllers/users_controller.rb
  def index
    @users = User.paginate(page: params[:page])
  end

paginateメソッドは、:pageパラメータが1であれば1−30のユーザーを、2であれば31−60のユーザーを取り出す(30人ずつ取り出す設定にしている場合)。

ここでエラーを吐く場合は、will_paginateジェムのバージョンを上げてみるとよい。

ユーザー一覧ページのテスト

テスト用ユーザーの生成

fixtureファイルに、テスト用ユーザーを大量生成するRailsタスクを書く。

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 %>

テスト

ユーザー一覧ページ用の統合テストを作成する。

$ 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

テストでは1ページ目に表示されるユーザーの名前が、各ユーザーのプロフィールページにリンクしていることを確認している。

ユーザー一覧ページのパーシャル

割愛する。

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

railsの`ActiveSupport::Callbacks`の各コールバックの実行順序

railsのActiveSupport::Callbacksの各コールバックの実行順序

結論

before_*, around_*はキューに積まれた新しい順番に、
after_*は積まれた順番の古い順に処理される

Calls the before and around callbacks in the order they were set, yields
the block (if given one), and then runs the after callbacks in reverse
order.

See: https://github.com/rails/rails/blob/master/activesupport/lib/active_support/callbacks.rb#L76-L78

実装

キューを処理する処理

まずコールバックの実行自体はActiveSupport::Callbacks#run_callbacks(https://github.com/rails/rails/blob/master/activesupport/lib/active_support/callbacks.rb#L96-L141) で処理されます。

その中の以下の処理で処理順を並び替えています。

callbacks.compile

# See: https://github.com/rails/rails/blob/master/activesupport/lib/active_support/callbacks.rb#L103
def compile
  @callbacks || @mutex.synchronize do
    final_sequence = CallbackSequence.new
    @callbacks ||= @chain.reverse.inject(final_sequence) do |callback_sequence, callback|
      callback.apply callback_sequence
    end
  end
end

# See: https://github.com/rails/rails/blob/d94263f3e76527f196cab2026cfa119ff26b6d9e/activesupport/lib/active_support/callbacks.rb#L562-L569

ポイントはreverseしている所です。
処理するときは、積まれたキューをreverseして頭から処理していきます。

キューを積む処理

キューを積む時はset_callbackはが呼ばれます。
その中で、prependappendどちらかが呼び出されます。

        def set_callback(name, *filter_list, &block)
          type, filters, options = normalize_callback_params(filter_list, block)

          self_chain = get_callbacks name
          mapped = filters.map do |filter|
            Callback.build(self_chain, filter, type, options)
          end

          __update_callbacks(name) do |target, chain|
            options[:prepend] ? chain.prepend(*mapped) : chain.append(*mapped)
            target.set_callbacks name, chain
          end
        end


# See: https://github.com/rails/rails/blob/d94263f3e76527f196cab2026cfa119ff26b6d9e/activesupport/lib/active_support/callbacks.rb#L673-L685
        def append(*callbacks)
          callbacks.each { |c| append_one(c) }
        end

        def prepend(*callbacks)
          callbacks.each { |c| prepend_one(c) }
        end

# See: https://github.com/rails/rails/blob/d94263f3e76527f196cab2026cfa119ff26b6d9e/activesupport/lib/active_support/callbacks.rb#L571-L577

このskip_callback(name, *filter_list, &block)を呼び出す時の第二引数*filter_listの値によって配列の順序を変更しています。

詳しく結論

キューを積む時

before_*, around_*prepend -> 先頭に追加

先頭から古い順

after_*append -> 末尾に追加

先頭から新しい順

キューを処理する時

reverseして先頭から処理していくので、
before_*, around_*はキューに積まれた新しい順番に、
after_*は積まれた順番の古い順に処理されます。

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

Rails × CircleCI × ECSのインフラ構築

簡単なDocker RailsアプリをECSを利用して本番環境に上げるまでのまとめ

* あくまで参考に(実務でそのまま利用できるほどしっかり構築しておりません)

image.png

前提知識

ECSとは?クラスターとは?サービスとは?タスクとは?って人は
ECSの概念を理解しよう
などを読んでください。

Railsアプリ作成

まずはローカルでRailsアプリを作成しましょう。
機能は簡単なものでいいので、scaffoldなどを利用してサクッと作成してしまいましょう。
脳死で作成したい人は下記をご覧下さい。
Docker Rails Sampleアプリ構築 - Qiita

AWS上で利用するリソースの作成

コンソール上(or Terraformなど)からあらかじめ作成しておくべきものになります。

IAMロール・ポリシーの作成

ECSで運用するための必要なIAMロール・ポリシーを作成していきます。
ちなみにポリシーとは、ロールに付与される権限情報です。なのでポリシーのないロールは何も権限がない状態なのでまずはポリシーを作成してロールを作成していきましょう。

ポリシーの作成

作成手順

  1. IAMページに行って、サイドバーの「ポリシー」選択
  2. 「ポリシーの作成」ボタン押下
  3. JSONタブを開いて下記に記載したJSON内容をコピペして、「ポリシーの確認」押下
  4. それぞれのポリシー名を入力する

下記の4つのポリシーを作成する。

  1. AmazonSSMReadAccess
  2. AmazonECSTaskExecutionRolePolicy
  3. AmazonEC2ContainerServiceforEC2Role
  4. AmazonECSServiceRolePolicy
AmazonSSMReadAccess
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "ssm:GetParameters",
                "secretsmanager:GetSecretValue",
                "kms:Decrypt"
            ],
            "Resource": "*"
        }
    ]
}
AmazonECSTaskExecutionRolePolicy
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "ecr:GetAuthorizationToken",
                "ecr:BatchCheckLayerAvailability",
                "ecr:GetDownloadUrlForLayer",
                "ecr:BatchGetImage",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": "*"
        }
    ]
}
AmazonEC2ContainerServiceforEC2Role
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "ec2:DescribeTags",
                "ecs:CreateCluster",
                "ecs:DeregisterContainerInstance",
                "ecs:DiscoverPollEndpoint",
                "ecs:Poll",
                "ecs:RegisterContainerInstance",
                "ecs:StartTelemetrySession",
                "ecs:UpdateContainerInstancesState",
                "ecs:Submit*",
                "ecr:GetAuthorizationToken",
                "ecr:BatchCheckLayerAvailability",
                "ecr:GetDownloadUrlForLayer",
                "ecr:BatchGetImage",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": "*"
        }
    ]
}
AmazonECSServiceRolePolicy
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "ECSTaskManagement",
            "Effect": "Allow",
            "Action": [
                "ec2:AttachNetworkInterface",
                "ec2:CreateNetworkInterface",
                "ec2:CreateNetworkInterfacePermission",
                "ec2:DeleteNetworkInterface",
                "ec2:DeleteNetworkInterfacePermission",
                "ec2:Describe*",
                "ec2:DetachNetworkInterface",
                "elasticloadbalancing:DeregisterInstancesFromLoadBalancer",
                "elasticloadbalancing:DeregisterTargets",
                "elasticloadbalancing:Describe*",
                "elasticloadbalancing:RegisterInstancesWithLoadBalancer",
                "elasticloadbalancing:RegisterTargets",
                "route53:ChangeResourceRecordSets",
                "route53:CreateHealthCheck",
                "route53:DeleteHealthCheck",
                "route53:Get*",
                "route53:List*",
                "route53:UpdateHealthCheck",
                "servicediscovery:DeregisterInstance",
                "servicediscovery:Get*",
                "servicediscovery:List*",
                "servicediscovery:RegisterInstance",
                "servicediscovery:UpdateInstanceCustomHealthStatus"
            ],
            "Resource": "*"
        },
        {
            "Sid": "ECSTagging",
            "Effect": "Allow",
            "Action": [
                "ec2:CreateTags"
            ],
            "Resource": "arn:aws:ec2:*:*:network-interface/*"
        },
        {
            "Sid": "CWLogGroupManagement",
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogGroup",
                "logs:DescribeLogGroups",
                "logs:PutRetentionPolicy"
            ],
            "Resource": "arn:aws:logs:*:*:log-group:/aws/ecs/*"
        },
        {
            "Sid": "CWLogStreamManagement",
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogStream",
                "logs:DescribeLogStreams",
                "logs:PutLogEvents"
            ],
            "Resource": "arn:aws:logs:*:*:log-group:/aws/ecs/*:log-stream:*"
        }
    ]
}

ロールの作成

IAMページに行って、サイドバーの「ロール」→「ロールの作成」より下記のロールを作成する。
作成後、各ロールのページにて「ポリシーをアタッチする」を押下して上記で作成したポリシーを紐づける。

  1. ecsInstanceRole(→AmazonEC2ContainerServiceforEC2Roleに紐づける)
  2. AWSServiceRoleForECS(→AmazonECSServiceRolePolicyに紐づける)
  3. ecsTaskExecutionRole(→AmazonECSTaskExecutionRolePolicy,AmazonSSMReadAccessを紐づける)

参考
Amazon ECS タスク実行 IAM ロール

ALBの作成

ECSのサービス作成時にALBを登録しておけば、コンテナに動的にポートマッピングをしてくれるようになるので楽になります。

  1. Application Load Balancerを選択
  2. 名前を入力。サブネットを二つ選択。(ない場合は、適宜作成)
  3. セキュリティグループを選択。(ない場合は、適宜作成)
  4. ターゲットグループを選択or作成
  5. ターゲットグループにインスタンスを登録

クラスターの作成

ECSのサイドバーにある「クラスター」から「クラスターの作成」ボタンを押下
「クラスターテンプレートの選択」は「EC2 Linux + ネットワーキング」を選択
1. クラスター名記載
2. EC2インスタンスタイプの選択(お好み)
3. キーペア(お好み。ただし、デバッグ時にSSHできた方がいいので設定しておくことをおすすめ)
4. コンテナインスタンスの IAM ロールに「ecsInstanceRole」を選択

RDSの作成

aws-cliでのRDS作成。
コンソール上からでもOKです。

    aws rds create-db-instance \
            --db-instance-identifier rails-sample-db-production \
            --db-instance-class db.t2.micro \
            --db-subnet-group-name rails-sample-db-subnet-group \
            --engine mysql \
          --engine-version 5.7.26 \
            --allocated-storage 20 \
            --master-username [username] \
            --master-user-password [password] \
            --backup-retention-period 3 \

参考
AWS CLI を使って RDS を作成する (自分用メモ) - Qiita
AWS-CLI Amazon Aurora インスタンス作成 - Qiita

AWS Systems Managerの設定

AWS Systems Managerは、タスク実行時にコンテナに注入する秘匿情報(環境変数)の管理に使えるAWSサービスです。
初めての人は設定の仕方を含め、
ECSでごっつ簡単に機密情報を環境変数に展開できるようになりました!
を見れば大体分かると思います。

AWS Systems Managerの左側メニューから「パラメータストア」→「パラメータの作成」をクリック。パラメータの詳細画面が表示されるので、パラメータのキー名と値を入力します。タイプには「安全な文字列」を選択します。

パラメータのキー名と値一覧

キー名
/production/database_username [RDSに設定したusername]
/production/database_password [RDSに設定したpassword]
/production/database_host [RDSインスタンスのエンドポイント]

RDSインスタンスのエンドポイント(RDS→データベース→[インスタンス名])
image.png

CircleCIの設定

circleci/config.yml
version: 2.1
orbs:
  aws-cli: circleci/aws-cli@0.1.13
executors:
  builder:
    docker:
      - image: circleci/buildpack-deps

commands:
  init:
    steps:
      - checkout
      - aws-cli/install
      - install_ecs-cli
      - setup_remote_docker
  install_ecs-cli:
    steps:
      - run:
          name: Install ECS-CLI
          command: |
            sudo curl -o /usr/local/bin/ecs-cli https://amazon-ecs-cli.s3.amazonaws.com/ecs-cli-linux-amd64-latest
            sudo chmod +x /usr/local/bin/ecs-cli

jobs:
  build:
    executor: builder
    steps:
      - init
      - run:
          name: Build application Docker image
          command: |
            docker build -f build.Dockerfile --rm=false -t rails-sample-app-build:latest .
      - run:
          name: Save image
          command: |
            mkdir -p /tmp/docker
            docker save rails-sample-app-build:latest -o /tmp/docker/image
      - persist_to_workspace:
          root: /tmp/docker
          paths:
            - image
  deploy:
    executor: builder
    steps:
      - init
      - attach_workspace:
          at: /tmp/docker
      - run: docker load -i /tmp/docker/image
      - run:
          name: Assets precompile and Push Docker image
          command: |
            docker build -f assets.Dockerfile --build-arg RAILS_MASTER_KEY=${RAILS_MASTER_KEY} --rm=false -t rails-sample-app-build:latest .
      - run:
          name: Push Docker image
          command: |
            ecs-cli push rails-sample-app-build:latest
      - run:
          name: ECS Config
          command: |
            ecs-cli configure \
            --cluster rails-sample-${CIRCLE_BRANCH} \
            --region ${AWS_DEFAULT_REGION} \
            --config-name rails-sample-${CIRCLE_BRANCH}
      - run:
          name: migrate deploy
          command: |
            ecs-cli compose \
            --file ecs/${CIRCLE_BRANCH}/migrate/docker-compose.yml \
            --ecs-params ecs/${CIRCLE_BRANCH}/migrate/ecs-params.yml \
            --project-name rails-sample-${CIRCLE_BRANCH}-migrate \
            up \
            --launch-type EC2 \
            --create-log-groups \
            --cluster-config rails-sample-${CIRCLE_BRANCH}
      - run:
          name: Unicorn + Nginx deploy
          command: |
            ecs-cli compose \
            --file ecs/${CIRCLE_BRANCH}/app/docker-compose.yml \
            --ecs-params ecs/${CIRCLE_BRANCH}/app/ecs-params.yml \
            --project-name rails-sample-${CIRCLE_BRANCH}-app \
            service up \
            --container-name nginx \
            --container-port 80 \
            --target-group-arn ${TARGET_GROUP_ARN} \
            --timeout 0 \
            --launch-type EC2 \
            --create-log-groups \
            --cluster-config rails-sample-${CIRCLE_BRANCH}

workflows:
  version: 2
  build-deploy:
    jobs:
      - build
      - deploy:
          requires:
            - build
          filters:
            branches:
              only:
                - master

CircleCIに設定する環境変数

CircleCIのプロジェクトの設定ページ(Settings→[アカウント名or組織名]→[プロジェクト名])に行き、下記の画像の箇所から設定する
https://circleci.com/gh/[アカウント名or組織名]/[プロジェクト名]/edit#env-vars

image.png

環境変数名
AWS_ACCESS_KEY_ID [AWSのアクセスキーID]
AWS_ACCOUNT_ID [AWSのアカウントID]
AWS_DEFAULT_REGION [AWSのデフォルトリージョン]
AWS_ECR_REPOSITORY_URL [AWSのECRリポジトリURL]
AWS_SECRET_ACCESS_KEY [AWSのシークレットアクセスキー]
RAILS_MASTER_KEY [config/master.keyの値]
TARGET_GROUP_ARN [ターゲットグループのarn]

Task definitionの作成

docker-compose.yml

rails-sample/ecs/production/app/docker-compose.yml
version: "3"

services:
  app:
    image: [ECRのリポジトリURI]
    entrypoint: bundle exec unicorn -c config/unicorn.rb
    env_file:
      - ../env
    working_dir: /projects/rails-sample
    logging:
      driver: "awslogs"
      options:
        awslogs-region: "ap-northeast-1"
        awslogs-group: "rails-sample-production/app"
        awslogs-stream-prefix: "rails-sample-app"
  nginx:
    image: [ECRのリポジトリURI]
    ports:
      - 0:80
    links:
      - "app:app"
    env_file:
      - ../env
    working_dir: /projects/rails-sample
    logging:
      driver: "awslogs"
      options:
        awslogs-region: "ap-northeast-1"
        awslogs-group: "rails-sample-production/nginx"
        awslogs-stream-prefix: "rails-sample-nginx"

* Nginxの設定ファイルは適宜用意してください。上記のnginxの欄にnginx設定ファイル群の設置・起動用のスクリプトentrypoint: /bin/bash /etc/nginx/start.shを用意するなど。

ecs-params.yml

タスク実行時に実行ロールの指定やコンテナに注入する環境変数をAWS Systems Managerから取得するして設定するためのファイル

rails-sample/ecs/production/app/ecs-params.yml
version: 1
task_definition:
  # タスク実行時のロールを指定
  task_execution_role: ecsTaskExecutionRole
  services:
    # 起動するコンテナを記載(app, nginx)
    app:
      # 何らかの理由で失敗・停止した際に、タスクに含まれる他のすべてのコンテナを停止するかどうか(デフォルトはtrue)
      essential: true
      # AWS Systems Managerから秘匿情報を取得してコンテナに環境変数を注入
      secrets:
        - value_from: /production/database_username
          name: DATABASE_USERNAME
        - value_from: /production/database_password
          name: DATABASE_PASSWORD
        - value_from: /production/database_host
          name: DATABASE_HOST
    nginx:
      essential: true
run_params:
  network_configuration:
    awsvpc_configuration:
      assign_public_ip: ENABLED

コンテナ全体に注入する環境変数の設定

各環境(production, stagingなど)ごとのディレクトリ以下にenvファイルを用意してそこに記載する

# ここのファイルに追加した環境変数は全てのコンテナに展開されます
# Rails
APP_HOST=54.238.241.230
RAILS_ENV=production
RAILS_LOG_TO_STDOUT=1
RAILS_SERVE_STATIC_FILES=1

# RDS
DATABASE_NAME=rails-sample_production
DATABASE_PORT=3306
DATABASE_POOL=10

# Unicorn
UNICORN_PORT=23380
UNICORN_TIMEOUT=180
UNICORN_WORKER_PROSESSES=2

# Nginx専用
NGINX_APP_SERVER_NAME=app
NGINX_APP_SERVER_PORT=23380
NGINX_DOCUMENT_ROOT=/projects/rails-sample/public
NGINX_FRONT_SERVER_NAME=54.238.241.230

構築の際に詰まる可能性のあるポイント

ECSコンテナインスタンスの作成

Defaultクラスター作成しているし、IAMロールにecs:CreateClusterの権限付与されているから自動で作成なんかもしてくれるのかと思ったら作成してくれなかった。
なので、クラスター作成→インスタンス作成の方が良い(ちな、クラスター作成時にインスタンスも作成するようにはできるっぽい)
→カスタマイズされてるAMI利用時のみ初期スクリプトによってDefaultクラスターを作成しているのかもしれない

:hatched_chick: 参考 :hatched_chick:
Amazon ECS コンテナインスタンスの起動 - Amazon Elastic Container Service
Amazon ECS-optimized AMI - Amazon Elastic Container Service

インスタンスタイプについて

image.png

ある程度余裕持たないとタスク実行するための容量を持たなくて死ぬ
(ほんとは、ローカルや本番環境で動かした時の使用量見てタスク実行に必要なメモリを設定した方が良い)

ecs-cliでのタスク実行

  • ecs-params.yml ファイル内でtask_execution_roleを指定すること
  • task_execution_roleで指定した適切なポリシーを適用したIAM Roleを用意すること(エラーが出なくて、単純に実行されないので気づきにくい)

まとめ

ECSについてググればたくさん記事出てくるのですが、実際に活用しようとしてみるとたくさん落とし穴があります。もし利用しようか考えている人は一度デモアプリで利用してみることをお勧めします。

最後に

UUUMではインフラに詳しいエンジニアを欲しています。
詳しくはこちら →→→→→→ UUUM攻殻機動隊の紹介

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

mergeメソッド

mergeメソッド

二つのハッシュを結合する

結合前

def create
      Tweet.create(image: tweets_params[:image], text: tweets_params[:text], user_id: current_user.id)
    end

結合後

def create
    Tweet.create(tweets_params)
 end
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Docker Rails Sampleアプリ構築

適当なRailsアプリを作成するのに脳死で作成する

前提

  • Ruby 2.6.5
  • Railsバージョン6.0.1
  • MySQL 5.7
  • Node.js 8系
  • webpacker用のコンテナは用意していない
$ mkdir rails-sample

$ rbenv local [使用するrubyバージョン]

$ git init

$ bundle init
gem 'rails'のコメントアウトを外す

$ bundle install --path vendor/bundle

$ bundle exec rails new . -B -d mysql --skip-test
-B bundle install をスキップする(お好み)
-d 利用するDBを指定(デフォルトはSQLite)
--skip-test railsのデフォルトのminitestというテストを利用しない場合は指定(お好み)

Gemfileの上書きしていいかどうかは Y でEnter

$ bundle exec rails webpacker:install

.gitignore に vendor/bundleを追記(お好み)

docker-compose.ymlとDockerfile作成

Dockerfile

FROM ruby:2.6.5
ENV LANG C.UTF-8
RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs

# nodejsとyarnはwebpackをインストールする際に必要
# Node.js
RUN curl -sL https://deb.nodesource.com/setup_8.x | bash - && \
apt-get install nodejs
# yarnパッケージ管理ツール
RUN apt-get update && apt-get install -y curl apt-transport-https wget && \
curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \
echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list && \
apt-get update && apt-get install -y yarn

WORKDIR /tmp
COPY Gemfile Gemfile
COPY Gemfile.lock Gemfile.lock
RUN bundle install
ENV APP_HOME /rails-sample
RUN mkdir -p $APP_HOME
WORKDIR $APP_HOME
COPY . /rails-sample
docker-compose.yml
version: '3'
services:
  db:
    image: mysql:5.7
    environment:
      MYSQL_USER: root
      MYSQL_ROOT_PASSWORD: password
    volumes:
      - ./tmp/docker/mysql:/var/lib/mysql:delegated

  web:
    build: .
    command: bundle exec rails s -p 3000 -b '0.0.0.0'
    volumes:
      - .:/chiko
    ports:
      - "3000:3000"
    depends_on:
      - db

database.ymlを編集(お好み)

database.yml
default: &default
  adapter: mysql2
  timeout: 5000
  encoding: utf8mb4
  charset: utf8mb4
  collation: utf8mb4_general_ci
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  username: root
  password: password
  host: db
  port: 3306

development:
  <<: *default
  database: rails-sample_development

test:
  <<: *default
  database: rails-sample_test

production:
  <<: *default
  database: <%= ENV["DATABASE_NAME"] %>
  username: <%= ENV["DATABASE_USERNAME"] %>
  password: <%= ENV["DATABASE_PASSWORD"] %>
  host: <%= ENV["DATABASE_HOST"] %>
  port: <%= ENV["DATABASE_PORT"] %>

defaultに
- charset: utf8mb4
- collation: utf8mb4_general_ci
- port: 3306

を追記

productionは
- database: <%= ENV["DATABASE_NAME"] %>
- username: <%= ENV["DATABASE_USERNAME"] %>
- password: <%= ENV["DATABASE_PASSWORD"] %>
- host: <%= ENV["DATABASE_HOST"] %>
- port: <%= ENV["DATABASE_PORT"] %>

を全部環境変数に変更

$ docker-compose build

$ docker-compose run --rm web rails db:create

ScaffoldでUserモデル作成

$ docker-compose run --rm web rails g scaffold user name:string age:integer

トップページを用意

$ bundle exec rails g home index
routes.rb
Rails.application.routes.draw do
  root 'home#index' # これを追記

  resources :users
end

Userページへのリンクを付与

index.html.erb
<h1>Home#index</h1>
<p>Find me in app/views/home/index.html.erb</p>

<%= link_to "user", users_path %>   <%# これを追記

マイグレーションして、コンテナを立ち上げる

$ docker-compose run --rm web rails db:migrate

$ docker-compose up -d

=> http://localhost:3000 にアクセスして確認

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

【Rails】フレンドリーフォワーディング【Rails Tutorial 10章まとめ】

認可機能によって、ログインしていないユーザーが編集ページにアクセスしようとすると、ログインページにリダイレクトされる。
その後ログインするとプロフィールページに移動するが、ここでもともとアクセスしようとしていた編集ページに移動してくれると親切である。
これをフレンドリーフォワーディングと呼ぶ。

フレンドリーフォワーディングのテスト

フレンドリーフォワーディングは少し複雑なので、テスト駆動開発で進める。
編集成功時のテストを修正する。

test/integration/users_edit_test.rb
  test "successful edit with friendly forwarding" do
    get edit_user_path(@user)
    log_in_as(@user)
    assert_redirected_to edit_user_url(@user)
    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

編集ページにアクセス→ログインページにリダイレクトしてログイン→編集ページにリダイレクト、という流れになる。

フレンドリーフォワーディングの実装

store_locationとredirec_back_orメソッド

ユーザーを希望のページに転送するには、リクエスト時点のページを保存しておく必要がある。

app/helpers/sessions_helper.rb
  # 記憶したURL (もしくはデフォルト値) にリダイレクト
  def redirect_back_or(default)
    redirect_to(session[:forwarding_url] || default)
    session.delete(:forwarding_url)
  end

  # アクセスしようとしたURLを覚えておく
  def store_location
    session[:forwarding_url] = request.original_url if request.get?
  end

store_locationメソッドでは、session[:forwarding_url]に、リクエスト先を取得するrequest.original_urlを入れる。
この時、セキュリティ上の問題を解決するために、request.getを使ってGETリクエストの場合のみとする。

redirect_back_orメソッドでは、or演算子||を使って、session[:forwarding_url]がある場合はそちらにリダイレクトし、無い場合は引数のURLにリダイレクトする。
リダイレクト後はsession[:forwarding_url]を削除しておく。

store_locationメソッドを使って、非ログイン時に編集ページにアクセスしたらURLを保存する。

app/controllers/users_controller.rb
    # beforeアクション

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

redirect_back_orメソッドを使って、ログイン後に保存したURLにリダイレクトする。

app/controllers/sessions_controller.rb
  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
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Rails と React+TypeScript でのGraphQLスキーマ運用プラクティス

Linc'well Advent Calendar3日目の記事です。

当社が展開するクリニックグループである CLINIC FOR の予約システム では一部機能にGraphQLを導入しているのですが、スキーマの管理等も踏まえてどのように構築し、運用したかについて紹介したいと思います。

※ ちなみに構築したのは2019/3~4頃であり、その点は割り引いてご覧ください :pray_tone2:
今だともっと良さげなプラクティスがあるかもしれません。

また、予約システム全体としてはモノリシックなRailsアプリケーションになっているのですが、GraphQLを導入した一部機能ではフロントエンドがTypeScript+Reactで構築されており、ruby側で定義されたスキーマ情報はTSの型定義へスムーズに繋げる必要がありました。

スキーマファイルの生成

GraphQLには様々な実装があるかと思いますが、今回rmosolgo/graphql-ruby というgemを利用しました。

こちらのgemはスキーマの定義ファイルを直接記述せず、個別のクエリや型の実装から最終的なスキーマを出力する code-first なアプローチを取っています。

なので処理を先に記述し、それを参照することで型定義が決定される、という順番になるのですが、最終的な定義の情報は、Schemaのクラスに生えている以下のメソッドで文字列として全出力され、その戻り値をファイル出力してあげることでスキーマファイルが出来上がります。

GraphQL::Schema#to_definition

今回は上記メソッドを以下のようなrakeタスクに組み込み、差分が生じたらファイル出力の上でコミットしていくという運用で行くことにしました。

# rake graphql:dump_schema
namespace :graphql do
  task dump_schema: :environment do
    schema_definition = LincwellSchema.to_definition
    schema_path = 'frontend/src/generated/schema.graphql'
    File.write(Rails.root.join(schema_path), schema_definition)
    puts "#{schema_path} updated."
  end
end

CIでのスキーマ定義チェック

定義ファイルが最新のものかチェックするため、rspecにてスキーマファイルの最新チェックも用意します。

describe 'Validate lastest-veresion' do
  let(:dump_path) { Rails.root.join('frontend', 'src', 'generated', 'schema.graphql') }
  let(:current_definition) { File.read(dump_path) }

  subject { described_class.to_definition }

  it { is_expected.to eq(current_definition) }
end

簡易な実装ですが、CIが回るたびにチェックが入るので、少なくともこれで更新忘れはなくなり、実装とスキーマの乖離を防止することができたのではないかと思います。

スキーマ情報からTSの型をつくる

続いてはこうして出来上がったスキーマ情報をフロントエンド側へ連携させていきます。

出力されたスキーマ情報をもとにTSの型を作りたいのですが、さすがに手ずから行うのは厳しいので graphql-codegenを用いて自動化することにします。

# codegen.yml
overwrite: true
schema: "src/generated/schema.graphql"
documents: "src/libs/queries/*.ts"
generates:
  src/generated/graphql.tsx:
    plugins:
      - "typescript"
      - "typescript-operations"
      - "typescript-react-apollo"
  src/generated/graphql.schema.json:
    plugins:
      - "introspection"
"scripts": {
  "generate": "graphql-codegen --config codegen.yml"
}

上記設定の上、 npm run generate コマンドを実行します。

そうするとrailsから出力されたスキーマファイルを元に、 src/generated/graphql.tsx にTSの型定義が自動生成されるようになります。ここまで繋がると気持ちイイですね!

これでReactコンポーネントから自由に任意のクエリやオブジェクトをTSの型として利用できるようになりました。Rails側で定義されている graphql/types と乖離しないため、非常に楽に、かつ楽しく開発を進めることができます。

{
    "paths": {
      "Components/*": ["src/components/*"],
      "Styles/*": ["src/styles/*"],
      "Libs/*": ["src/libs/*"],
      "Generated/*": ["src/generated/*"]
    }
  }
}

tsconfig.jsonで上記のようにaliasを切り、Componentで以下のように呼び出せます。

import * as React from "react";
import { useQuery } from "react-apollo-hooks";
import styled from "styled-components";
import { CancelRate } from "Generated/graphql";

めでたしめでたし :tada:

ちなみにフロントエンド側の型生成まわりに関しては以下の記事を大変参考にさせて頂きました。

https://qiita.com/mizchi/items/fb9f598cea94d2c8072d

投稿時期も非常にタイムリーで、もしこのエントリがなかったらと思うと(ry

まとめ

  • code-firstな graphql-ruby の利用
  • #to_definition をrake経由で実行し、型定義の .graphql ファイルをフロントへ配置
  • rspec にて最新dumpの検査
  • graphql-codegen にてTSの型定義ファイルへコンバート
  • フロントアプリケーションにて利用可能に

だいたいこんな感じになりました。
現状もこの当時からあまり大きく変わっていませんが、もしより良いプラクティスがあればフィードバック頂けたら幸いです。

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

【Ruby | Rails】Dockerfileの中で"ADD Gemfile ~ RUN bundle install"をするのはやめませんかという話

今回の検証環境

  • Ruby 2.6.5
  • Docker 19.03.5
  • Docker-Compose 1.24.1

はじめに

  • Railsの設定を例にすると結構複雑になっちゃうので、今回は単純にRubyをDocker上で使用する例で解説します。
    Railsを使用する場合も要点は同じなので適宜読み替えてください。
  • 今回はhogehogeディレクトリ配下で作業します。サンプルコード中のhogehogeの部分は自由に変更して構いません。

やめませんか

RailsやRubyのDocker環境構築の解説をしている記事で、Dockerfile内で以下のようにADD Gemfile~RUN bundle installしている記事を本当にたくさんよく見かけます。

Dockerfile
FROM ruby:2.6.5

WORKDIR /hogehoge

RUN gem install bundler

# ↓こういうの↓
ADD Gemfile Gemfile
ADD Gemfile.lock Gemfile.lock
RUN bundle install

これ、やめませんか?

なんでやねん

Dockerfileの中でADD Gemfile~RUN bundle installをすることには以下のようなデメリットがあります。

  • Gemfileを編集するたびに毎回docker builddocker-compose buildをしないといけなくなる。
    • Gemを1つ追加するだけでも全Gemをインストールし直す羽目になる。
      • nokogiriとかインストール遅いよね!毎回待たされるの嫌だよね!

何よりRubyistの皆さんとしては、Gemfileを編集したら本能的にbundle installしたいですよね?したくないですか?したいですよね?

じゃあどうすんねん

docker-composeを上手く使えばもっと効率よく楽しく開発できます。

Dockerfile

containersディレクトリ配下にDockerfileを作成します。

containers/Dockerfile
FROM ruby:2.6.5

WORKDIR /hogehoge

RUN gem install bundler

こんだけ。

docker-compose.yml

次に、アプリケーションのルートディレクトリにdocker-compose.ymlを作成します。

docker-compose.yml
version: '3'
services:
  app:
    build:
      context: .
      dockerfile: containers/Dockerfile
    environment:
      # これがないとGemを`vendor/bundle`以下から読み込んでくれないので注意
      # (正確には、`.bundle/config`の設定を読み込んでくれない)
      BUNDLE_APP_CONFIG: /hogehoge/.bundle
    volumes:
      - .:/hogehoge

こんだけ。
単純にカレントディレクトリ全体をマウントしてるだけですね。

ビルドしよう

さぁdocker-compose buildしていきましょう。

$ docker-compose build
Building app
Step 1/4 : FROM ruby:2.6.5
 ---> d98e4013532b
Step 2/4 : ENV APP_ROOT /hogehoge
 ---> Using cache
 ---> 97b5a8bca2d0
Step 3/4 : WORKDIR $APP_ROOT
 ---> Using cache
 ---> 54066d2ae384
Step 4/4 : RUN gem install bundler
 ---> Using cache
 ---> 290d99a58c5b
Successfully built 290d99a58c5b
Successfully tagged hogehoge_app:latest

すぐ終わりますね。
(初回だけruby:2.6.5のDocker imageのpullに時間がかかります。)

Gemをインストールしてみよう

とりあえずGemfileを作成します。

$ docker-compose run --rm app bundle init
Writing new Gemfile to /hogehoge/Gemfile

適当にbcryptでも入れてみますかね。

Gemfile
# frozen_string_literal: true

source "https://rubygems.org"

git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }

# gem "rails"

# ↓追加↓
gem 'bcrypt'

それでは念願のbundle installです。
docker-compose経由で実行するのと、--pathオプションを指定するのを忘れずに。

補足: 一度--pathオプションを付けてbundle installを実行すると、.bundle/configが作成されて設定が追加されるため、次回以降bundle installの際に--pathオプションを付ける必要はありません。

$ docker-compose run --rm app bundle install --path vendor/bundle
Creating network "hogehoge_default" with the default driver
Fetching gem metadata from https://rubygems.org/.
Resolving dependencies...
Fetching bcrypt 3.1.13
Installing bcrypt 3.1.13 with native extensions
Using bundler 2.0.2
Bundle complete! 1 Gemfile dependency, 2 gems now installed.
Bundled gems are installed into `./vendor/bundle`

ちゃんとインストールできているか確認してみましょう。

$ docker-compose run --rm app bundle exec gem list

*** LOCAL GEMS ***

bcrypt (3.1.13)
bundler (2.0.2)

できていますね。
次回以降もGemfileを編集した際にはdocker-compose run --rm app bundle installするだけで大丈夫です。
docker-compose buildし直す必要はありません。

試しにRubyスクリプトを実行してみよう

test.rbを作って適当にbcryptを使ってみます。

test.rb
# vendor/bundle配下から読み込むようにしてくれる
require 'bundler/setup'

# Gemfileの中のGemを一発でrequireしてくれる
Bundler.require

# NOTE: ↑上の2つはRailsの場合は勝手にやってくれるため必要ないです↑

puts BCrypt::Password.create('password')
$ docker-compose run --rm app ruby test.rb
$2a$12$xWXitLplfvcIuxUdTg.1I.bb/Jo0btGGnqWE02ZiMFsne.hDQXaDW

実行できましたね。

1つ問題点が!!

現状だとインストールしたGemはローカルvendor/bundleディレクトリ配下に配置されます。
docker-compose run ...を実行するたびにこのvendor/bundleディレクトリ配下が毎回マウントされるため、
Gemが増えてくるとdocker-compose run ...を実行するたびにマウントに時間がかかり、コマンド実行が遅くなってしまいます。(この問題はDocker for Macを使用している場合のみ発生するらしいです)

「毎回docker-compose buildし直すのが面倒だからこうしたのに、本末転倒じゃねぇか!!」

落ち着いてください。こんな時のためにDockerにはvolumeという機能があるじゃないですか。

vendor/bundleをボリュームに切り出す

docker-compose.ymlを以下のように修正するだけで解決します。

docker-compose.yml
version: '3'
services:
  app:
    build:
      context: .
      dockerfile: containers/Dockerfile
    environment:
      BUNDLE_APP_CONFIG: /hogehoge/.bundle # これがないとGemを`vendor/bundle`以下から読み込んでくれないので注意
    volumes:
      - .:/hogehoge
      # ↓追加↓
      - bundle:/hogehoge/vendor/bundle

# ↓追加↓
volumes:
  bundle:
    driver: local

ローカルの方のvendor/bundle配下のファイルはもう必要ないため削除しちゃいましょう。

$ rm -rf vendor/bundle/*

ボリュームとして切り出したら改めてbundle installし直しましょう。

$ docker-compose run --rm app bundle install
Creating volume "hogehoge_bundle" with local driver
Fetching gem metadata from https://rubygems.org/.
Fetching bcrypt 3.1.13
Installing bcrypt 3.1.13 with native extensions
Using bundler 2.0.2
Bundle complete! 1 Gemfile dependency, 2 gems now installed.
Bundled gems are installed into `./vendor/bundle`

これでvendor/bundleディレクトリ配下はbundleボリュームとして切り出されて毎回ローカルからマウントされることがなくなるため、docker-compose runで余計な時間がかかることはなくなります。

注意しておくこと

Gemのコマンドを使う際にはbundle execを付け足すのを忘れないようにしてください。
こんな感じで↓

$ docker-compose run --rm app bundle exec rpsec

え?docker-compose run --rm appだけでも長いのにbundle execまで毎回付けるのは面倒くさいって?
alias設定するなりMakefile使うなりやりようはいくらでもあるじゃないですか。

おわりに

僕自身エンジニア歴1年ちょっと、Dockerを使い始めて2ヶ月程度なので知識不足が否めません。
見当違いの事を言っている可能性も十分にあります。
誤った表現や設定等ありましたらコメントにてご指摘をお願いします。

Docker便利ですね!

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

そのマイグレーション、戻せますか?

Railsを使うときに、マイグレーションは便利ですが、戻すときのことまで考えて実装していますでしょうか。

bin/rails db rollback

bin/rails db migrateでマイグレーションを実行できますが、逆にbin/rails db rollbackで戻すこともできます。ただし、マイグレーションが戻ることを前提にしている必要があります。

考えなくて良いパターン

create_tableadd_columnなど追加系のメソッドをchangeに書いていた場合、逆向きは「追加したものを削除する」ことが明らかですので、自分で書かなくても対応してくれます。

戻し方を書けば対応してくれるパターン

drop_tableremove_columnを行う場合、ただ削除する内容を書くだけでは、戻し方がわかりません。そこで、これらのメソッドはcreate_tableadd_columnと同様に元の定義を書けるようになっています。ロールバック時にはそれが使われます。

戻さない・戻せないパターン

たとえば、NULLの列をNOT NULLに変えるとか、utf8だった列をutf8mb4に入れ替えるとか、intの列をstringに変えるとか、値域を広げる方向にマイグレーションをかけた場合、元に戻そうにも広がった後のデータが入らないことがあるので、戻しようがありません。

このような場合はupだけ書いて、downraise ActiveRecord::IrreversibleMigrationとすることで、「戻せない」ことをコードで明示できます。

また、現実問題としてデプロイ後にマイグレーションをロールバックするような運用は通常行いませんので、ある程度以上複雑なマイグレーションを書いた場合に、戻す方まで厳密に書かずにraise ActiveRecord::IrreversibleMigrationで済ます、という手段も、状況によってはありかもしれません。

戻す側が不要になる場合

時には、戻す側で何もしなくていいことがあります。たとえば、「あるカラムに空文字列とNULLが混在しているので、全てを一方に揃える」というようなマイグレーションを立てたとします。これのdown側は、特に何もしなくても元のデータと整合するので、放置して構いません。このような場合、

def down
  # 戻す側で何もしなくていい理由
end

のように、コメントで間違いではないことを明記しておきましょう。

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

Railsでmodelに動的なカウンターをつける

要約

railsで一覧画面に何かしらの集計値とかフラグを表示したいときがあると思います。
いいね数とかそういうの。
それをcounter_cacheとかでデータとして持たずに、動的に集計してきれいに組み込む方法です。

やり方

class Post < ApplicationRecord
  has_many :likes, as: :likable
end

class Like < ApplicationRecord
  belongs_to :likable, polymorphic: true
end

@posts = Post.all.select("posts.*, (select count(*) from likes l where l.likable_type='Post' and l.likable_id=posts.id) as likes_count")

@posts.first.likes_count # as xxxがattributeになる
=> 3

サブクエリ(select count(*) from likes l where l.likable_type='Post' and l.likable_id=posts.id)の中は好きなように書けるので、かなり汎用性高くフラグなりなんなりつけられるので、これだけ覚えておけば集計付き一覧はだいたいできると思います。

個人的にrailsのjoin+preload/eager_load/includesなどを使ってrailsのオブジェクトとしてしっかりつくる、という方法にこだわってきたのですが、これだとassociationの書かれ方に影響されて毎回違う書き方になってしまいます。フラグがひとつふたつ付けばいい場合にはやりすぎになってしまいがちです。
一方で、この書き方は多少生のsql書いてしまいますが、使い回しが効くのではないかと思います。

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

[Rails] CircleCIでRspecを回すと「Please call Stripe() with your publishable key. You used an empty string.」というエラーが出る

問題

注文画面から「決済方法を選択する」をクリックし、決済方法選択画面に各種支払方法(クレカ、PayPal、銀行振込)が
表示されているかを確認するためfeature specを作成しました。

ローカルでパスしたのでCIで確認したところ、このようなエラーがでました。

Capybara::Webkit::JavaScriptError:
{:line_number=>1, :message=>"IntegrationError: Please call Stripe() with your publishable key. 
You used an empty string.", :source=>"https://js.stripe.com/v3/"}

エラーをみてみる

Please call Stripe() with your publishable key.
You used an empty string."

どうやらStripe()という関数?をpublishable keyと一緒に呼ばなければならないようです。

そもそもStripe()って何かわからなかったので、
公式ドキュメントをみたところ、発見しました。
https://stripe.com/docs/stripe-js/reference#stripe-function

やっぱり関数ですね。
そして Use Stripe(publishableKey[, options]) to create an instance of the Stripe object.
とあるように、publishable keyを引数に渡してインスタンスを作る関数のようです。
となると、このエラー文は
1. Stripe()が呼べていない
2. publishable keyが存在しない
ことを意味しているように思われます。

しかし

ローカルのENVにはしっかりとpublishable_keyが定義されていて、そちらもcredentialsで参照されています。
だからpublishable keyも存在するはず。そしてStripeMockを使用しているので、Stripe()も呼ばれているはず。
謎は深まるばかりでした。

結論

CircleCIにはCircleCIの環境変数があります。
そこでpublishable keyが定義されていませんでした。

だから、CI上だけテストが落ちていました。

なので、新しく環境変数を定義してかつそれをテストで使用するような場合は
CircleCIの環境変数にも別途追加するようにしましょう、というお話でした。

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

Railsの新卒研修で役に立つもの一覧

この記事はZeals Advent Calendar 2019の3日目の記事です。
はじめまして。ZealsでRailsエンジニアとして働いている鈴木です。
プログラミングの初心者向けの方ように、新卒研修のカリキュラムを作成しました。
そのカリキュラム作成時に、役に立ったサイトや書籍を、紹介させていただきます。

役に立った本一覧

なぜオブジェクト指向で作るのか

なぜオブジェクト指向で作るのか

オブジェクト指向について1からわかりやすく書いているため、プログラミング言語の入門本でオブジェクト指向を知ったけど、よくわからないという人がオブジェクト指向について理解するために適しているので課題図書としてピックアップしました。

プロを目指す人のためのRuby入門

プロを目指す人のためのRuby入門

Rubyについてわかりやすく一通りのことを書いてあるため、Rubyの言語仕様をわかりやすく理解するのに一番適しているだろうということで課題図書としてピックアップしました。

現場で使える Ruby on Rails 5速習実践ガイド

現場で使える Ruby on Rails 5速習実践ガイド

万葉さんが公開している研修の参考本ということと、Railsで開発する時に必要になることを一通り解説しているということでこの本をピックアップしました。

Everyday Rails - RSpecによるRailsテスト入門

Everyday Rails - RSpecによるRailsテスト入門
Railsで開発をしていくためには、Rspecを使いこなすのが必要不可欠だと思います。
この本はFactoryBotやフィーチャースペックまで解説しているため課題図書としてピックアップしました

改訂第3版 SQL書き方ドリル

改訂第3版 SQL書き方ドリル
SQLを理解するには、直接手を動かした記述していくほうが理解しやすくなるため課題図書としてピックアップしました。

サルでもわかるGit入門

サルでもわかるGit入門

無料でGitのコマンドだけではなく、プルリクエストのやりとりまで解説しているため
この資料をピックアップしました。

el-training

el-training
株式会社万葉さんの新卒研修の課題です
Railsアプリのタスク管理システムを開発していくので、実践的な課題になると思いピックアップしました。

TDD Boot Campの課題

TDD Boot Campの課題一覧
Rubyやテストコードを書いていくのには、ある程度大きな問題になれる必要があります。
TDD Boot Campの課題は、TDD初心者がTDDに慣れるということを考えて問題設計をしていると思いピックアップしました

最後に

以上の本やサイトがRailsでシステムを開発していくために、勉強しておいたほうがいいと思った本です。
今回リストアップしたのがこれから、Railsを勉強している人や新卒研修を考えている人の参考になれば幸いです。

明日の担当者は、aburdさんです。 ぜひお楽しみにしてください。

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

Rails: can not be used with :count => 1. key ‘one’ is missing.

背景

オリジナルで制作中、Rails: can not be used with :count => 1. key ‘one’ is missing.のエラーがでた。

環境

Rails 5.2.3
Ruby 2.6.3

解決策

おそらくenumが何か関係している

下記でラジオボタンのところで問題が発生していると予想

  <li class="gender">
    <%= form.radio_button :gender, "男性" %>
    <%= form.label :gender_men, "男性", class: "mens" %>
    <%= form.radio_button :gender, "女性" %>
    <%= form.label :gender_female, "女性", class: "females" %>
  </li>

ここのモデルの記述を確認

enum gender: {男性: 0, 女性: 1}
validates :gender, inclusion: {in: ["男性", "女性"]}
ja:
  activerecord:
    attributes:
      staff:
        content: 自己紹介
        gender:
          one: gender   *この記述が必要だとのこと
          true: 男性
          false: 女性
        gender: 性別

参考サイト
Rails::count => 1では使用できません。キー 'one'がありません。

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

hamlの基本とよくあるエラー集

◆概要

Ruby on Rails学習者向けのhamlの解説です。
初学者の方は基本的なclassなどの書き方は勿論ですが、特に後半の「hamlのよくあるエラー集」を参考にしていただければと思います。

◆hamlって?

html.erbの派生版。
少し難易度は上がるもののコードの量が減りhtml.erbに比べて読みやすいコードになりやすい。
Railsで使用する際にはhaml-railsというgemが必要。

●これがhtml.erb

<div id="information">
  <% if controller.action_name == "index" %>
    ここはトップページです。
  <% else %>
    ここはトップページではありません。
  <% end %>
</div>

<div class="back-gray border-black" id="menu">
  <%= link_to "トップページへ", root_path %>
  <br>
  <%= link_to hoge_path do %>
    hogeページへ
  <% end %>
</div>

●これがhtml.haml

#information
  -if controller.action_name == "index"
    ここはトップページです。
  -else
    ここはトップページではありません。
#menu.back-gray.border-black
  = link_to "トップページへ", root_path
  %br
  = link_to hoge_path do
    hogeページへ

●hamlのメリット

  • 閉じタグ(</hoge>)や<% end %>を記述しなくて良いので記述量が減る。
  • 閉じタグ忘れなどの些細なミスが減る。
  • インデント(半角スペース)のルールが厳しいので、コードが汚くなりにくい。

●hamlのデメリット

  • html.erbの頃では出なかったエラーが出るので最初はエラーが頻出する。

●Q. hamlってエラー出やすくなるだけだしerbで良くない?

●A. 確かに最初は戸惑いますがこの記事の内容が頭に入っていればそうそう躓くことはなくなるので慣れましょう!

◆hamlの基本

●タグ、class、IDの指定の仕方

htmlの場合
<p class="hoge fuga" id="foo">
  こんにちは
  <br>
  いいお天気ですね
</p>
hamlの場合
%p#foo.hoge.fuga
  こんにちは
  %br
  いいお天気ですね
  • タグを指定する際は「%タグ名」と書く(省略するとdivタグになる)。
  • classを指定する際は「.class名」と書く。複数のclassを設定する場合は.class名を繋げる。
  • idを指定する際は「#id名」と書く。

●タグ、class、idの書き方一覧

html.erbの場合 html.hamlの場合
タグ <p></p> %p
タグ+class <p class="hoge fuga"></p> %p.hoge.fuga
タグ+id <p id="fuga"></p> %p#fuga
タグ+id+class <p id="fuga" class="foo bar"></p> %p#fuga.foo.bar

●ネスト(入れ子)の仕方

htmlの場合は以下のようにネストしました。

htmlの場合
<p>
  <span>
    ほげほげふがふが
  <span>
</p>

hamlの場合は以下のようにネストしたいものの行のインデントを下げます(半角スペースを入れます。)

hamlの場合
%p
  %span
    ほげほげふがふが

●viewファイル内でRubyのコードを使う。

viewファイル内でRubyの処理を使いたい場合、erbでは以下のように<%= %><% %>を使いました。

erbの場合
<% fruits = ["りんご", "みかん", "ぶどう"] %>
<% fruits.each do |fruit| %>
  <%= fruit %>
<% end %>

hamlの場合は以下のように書くので<% end %>は不要です(書かないだけで存在はしています)。

hamlの場合
- fruits = ["りんご", "みかん", "ぶどう"]
- fruits.each do |fruit|
  = fruit
html.erbの場合 html.hamlの場合 使い分け
画面に出力したくないもの <% if user_signed_in? %> - if user_signed_in? if, eachなど
画面に表示したいもの <%= current_user.name %> = current_user.name form_with, renderなど

◆hamlのよくあるエラー集

hamlではerbと違って文法を間違えるとすぐにエラーが起きます。エラーは大抵の場合インデントに依るものです(もしくはカンマなど)。
主要なものを紹介します。

●パターン①:全体のインデントが統一されていない。

インデントの増やしていく数は、最初のインデントによって決まります。例えば最初にネストした際にインデントを2つ下げた場合、以降の行でも2つずつ下げる必要があります。途中で変更するとエラーが出ます。インデントは半角スペース2つずつで統一すると覚えておきましょう。

2行目はインデントが2つに対して、4行目、6行目はインデントが1つになっている
.hoge
  .fuga
.foo
 .bar
.abc
 .def

・実際のエラー画面

error1.png

●パターン②:途中で一気にインデントが増えている。

インデントは徐々に増やしていく必要があります。例えば最初にインデントを2つ下げた場合、以降も2つずつ下げましょう。途中で一気に3つ4つ増やすとエラーが起きます。

2行目はインデントが2つに対して、3行目で一気に4つ増えている。
.hoge
  .fuga
      .foo

・実際のエラー画面

error2.png

●パターン③:ネストしてはいけないものでネストしている、もしくはネストするべきものでネストしていない。

hamlでは<% end %>は記述しませんが省略しているだけで存在はしています。ネストを誤るとendの数がおかしくなります。
SyntaxErrorが出た際に以下のようなメッセージが含まれている場合はendの数がおかしくなっています。

endが多い
syntax error, unexpected ensure, expecting end-of-input ensure
endが足りない
syntax error, unexpected end-of-input, expecting end

それではパターンをいくつか見ていきましょう。


ネストしてはいけないものでネストするとエラーが発生します。

ネストしてはいけないものでネストしている①
= render 'foo'
  .hoge
ネストしてはいけないものでネストしている②
= form_for @message do |f|
  = f.text_field :text
    .hoge

●実際のエラー画面

error3.png


ネストするべきものでネストしていない場合もエラーが発生します。doがある場合は何らかをネストする必要があります。

form_forはネストしなければならない
= form_for @message do |f|
= f.text_field :text
each~doはネストしなければならない
- fruits = ["りんご", "みかん", "ぶどう"]
- fruits.each do |fruit|
= fruit
link_to~doはネストしなければならない
= link_to root_path do
トップページへ

●実際のエラー画面

error5.png

文字列で何かをネストした際にもエラーが発生します。

文字列でネストしてはいけない①
.hoge
  こんにちわ
    .fuga
      おはよう
文字列でネストしてはいけない②
.hoge  こんにちわ
  .fuga
    おはよう

●実際のエラー画面

error4.png

「文字列でネストしてはいけない①と②」は書き方が違うだけで内容は同じです。②のような書き方(要素と同じ行に文字列を書く)はエラーを起こしやすいので避けましょう。

●パターン④:カンマに過不足がある。

haml特有というよりはerbでも発生しますが紹介します。

「:text」の後ろにカンマ(,)が必要
= form_for @message do |f|
  = f.text_field :text placeholder: "ほげほげ"

●実際のエラー画面

error6.png

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

Rails6 のちょい足しな新機能を試す109(while_preventing_writes 編)

はじめに

Rails 6 に追加された新機能を試す第109段。 今回は、while_preventing_writes 編です。
Rails 6 では、 while_preventing_writes が追加されました。

multi-db 関連のメソッドで、 while_preventing_writes のブロック内では、 DBへの書き込みができません。
同じDBに対してDBのConnection を別に作成しても書き込みはできないようになっています。

Ruby 2.6.5, Rails 6.0.0 で確認しました。

$ rails --version
Rails 6.0.0

今回は、簡単なスクリプトを作って確認します。

Rails プロジェクトを作成する

$ rails new rails_sandbox
$ cd rails_sandbox

User モデルを作成する

User モデルを作成します。

$ bin/rails g model User name

User モデルを編集する

User モデルのDBの Connection を ActiveRecord::Base.connection とは別になるように変更します。

app/models/user.rb
class User < ApplicationRecord
  connects_to database: { writing: :primary, reading: :primary }
end

動作確認のスクリプトを作成する

ActiveRecord::Base.connectionUser.connection が違うことを確認し、 while_preventing_writes のブロック内では、DBへの書き込みができないことを確認します。

scripts/while_preventing_writes.rb
puts User.count

if ActiveRecord::Base.connection.object_id != User.connection.object_id
  puts 'ActiveRecord::Base.connection != User.connection'
end

# ActiveRecord::Base.connection.while_preventing_writes do # Rails 6.0.0.rc1
ActiveRecord::Base.connection_handler.while_preventing_writes do # Rails 6.0.0
  User.create!(name: 'Taro')
end

puts User.count

マイグレーションを実行する

$ bin/rails db:create db:migrate

スクリプトを実行する

スクリプトを実行します。 Write query attempted while in readonly mode: のメッセージが出力され、書き込みが失敗することがわかります。

$ bin/rails runner scripts/while_preventing_writes.rb
Running via Spring preloader in process 81
0
ActiveRecord::Base.connection != User.connection
Traceback (most recent call last):
...
/usr/local/bundle/gems/activerecord-6.0.0/lib/active_record/connection_adapters/postgresql_adapter.rb:643:in `execute_and_clear': Write query attempted while in readonly mode: INSERT INTO "users" ("name", "created_at", "updated_at") VALUES ($1, $2, $3) RETURNING "id" (ActiveRecord::ReadOnlyError)

ちなみに

Rails 6.0.0rc1 では、エラーにならず、保存できてしまいます。(6.0.0rc1 では、メソッドの定義場所が異なるため、 ActiveRecord::Base.connection.while_preventing_writes とする必要があります。)

試したソース

試したソースは以下にあります。
https://github.com/suketa/rails_sandbox/tree/try109_while_preventing_writes

参考情報

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

Reformで親子関係のあるフォームオブジェクトを作ってみる

Ateam cyma Adevent Calendar 2019、4日目です!
本日は株式会社エイチームでcymaのエンジニアの @bayasist が務めさせていただきます。

Ruby on Railsで書かれたプログラムでは、フォームオブジェクトを利用することで、分かりやすく書くことができる場合があります。RailsでFormObjectを簡単に作れるtrailblazerというgemのReformというものがあり、使いこなせばなかなか便利です。
フォームオブジェクトが楽に作れるようにいくつか機能はありますが、ドキュメント(特に日本語)が少ないので、今回は特に親子関係をもつデータのReformを用いたフォームオブジェクトの作り方を解説できたらと思います。

フォームオブジェクトを利用する利点

フォームオブジェクトはmodelをそのままformにするのではなく、バリデートなどformに関係する処理を行うオブジェクトです。これらのオブジェクトを作成することで、以下のようなメリットがあります。

  • 複数モデルをまたがったFormの処理をコントローラに書かなくても済む
  • ActiveRecordのモデル以外のデータの更新などでも同じようなお作法でView,Controllerが書ける

Reformのメリット

Reformは下記のような特徴があります

フォームオブジェクトを簡易的に作成できるtrailblazerというgemのReformというものがあります。それを用いると下記のようなメリットを受けることができます。

  • ActiveModelと同じような記法でValidateや要素などが記載できる
  • ActiveModel以外のデータの読み書きでも同様の記法で扱える
  • 親子関係など多少データ構造が多少複雑になってもプログラムが複雑になることはない

まずはReformでフォームオブジェクトの基本形を作る

まずは、一つのmodelのみでReformを使ったフォームオブジェクトを作っていきます。nameカラムを持ったparentというモデルを更新するだけのものを作ります。(あとでchildというモデルをparentの子供にします)

app/models/parent.rb
class Parent < ApplicationRecord
end
app/forms/parent_form.rb
class ParentForm < Reform::Form
  property :name
  validates :name, length: { maximum: 5}
end
app/controllers/parent_controller.rb
class ParentController < ActionController::Base
  def edit
    @form = form
  end

  def update
    @form = form
    if @form.validate(update_param)
      @form.save
    end
  end

  private 

  def form
    ParentForm.new(Parent.find(params[:id]))
  end

  def update_param
    params.require(:parent).permit(:name)
  end
end
app/views/parent/edit.html.rb
<%= form_with model: @form do |form| %>
  <%= form.text_field :name %>
  <%= form.submit %>
<% end %>

formオブジェクトができました。
Controllerを見ていただきたいのですが、ほとんどActiveRecordのお作法で書くことができます。
一点大きく違うところはvalidateの部分。Reformでは、validateメソッドでパラメータのバリデートをし、フォームオブジェクトの各要素ににパラメータを渡していきます。
またsaveメソッドでは、フォームオブジェクトの値をもとのモデルに渡してから、モデルのsaveメソッドが呼び出されています。モデルのsaveメソッドを呼び出さず、元のモデルへのデータの移動のみをやりたい場合はsyncメソッドを用います。

parentモデルにchildという子要素を作る

parentモデルの子要素としてchildモデルを作ります。

app/models/parent.rb
class Parent < ApplicationRecord
  has_many :children
end
app/models/child.rb
class Child < ApplicationRecord
  belongs_to :parent
end
app/forms/parent_form.rb
class ParentForm < Reform::Form
  property :name
  validates :name, length: { maximum: 5}
  collection :children, populate_if_empty: Child do
    property :name
    validates :name, length: { maximum: 5}
  end
end
app/controllers/parent_controller.rb
class ParentController < ActionController::Base
  def edit
    @form = form
  end

  def update
    @form = form
    if @form.validate(update_param)
      @form.save
    end
  end

  private 

  def form
    ParentForm.new(Parent.includes(:children).find(params[:id]))
  end

  def update_param
    params.require(:parent).permit(:name, children_attributes: [:name])
  end
end
app/views/parent/edit.html.rb
<%= form_with model: @form do |form| %>
  <%= form.text_field :name %><br />
  <%= form.fields_for :children do |child_form| %>
    <%= child_form.text_field :name %><br />
  <% end %>
  <%= form.submit %>
<% end %>

Formオブジェクトでcollectionを使うこと以外はActiveRecordを使用したときとほとんど変わらずに実装できます。
複数モデルのバリデーションや保存処理はフォームオブジェクトが引き受けるため、モデルが複数になってもControllerが散らかったりすることなく記述できます。また、Reformではそれらの処理をほとんど書くことなく行えます。

上記プログラムの重要な問題点

上記プログラムではChildのアップデートの際に、Textboxの値を上からDBで検索された順にあてはめていきます。/editの表示からアップデートまでにほかのブラウザなどでChildの一部要素がdeleteやinsert等されると予期せぬ動作につながります。
そこで、/editの表示の際にHiddenFieldにchildのidを入れておき、保存の際にChildのidとPOSTで送られてきたidを突合しながら保存していく必要があります。
そのために下記のプログラムを変更する必要があります。

app/controllers/parent_controller.rb
class ParentController < ActionController::Base
  # (中略)
  def update_param
    params.require(:parent).permit(:name, children_attributes: [:id, :name])
  end
end
app/views/parent/edit.html.rb
<%= form_with model: @form do |form| %>
  <%= form.text_field :name %><br />
  <%= form.fields_for :children do |child_form| %>
    <%= child_form.text_field :name %><br />
    <%= child_form.hidden_field :id %>
  <% end %>
  <%= form.submit %>
<% end %>
app/forms/parent_form.rb
class ParentForm < Reform::Form
  property :name
  validates :name, length: { maximum: 5}
  collection :children, populate_if_empty: Child,
    populator: ->(fragment:, **) {
      children.find_by(id: fragment["id"].to_i)
    } do
    property :name
    validates :name, length: { maximum: 5}
  end
end

これを行うことでHiddenFieldのidとDBのIDが同一のものを更新するようになります。ほかのブラウザで該当レコードが削除されていた際はvalidateを行う際にエラーとなり、ほかの関係ないデータを更新しに行くということはありません。
ここではfragmentはvalidateの際に送られてきたデータ(今回でいうとformで入力したデータ)、childrenはDBから持ってきたデータとなります。
この機能を用いれば、複合キーなどID以外で突合することもできます。

例)

app/forms/parent_form.rb
class ParentForm < Reform::Form
  property :name
  validates :name, length: { maximum: 5}
  collection :children, populate_if_empty: Child,
    populator: ->(fragment:, **) {
      children.find(***_id: fragment["***_id"].to_i, ~~~_code: fragment["~~~_code"].to_i)
    } do
    property :name
    validates :name, length: { maximum: 5}
  end
end

ChildのFormを外だしする

Childが複雑になってきたら、Childのフォームを別のファイルに移したくなるかもしれません。そのようにFormObjectの子要素を外に出すことも可能です。

app/forms/parent_form.rb
class ParentForm < Reform::Form
  property :name
  validates :name, length: { maximum: 5}
  collection :children, populate_if_empty: Child,
    populator: ->(fragment:, **) {
      children.find_by(id: fragment["id"].to_i)
    }, form: ChildForm
end
app/forms/child_form.rb
class ChildForm < Reform::Form
  property :name
  validates :name, length: { maximum: 5}
end

他にもいろいろなことができます

Reformを使うことで、子要素の追加や削除、デフォルト値の設定等がModel,Controllerを大きく汚すことなく比較的簡単に行えます。また、ActiveRecord以外のインスタンスにも利用できるため、FormからDBと関係ないインスタンスにデータを移すときに重宝します。
もっと調べてみたい人は公式ドキュメントを見てみてくださいね。

最後に

Ateam cyma Adevent Calendar 2019 の 4日目、いかがでしたか。
5日目は cymaのインフラつよつよエンジニアの @ihsiek がSQLのチューニング入門の記事を書くそうですよ!SQL苦手な人、早く動くSQLを書きたい人は必見ですよ!

株式会社エイチームでは、一緒に働けるチャレンジ精神旺盛な仲間を募集しています。

エンジニアとしての働き方に興味を持たれた方はcymaの Qiita Jobs をご覧ください。

そのほかの職種は、エイチームグループ採用サイトをご覧ください。

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

rails-tutorial第7章

Restfulってなんだ?

RESTfulなアーキテクチャの場合、URLは同一のものを使うかわりに、HTTPリクエストメソッドにそれぞれ、GET、POST、PATCH、DELETEを使って異なるアクションに結びつけるようです。

こうすることでURLを名詞とし、HTTPリクエストメソッドを動詞とすることができ
シンプルにURLをいろいろなアクションに結びつけることができるようになります。

ところで、現在のブラウザにはPATCHやDELETEといったメソッドはないようです。

Railsは存在しないHTTPメソッドを存在するかのように見せかけ
RESTfulなアーキテクチャの実装を実現しているようです。

なんでRestfulなアーキテクチャがいいのか?

Progateのrailsアプリを作った時もそうだったけど、

現在使われているHTTPリクエストメソッドは、GETとPOSTのため
普通は、showアクションは/users_show/1、updateアクションは/users_update/1、
destroyアクションは/users_destroy/1とかのURLを考えますよね。

昔Django(pythonのwebフレームワーク)でアプリを作った時は実際にそうしてました。

でもそうするとurlを管理するファイルの中がアクションの数によっては大変なことになったのを
記憶しています。

これをresources :users
とすることで、ファイルを見やすくすることができる。

ユーザー登録機能を実装する

まずは開発環境でのみデバッグ情報を表示するようにしてみよう

app/views/layouts/application.html.erb
<!DOCTYPE html>
<html>
  .
  .
  .
  <body>
    <%= render 'layouts/header' %>
    <div class="container">
      <%= yield %>
      <%= render 'layouts/footer' %>
      <%= debug(params) if Rails.env.development? %>
    </div>
  </body>
</html>

<%= debug(params) if Rails.env.development? %>
ここではデバッグメソッドが使われている。paramsでデバッグ情報を受け取り、開発環境でのみそれを表示させる。putsメソッドと似ている。ついでに、後置if文を使うときは1行で済む時に使われることが多い。2行以上の時は前置if文を使おう。

ルーティングを設定しよう

config/routes.rb
Rails.application.routes.draw do
  root 'static_pages#home'
  get  '/help',    to: 'static_pages#help'
  get  '/about',   to: 'static_pages#about'
  get  '/contact', to: 'static_pages#contact'
  get  '/signup',  to: 'users#new'
  resources :users
end

ここで、どのurlがどのアクションに対応するのか知りたい。
そんな時は、

$ rails routes

このコマンドを打つと、

ec2-user:~/environment/sample_app (sign-up) $ rails routes
   Prefix Verb   URI Pattern               Controller#Action
     root GET    /                         static_pages#home
     help GET    /help(.:format)           static_pages#help
    about GET    /about(.:format)          static_pages#about
  contact GET    /contact(.:format)        static_pages#contact
   signup GET    /signup(.:format)         users#new
    users GET    /users(.:format)          users#index
          POST   /users(.:format)          users#create
 new_user GET    /users/new(.:format)      users#new
edit_user GET    /users/:id/edit(.:format) users#edit
     user GET    /users/:id(.:format)      users#show
          PATCH  /users/:id(.:format)      users#update
          PUT    /users/:id(.:format)      users#update
          DELETE /users/:id(.:format)      users#destroy

というように、urlと対応するアクションを調べることができる。

PUTリクエストとPATCHリクエストの違いって?

どちらもupdateアクションを指定しているが、PUTリクエストは基本的に全ての情報を更新する時に使う。PATCHリクエストは全部、または一部の情報を更新する時に使う。そのため情報の更新にはPATCHリクエストの方が適切であると言える。

ローカル変数とインスタンス変数

user ローカル変数
ローカル変数のスコープはメソッド内。

@userはインスタンス変数
インスタンス変数は、method外、例えばviewで使うことができる。

Userリソースのshowアクションを実装

app/controllers/users_controller.rb
class UsersController < ApplicationController

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

  def new
  end
end

showアクションが実行される条件は、GET /users/:id
そのため、showアクションが実行される時は必ずidがurlに含まれている。
paramsにはハッシュで情報が保存されるので、params[:id]という書き方で情報を取得する。

params[:id]超わかりやすく解説

・User.new ~ @user.saveでインスタンスが保存される。
@userは {id: 1}というハッシュの情報を持っている。
・で、例えば/users/1というurlにアクセスしたとする。
・このurlは/users/:idという型に当てはまるので{id: 1}という前提でshowアクションが呼び出される。
・重要なのは、urlにアクセスすると、{id: 1}という情報が送られ、それをparamsで取得することができるということ。

debuggerメソッド

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

  def new
  end
end

debuggerメソッドはブレイクポイントみたいなもの。
そこで処理を止めて何が起きてるかrails sをしたターミナルに表示される。

ユーザー登録機能を作ろう

form_forを使ったUser登録フォーム

app/views/users/new.html.erb
<% provide(:title, 'Sign up') %>
<h1>Sign up</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_for(@user) do |f| %>
      <%= f.label :name %>
      <%= f.text_field :name %>

      <%= f.label :email %>
      <%= f.email_field :email %>

      <%= f.label :password %>
      <%= f.password_field :password %>

      <%= f.label :password_confirmation, "Confirmation" %>
      <%= f.password_field :password_confirmation %>

      <%= f.submit "Create my account", class: "btn btn-primary" %>
    <% end %>
  </div>
</div>

例えば、
<%= f.label :email %>
<%= f.email_field :email %>
は、ユーザーがemail欄に書いたvalueを:emailというキーに対応させますよーって意味。

<%= f.label :password_confirmation, "Confirmation" %>
はpassword_confirmationという文字列だと長いので、"Confirmation"に上書きするよーって意味。
表示される文字がConfirmationになる。

<%= f.submit "Create my account", class: "btn btn-primary" %>
このボタンを押すと、フォームの中身がparamsに代入される。

createアクションを見てみよう

app/controllers/users_controller.rb
def create
    @user = User.new(params[:user])    # 実装は終わっていないことに注意!
    if @user.save
      # 保存の成功をここで扱う。
    else
      render 'new'
    end 
  end 

本来User登録は、
User.new(name: ~~, email: ~~....)という感じ。

で、paramsには
{user: {name: , email:}}というハッシュが代入されている。
なので、params[:user]とすれば、:userをキーとするハッシュが代入されてインスタンスを作れるが、、、、、

このままだとクラッキングされてしまう。
{ admin: true }などのメッセージを入れられると、管理者権限を付与することになってしまう。

そこで、、

Strong Parametersを使おう

app/controllers/users_controller.rb
class UsersController < ApplicationController
  .
  .
  .
  def create
    @user = User.new(user_params)
    if @user.save
      # 保存の成功をここで扱う。
    else
      render 'new'
    end
  end

  private

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

こうすることで、permitで指定したキー以外は扱わないよー、変なキーと値が入ってたら弾くよーってしている。

エラーメッセージを出そう。

validationに引っかかって登録に失敗した時、errors.full_messagesオブジェクトは、エラーメッセージの配列を持っています。

なので、

>> user.errors.full_messages
=> ["Email is invalid", "Password is too short (minimum is 6 characters)"]

このように、失敗した理由を出すことができる。エラー要因が複数あれば複数渡してくれる。

登録フォームのviewにエラーメッセージを表示させよう。

app/views/users/new.html.erb
<% provide(:title, 'Sign up') %>
<h1>Sign up</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_for(@user) 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 "Create my account", class: "btn btn-primary" %>
    <% end %>
  </div>
</div>

render 'shared/error_messages'これはエラーメッセージを表示するviewをパーシャル化しますよーってこと。

ディレクトリの作成

$ mkdir app/views/shared
$ touch app/views/shared/_error_messages.html.erb

app/views/shared/_error_messages.html.erb
<% if @user.errors.any? %>
  <div id="error_explanation">
    <div class="alert alert-danger">
      The form contains <%= pluralize(@user.errors.count, "error") %>.
    </div>
    <ul>
    <% @user.errors.full_messages.each do |msg| %>
      <li><%= msg %></li>
    <% end %>
    </ul>
  </div>
<% end %>

signup(失敗時)の統合テストを書いていこう。

$ rails generate integration_test users_signup

インテグレーションテストの名前の付け方は、この場合、signupという一連の動作を確認するものだから、users_signupとしている。

test/integration/users_signup_test.rb
require 'test_helper'

class UsersSignupTest < ActionDispatch::IntegrationTest

  test "invalid signup information" do
    get signup_path
    assert_no_difference 'User.count' do
      post users_path, params: { user: { name:  "",
                                         email: "user@invalid",
                                         password:              "foo",
                                         password_confirmation: "bar" } }
    end
    assert_template 'users/new'
  end
end

post users_pathは/usersにpostリクエストを送っていますよー。
その際に、params: { user: ~~~}を送ってますよーって意味。

上記のテストはユーザー登録が失敗することを期待している。

じゃあ、どうやってそれを判断するのか?

test/integration/users_signup_test.rb
assert_no_difference 'User.count' do
      post users_path, params: { user: { name:  "",
                                         email: "user@invalid",
                                         password:              "foo",
                                         password_confirmation: "bar" } }
    end

assert_no_difference は引数(この場合、User.count)がdo end を実行する前と後では変更ないよね?っていうアサーション。

この場合、validationを設定しているのでuserインスタンスは登録されず、テストは通る。

ユーザー登録成功

まずはcreateアクションの中身を埋めよう

app/controllers/users_controller.rb
class UsersController < ApplicationController
  .
  .
  .
  def create
    @user = User.new(user_params)
    if @user.save
      redirect_to @user
    else
      render 'new'
    end
  end

  private

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

redirect_to @userこれ気になる。

rails routesを見てみよう

user GET    /users/:id(.:format)      users#show
          PATCH  /users/:id(.:format)      users#update
          PUT    /users/:id(.:format)      users#update
          DELETE /users/:id(.:format)      users#destroy

/users/:id は名前付きルートでuser_pathで表すことができる。

本来は user_path(@user.id)で実現できる。
しかし、user_pathはデフォルトで:idに値を入れて渡す

そのため、user_path(@user)というように引数を渡すことでuserインスタンスの情報を渡すことができる。

これをさらに省略すると、
redirect_to @user というようにredirect_toメソッドの引数に直接Userオブジェクトを渡すことで、showアクションへリクエストできる。

補足すると、redirect_toは基本的に指定したurlにgetリクエストを送るという考えでいいと思う。

flash 成功時に一時的なメッセージを出そう!

flashは特殊な変数で、使いたい時はflashという特殊な変数が最初から用意されていると考えるとわかりやすい。実際はメソッド。

usersコントローラに実装してみよう

app/controllers/users_controller.rb
class UsersController < ApplicationController
  .
  .
  .
  def create
    @user = User.new(user_params)
    if @user.save
      flash[:success] = "Welcome to the Sample App!"
      redirect_to @user
    else
      render 'new'
    end
  end

  private

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

flashはキーと値を設定すると、それが次のリクエストまで残り、次の次のリクエストが来た時に消えてくれるという特徴をもつ。

flashメッセージを画面に表示するには?

flashはいろいろなところで使われるので、共通のapplicationテンプレートに表示するためのコードをかくと便利。

具体的には、

app/views/layouts/application.html.erb
<!DOCTYPE html>
<html>
  .
  .
  .
  <body>
    <%= render 'layouts/header' %>
    <div class="container">
      <% flash.each do |message_type, message| %>
        <div class="alert alert-<%= message_type %>"><%= message %></div>
      <% end %>
      <%= yield %>
      <%= render 'layouts/footer' %>
      <%= debug(params) if Rails.env.development? %>
    </div>
    .
    .
    .
  </body>
</html>

<% flash.each do |message_type, message| %>には先ほどコントローラで設定したキーと値が入っている。

この場合、キーがmessage_type 値が、messageに代入されてeachメソッドが実行される。

先ほどは キーに :successを入れた。

実はbootstrapで alert-successというclassが元から用意されているため、キーをsuccessにした。これはcssによって、緑色の文字と縁を作る。
そのため、実際に表示されるのはmessageに代入された値だけとなる。

成功時のテスト

test/integration/users_signup_test.rb
class UsersSignupTest < ActionDispatch::IntegrationTest
  .
  .
  .
  test "valid signup information" do
    get signup_path
    assert_difference 'User.count', 1 do
      post users_path, params: { user: { name:  "Example User",
                                         email: "user@example.com",
                                         password:              "password",
                                         password_confirmation: "password" } }
    end
    follow_redirect!
    assert_template 'users/show'
  end
end

follow_redirect!は「POSTリクエストを送信した結果を見て、指定されたリダイレクト先に移動するメソッド」だそうです。ちなみに、このアプリケーションではユーザー登録がうまくいった場合そのユーザーのページ(users/show.html.erb)にリダイレクトするようにしています。assert_template 'users/show'はそれをチェックしているわけですね。
つまり、 redirect_toする前のテストの結果を精査してから、redirect_to後のテストに進みたい時に使うってこと?

ch7には以下のように書いてある。

ここで、users_pathにPOSTリクエストを送信した後に、follow_redirect!というメソッドを使っていることに注目してください。このメソッドは、POSTリクエストを送信した結果を見て、指定されたリダイレクト先に移動するメソッドです。したがって、この行の直後では'users/show'テンプレートが表示されているはずです。

これが わかりやすい!!!!!!!

つまり、follow_redirect!は、assert_difference内で、users_pathへのPOSTリクエスト(URL:/users、アクション:create)を送信した結果(レスポンス)を見て、controllerで指定しているリダイレクト先のuser_url @user(ユーザ登録完了後のユーザ画面)へ移動している。

これにより、assert_template 'users/show'がテストされるのはpostリクエストがうまくいった時のみとなる。重要なのはredirect_toの前と後どちらもテストが存在するということ。

SSLを使ったデプロイ

SSLを使うと、http から httpsになる。
httpsは流れる情報が暗号化されるらしい。

herokuのサブドメインの場合は問題ないが、自分で独自ドメインを設定する際はSSL証明書を発行する必要がある。

Railsではありがたいことに、本番環境用の設定ファイルであるproduction.rbのコードをたった1行変更するだけでSSLを強制し、httpsによる安全な通信を確立できます。具体的には次のリスト 7.36に示すように、config.force_sslをtrueに設定するだけで完了です。

config/environments/production.rb
Rails.application.configure do
  .
  .
  .
  # Force all access to the app over SSL, use Strict-Transport-Security,
  # and use secure cookies.
  config.force_ssl = true
  .
  .
  .
end

コメントアウトされてるので外してあげればOK

pumaの設定は7章見ながら設定すればOK

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

Classiの新卒エンジニア向け研修、「万葉研修」について

皆様こんにちは!この記事はClassi Advent Calendar 4日目の記事です。

新卒の小野優子(@yukoono)と申します。ポートフォリオチームで、高校生がやったことを記録し、振り返るための「ポートフォリオ」の開発を行っております。

5月にClassiにjoinして以来、合同会社Fjordさんの「Fjord boot camp」で2ヶ月の研修→社内で2ヶ月半の研修→チームに配属され業務へ、というフローで動いておりました。
今回は社内で受けた新卒研修、通称「万葉研修」について書いていきます。

万葉研修とは

株式会社万葉さんがgithub上に公開している、Ruby on Railsのプログラマーになるための教育プログラムです。リンクはこちら。25ステップ+αあり、エンジニアとしてClassiにjoinした新卒メンバーは、この研修プログラムに2ヶ月程かけて取り組みます。メンターとして、「ゼロからわかるRuby超入門」の著者であるigaigaさん、12/21のTokyoGirls.rbで登壇されるただあきさんをはじめとした社内のエンジニアの皆様にお世話になりました。

大まかな流れ

プログラムのステップ3を終えた後、ステップ4で自分が作りたいアプリの仕組みやDB構造を考えます。私の場合は、「暇な時間を使って、普段先延ばしにしていることをやるアプリ」をコンセプトに置きました。利用の流れとしては、
1. アプリに筋トレや英語の勉強など、「やりたいけれど緊急ではない」ことを登録する
2. 暇な時間ができたら、何分程度暇なのかを登録する
3. 所要時間に合わせて、やることをアプリが提案する
4. 終わった後、記録をアプリに入力し、保存する

という感じです。このアプリのペーパープロトタイピングやDB構造案を見せながら、メンターとどう実装するかを相談します。私の場合は最初にやりたいことをかなり盛りだくさんで考えていたため、「まずはやりたいことを登録するだけの、最小の機能で実装してみよう。研修が進むのに合わせて、コンセプトの要素を取り入れていこう」という話になりました。
このようにしてアプリの方向性を決定した後は、研修のステップに沿って実装していきます。方向性がそれぞれ違うため、同じ研修を受けていても出来上がるアプリは人によってかなり違ったものになります。例として、同期の@ruru8は社内で利用できる書籍サービスを作っていました。詳細は22日の投稿をお楽しみに。

研修期間中の過ごし方

  1. プログラムに沿って、アプリに機能を実装する
  2. 取り組んでいたステップの実装が終わる、または途中でもキリのいい単位で実装が行えたら、Githubの自分のリポジトリにローカルの内容をpushする
  3. 見て欲しいところにコメントを書いてプルリクエストを出し、メンターにレビューをお願いする
  4. LGTM(Looks Good To Me…レビューがOKであるという意味)がもらえれば次の実装に取り掛かる。修正が必要な場合は、ローカルで修正を行い、pushして再レビューをお願いする

という感じです。
わからないことがあったときは、会社で買っていただいている参考書籍を読むか、メンターや社内の先輩に質問します。参考書籍についてはこちらにまとめられています。
また、Classiはコミュニケーションツールとしてslackを使っておりまして、個人が分報チャンネルを持ち、困りごとや思ったことなどをつぶやく文化が活発です。分報についてはこちら。私も自分の分報である「times_ono」というチャンネルに研修の進捗を書くようにしておりまして、ここで質問するとチャンネルを見ていただいている先輩からよくアドバイスを頂けます。

image.png

これはtimes_onoの一幕でして、私がわからない!と言ったことに対して、同期の@hxrxchang君が席に助けに来てくれて、参考になるリンクも貼ってくれたところです。こんな感じで、気軽にSOSが出せて、助けてくれる人が社内にたくさんいる、すごく温かい環境です。

成果物

出来上がったアプリがこんな感じです。

image.png

私はレビューで時間をかけたため、ステップ4で考えていたコンセプトの実現は十分には行えず、シンプルに万葉研修のステップを進めたアプリ、という感じの成果物になりました。ただ、私の場合はレビューで得た知識が評価され、チームで研修内容の共有会を開かせていただきました。

研修のゴールは人それぞれです。私はステップ23まで+研修内容の共有会の開催がゴールになりましたが、今研修中のベトナム人の新卒二人は1ヶ月で早くもステップ25を完了し、オプション要件に取り組んでいます。また、フロントエンドのスキルがあるメンバーはRailsのプログラミングと並行してAngular jsで画面の作成を行い、操作性に優れた格好いいアプリを作っています。Classiの先輩方に協力してもらい、ユーザビリティテストを行なったメンバーもいます。

研修を終えて

研修を通して、「プログラマーの仕事の大部分は、人への気遣いである」という感想を持ちました。なぜなら、メンターからの指摘事項のほとんどは、「他のエンジニアがアプリをメンテナンスする時に分かりやすい/後で困らないコーディング」についてのアドバイスだったためです。

  • こうした方が、他のプログラマーが分かりやすい!
  • この方がRailsや他のgemのアップデートに関わらず使える!
  • このコメントをつけた方が、レビュアーや他のエンジニアに親切!

などのコメントをたくさん頂くうちに、プログラマーの仕事像が「黙々と実装だけ行えれば良い、他の人とは関わらない」から、「他の人へ気遣い、情報を伝達し、理解して貰えるコードを書く」に変わっていきました。この意識は、現在チームで仕事をする上でも、チームメンバーへの気遣いや、伝わりやすいプルリクエストという形で活きています。

終わりに

この研修について社内の先輩エンジニアに話したところ、「羨ましい」という声を多く頂きました。2ヶ月以上かけてコードの書き方を教えてもらえる機会はとても貴重で、多くのプログラマーは仕事の中でコードの書き方やプルリクエストの仕方を覚えていくそうです。手厚い研修プログラムを組んでいただいたigaigaさん、ただあきさん、わからないところを教えて頂いた先輩エンジニアの皆さん、また充実した研修を受けさせて貰える会社の懐の広さに感謝しつつ、この投稿を締めます。

明日の投稿は@onigraさんです。お楽しみに。

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

Classiの新卒エンジニア向け研修について

皆様こんにちは!この記事はClassi Advent Calendar 4日目の記事です。

新卒の小野優子(@yukoono)と申します。ポートフォリオチームで、高校生がやったことを記録し、振り返るための「ポートフォリオ」の開発を行っております。

5月にClassiにjoinして以来、合同会社Fjordさんの「Fjord boot camp」で2ヶ月の研修→社内で2ヶ月半の研修→チームに配属され業務へ、というフローで動いておりました。
今回は社内で受けた新卒研修について書いていきます。

研修内容

株式会社万葉さんがgithub上に公開している、Ruby on Railsのプログラマーになるための教育プログラムを使っています。リンクはこちら
25ステップ+αあり、エンジニアとしてClassiにjoinした新卒メンバーは、この研修プログラムに2ヶ月程かけて取り組みます。メンターとして、「ゼロからわかるRuby超入門」の著者であるigaigaさん、12/21のTokyoGirls.rbで登壇されるただあきさんをはじめとした社内のエンジニアの皆様にお世話になりました。

大まかな流れ

プログラムのステップ3を終えた後、ステップ4で自分が作りたいアプリの仕組みやDB構造を考えます。私の場合は、「暇な時間を使って、普段先延ばしにしていることをやるアプリ」をコンセプトに置きました。利用の流れとしては、
1. アプリに筋トレや英語の勉強など、「やりたいけれど緊急ではない」ことを登録する
2. 暇な時間ができたら、何分程度暇なのかを登録する
3. 所要時間に合わせて、やることをアプリが提案する
4. 終わった後、記録をアプリに入力し、保存する

という感じです。このアプリのペーパープロトタイピングやDB構造案を見せながら、メンターとどう実装するかを相談します。私の場合は最初にやりたいことをかなり盛りだくさんで考えていたため、「まずはやりたいことを登録するだけの、最小の機能で実装してみよう。研修が進むのに合わせて、コンセプトの要素を取り入れていこう」という話になりました。
このようにしてアプリの方向性を決定した後は、研修のステップに沿って実装していきます。方向性がそれぞれ違うため、同じ研修を受けていても出来上がるアプリは人によってかなり違ったものになります。例として、同期の@ruru8は社内で利用できる書籍サービスを作っていました。詳細は22日の投稿をお楽しみに。

研修期間中の過ごし方

  1. プログラムに沿って、アプリに機能を実装する
  2. 取り組んでいたステップの実装が終わる、または途中でもキリのいい単位で実装が行えたら、Githubの自分のリポジトリにローカルの内容をpushする
  3. 見て欲しいところにコメントを書いてプルリクエストを出し、メンターにレビューをお願いする
  4. LGTM(Looks Good To Me…レビューがOKであるという意味)がもらえれば次の実装に取り掛かる。修正が必要な場合は、ローカルで修正を行い、pushして再レビューをお願いする

という感じです。
わからないことがあったときは、会社で買っていただいている参考書籍を読むか、メンターや社内の先輩に質問します。参考書籍についてはこちらにまとめられています。
また、Classiはコミュニケーションツールとしてslackを使っておりまして、個人が分報チャンネルを持ち、困りごとや思ったことなどをつぶやく文化が活発です。分報についてはこちら。私も自分の分報である「times_ono」というチャンネルに研修の進捗を書くようにしておりまして、ここで質問するとチャンネルを見ていただいている先輩からよくアドバイスを頂けます。

image.png

これはtimes_onoの一幕でして、私がわからない!と言ったことに対して、同期の@hxrxchang君が席に助けに来てくれて、参考になるリンクも貼ってくれたところです。こんな感じで、気軽にSOSが出せて、助けてくれる人が社内にたくさんいる、すごく温かい環境です。

成果物

出来上がったアプリがこんな感じです。

image.png

私はレビューで時間をかけたため、ステップ4で考えていたコンセプトの実現は十分には行えず、シンプルに研修プログラムのステップを進めたアプリ、という感じの成果物になりました。ただ、私の場合はレビューで得た知識が評価され、チームで研修内容の共有会を開かせていただきました。

研修のゴールは人それぞれです。私はステップ23まで+研修内容の共有会の開催がゴールになりましたが、今研修中のベトナム人の新卒二人は1ヶ月で早くもステップ25を完了し、オプション要件に取り組んでいます。また、フロントエンドのスキルがあるメンバーはRailsのプログラミングと並行してAngularで画面の作成を行い、操作性に優れた格好いいアプリを作っています。Classiの先輩方に協力してもらい、ユーザビリティテストを行なったメンバーもいます。

研修を終えて

研修を通して、「プログラマーの仕事の大部分は、人への気遣いである」という感想を持ちました。なぜなら、メンターからの指摘事項のほとんどは、「他のエンジニアがアプリをメンテナンスする時に分かりやすい/後で困らないコーディング」についてのアドバイスだったためです。

  • こうした方が、他のプログラマーが分かりやすい!
  • この方がRailsや他のgemのアップデートに関わらず使える!
  • このコメントをつけた方が、レビュアーや他のエンジニアに親切!

などのコメントをたくさん頂くうちに、プログラマーの仕事像が「黙々と実装だけ行えれば良い、他の人とは関わらない」から、「他の人へ気遣い、情報を伝達し、理解して貰えるコードを書く」に変わっていきました。この意識は、現在チームで仕事をする上でも、チームメンバーへの気遣いや、伝わりやすいプルリクエストという形で活きています。

終わりに

この研修について社内の先輩エンジニアに話したところ、「羨ましい」という声を多く頂きました。2ヶ月以上かけてコードの書き方を教えてもらえる機会はとても貴重で、多くのプログラマーは仕事の中でコードの書き方やプルリクエストの仕方を覚えていくそうです。手厚い研修プログラムを組んでいただいたigaigaさん、ただあきさん、わからないところを教えて頂いた先輩エンジニアの皆さん、また充実した研修を受けさせて貰える会社の懐の広さに感謝しつつ、この投稿を締めます。

明日の投稿は@onigraさんです。お楽しみに。

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

【Rails】errors.addって何?

モデルでは特に考えずにerrors.add(:base, '名前の文字数オーバー')とかしておけば良いかーみたいな風潮ありますよね:church:
これ

> user = User.new
> user.errors
=> #<ActiveModel::Errors:0x00007fc0ed8b5a50 @base=#<User id: nil, name: nil, created_at: nil, updated_at: nil>, @messages={}, @details={}>
> user.errors.add(:base, '名前の文字数オーバー')
> user.errors.full_messages
=> ["名前の文字数オーバー"] 

そもそも.addってなんなのさと、すぐraiseされるならわかるけど、何個も追加(add)できるの????

errors.addの使い方一覧

結論から書きます。

# シンプルな構文(どんなエラーなのか)
> user.errors.add(:base, '名前の文字数オーバー')
> user.errors.full_messages
 => ["名前の文字数オーバー"]

# 一般的な構文(どのカラムの、どんなエラーなのか)
> user.errors.add(:name, '文字数オーバー')
> user.errors.full_messages
=> ["Name 文字数オーバー"] 

# I18nを利用した構文
# (対象がないと translation missing)
## activerecord.errors.models.user.attributes.name.over_char_limit
## activerecord.errors.models.user.over_char_limit
## activerecord.errors.messages.over_char_limit
## errors.attributes.name.over_char_limit
## errors.messages.over_char_limit
> user.errors.add(:name, :over_char_limit)
> user.errors.full_messages
=> ["Name 文字数オーバー"] 

# すぐraiseしたい構文
> user.errors.add(:name, :over_char_limit, strict: true)
ActiveModel::StrictValidationFailed (Name 文字数オーバー)
# StrictValidationFailed < StandardError です

# 何の為にあるのか不明
> user.errors.add(:name, :over, message: '文字数オーバー')
> user.errors.full_messages
=> ["Name 文字数オーバー"] 
# .details のため?
> user.errors.details
=> {:name=>[{:error=>:over}]}

すぐraiseできるじゃん!
良い機会なので、ここからerrorsvalidationsの関係性を考えます。

ここから下は読まなくて良いです。

railsを調べてみた。

rails 5-1-stableブランチを見てます!

errorsのmethodはどこから?

すぐ見つけた
active_model/validations.rb

activemodel/lib/active_model/validations.rb
# Errors の引数でモデルのオブジェクトを渡してる
def errors
  @errors ||= Errors.new(self)
end

モデルはActiveRecord::Baseを継承しているので、辿っていくと以下のようにincludeされてる

# activerecord/lib/active_record/base.rb
include Validations

# activerecord/lib/active_record/validations.rb
include ActiveModel::Validations

.addのmethodはどうなってるの?

normalize_messageI18nで使えるように頑張ってパースしてた
raiseとかはここで制御してるんだね
active_model/errors.rb

activemodel/lib/active_model/errors.rb
def add(attribute, message = :invalid, options = {})
  message = message.call if message.respond_to?(:call)
  detail  = normalize_detail(message, options)
  message = normalize_message(attribute, message, options)
  if exception = options[:strict]
    exception = ActiveModel::StrictValidationFailed if exception == true
    raise exception, full_message(attribute, message)
  end

  details[attribute.to_sym]  << detail
  messages[attribute.to_sym] << message
end

.addしたけど、いつraiseされるの?

railsでは.create!.update!も結果的に.save!が呼ばれます。
perform_validationsvalid?でfalseになるとraiseされます。

activerecord/lib/active_record/validations.rb
def save!(options = {})
  perform_validations(options) ? super : raise_validation_error
end

private

def raise_validation_error
  raise(RecordInvalid.new(self))
end

def perform_validations(options = {})
  options[:validate] == false || valid?(options[:context])
end

RecordInvalidが呼び出されたら

errorsの配列のメッセージはここでjoinされてるんだね

activerecord/lib/active_record/validations.rb
class RecordInvalid < ActiveRecordError
  def initialize(record = nil)
    if record
      @record = record
      errors = @record.errors.full_messages.join(", ")
      message = I18n.t(:"#{@record.class.i18n_scope}.errors.messages.record_invalid", errors: errors, default: :"errors.messages.record_invalid")
    else
      message = "Record invalid"
    end
    super(message)
  end
end

以下のja.ymlが必要なので忘れないように!

config/locales/models/ja.yml
ja:
  activerecord:
    errors:
      messages:
        record_invalid: "%{errors}"

valid?では何をしてるの?

  1. errors.clear : valid?以前に追加されたerrorsを削除
  2. run_validations! : モデルに紐づいてる:validateを1つずつ実行してる
  3. errors.empty? : errorsの配列にあればraise!

つまり、コンソール上でerrors.addをしても何も起きないんです?(clearされる)

activerecord/lib/active_record/validations.rb
def valid?(context = nil)
  context ||= default_validation_context
  output = super(context)
  errors.empty? && output
end
activemodel/lib/active_model/validations.rb
def valid?(context = nil)
  current_context, self.validation_context = validation_context, context
  errors.clear
  run_validations!
ensure
  self.validation_context = current_context
end

run_validations!はちょっと難しかった
active_support/callbacks.rbrun_callbacksが呼ばれるんだけど、これが結構難しい
(結果的に、:validateを1つずつ実行してるだけなんだけどね)

解説は以下のqiitaにありました
Railsのvalidationの実行過程を調べる

どんな:validateが実行するのか知りたいなら

# モデルに紐づいてる:validateの一覧
> user.__callbacks[:validate]
# 自作validatorの一覧
> user._validators

自作validatorを作りたいなら

これは弊社でもおなじみのvalidatorの作り方です!
オリジナルのバリデーションクラス:validates_with

所感

初めてrailsのソースコード読んでみたけど、結構読みやすかった!
でもprocとかyieldとか意味は知ってるけど、普段使わない関数が急に出てくると困惑するし、急にわからなくなるなあ。

これを機に俺もrailsのソースコードを読み込んでみよう!と10分思ったが、めんどくさいし、タピオカ飲みたいので辞めた?

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

CarrierWaveでpng画像を処理したときに色空間がGRAYになってしまってた

バージョン

  • ruby 2.6.1
  • Ruby on Rails 5.2.3
  • MacOS Catalina 10.15.1
  • ImageMagick 7.0.9-5
  • gem mini_magick 4.9.5
  • gem carrierwave 1.3.1

問題

CarrierWaveでMiniMagickを使って画像を処理する時。例えば、resize_to_fit とかresize_and_pad とか resize_to_fill とかを使うと、png画像の色空間が元々RGBだったのにGRAYになってしまう現象があった。

調査

どうやら、色空間をカラーで持っていても、グレイスケールっぽい画像だとImageMagickがカラースペースをグレイスケールに変換してしまうようだった。
なぜ・・、やめてくれ・・!

解決方法

上記した resize_to_fit とかのメソッドには、 combine_options という名前付き引数が渡せる。これはImageMagickのオプションと対応しているので、とにかく大量のものが指定できて、その中で、png画像での色の設定は、-defineオプションでpng:color-type=?を指定できる。
今回はRGBAで色情報を持ってほしかったので、png:color-type=6となる。

つまり、combine_options: {define: 'png:color-type=6'}を指定することで解決した。
Carrierwaveがどうとかいう問題ではなく、ImageMagick側の話だったようだ。

注意

これで解決したんだけど、このオプションは要注意なことが書いてあるので、png:color-typeの説明は見ておいた方が良さそう。

最初は色空間が変わるという現象だったから、combine_optionsに-colorspace sRGBを指定してたんだけど、これでは問題は解決できなかったのでちょっとハマった・・。

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

redirect_toとrenderの違い・使い分け

【結論】アクションを実行するかしないかの違い

xxx_controller.rb
def create
  message = Message.new(message_params)
  if message.save
    redirect_to :new
  else
    render :new
  end
end

このようなcreateアクションがあります。

saveが成功した場合はnewアクションが実行され、一度ページがリセットされnewのページが表示されます。

saveが失敗した場合はnewアクションを通さず、入力された情報はそのままにnewのページが表示されます。



保存が成功したらフォームに情報を残しておく必要はありませんよね。
保存が失敗したらエラーメッセージを表示するなどして、入力情報のエラー部分のみを変更してもらいましょう。



参考

https://www.pikawaka.com/rails/render



ではまた!

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

フォロー機能がほしいぃぃ

RailsTutorialを参考にフォロー機能をつけました。

簡単にまとめます。

※クラス名・id名は本家と変えたところがあります。
※ところどころ自己流にアレンジしています。ご容赦ください。

Relationshipモデル

ターミナル
$ rails g model Relationship follower_id:integer followed_id:integer
db/migrate/20191128014554_create_relationships.rb
class CreateRelationships < ActiveRecord::Migration[5.2]
  def change
    create_table :relationships do |t|
      t.integer :follower_id
      t.integer :followed_id

      t.timestamps
    end
    add_index :relationships, :follower_id
    add_index :relationships, :followed_id
    add_index :relationships, [:follower_id, :followed_id], unique: true
  end
end
ターミナル
$ rails db:migrate
app/models/relationship.rb
class Relationship < ApplicationRecord
  belongs_to :follower, class_name: "User"
  belongs_to :followed, class_name: "User"
  validates :follower_id, presence: true
  validates :followed_id, presence: true
end

Userモデル

app/models/user.rb
class User < ApplicationRecord
  
  has_many :active_relationships, class_name: "Relationship", foreign_key: "follower_id", dependent: :destroy
  has_many :passive_relationships, class_name: "Relationship", foreign_key: "followed_id", dependent: :destroy
  has_many :following, through: :active_relationships, source: :followed
  has_many :followers, through: :passive_relationships, source: :follower

  def follow(other_user)
    following << other_user
  end

  def unfollow(other_user)
    active_relationships.find_by(followed_id: other_user.id).destroy
  end

  def following?(other_user)
    following.include?(other_user)
  end
end

ルーティング

app/config/locals/routes.rb
Rails.application.routes.draw do
  
  resources :users, only: [:show] do
    member do
      get :following, :followers
    end
  end
  resources :relationships, only: [:create, :destroy]
end

ビュー

app/views/users/_stats.html.haml
- @user ||= current_user
%div
  = link_to following_user_path(@user) do
    #following
      = @user.following.count
  = link_to followers_user_path(@user) do
    #followers
      = @user.followers.count
app/views/users/_follow_form.html.haml
- unless current_user == @user
  #follow_form
    - if current_user.following?(@user)
      = render 'unfollow'
    - else
      = render 'follow'
app/views/users/_follow.html.haml
= form_for(current_user.active_relationships.build, remote: true) do |f|
  = hidden_field_tag :followed_id, @user.id
  = f.submit "Follow", class: "btn btn-primary"
app/views/users/_unfollow.html.haml
= form_for(current_user.active_relationships.find_by(followed_id: @user.id), html: { method: :delete }, remote: true) do |f| 
  = f.submit "Unfollow", class: "btn"
xxx.html.haml
#好きなところへ
= render "stats"
= render "follow_form" if user_signed_in?



以下2つは完全自己流なのでスルーしてください。

following.html.haml
.contents
  = render "tweets/side-bar"
  .contents__right
    .contents__right__tweets
      .contents__right__tweets__follows
        .contents__right__tweets__follows__user-nickname
          = @user.nickname
          さん
        .contents__right__tweets__follows__text
          フォロー中
      - @users.each do |user|
        .contents__right__tweets__follow-users
          %p.contents__right__tweets__follow-users__user
            = link_to "#{user.nickname}", user_path(user)
      .contents__right__tweets__paginate
        = paginate(@users)
followers.html.haml
.contents
  = render "tweets/side-bar"
  .contents__right
    .contents__right__tweets
      .contents__right__tweets__follows
        .contents__right__tweets__follows__user-nickname
          = @user.nickname
          さん
        .contents__right__tweets__follows__text
          フォロワー
      - @users.each do |user|
        .contents__right__tweets__follow-users
          %p.contents__right__tweets__follow-users__user
            = link_to "#{user.nickname}", user_path(user)
      .contents__right__tweets__paginate
        = paginate(@users)

本家はshow_follow.html.erbというビューファイルを作って、そこにレンダリングしています。
僕はfollowing followers2つのビューファイルを作りました。冗長かもしれませんが明確に分けて管理したかったのでこうしました。

コントローラー

relationships_controller.rb
class RelationshipsController < ApplicationController
  before_action :authenticate_user!

  def create
    @user = User.find(params[:followed_id])
    current_user.follow(@user)
    respond_to do |format|
      format.html { redirect_to @user }
      format.js
    end
  end

  def destroy
    @user = Relationship.find(params[:id]).followed
    current_user.unfollow(@user)
    respond_to do |format|
      format.html { redirect_to @user }
      format.js
    end
  end
end
users_controller.rb
class UsersController < ApplicationController
  before_action :authenticate_user!, only: [:following, :followers]

  

  def following
    @user = User.find(params[:id])
    @users = @user.following.page(params[:page]).per(3)
  end

  def followers
    @user = User.find(params[:id])
    @users = @user.followers.page(params[:page]).per(3)
  end
end



本家大正義RailsTutorial

https://railstutorial.jp/chapters/following_users?version=5.1#cha-following_users



ではまた!

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

Railsチュートリアル 第11章 アカウントの有効化 - 前提

サンプルアプリケーションの現状と、これから実装する変更内容

Railsチュートリアルの第10章終了時点では、「新規登録したユーザーは、はじめからすべての機能にアクセスできる」という実装となっています。第11章では、「アカウントを有効化するステップを新規登録の途中に差し込むことで、本当にそのメールアドレスの持ち主なのかを確認する」という機能を実装していきます。

アカウントの有効化プロセス

概要

「アカウントの有効化」というユースケースは、大まかには以下のプロセスからなります。

  1. 有効化トークン・ダイジェストを新規に生成したユーザー情報と関連付ける
  2. 有効化トークンを含むリンクを、ユーザーにメールで送信する
  3. ユーザーが2.のリンクをクリックすると、当該アカウントが有効化される

詳細

上記「アカウントの有効化プロセスの概要」をさらに詳細に見ていくと、以下のような機能の実装が必要となるのがわかります。

  • ユーザーの初期状態は、「有効化されていない(unactivated)」であること
  • ユーザー登録が行われた際に、以下が生成されること
    • 有効化トークン
    • 当該有効化トークンに対応する有効化ダイジェスト
  • 有効化ダイジェストがRDBに保存されること
  • 新規登録ユーザーの登録メールアドレス宛に有効化用メールが送信されること
    • 当該有効化用メール中に記載されるリンクに有効化トークンが含まれること
  • 有効化トークンの認証機能
    • ユーザーが有効化用メール中のリンクをクリックしたことが検出できる機能
    • 登録メールアドレスをキーとしてユーザーを検索する機能
    • ユーザーから送信された有効化トークンと、RDB中の有効化ダイジェストを比較する機能
  • ユーザーの状態を「有効化されていない」から「有効化済み(activated)」に変更する機能
    • 有効化トークンの認証に成功した時点で変更される

実は、上記の仕組みは、これまで実装してきた「パスワード」「(永続cookiesにおける)記憶トークン」の仕組みとよく似ています。少なからぬメソッドは使いまわしも可能なのです。

検索キー string digest authentication
email password password_digest authenticate(password)
id remember_token remember_digest authenticated?(:remember, token)
email activation_token activation_digest authenticated?(:activation, token)
email reset_token reset_digest ahthenticated?(:reset, token)

第11章で行う実装

  • アカウント有効化に必要なリソースやデータモデルの生成
  • アカウント有効化時のメール送信部分の実装
    • Railsのmailer機能を用いる
  • User#authenticated?メソッドの機能拡張
  • アカウント有効化処理の本体の実装
    • 機能拡張したUser#authenticated?を用いる
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Rails】ユーザー情報の編集【Rails Tutorial 10章まとめ】

Usersコントローラの編集

Usersコントローラにアクションを追加し、ユーザー情報の編集やユーザー一覧の表示、ユーザーの削除を行えるようにする。

ユーザー情報の編集

editアクションと編集フォーム

Usersコントローラにeditアクションを追加する。
editアクションに対応するURLはusers/:id/edit(名前付きルートはedit_user_path(user))なので、params[:id]を使えば編集したいユーザーを取得できる。

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

editアクションに対応するeditビューは以下のようになる。

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_for(@user) 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="http://gravatar.com/emails" target="_blank" rel="noopener">change</a>
    </div>
  </div>
</div>

これはユーザー新規登録フォームとほぼ同じである。
編集フォームのform_forは以下のような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>

form_forのコードは新規登録フォームと同じなのに、各属性が更新用になっているのは、Railsが新規ユーザーであるか既存ユーザーの編集であるかを自動で判別してくれるからである。

なお、gravatar用のaタグについているtarget="_blank"とrel="noopener"は、リンク先を別のタブで開くためのものである(rel属性はセキュリティ上の問題を解決するためのもの)。

editアクションとビューができたので、レイアウトのヘッダー部分にリンクを貼っておく。

app/views/layouts/_header.html.erb
<% 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>
      <li class="divider"></li>
      <li><%= link_to "Log out", logout_path, method: :delete %></li>
    </ul>
  </li>
<% else %>

editページとsignupページのパーシャル

ユーザー編集フォームと新規登録フォームは、フォームの送信ボタンの文字と、gravatarのリンクしか違いがないので、パーシャルにまとめる。

app/views/users/_form.html.erb
<%= form_for(@user, url: yield(:url)) do |f| %>
  <%= render 'shared/error_messages', object: @user %>

  <%= 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 %>
  <%= f.password_field :password_confirmation, class: 'form-control' %>

  <%= f.submit yield(:button_text), class: "btn btn-primary" %>
<% end %>

signupビュー

app/views/users/new.html.erb
<% provide(:title, 'Sign up') %>
<% provide(:url, signup_path) %>
<% provide(:button_text, 'Create my account') %>
<h1>Sign up</h1>
<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= render 'form' %>
  </div>
</div>

editビュー

app/views/users/edit.html.erb
<% provide(:title, 'Edit user') %>
<% provide(:url, user_path) %>
<% provide(:button_text, 'Save changes') %>
<h1>Update your profile</h1>
<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= render 'form' %>
    <div class="gravatar_edit">
      <%= gravatar_for @user %>
      <a href="http://gravatar.com/emails" target="_blank">Change</a>
    </div>
  </div>
</div>

ここでは、2つのprovideメソッドとyieldを使っている。
一つはform_forの送信先である。
新規登録フォームではcreateアクションに送信するので、signup_pathとする。
編集フォームではupdateアクションに送信するので、user_pathとする(引数(user)は不要)。

もう一つはフォーム送信ボタンの文字である。

編集の失敗

編集失敗時の処理

更新フォームの送信先であるupdateアクションを書く。

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

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

ユーザーを取得した後update_attributesでその属性を更新する。
更新に失敗した場合は編集画面に戻る。

編集失敗時のテスト

ユーザー編集用の統合テストを作成する。

$ rails generate integration_test users_edit

ユーザー情報の更新に失敗した場合のテストを書く。

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'
    patch user_path(@user), params: { user: { name:  "",
                                              email: "foo@invalid",
                                              password:              "foo",
                                              password_confirmation: "bar" } }
    assert_template 'users/edit'
  end
end

updateアクションにはPATCHリクエストを送信する点に注意する。

編集の成功

編集成功時の処理

編集に成功した場合は、新規登録のcreateアクションと同様に、フラッシュメッセージを表示してユーザー表示ページに移動する。

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

編集成功時のテスト

ユーザー情報の編集に成功した場合のテストを書く。

test/integration/users_edit_test.rb
  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?
    assert_redirected_to @user
    @user.reload
    assert_equal name,  @user.name
    assert_equal email, @user.email
  end

変更したいnameやemailを変数に入れているのは、編集完了後のUserオブジェクトの各属性と比較するためである。
assert_equalでエラーが出れば、編集に成功したはずなのにnameやemailが変わっていないことになる。

ここでテストはREDになるのだが、それはパスワードが有効な値でないためである。
パスワードの変更は必須ではないため、Userモデルのパスワードのバリデーションにallow_nil: trueを追加して、この例外に対応する。

app/models/user.rb
validates :password, presence: true, length: { minimum: 6 }, allow_nil: true

テストがGREENとなることを確認する。

ユーザーの認可

認可

ユーザーの編集機能が完成したが、この状態では誰もが任意のユーザー情報を変更できてしまう。
そこで、認可(authorization)機能を追加して、特定のページへのアクセスを制限する。

before_actionとlogged_in_userメソッド

before_acitonメソッドを使うことで、各アクションの実行前に特定の操作を行うことができる。

ログインしていないとユーザー情報を編集できないようにする。
editアクションとupdateアクションの前にログインしているかを判定して、ログインしていなければログインページにリダイレクトするlogged_in_userメソッドを定義する。

app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:edit, :update]
  .
  .
  .
  private
    .
    .
    .
    # beforeアクション
    # ログイン済みユーザーかどうか確認
    def logged_in_user
      unless logged_in?
        flash[:danger] = "Please log in."
        redirect_to login_url
      end
    end
end

before_actionでは、アクション前に実行するメソッドをハッシュの形(:logged_in_user)で指定する。
これで、ログインしていないユーザーだとeditページにアクセスできなくなる。
また、これが原因でテストがREDになるので修正する。
各テストの前にlog_in_asヘルパーメソッドを使ってログインしておく。

test/integration/users_edit_test.rb
  test "unsuccessful edit" do
    log_in_as(@user)
    get edit_user_path(@user)
    .
    .
    .
  end

  test "successful edit" do
    log_in_as(@user)
    get edit_user_path(@user)
    .
    .
    .
  end

logged_in_userのテスト

logged_in_userのテストを書く。

test/controllers/users_controller_test.rb
  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

updateアクションでは、PATCHリクエストでユーザー情報を送信する。
テストの前後でbefore_actionをコメントアウトして、正しくテストできているかを確認しておく。

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

ユーザーが他のユーザー情報を編集できないようにするために、correct_userメソッドを作成し、before_actionでedit、updateアクションに設定する。

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?
        flash[:danger] = "Please log in."
        redirect_to login_url
      end
    end

    # 正しいユーザーかどうか確認
    def correct_user
      @user = User.find(params[:id])
      unless @user == current_user
        flash[:danger] = "You have no authority for the page"
        redirect_to(root_url)
      end
    end
end

このメソッドに該当するのはログイン済みのユーザーなので、ログインページではなくルートURLにリダイレクトする。
(ちなみに、チュートリアルではここでフラッシュメッセージを入れてないせいで後のテストがREDになるので勝手に入れてます。)

慣習として、unless @user == current_userの部分をcurrent_user?というヘルパーメソッドにしておく。

app/helpers/sessions_helper.rb
  # 渡されたユーザーがログイン済みユーザーであればtrueを返す
  def current_user?(user)
    user == current_user
  end
app/controllers/users_controller.rb
    # 正しいユーザーかどうか確認
    def correct_user
      @user = User.find(params[:id])
      unless current_user?(@user)
        flash[:danger] = "You have no authority for the page"
        redirect_to(root_url)
      end
    end

正しいユーザーのテスト

認可機能をテストするために、fixtureファイルに別のユーザーを作成する。

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') %>

テストを書く。

test/integration/users_edit_test.rb
 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)
    assert flash.empty?
    assert_redirected_to root_url
  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,
                                              email: @user.email } }
    assert flash.empty?
    assert_redirected_to root_url
  end

log_in_asメソッドを使って別のユーザーでログインすることと、ルートURLにリダイレクトすること以外は、非ログイン時のテストと同じである。

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

【Rails】ユーザーの情報の編集【Rails Tutorial 10章まとめ】

Usersコントローラの編集

Usersコントローラにアクションを追加し、ユーザー情報の編集やユーザー一覧の表示、ユーザーの削除を行えるようにする。

ユーザー情報の編集

editアクションと編集フォーム

Usersコントローラにeditアクションを追加する。
editアクションに対応するURLはusers/:id/edit(名前付きルートはedit_user_path(user))なので、params[:id]を使えば編集したいユーザーを取得できる。

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

editアクションに対応するeditビューは以下のようになる。

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_for(@user) 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="http://gravatar.com/emails" target="_blank" rel="noopener">change</a>
    </div>
  </div>
</div>

これはユーザー新規登録フォームとほぼ同じである。
編集フォームのform_forは以下のような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>

form_forのコードは新規登録フォームと同じなのに、各属性が更新用になっているのは、Railsが新規ユーザーであるか既存ユーザーの編集であるかを自動で判別してくれるからである。

なお、gravatar用のaタグについているtarget="_blank"とrel="noopener"は、リンク先を別のタブで開くためのものである(rel属性はセキュリティ上の問題を解決するためのもの)。

editアクションとビューができたので、レイアウトのヘッダー部分にリンクを貼っておく。

app/views/layouts/_header.html.erb
<% 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>
      <li class="divider"></li>
      <li><%= link_to "Log out", logout_path, method: :delete %></li>
    </ul>
  </li>
<% else %>

editページとsignupページのパーシャル

ユーザー編集フォームと新規登録フォームは、フォームの送信ボタンの文字と、gravatarのリンクしか違いがないので、パーシャルにまとめる。

app/views/users/_form.html.erb
<%= form_for(@user, url: yield(:url)) do |f| %>
  <%= render 'shared/error_messages', object: @user %>

  <%= 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 %>
  <%= f.password_field :password_confirmation, class: 'form-control' %>

  <%= f.submit yield(:button_text), class: "btn btn-primary" %>
<% end %>

signupビュー

app/views/users/new.html.erb
<% provide(:title, 'Sign up') %>
<% provide(:url, signup_path) %>
<% provide(:button_text, 'Create my account') %>
<h1>Sign up</h1>
<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= render 'form' %>
  </div>
</div>

editビュー

app/views/users/edit.html.erb
<% provide(:title, 'Edit user') %>
<% provide(:url, user_path) %>
<% provide(:button_text, 'Save changes') %>
<h1>Update your profile</h1>
<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= render 'form' %>
    <div class="gravatar_edit">
      <%= gravatar_for @user %>
      <a href="http://gravatar.com/emails" target="_blank">Change</a>
    </div>
  </div>
</div>

ここでは、2つのprovideメソッドとyieldを使っている。
一つはform_forの送信先である。
新規登録フォームではcreateアクションに送信するので、signup_pathとする。
編集フォームではupdateアクションに送信するので、user_pathとする(引数(user)は不要)。

もう一つはフォーム送信ボタンの文字である。

編集の失敗

編集失敗時の処理

更新フォームの送信先であるupdateアクションを書く。

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

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

ユーザーを取得した後update_attributesでその属性を更新する。
更新に失敗した場合は編集画面に戻る。

編集失敗時のテスト

ユーザー編集用の統合テストを作成する。

$ rails generate integration_test users_edit

ユーザー情報の更新に失敗した場合のテストを書く。

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'
    patch user_path(@user), params: { user: { name:  "",
                                              email: "foo@invalid",
                                              password:              "foo",
                                              password_confirmation: "bar" } }
    assert_template 'users/edit'
  end
end

updateアクションにはPATCHリクエストを送信する点に注意する。

編集の成功

編集成功時の処理

編集に成功した場合は、新規登録のcreateアクションと同様に、フラッシュメッセージを表示してユーザー表示ページに移動する。

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

編集成功時のテスト

ユーザー情報の編集に成功した場合のテストを書く。

test/integration/users_edit_test.rb
  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?
    assert_redirected_to @user
    @user.reload
    assert_equal name,  @user.name
    assert_equal email, @user.email
  end

変更したいnameやemailを変数に入れているのは、編集完了後のUserオブジェクトの各属性と比較するためである。
assert_equalでエラーが出れば、編集に成功したはずなのにnameやemailが変わっていないことになる。

ここでテストはREDになるのだが、それはパスワードが有効な値でないためである。
パスワードの変更は必須ではないため、Userモデルのパスワードのバリデーションにallow_nil: trueを追加して、この例外に対応する。

app/models/user.rb
validates :password, presence: true, length: { minimum: 6 }, allow_nil: true

テストがGREENとなることを確認する。

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

【Rails】パンクズリストの作り方

まえがき

みなさんパンくずリストは知っていますでしょうか。どこかで耳にした方もいるかもしれません。ちょっと変わった名前ですがbreadcrumbsと書くとかっこいいですね。このパンくずリストですが名前によらずとても便利な物なのでぜひ作り方を覚えましょう。

パンクズリストとは

ホーム>おすすめ一覧>家電製品
のような現在いる位置を視覚的に見ることができる物です。大抵画面上部に用いられることが多いです。
これはよくSEO対策的に使われることが多く、SEOに詳しい人にとってはおなじみなのではないでしょうか。

パンクズリストを作るメリットは以下の通りですです。

設置するメリット

ユーザビリティーの向上

パンくずリストを用いることで視覚的に現在いる位置を構造的に見ることができるので、そのwebアプリを使うユーザーがサイト内で迷子になることがなくなり、ストレスなくサイト内巡回をすることができます。また、様々なユーザーはみなトップページからサイトに訪れるわけではなくそれぞれ必要なページに直接飛んでくるので、本当に目的のページを開けているか確認をするという使い方もされます。

検索ページに表示される時がある

googleなどで検索をかけた時にページの説明欄にパンクズリストが表示されているのを見たことがある人もいるかもしれません。パンくずが表示されていると検索ページにいるだけでサイト内の構造を見ることができ、飛びたいページの上層カテゴリも表示されるのでクリック率対策になります。

内部SEO対策

googleはページ内を評価する際に一つ一つのページに飛びリンクの文字などを認識しそのページが有用なのかどうかを判断しています。その際にパンクズリストがあるとその作業がスムーズになるために高く評価されSEO対策に効くとされています。

設置方法

では本題です。今回はRailsでの設置になります。
RailsではGretelというgemを用意してくれています。

GitHub
https://github.com/lassebunk/gretel

gem "gretel"

このようにgemfileの一番下に追加します。

$ bundle install

そしてコマンドを打ち、設定ファイルを作っていきます。

$ rails generate gretel:install

すると以下のようなファイルが生成されます。

config/breadcrumbs.rb
crumb :root do
  link "Home", root_path
end

# crumb :projects do
#   link "Projects", projects_path
# end

# crumb :project do |project|
#   link project.name, project_path(project)
#   parent :projects
# end

# crumb :project_issues do |project|
#   link "Issues", project_issues_path(project)
#   parent :project, project
# end

# crumb :issue do |issue|
#   link issue.title, issue_path(issue)
#   parent :project_issues, issue.project
# end

# If you want to split your breadcrumbs configuration over multiple files, you
# can create a folder named `config/breadcrumbs` and put your configuration
# files there. All *.rb files (e.g. `frontend.rb` or `products.rb`) in that
# folder are loaded and reloaded automatically when you change them, just like
# this file (`config/breadcrumbs.rb`).

今回はマイページを作っていきたいのでrootを設定した後にマイページを表記していきます。

config/breadcrumbs.rb
# ルート
crumb :root do
  link "ホーム", root_path
end

# マイページ
crumb :mypage do
  link "マイページ", mypage_users_path
end

crumbs :mypage doはなんという名前でhtml上に表記し呼び出すかを書きます、後々使います。
linkはパンクズリストに表示される文字とそのページがどこのパスに属しているかを表記します。
パンクズリストはリンクになっている場合が多いのでそのリンクはここで設定します。

ではビューファイルに表記していきます

mypage.html.haml
- breadcrumb :mypage
= breadcrumbs pretext: "You are here:",separator: " &rsaquo; "

- breadcrumb :mypageはconfig/breadcrumbs.rbに定義したmypageを呼び出すことができ、
= breadcrumbs pretext: "You are here:",separator: " &rsaquo; "
で表示したい位置を指定することができます。

&rsaquo;という表記はHTML特殊文字と言われる物での部分を表記しています。

また親子の関係を示すために以下のような表記方法もあります

profile.haml.haml
# プロフィール
crumb :profile do
  link "プロフィール", edit_user_path
  parent :mypage
end

parentと表記しdoとendで挟むことによりcrumb :profile doの親を書くことができます。
この表記だと次のような表示になります。

ホーム > マイページ > プロフィール

といった感じでしょうか。

まとめ

いかがだったでしょうか、今回書いて行ったパンクズリストは企業としても重宝する技術ですし、習得していて損はないのではないでしょうか。ぜひポートフォリオなどにも実装してみてください!

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