【Rails】accepts_nested_attributes_forをFormObjectにやりかえる

はじめに

GWが終わりましたね。
といっても何が変わったわけでもないんですが、GWはRUNTEQ内の受講生・卒業生たちで、なんか作ってデプロイして発表しようぜというファラオイベントが行われました。

私は、来たる20xx年大学入試共通試験(正式名称なんだっけ?私のときでいうセンター試験)で、出題科目が日本史から日本酒に変わるということで、日本酒4択クイズアプリを作ることにしました。

問題と回答の登録を同時に行いたかったため、非推奨とは知りつつ、accepts_nested_attributes_forを使って子モデルも同時に登録するという処理を実装しました。
時間がなかったので(言い訳)、一旦そのままデプロイしましたが、イベント乗り切ったので、FormObjectで登録フォームを作り直しました。
その際の手順を残しておきますー。

環境

QuestionモデルとChoiceモデル

この記事で登場するモデルは2つです。
(Userモデルもあるけど、そこまで重要ではなし)

Image from Gyazo

QuestionとChoiceの1対多のモデルです。
で、実現したいことは、Questionと4つのChoiceを同時に登録したいってこと。

accepts_nested_attributes_forで実装

先に、accepts_nested_attributes_forを使って実装した時のやり方を残しておきます。
ただ、これ(accepts_nested_attributes_for)はDHHも「消し去りてぇ」っていってるくらい非推奨らしいです。
DHHのコメント

理由は多々あるかと思いますが、コードの可読性が下がるのが一つの理由かなと思います。

下の例を見てもらうと、Questionsモデルに書いてあるaccepts_nested_attributes_forに関する記述は、特定の画面でしか使わない機能であるのに、Questionsモデルに書くことでファットモデルになってしまう恐れがあります。

Models
class Question < ApplicationRecord
  belongs_to :user
  has_many :choices,
            dependent: :destroy,
            index_errors: true # nested attributesの何番目のエラーかを検知する
  
  accepts_nested_attributes_for :choices, allow_destroy: true # 今回はこれを取り払いたい

  validates :title, length: { maximum: 100 }, presence: true
  validates :description, length: { maximum: 1000 }, presence: true
end
class Choice < ApplicationRecord
  belongs_to :questions, required: false

  validates :content, presence: true
  enum is_correct: { correct: true, incorrect: false }
  validates :is_correct, inclusion: {in: ["correct", "incorrect"]}
end
  • boolean型のカラムに存在性のバリデーションかけるとエラー出るので注意
Controllers
class QuestionsController < ApplicationController
  before_action :set_question, only: %i[edit update destroy]

  def index
    @questions = Question.all
  end

  def new
    @question = Question.new
    4.times { @question.choices.build } # これで4択を生成してる
  end

  def create
    @question = Question.new(question_params)
    if @question.save
      redirect_to questions_path
    else
      render :new, status: :unprocessable_entity
    end
  end

  def show
    @question = Question.find(params[:id])
  end

  def edit; end

  def update
    if @question.update(question_params)
      redirect_to @question
    else
      render :new, status: :unprocessable_entity
    end
  end

  def destroy
    @question.destroy!
    redirect_to questions_path
  end

  private

  def question_params
    params.require(:question).permit(
      :title,
      :description,
      choices_attributes: [
        :id,
        :content,
        :is_correct,
        :_destroy
      ]
    ).merge(user_id: current_user.id)
  end

  def set_question
    @question = current_user.questions.find(params[:id])
  end
end
  • flashメッセージは消してます
  • --skip-javascriptしてhotwireを入れてたので、status: :unprocessable_entityを書いてあげないとエラーメッセージが描画されなかた
Views
<%= form_with(model:@question) do |f| %>
    <%= f.label :title %>
    <%= f.text_area :title %>
    <%= f.label :description %>
    <%= f.text_area :description %>

 # ここからChoiceモデルのフォーム
    <%= f.fields_for :choices do |s| %>
      <%= s.label :content %>
      <%= s.text_field :content %>
      <%= s.label :is_correct %>
      <%= s.radio_button :is_correct, :correct %>
      <%= s.label :is_correct, "正解", {value: :true, style: "display: inline-block;"} %>
      <%= s.radio_button :is_correct, :incorrect %>
      <%= s.label :is_correct, "不正解", {value: :false, style: "display: inline-block;"} %>
    <% end %>
 # ここまで
  <%= f.submit '投稿' %>
<% end %>
  • レイアウトはここでは無視

    • QuestionモデルのフォームにChoiceモデルのフォームをネストさせてる

FormObjectで実装

FormObjectにする際に、update周りの実装に苦戦した(綺麗なコードは書けなかった)ので、この記事では、create周りとupdate周りに分けて手順を残しますー。

まずはcreate周り。

new/create

Forms
class CreateQuestionForm
  include ActiveModel::Model

  attr_accessor :title, :description, :correct, :image_url, :incorrect_0, :incorrect_1, :incorrect_2

  with_options presence: true do
    validates :title, length: { maximum: 150 }
    validates :description, length: { maximum: 255 }
    validates :correct, length: { maximum: 50 }
    validates :incorrect_0, length: { maximum: 50 }
    validates :incorrect_1, length: { maximum: 50 }
    validates :incorrect_2, length: { maximum: 50 }
  end

  def initialize(user, params, question)
    @question = Question.new

    @question.assign_attributes({ 
      user: user,
      title: params[:title],
      description: params[:description], 
      image_url: params[:image_url] 
    })
    super(params)
  end

  def save_question!
    return false unless valid? # バリデーションを検証
    persist
  end

  private

  def persist
    @question.save!

    @question.choices.build(question_id: @question.id, content: correct, is_correct: true).save!

    @question.choices.build(question_id: @question.id, content: incorrect_0, is_correct: false).save!

    @question.choices.build(question_id: @question.id, content: incorrect_1, is_correct: false).save!

    @question.choices.build(question_id: @question.id, content: incorrect_2, is_correct: false).save!
  end
end
  • ディレクトリはapp/forms配下

  • with_options presence: true do ... end共通のバリデーション(存在性)をこれでまとめてる

Controllers
class QuestionsController < ApplicationController

  def new
    @create_question_form = CreateQuestionForm.new(current_user, {}, Question.new)
  end

  def create
    @create_question_form = CreateQuestionForm.new(current_user, question_form_params, Question.new)
    if @create_question_form.save_question!
      redirect_to questions_path
    else
      render :new, status: :unprocessable_entity
    end
  end

  private

  def question_form_params
    params.require(:question).permit(
      :title,
      :description,
      :image_url,
      :correct,
      :incorrect_0,
      :incorrect_1,
      :incorrect_2,
    )
  end
end
  • save_question!はFormObjectで定義
Views
<%= form_with(model:form) do |f| %>
  <%= f.label :title %>
  <%= f.text_area :title %>
  <%= f.label :description %>
  <%= f.text_area :description %>
  <%= f.label :image_url %>
  <%= f.text_field :image_url %>

  # ここからChoiceのフォーム
  <%= f.label :correct %>
  <%= f.text_field :correct %>
  <%= f.label :incorrect_0 %>
  <%= f.text_field :incorrect_0 %>
  <%= f.label :incorrect_1 %>
  <%= f.text_field :incorrect_1 %>
  <%= f.label :incorrect_2 %>
  <%= f.text_field :incorrect_2 %>
  <%= f.submit '投稿' %>
<% end %>
  • editページでも使用するので、modelをformに変更

create周りは特に迷うことなく実装完了。

次はupdate周り。
色々やって以下の形になったが、正直見づらい、、。
後々リファクタする予定です。

edit/update

Forms
class CreateQuestionForm
  include ActiveModel::Model

  attr_accessor :title, :description, :correct, :image_url, :incorrect_0, :incorrect_1, :incorrect_2

  with_options presence: true do
    validates :title, length: { maximum: 150 }
    validates :description, length: { maximum: 255 }
    validates :correct, length: { maximum: 50 }
    validates :incorrect_0, length: { maximum: 50 }
    validates :incorrect_1, length: { maximum: 50 }
    validates :incorrect_2, length: { maximum: 50 }
  end

  def form_path(question)
    question.nil? ? '/questions' : "/questions/#{question.id}"
  end

  def form_method(question)
    question.nil? ? :post : :put
  end

  def id
    @question.nil? ? nil : @question.id
  end

  def image(question)
    question.nil? ? "https://source.unsplash.com/collection/92340446/800x600" : @question.image_url
  end

  def set_choice(question, bool, index)
    question.nil? ? '' : Choice.where(question_id: question.id, is_correct: bool).order("id DESC").pluck(:content)[index]
  end

  def initialize(user, params, question)
    @question = Question.find_or_initialize_by(id: question.id)
    @question = Question.new if @question.nil?

    @question.assign_attributes({ 
      user: user,
      title: !@question.id.nil? ? params[:title] : @question.title,
      description: !@question.id.nil? ? params[:description] : @question.description,
      image_url: !@question.id.nil? ? params[:image_url] : @question.image_url,
    })
    super(params)
  end

  def to_model 
    @question
  end

  def save_question!
    return false unless valid?
    persist
  end

  private

  def persist
    @question.save!

    @question.choices.build(question_id: @question.id, content: correct, is_correct: true).save!

    @question.choices.build(question_id: @question.id, content: incorrect_0, is_correct: false).save!

    @question.choices.build(question_id: @question.id, content: incorrect_1, is_correct: false).save!

    @question.choices.build(question_id: @question.id, content: incorrect_2, is_correct: false).save!
  end
end
  • form_path(question) メソッドとform_method(question)メソッド、idメソッドでルーティングを無理矢理修正してる questions_pathだとupdateではなくcreateに飛んでしまうため。
    本当はpersisted?メソッドを使ってよしなに切り替えてくれるようにしたかったが、意図した挙動にならず。原因は調査中。

  • editページを開いたときに、うまくフォームに値を渡せなかったので、value属性に無理矢理値を渡すようにした そのせいでinitializeメソッドが煩雑になってしまった

Controllers
class QuestionsController < ApplicationController

  def edit
    @create_question_form = CreateQuestionForm.new(current_user, {}, @question)
  end

  def update
    @create_question_form = CreateQuestionForm.new(current_user, question_form_params, @question)
    if @create_question_form.save_question!
      redirect_to @question
    else
      render :new, status: :unprocessable_entity
    end
  end

  private

  def question_form_params
    params.require(:question).permit(
      :title,
      :description,
      :image_url,
      :correct,
      :incorrect_0,
      :incorrect_1,
      :incorrect_2,
    )
  end

  def set_question
    @question = current_user.questions.find(params[:id])
  end
end
Views
<%= form_with(model:form, url: form.form_path(@question), method: form.form_method(@question) do |f| %>
  <%= f.label :title %>
  <%= f.text_area :title, value: @question.nil? ? '' : @question.title %>
  <%= f.label :description %>
  <%= f.text_area :description, value: @question.nil? ? '' : @question.description  %>
  <%= f.label :image_url %>
  <%= f.text_field :image_url, value: form.image(form) %>

  # ここからChoiceのフォーム
  <%= f.label :correct %>
  <%= f.text_field :correct, value: form.set_choice(form, true, 0)  %>
  <%= f.label :incorrect_0 %>
  <%= f.text_field :incorrect_0, value: form.set_choice(form, false, 2) %>
  <%= f.label :incorrect_1 %>
  <%= f.text_field :incorrect_1, value: form.set_choice(form, false, 1) %>
  <%= f.label :incorrect_2 %>
  <%= f.text_field :incorrect_2, value: form.set_choice(form, false, 0) %>
  <%= f.submit '投稿' %>
<% end %>

無理矢理感がすごい。
もっといい方法があるはず。

参考

moneyforward.com

product-development.io

qiita.com

Rails6.1からform_withがデフォルトでdata-remote = "true"オフになっていた。

はじめに

少し今更感のある記事かもしれないですが、数ヶ月フロント周りをRailsで書いていなかった私はタイトルのことを先日知りました。
浦島太郎気分になったので、簡単に記事にしておきます。

概要

Railsでform_withヘルパーを使う際に、Rails 6.0、5.2、5.1ではデフォルトでAjaxレスポンスを返すようになっていました。
しかし、Rails 6.1ではデフォルトでAjax通信を行わないように変更されたため、従来通りAjax通信を使用したい場合には、config/application.rbで以下の設定をしておく必要があります。

config.action_view.form_with_generates_remote_forms = true

Rails 6.1以前はどうだった?

Rails 6.0、5.2、5.1では手軽にAjax化するため、ヘルパーメソッドのform_withはデフォルトでAjax通信を行うようになっており、formタグにdata-remote = "true"を付与していました。

逆を言えば、通常の画面遷移を期待する場合は、local: trueを明示的に指定する必要がありました。

なんでremoteなしになった?

こちらのissueでは以下のように指摘されています。

That is a problem because it forces the unsuspecting user to handle XHR requests.

github.com

つまり、通常はHTTPレスポンスを期待しているのに、Ajaxレスポンスを返されると困惑してしまうよってことですね。
もともとTurbolinksを意識して、remote=trueがデフォになっていたようなので、Trubolinks使ってないよっていうほとんどの人からすれば、余計なお世話になってしまっていたようです。

remote=trueをデフォルトにしたい場合

概要にすでに記載済みですが、あえてremote=trueをデフォルトにしたい場合は、別途設定しておく必要があります。

<再掲>

config.action_view.form_with_generates_remote_forms = true

 

参考

Rails 6.1で form_withのデフォルトが「remoteなし」になった(翻訳)|TechRacho(テックラッチョ)〜エンジニアの「?」を「!」に〜|BPS株式会社

HotwireからDHHが考えるこれからのRailsとJSの付き合い方を知る - Speaker Deck

【Ruby】map(&:to_i)とはなんなのか?

map(&:to_i)とはなんなのか?

標準入力でスペース区切りの数値を、配列として受け取る場合は以下のように記述できる。

numbers = gets.chomp.split.map(&:to_i)

このときの、map(&:to_i)とはなんなのか?

map.(&:to_i)までの処理

numbers = gets.chomp.split

Image from Gyazo

文字列として配列に格納できる

mapで数値に変換

先程の配列をmapを使って数値に変換する場合、以下のようになる.

numbers.map{|n| n.to_i}

Image from Gyazo

つまり、標準入力でスペース区切りの数値を、配列として受け取る場合、省略なしで記述すると以下のようになる。

numbers = gets.chomp.split.map{|n| n.to_i}

&は何をしてるのか?

ヒントはここにありました。
https://docs.ruby-lang.org/ja/latest/class/Proc.html#block

Proc オブジェクトをブロック付きメソッド呼び出しに使う ブロック付きメソッドに対して Proc オブジェクトを `&' を指定して渡すと呼び出しブロックのように動作します。しかし、厳密には以下の違いがあります。これらは、Proc オブジェクトが呼び出しブロックとして振舞う際の制限です。

問題なし

(1..5).each { break }  

LocalJumpError が発生します。

pr = Proc.new { break }  
(1..5).each(&pr)

ブロックをProc.newでProcオブジェクトとした場合、&を指定して渡さないとブロックとして動作してくれないために、&を使っているということでした。

Image from Gyazo

:to_iは何をしているのか?

結論からいうと、Procオブジェクトを自動的に生成し、第一引数を数値に変換しています。
ヒントはこちらに。
https://docs.ruby-lang.org/ja/latest/method/Symbol/i/to_proc.html

instance method Symbol#to_proc to_proc -> Proc[permalink][rdoc][edit] self に対応する Proc オブジェクトを返します。 生成される Proc オブジェクトを呼びだす(Proc#call)と、 Proc#callの第一引数をレシーバとして、 self という名前のメソッドを残りの引数を渡して呼びだします。 生成される Proc オブジェクトは lambda です。

:object_id.to_proc.lambda? # => true

明示的に呼ぶ例

:to_i.to_proc["ff", 16]  # => 255 ← "ff".to_i(16)と同じ

暗黙に呼ばれる例

# メソッドに & とともにシンボルを渡すと
# to_proc が呼ばれて Proc 化され、
# それがブロックとして渡される。
(1..3).collect(&:to_s)  # => ["1", "2", "3"]
(1..3).select(&:odd?)   # => [1, 3]

【JWT】もう怖くない、JWT認証(Vue.js編)

はじめに

この記事は、【JWT】もう怖くない、JWT認証(RailsAPI編)の続編です。
まだ読まれていない方は、こちらからどうぞ!

bon-voyage23.hatenablog.com

今回は、Vue(client)側の設定をやっていきます!

JWT認証の流れ(Vue.jsとRails

前回の記事では、Rails(server)側の設定を行いました!
下の図でいうと、赤色の部分ですね。
今回は、Vue.js側の緑色の部分の内容です。

f:id:voyag:20210326173816p:plain
JWT

環境

既存のRailsにVue.jsを導入

RailsにVue.jsを導入する方法としては、大きく分けると以下の2つあるかと思います。

  1. webpackオプションを使って、Rails newをする方法

  2. 既存のRailsrails webpacker:install:vueをして導入する方法

今回は、すでにRails newをしてしまっているので、後者の方法をとります。
ちなみに前者は、こちらの記事で簡単にやり方を記載していますので、
気になる方はcheck it out!! bon-voyage23.hatenablog.com

インストール

Gem webpackerを追加。

gem 'webpacker', github: 'rails/webpacker'

yarnもインストール。

brew install yarn

webpacker 初期化。

rails webpacker:install

vueインストール。

rails webpacker:install:vue

これで、vueの埋め込みが完了!

Hello Vueする

Vueの埋め込みができたので、次は「Hello Vue」を表示させていきます。

前回、routes.rbでrootをhome#indexに設定したので、Homeコントローラを作成していきます。

rails g controller home

続いて、Homeコントローラにindexメソッドを追加。

class HomeController < ApplicationController
  def index; end
end

最後にhomeビューでjavascript_pack_tag を設定。

<%= javascript_pack_tag 'hello_vue' %>

これで、rails sすると、先程インストールしたVue.jsのapp.vueを読みに行ってくれます!
下のように表示されればOK!!

Image from Gyazo

トップページ

最初にトップページを作成していきます!

ライブラリの追加

ユーザー登録の処理には、axios,vue-router,vuex,を使用しますので、
yarnを使ってライブラリを追加しておきます。
(vuetifyはせっかく作るならちょっとは見た目を整えたいという私個人の問題なので、ただ機能だけ実装したいのであれば入れなくて大丈夫です!)

yarn add axios
yarn add vue-router
yarn add vuex
yarn add vuetify

インストールができたら、hello_vue.jsにこのライブラリ使うよ〜って言っといてあげましょう!

import Vue from 'vue'
import vuetify from '../plugins/vuetify'
import App from '../app.vue'
import router from '../router'
import store from '../store'

document.addEventListener('DOMContentLoaded', () => {
  const app = new Vue({
    router,
    vuetify,
    store,
    render: h => h(App)
  }).$mount()
  document.body.appendChild(app.$el)

  console.log(app)
})

vuetifyに関する設定は、pluginとして定義してあげます。
テーマをlightにしてますが、darkにするとダークモードになります(当たり前)。

vuetifyjs.com

import Vue from 'vue'
import Vuetify from 'vuetify/lib'

Vue.use(Vuetify)

export default new Vuetify({
  theme: { light: true },
})
ルーティングの設定

次にVue-routerのルーティングを設定していきます。
ユーザー登録ページのコンポーネント名をSignupとして作成するので、このようになります。
(ついでにトップページとログインページも設定してます。)

import Vue from "vue";
import Router from "vue-router";
import Top from "../pages/Top";
import Signup from "../pages/Signup";
import Login from "../pages/Login";

Vue.use(Router)

const router = new Router({
  mode: 'history',
  routes: [
    {
      path: '/',
      name: 'Top',
      component: Top,
    },
    {
      path: '/signup',
      name: 'Signup',
      component: Signup,
    },
    {
      path: '/login',
      name: 'Login',
      component: Login,
    }
  ],
})

export default router
トップページの表示

まずはrouterで設定したページを表示させるために、app.vueを修正しておきます。

<template>
  <div id="app">
    <router-view/>
  </div>
</template>

トップページには簡単にログインボタンと新規登録ボタンを置いておきます。

<template>
  <div>
    <v-app>
      <v-card width="400px" class="mx-auto mt-5">
        <v-card-title>
          <h1 class="display-1">JWT認証SampleApp</h1>
        </v-card-title>
      </v-card>
      <v-row  justify="center" align-content="center">
        <v-img src="https://cdn.vuetifyjs.com/images/parallax/material2.jpg">
          <div class="fill-height bottom-gradient"></div>
        </v-img>
      </v-row>
      <v-row  justify="center" align-content="center">
        <v-spacer />
        <v-btn
          to="/login"
          class="btn btn-dark mt-5"
        >
          ログイン
        </v-btn>
        <v-spacer />
        <v-btn
          to="/signup"
          class="btn btn-dark mt-5"
        >
          新規登録
        </v-btn>
        <v-spacer />
      </v-row>
    </v-app>
  </div>
</template>

<script>
export default {
  name: "Top"
}
</script>

こんな感じになります。 (なんか寂しかったんで、画像入れました←余計) Image from Gyazo

ユーザー登録

トップページができたので、ユーザー登録フォームとその処理を設定していきます!

ユーザー登録フォーム

まずは見た目から。笑

<template>
<div>
  <v-app>
    <v-card width="400px" class="mx-auto mt-5">
      <v-card-title>
        <h1 class="display-1">ユーザー登録</h1>
      </v-card-title>
      <v-card-text>
        <v-form>
          <v-text-field 
            label="ユーザ名"
            v-model="user.name" 
          />
          <v-text-field 
            type="email"
            label="メールアドレス"
            v-model="user.email" 
          />
          <v-text-field 
            type="password"
            label="パスワード"
            v-model="user.password"
          />
          <v-text-field 
            type="password"
            label="パスワード(確認)"
            v-model="user.password_confirmation"
          />
        </v-form>
          <v-card-actions>
            <v-btn class="info" @click="signup">登録</v-btn> 
          </v-card-actions>
      </v-card-text>
    </v-card>
  </v-app>
</div>
</template>

<script>
export default {
  name: "Signup",
  data() {
    return {
      user: {
        name: '',
        email: '',
        password: '',
        password_confirmation: '',
      }
    }
  },
  methods:{
    submit(){
      console.log(this.user.name,this.user.email,this.user.password,this.user.password_confirmation)
    }
  }
};
</script>

これでこんな感じの登録フォームができるかと思います。
今は登録ボタンを押しても、ただコンソールに入力した情報が表示されるだけになっています。

Image from Gyazo

axiosの設定
import axios from 'axios';

const token = document.querySelector('meta[name="csrf-token"]').content;
const axiosInstance = axios.create({ 
  baseURL: '/api/v1',
  headers: {
    'Content-Type': 'application/json',
    'X-CSRF-Token': token
  },
  responseType: 'json'
});
export default axiosInstance
  • tokencsrf-tokenを取得しています
  • headersで取得したcsrf-tokenを標準でheadersに仕込むようにしています

hello_vue.jsに以下を追記しておきます。

import axios from '../plugins/axios'

Vue.prototype.$axios = axios
signup

axiosの設定ができたので、signupの処理を追記していきます。

.
.
.
  methods: {
    signup() {
      this.$axios.post('users', { user: this.user })
        .then(res => {
          this.$router.push({ name: 'Login' })
        })
        .catch(err => {
          console.log(err)
        })
    }
  }
}
</script>
  • users_controllerのcreateメソッドを呼ぶように、postします
  • 問題なく登録できた場合は、$route.pushでLoginページに遷移するようにしています

ログイン

ユーザー登録ができたので、ログインの処理をしていきます。

ログインフォーム

このような形で実装しました!
見た目はSignup.vueを流用してます。

<template>
<div>
  <v-app>
    <v-card width="400px" class="mx-auto mt-5">
      <v-card-title>
        <h1 class="display-1">ログイン</h1>
      </v-card-title>
      <v-card-text>
        <v-form>
          <v-text-field 
            label="メールアドレス"
            v-model="user.email" 
          />
          <v-text-field 
            type="password"
            label="パスワード"
            v-model="user.password"
          />
        </v-form>
          <v-card-actions>
            <v-btn class="info"
              @click="login" 
              :disabled="handleIsDisabled"
            >ログイン</v-btn> 
          </v-card-actions>
      </v-card-text>
    </v-card>
  </v-app>
</div>
</template>

<script>
import { mapActions } from "vuex"

export default {
  name: "Login",
  data() {
    return {
      user: {
        email: '',
        password: ''
      }
    }
  },
  computed: {
    handleIsDisabled() {
      if(this.user.name !== '' && this.user.email !== '') { return false } else { return true }
    }
  },
  methods: {
    ...mapActions("users", [
      "loginUser",
    ]),
    async login() {
      try {
        await this.loginUser(this.user);
        this.$router.push({ name: 'Top' })
      } catch (error) {
        console.log(error)
        alert('ログインに失敗しました')
      }
    },
  }
}
</script>

<style lang="scss" scoped>
</style>
  • ログインボタンを押したときに、loginメソッドが走るようになっており、loginUserはstoreのuserモジュールで定義するmapアクションです
  • ログインが成功した際には、Topページに遷移するようにしています(ほんとはログインの制約をかけたページに遷移するべきなんですが、、)
  • ログインが失敗したときは、alertが表示されます
Cookieの設定

今回は、JWTtokenをCookieに格納しておきたいと思います。 そのために、vuex-persistatestorejs-cookieライブラリを追加しておきます。

yarn add vuex-persistatestore
yarn add js-cookie

vuex-persistatestoreの使い方は、こちらの記事にもまとめているので、なんじゃそりゃって方はどうぞ!

bon-voyage23.hatenablog.com

js-cookieは公式を参照のこと。

github.com

userモジュール

今回はストアをモジュールに分けています。
userしか無いのに分けてるのは、userしかないアプリを作らんやろっていう理由です。はい。

import axios from '../../plugins/axios'
import createPersistedState from 'vuex-persistedstate';
import Cookies from 'js-cookie';

const state = {
  loginUser: null
}

const getters = {
  loginUser: state => state.loginUser
}

const mutations = {
  setUser: (state, user) => {
    state.loginUser = user
  }
}

const actions = {
  async loginUser({ commit }, user) {
    const sessionsResponse = await axios.post('sessions', user)
    const auth_token = sessionsResponse.data.token
    Cookies.set('token', auth_token, 1)
    axios.defaults.headers.common['Authorization'] = `Bearer ${auth_token}`
    
    const userResponse = await axios.get('users/info')
    commit('setUser', userResponse.data)
    }
  }
}

export default {
  namespaced: true,
  state,
  getters,
  mutations,
  actions,
  plugins: [createPersistedState({
    storage: {
      getItem: key => Cookies.get(key),
      setItem: (key, value) => Cookies.set(key, value, { expires: 3, secure: true }),
      removeItem: key => Cookies.remove(key)
    }
  })],
}

--loginUserの処理の流れ--

  1. const sessionsResponse = await axios.post('sessions', user) sessions_controllerのcreateメソッドから帰ってきたtokenをsessionsResponseに格納

  2. const auth_token = sessionsResponse.data.token sessionsResponseからtokenを取り出して、auth_tokenに格納

  3. Cookies.set('token', auth_token, 1) cookieの中にtokenという箱を作って、その中にauth_tokenを格納。1は保存日数を表しています。

  4. axios.defaults.headers.common['Authorization'] =Bearer ${auth_token}`` axiosを用いて、Authorizationリクエストヘッダにauth_token付与して、サーバ側に送信できるようにする。

  5. const userResponse = await axios.get('users/info') users_controllerのinfoメソッドにgetリクエストを送って、user情報を取得し、その結果をuserResponseに格納。

これでログイン処理の完了です!

Image from Gyazo

トップページに戻ってくるので、ログインした感じしないですが、ちゃんと戻って来れているので、成功です!笑

おわりに

本当はbeforeEachで認証が必要なページへの画面遷移の制御とかもやりたかったんですが、そこまですると蛇足感があったので、省きました。
押さえたかったのはログイン時のJWTtokenがどうやって生成されて、どう行き来するのかというところだったので。
コードの部分が多く読みづらい記事になってしまいましたが、誰かの役に立てれば!!

今回作成したサンプルアプリのコードはGithubに置いてますので、ご参考までに。。

github.com

参考

【JWT】もう怖くない、JWT認証(RailsAPI編)

はじめに

今回はVueとRailsでJWT認証の実装ができず、泣いていた数日前の自分にあててかくラブレターです。
いわゆる「JWT完全に理解した」状態の今の私にできる、最大限の愛を込めた説明をしていきたいと思います。

用語一覧

この後の説明を理解するために、用語を確認しておきます。

用語 説明
JWT JSON Web Tokenの略。
属性情報をJSONデータ構造で表現したトークンの仕様。
<ヘッダー>.<ペイロード>.<署名>をエンコードする。
Bearer セキュリティトークンのうちその利用可否が「トークンの所有」のみで決定されるもの。
持参人トークン・署名なしトークンとも呼ばれる。
Authorization 認証情報を指定するヘッダ。
X-CSRF-Token CSRF対策としてリクエス送信先のページで正規のページからリクエストが送信されたか確認するために発行するトークン。

JWT認証の流れ(Vue.jsとRails

f:id:voyag:20210326173816p:plain
JWT

  1. ユーザーはemailとpasswordを送信してログイン処理を開始

  2. サーバー側でemailとpasswordからユーザーを認証

  3. 認証が完了したら、JWS tokenを生成してクライアント側に返却

  4. クライアント側は、JWS tokenをLocalStorage(Cookieなど)に保存

  5. LocalStorageに保存したJWS tokenをAuthorizationリクエストヘッダに付与して、サーバ側に送信

  6. サーバー側で、JWS tokenを認証し、ログイン中のユーザー情報などをクライアント側に返す

この記事では、Rails(server)側の処理である赤色の部分を解説します。

環境

gem

今回使用する主要なgemはこちら。

gem 'bcrypt', '~> 3.1.7' # Passwordの暗号化
gem 'sorcery' # ユーザー認証
gem 'jwt' # JWTtokenの生成

Sorceryの注意点

いつも通り、rails g sorcery:installをすると失敗するので注意!
今回はrails g sorcery:install --model Userで必要なファイルを生成しておきます。

大きな違いは、生成されるテーブルの名前。
rails g install:sorceryではUserテーブルが生成されますが、rails g sorcery:sorcery --model Userではusersテーブルが生成されます。

*このissueでも上がっているため、今後のバージョンによっては、修正されるかも?

github.com

Userモデル

rails g sorcery:install --model Userで生成されるmigrationファイルに、nameカラムを追加して、migrateしておきます!
なので、Userモデルはこんな感じ。

User
id
name: string
email: string
crypted_password: string
salt: string
created_at: datetime
updated_at: datetime

Userコントローラ

Userコントローラにはユーザー登録の処理を記述していきます!
また、ログイン後にユーザー情報を取得するshowメソッドも合わせて定義しています。

今回RailsAPIとして使用するため、コントローラのディレクトリは名前空間を使って区切っています。

rails g controller api/v1/user create show
class Api::V1::UsersController < ApplicationController
  before_action :authenticate!, only: %i[show]

  def create
    user = User.new(user_params)

    if user.save
      render json: user
    else
      render json: user.errors, status: :bad_request
    end
  end

  def info
    render json: current_user
  end

  private

  def user_params
    params.require(:user).permit( :name, :email, :password, :password_confirmation)
  end
end
  • 未定義のcurrent_userを使ってしまいましたが、後ほど定義していきます。

認証周りの処理をConcernに切り出す

module Api::UserAuthenticator
  extend ActiveSupport::Concern

  def current_user
    return @current_user if @current_user
    return unless bearer_token

    payload, = User.decode bearer_token
    @current_user ||= User.find_by(id: payload['user_id'])
  end

  def authenticate!
    return if current_user

    head :unauthorized
  end

  def bearer_token
    header = request.headers['Authorization']

    header.gsub(/^Bearer /, '') if header&.match(/^Bearer /)
  end
end
  • bearer_tokenAuthorizationに付与されているので、そこから取り出す処理
  • current_userbearer_tokenをデコードして取り出したuser_idを元にUserから探す処理
class ApplicationController < ActionController::Base
  include Api::UserAuthenticator
  protect_from_forgery with: :null_session
end
  • UserAuthenticatorはApplicationControllerにincludeしてどのControllerからでも使えるようにしておく
  • protect_from_forgery with: :null_sessionRailsAPIとして使う際の、CSRFのための設定

Sessionsコントローラ

続いて、ユーザー認証を行うSessionsコントローラを定義していきます。

class Api::V1::SessionsController < ApplicationController
  def create
    user = User.authenticate(params[:email], params[:password]) 

    if user
      token = user.create_tokens
      render json: { token: token }
    else
      head :unauthorized
    end
  end
end
  • emailとpasswordからUserを認証
  • 認証できたら、tokenを発行

ここで、また未定義のメソッドcreate_tokensが出てきましたので、定義していきます。

JwtTokenモジュール

JwtTokenモジュールは、modelのconcernsに切り出しておきます。

module JwtToken
  extend ActiveSupport::Concern

  SECRET_KEY = Rails.application.secrets.secret_key_base

  class_methods do
    def decode(token)
      JWT.decode(token, SECRET_KEY, true, algorithm: 'HS256')
    end
  end

  def create_tokens
    expires_in = 1.month.from_now.to_i # 再ログインを必要とするまでの期間を1ヶ月とした場合
    payload = { user_id: id }
    issue_token(payload.merge(exp: expires_in))
  end

  private

  def issue_token(payload)
    JWT.encode(payload, SECRET_KEY, 'HS256')
  end
end

ルーティング

最後にルーティングの設定をして、Rails側の設定は終了です。
routes.rbはこのように設定しておきます。

Rails.application.routes.draw do
  root to: 'home#index'

  namespace 'api' do
    namespace 'v1' do
      resources :sessions
      resources :users, only: %i[create info] do
        collection do
          get 'info'
        end
    end
  end
end

おわりに

今回の設定で、Rails(server)側のユーザーの新規登録・認証の設定が完了しました!
次回はVue(client)側の処理を説明します!

参考

【Nuxt.js】と【Rails】を連携させて【AWS】でデプロイするときのハマりポイントまとめ

はじめに

先日「さけぐらむ」というアプリをデプロイしました。

sg.sakegram.site

このアプリを作った理由は次の2つです。

  • Nuxt.jsを勉強したので、勉強用にアプリを作ってみようと思ったから
  • 日本酒に馴染みのない人に、日本酒に興味をもってもらえるようなサービスを作ってみたかったから

とりあえず勉強用に作ってみたので、OGPも作ってないですし、レスポンシブ対応も中途半端ですが、
デモ動画を公開したところ、使ってみたいという声をいただいたので、AWSへのデプロイの練習も兼ねて作りきることにしました。

デプロイまでの流れはこちらの記事を参考にさせてもらいました。

zenn.dev

ですが、AWS初心者の私には結構なハマりポイントがあったので、忘れないようにまとめていきたいと思います。

前提

Dockerを使ってRailsとNuxtの環境は構築済みとします。
ちなみにNuxt.jsの環境構築については、こちらの記事にもポイントをまとめています!

bon-voyage23.hatenablog.com

bon-voyage23.hatenablog.com

環境

  • macOS 11.2.2
  • Doker for Mac 3.2.1
  • create-nuxt-app : 3.5.2
  • npm 6.14.5
  • node v14.4.0

AWSの環境構築について(構成図の確認)

参考にしたZennの記事では以下のようなインフラ構成図が載っており、全体の流れが説明されていました。
初見の感想は、ドユコト??

Image from Gyazo

(引用:AWS編の概要|Nuxt.js + Ruby on Rails + AWS Fargate の開発・デプロイチュートリアル)

この図を見ただけでわかる方はこの項は読み飛ばしてください。
私と同じく何が何かわからないという方は、本項で簡単に説明していきますので、
参考にしてみてください。

ちなみに、環境構築については、こちらの書籍がかなりわかりやすかったです!
手順だけを説明している書籍や、反対に概念的な説明や用語の説明が詳しい書籍は多いですが、
こちらの書籍はそのどちらもが、ちょうどよくまとまっていました!

AWSではじめるインフラ構築入門 安全で堅牢な本番環境のつくり方

VPCがないと始まらん!

VPC(Virtual Private Cloud)とは、地球の広大な土地に、自分専用の野球場を開設することですね。
ドーン!
今からこの中にいろんな箱(リソース)を追加していきます。
先程の図ではVPCとは書いていないですが、矢印が入ってきている大枠がVPCです。

Image from Gyazo

ECRはAWSのDocker!?

ECR(Elastic Container Registry)は、Dockerのようにコンテナイメージを置いておけるレジストリで、
自分のDocker環境で作ったものをECRにpushすることで、どこにでも簡単にデプロイできるという代物です。

なので、先程のVPCにECRをドーン!

Image from Gyazo

ついでにデベロッパーとDockerも追加しておきました!
今回はCircleCIを導入しなかったので、外しています。

ECSとFargate

ECS(Elastic Container Service)は、簡単にいうとコンテナを起動する物です。
このコンテナを起動する方法は2つあり、

  • EC2上でコンテナを起動する

または、

  • Fargateを使って起動する

パターンがあります。
今回はFargateを使うパターンでやっていきます!

また、ECSの中にはクラスターというものを作成し、その中にタスク(Dockerコンテナを実行するためのパラメータを指定するもの)と、そのタスクを定期実行するための、サービスを定義していきます。

Image from Gyazo

なので、構成図はこんなこんな感じになります。

Image from Gyazo

コンテナはFrontendとBackendに分ける

フロントエンドとバックエンドという表現が適切かどうかは定かでないですが、
NuxtとRailsはそれぞれコンテナを分けておきます。

Image from Gyazo

Route53でドメイン管理

続いてDNS(Domain Name System)です。
AWSにはRoute53というDNSのネイティブサービスがあり、こちらを利用します。
Route53ではドメインの取得から、SSLサーバー証明書の取得まで一括して行えるのですが、
今回はお名前.comでドメイン名を取得し、Route53でサブドメインとして登録する方法を取りました。

Zennの記事の中ではドメインの取得もRoute53で行っているのですが、倹約家(ケチ)の私はお名前.com一択でした。
ちなみに無料でドメインを取得できるサービスもあるようなので、今後機会があれば利用してみたいと思います。

www.freenom.com

ロードバランサー

ユーザーのリクエストをさばくための仕組み(スケールアウト)を行うのがロードバランサーです。
そのほかSSL処理や不正リクエスト対策のためにも、ロードバランサーが用いられます。
AWSでは、ELB(Elastic Load Balancing)というサービスが提供されており、今回はその中のALB(Application Load Balancer)を利用しました。

Image from Gyazo

データベースサーバーを準備

AWSではRDS(Relational Database Service)を使用することで簡単にデータベースサーバーを構築できます。
またデータベースにはMySQLを利用しました。

Image from Gyazo

ここまでが、インフラ構成図の基本の確認です!
次項からハマりポイントをまとめていきます。

アベイラビリティーゾーンは2つ以上作るべし

インフラ構築図についてなんとなく理解ができてきたので、VPCを作って構築を進めていっていましたが、ロードバランサーの作成まで進んで振り出しに戻る羽目になりました。

先程のインフラ構築図ではおそらく初歩すぎて割愛されているのですが、VPCにはアベイラビリティーゾーンという概念があり、少なくとも2つは作成していないとALBが作成できませんでした。

そもそもなぜ、アベイラビリティーゾーンを作るのかというと、耐障害性を高めるためです。
要は、不測の事態が発生した際に、すべて止まってしまわないようにネットワーク(サブネット)を複数割り当てておこうということです。
そのサブネットを割り当てる場所のことをアベイラビリティーゾーンという訳です。

そしてこのサブネット、一度作成してしまうとサブネットが利用するCIDRブロックは変更できないため、はじめにしっかり設計する必要があるのです。
ここが落とし穴ポイントでした。

IGWでVPCから外へ出る

これはハマりポイントというか、常識なんだと思うんですが、素人の私にはどんなものも落とし穴になりえます。
それは、インターネットゲートウェイがないとVPCで作成されたネットワークとインターネットは繋がらないということです。

Image from Gyazo
(引用:インターネットゲートウェイ - Amazon Virtual Private Cloud

なので最初のインフラ構築図をこうしてこうしてこうです!

Image from Gyazo

AWS Systems Managerに定義した環境変数Value fromで引っ張る

今回、本番環境の環境変数はSystemManagerに定義しました。
参考記事では文字列として定義していましたが、念の為「安全な文字列」として定義しました。

この環境変数(例えばDBのPasswordなど)は、Backendコンテナをタスクに追加する際に引っ張ってくるのですが、
その際に「Value」として設定してしまったため、繋がらないという凡ミスをしていました。

SystemManagerから環境変数を引っ張ってくる時は「ValueFrom」として設定しましょう(戒め)。

Image from Gyazo

(参考:AWS ECS(バックエンド編)|Nuxt.js + Ruby on Rails + AWS Fargate の開発・デプロイチュートリアル

Mysql2::Error::ConnectionError: Can't connect to MySQL server on '~db名~'

先程、「安全な文字列」として環境変数をSystemManagerのパラメータストアに定義したと説明しました。
環境変数の設定関係については、前項の内容で問題ないのですが、実際にタスクを実行すると以下のエラーが発生しました。

Mysql2::Error::ConnectionError: Can't connect to MySQL server on 'db'

今回の場合は、KMSで暗号化した情報をとってくる権限がないことによるエラーでした。
なので、タスクロールにSSMのアクセスポリシーをアタッチすることで解消しました!

pushしすぎて容量なくなる問題

今回は手元のDocker環境をECRにpushして何度も修正しては更新するという作業を繰り返しました。
ただ、当然ですが何度もDocker環境をbuildしているとtmpディレクトリなどに不要なファイルが溜まったり、
不要になったボリュームが溜まったりなどで、以下のようなエラーがたびたび発生しました。

ERROR: Service 'backend' failed to build: Error processing tar file(exit status 1): write /tmp/~~~~/~~~: no space left on device

私の場合は、基本的にはホストのディスク容量不足が原因であったため、圧迫している箇所を探し、削除するという形で進めていきました。

また、不要なことがわかっているということであれば、以下のコマンドも有効かと思うので、メモとして残しておきます。

# 不要なコンテナを削除
docker container prune 

# 不要なボリュームを削除
docker volume prune

docker system prune -a --volumes

おわりに

今回初めてAWSへのデプロイに挑戦しました。
おそらく未経験として駆け出しエンジニアを目指す私にとっては、ECSやFagateを使ってデプロイする時間があるなら、
一行でも多くコードを書いたり、RubyRailsを勉強するべきなんだろうなと思います。

ただどうしても、溢れ出す好奇心を抑えられず、無駄になるかもしれないと思いながらもデプロイしました。

うまくまとまっておらず、読みづらい記事であることは承知していますが、今回学んだことがこの記事を通して、少しでも誰かの役に立てればと思います。

そうしたら無駄じゃなくなるかもしれない。
あと「さけぐらむ」で遊んでくれれば無駄じゃなくなるかもしれない。

あとおすすめの日本酒は「空蔵」と「くどき上手」と「米のささやき」です。
空蔵はリンクなかった。多分神戸の一部の酒屋でしか取り扱ってなかったはず。

参考

はじめに|Nuxt.js + Ruby on Rails + AWS Fargate の開発・デプロイチュートリアル

Amazon ECR(Docker イメージの保存と取得)| AWS

Amazon Elastic Container Service とは - Amazon Elastic Container Service

AWS Fargate(サーバーやクラスターの管理が不要なコンテナの使用)| AWS

【AWS】ECS(FARGATE) + ECRでNginxのコンテナからHello Worldする手順

ECSでごっつ簡単に機密情報を環境変数に展開できるようになりました! | DevelopersIO

no space left on deviceとなった時の対応方法 - Qiita

DockerでNuxt.jsからRailsAPIを叩いたら「[HPM] Error occurred while trying to proxy request」にハマった話

はじめに

RailsAPIモードとNuxt.jsを使ってアプリを作成する際に、エラーにはまったので、経緯と解決方法を残しておきます。
環境やディレクトリなどは、こちらの記事と同様です。

bon-voyage23.hatenablog.com

環境

  • macOS 11.2.2
  • Doker for Mac 3.2.1
  • create-nuxt-app : 3.5.2
  • npm 6.14.5
  • node v14.4.0

先に結論

先に今回解決した方法を書いておきます。

proxy設定でtargetをDockerのIPに設定することで解決しました。

  proxy: {
    '/api': {
      target: 'http://172.20.0.1:5000',
      pathRewrite: {
        '^/api': '/api'
      }
    }
  },

docker-compose ps

 Name                Command               State    Ports
---------------------------------------------------------
backend   bundle exec rails server - ...   Exit 1        
db        docker-entrypoint.sh mysqld      Exit 0        
front     docker-entrypoint.sh npm r ...   Exit 0   

コンテナの構成はこんな感じです。

順当な手順

RailsAPIとNuxt.jsを組み合わせるときの定石(?)のような手順をざっくりと書いておきます。
大きく分けると以下の2つの手順を踏むことになるかと思います。

  • RailsでCORSを設定
  • NuxtでProxyを設定
RailsでCORS設定

まずはGemをインストールします。

gem 'rack-cors', :require => 'rack/cors'

application.rbの設定をします。

Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins 'localhost:3000'
    resource '*',
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head]
  end
end
NuxtでProxy設定

@nuxtjs/proxyをインストール

npm install @nuxtjs/proxy
  proxy: {
    '/api': {
      target: 'http://localhost:5000', #Dockerを導入しない時はこれでOK!
      pathRewrite: {
        '^/api': '/api'
      }
    }
  },

Dockerを使わない場合は上記の設定で問題なく通信できることが確認できましたが、docker-compose upして通信してみると、表題のエラーが発生します。
その後調べていくと、名前解決を使って通信をする手法があるということがわかりました。

名前解決 is 何?

Dockerにはコンテナ間で通信したり処理の負荷分散したりするため、ネットワーク内部で利用可能な「コンテナ名」「IPアドレス」の名前解決(サービス・ディスカバリ)を行う仕組みが標準で搭載されています

Docker Compose入門 (3) ~ネットワークの理解を深める~ | さくらのナレッジ

正しい理解かどうか分かりませんが、docker-compose.ymlに記載したDockerではコンテナ名とIPアドレスが対応しているようです。
そこで先述した、名前解決を使って通信ができないエラーを解消する手法が取れるということで、こちらを参考にnuxt.config.jsを修正しました。

Docker Compose上で起動したwebpack-dev-serverに設定されたプロキシが動作しなくてハマった話 - yn2011's blog

  proxy: {
    '/api': {
      target: 'http://backend:5000', #コンテナ名のbackendに変更
      pathRewrite: {
        '^/api': '/api'
      }
    }
  },

もう一度docker-compose up --buildしてAPIを叩きましたが、エラーは変わらずでした。。。 nandeyaaa....

名前解決がダメなら泥臭く

名前解決でもダメだったので、泥臭くcurlコマンドを使ってlocalhost:3000にリクエストを送ってみました。

curl http://localhost:5000/api/v1/xxxxx

返ってきた結果がこちら

backend     | Started GET "/api/v1/xxxxat 2021-03-13 18:53:56 +0900
backend     | Processing by Api::V1::AreasController#index as JSON
backend     | Completed 200 OK in 7ms (Views: 5.4ms | ActiveRecord: 0.0ms | Allocations: 2541)

想定通りの結果が返ってきました!

ログを確認してみると、 for 172.20.0.1 となっているので、172.20.0.1にリクエストが送られていることがわかります。

そこで、nuxt.config.jsの設定をIPに変えてみた結果、今回のエラーの解決に至りました!!

おわりに

記事にすると、あっさりとした内容でしたが、実際はmysql-clientがインストールできておらず、悪戦苦闘した結果、mariadb-client に統合されてしまっていたので、そちらをインストールしたりなど、紆余曲折ありました。
Docker便利だけど難しい。

参考

Docker Compose上で起動したwebpack-dev-serverに設定されたプロキシが動作しなくてハマった話 - yn2011's blog

docker-compose buildするときにbundle installやmysql-clientでコケた話 - Qiita

Dockerでコンテナの内部IPを調べる - Qiita

当サイトは、Amazon.co.jpを宣伝しリンクすることによってサイトが紹介料を獲得できる手段を提供することを目的に設定されたアフィリエイトプログラムである、Amazonアソシエイト・プログラムの参加者です。