Madogiwa Blog

主に技術系の学習メモに使っていきます。

Ruby on Rails: Controllerのaction内に分岐を持たずにredirectと処理の続行を切り替える実装をしたいときのメモ

Controller内でaction内に分岐を持たずに特定条件でredirectと処理の続行を切り替えるみたいなことをやりたいときに、単純にredirect処理をprivate methodに移すみたいなやり方だと結局アクションに戻ってしまい上手いこと行かないので、ちょっと悩んだのですがブロックを使うといい感じに実装出来た気がしたのでメモ📝

サンプル実装

サンプルとして簡単にですが、なにかしらの品物に対してユーザーが支払った金額が一致していた場合に購入するような以下のようなコントローラーがあったとします。

class PurchasesController < ApplicationRecord
  def create
    @item = Item.find(item_params[:item_id])
    @payment = current_user.payments.where(item: item).last
    return redirect_to root_path unless @payment.amount == @item.price

    current_user.buy!(item: @item, payment: @payment)
    redirect_to thanks_path
  end
end

この処理がもりもりになってきて、不正な金額を検知してリダイレクトするような処理はアクションの外に切り出したいという想定で色々考えてみます。

🙅‍♀️ 単純にprivate methodに判定とリダイレクト処理を移す

単純にprivate methodに判定とリダイレクト処理を移しても、あくまでreturn先createアクションなので購入処理を中断することは出来ないのでだめです。

class PurchasesController < ApplicationRecord
  def create
    @item = Item.find(item_params[:item_id])
    @payment = current_user.payments.where(item: item).last
    prevent_illegal_payment

    current_user.buy!(item: @item, payment: @payment)
    redirect_to thanks_path
  end

  private

  def prevent_illegal_payment
    return redirect_to root_path unless @payment.amount == @item.price
  end
end

🤔 例外処理を使ってリダイレクトする

例外を使うみたいなやり方もありますが、ちょっと大げさすぎる気も。。。

class PurchasesController < ApplicationRecord
  def create
    @item = Item.find(item_params[:item_id])
    @payment = current_user.payments.where(item: item).last
    prevent_illegal_payment!
    current_user.buy!(item: item)
    redirect_to thanks_path
  rescue IllegalPayment
    redirect_to root_path
  end

  private

  def prevent_illegal_payment!
    raise IllegalPayment unless @payment.amount == @item.price
  end
end

😀 ブロックを使う

このようなケースだと、ブロックを使ってyieldで実行するようにするといい感じにaction内に分岐を持たずに書ける。

class PurchasesController < ApplicationRecord
  def create
    @item = Item.find(item_params[:item_id])
    @payment = current_user.payments.where(item: item).last
    prevent_illegal_payment do
      current_user.buy!(item: @item, payment: @payment)
      redirect_to thanks_path
    end
  end

  private

  def prevent_illegal_payment
    if @payment.amount == @item.price
      yield
    else
      redirect_to root_path
    end
  end
end

おわりに

Rubyはいろんな書き方出来て便利ですね✨

参考

docs.ruby-lang.org

Ruby on Rails: Capybaraを使ったシステムテストでHTTPリクエストを送信する方法

Capybaraを使ったシステムテストで何かしらHTTPリクエストを送信したいときに色々ハマったのでメモしておきます📝

システムテストについてはこちら

railsguides.jp

リクエストを送る方法

以下を行うとシステムテスト内でリクエストを送信することができます。

  • current_driver:rack_testに変更
  • allow_forgery_protectionfalseに変更

上記を行うHelperみたいなのを用意してあげると便利そう(?)

module SendRequestHelper
  def send_request(method, path, params={})
    use_rack_driver do
      disable_forgery_protection do
        page.driver.send(method, path, params)
      end
    end
  end

  private

  def use_rack_driver
    driver = Capybara.current_driver
    Capybara.current_driver = :rack_test
    yield
    Capybara.current_driver = driver
  end

  def disable_forgery_protection
    csrf_protection = ActionController::Base.allow_forgery_protection
    ActionController::Base.allow_forgery_protection = false
    yield
    ActionController::Base.allow_forgery_protection = csrf_protection
  end
end

# パラメータ付きで"/foo"にPOST
send_request(:post, foo_path, { foo: 'foo'})

対応が必要な理由

current_driver:rack_testに変更

デフォルトで利用されるCapybara::Selenium::Driverだとpostgetといったリクエストを送信するようなメソッドが実装されていませんが、

www.rubydoc.info

しかしCapybara::RackTest::Driverの場合はpostgetといったリクエストを送信するようなメソッドが実装されているので、current_driverをリクエストを送信出来るCapybara::RackTest::Driverに切り替えることで任意のタイミングでリクエストを送信出来るということですね。

www.rubydoc.info

※なお、認証状態といったものをRackTestとSelenium間で引き継ぐようなことは出来ないようです。(多分、Sessionが別になるため)

allow_forgery_protectionfalseに変更

直接リクエストを送る際にCSRFの保護が有効になっているとトークンがリクエストに含まれていない場合に、422 Unprocessable Entityが発生してしまいます。

そのためリクエスト送信時にはActionController::Base.allow_forgery_protectionfalseに変更しときます。

Railsのテスト環境ではデフォルトで無効になっているのでやらなくても良いかも

github.com

参考

qiita.com

poketo7878-dev.hatenablog.com

今更ながらActiveStorageの仕組みとか使い方とかメモ📝

今更ながらActiveStrageの仕組みとか使い方とかを動かしながらちょっと勉強したのでメモ📝

ActiveStorageとは

Active Storageは、Amazon S3Google Cloud Storage、Microsoft Azure Storageなどのクラウドストレージサービスへのファイルのアップロードや、ファイルをActive Recordオブジェクトにアタッチする機能を提供します。 Active Storage の概要 - Railsガイド

Rails 5.2で導入された簡単にファイルアップデート/参照が実装出来る機能です。

※2021/11/20現在、6.1.4のガイドよりも以下のedgeガイドのほうが内容がかなり充実しているので、edgeガイドを参照したほうが良いきがした。

edgeguides.rubyonrails.org

ActiveStrageの基本的な使い方

使い方は簡単でapplication.rbでコア機能がrequireされていることを確認し、

require "active_storage/engine"

利用するサービスに合わせてstorage.ymlを作成

local:
  service: Disk
  root: <%= Rails.root.join("storage") %>

amazon:
  service: S3
  access_key_id: ""
  secret_access_key: ""
  bucket: ""
  region: "" # e.g. 'us-east-1'

各種環境に利用するストレージサービスを設定

# development.rb
Rails.application.configure do
  config.active_storage.service = :local
end

以下のコマンドを実行し依存するtableを作成すれば準備OKです。

$ bin/rails active_storage:install

※S3等のクラウドストレージを利用する場合には対象のgemを、ダイレクトアップロードが必要な場合は、activestorage - npm、画像変換等を行う場合には、libvips等を適宜installしてください。詳しくはガイドを御覧ください。

あとは該当のmodelに画像添付用のDSLを記載することで、

class User < ApplicationRecord
  has_one_attached :avatar
  # 複数添付する場合は以下
  # has_many_attached :avatars
end

以下のような感じで画像のアップロードを参照を行う事ができます。

class UsersController < ApplicationController
  def new
    @user = User.new
  end

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

  def create
    @user = User.new(user_params)
    if @user.save
      redirect_to user_path(@user)
    else
      render :new
    end
  end

  private

  def user_params
    params.require(:user).permit(:email, :avatar)
  end
end
<!-- users/new -->
<div class="user-new">
  <%= form_with model: @user, method: :post, url: users_path do |f| %>
    <%= f.text_field :email %>
    <%= f.file_field :avatar %>
    <%= f.submit %>
  <% end %>
</div>


<!-- users/show -->
<%= @user.email %>
<%= image_tag url_for(@user.avatar) %>

ActiveStrageの仕組み

データ構造

ActiveStrageは以下の3つのtableを利用します。

  • active_storage_attachments : アタッチ対象のテーブルとblobとのリレーションを定義
  • active_storage_blobs: ファイル本体を表すテーブル
  • (active_storage_variant_records) : 変換処理等に使用するが大枠には関与しないため一旦今回の記載では扱わない

それぞれの関係は以下の通りです

アタッチ対象のtable 1-N active_storage_attachments 1-1 active_storage_blobs

inspectで見るとどういうデータが格納されるのか分かりやすいです

$ User.last.avatar
#<ActiveStorage::Attached::One:0x00007f918fae33f0
 @name="avatar",
 @record=
  #<User:0x00007f918d780868
   id: 2,
   email: "mail_234@example.com",
   secret_digest: "[FILTERED]",
   verified: true,
   created_at: Sat, 20 Nov 2021 15:01:04.745761000 JST +09:00,
   updated_at: Sat, 20 Nov 2021 15:03:49.002289000 JST +09:00>>

$ User.last.avatar.blob
#<ActiveStorage::Blob:0x00007f9191124470
 id: 1,
 key: "q71m490swybfjgnqpto4egnl1s86",
 filename: "sprite.png",
 content_type: "image/png",
 metadata: {"identified"=>true, "analyzed"=>true},
 service_name: "local",
 byte_size: 1655,
 checksum: "CSG/eyd0ZL4dTU/ccUUEYA==",
 created_at: Sat, 20 Nov 2021 15:01:04.787685000 JST +09:00>

ファイルのアップロードの流れ

実際にアップロードの流れを追ってみます。

まずはmodelにhas_one_attached使うと、定義したname(例: avator)で以下が定義されます。

  • getter/setter
  • active_storage_attachmentsとactive_storage_blobsのassociation
  • active_storage_attachments, active_storage_blobsをeager lodingするscope
  • 以下のcallback
    • after_save: attachment_changesにアップロード・削除等用のClassインスタンスを格納
    • after_commit: attachment_changesから該当のkeyで取得し、uploadが実行できれば実行

github.com

実際にアップロード処理を行なっている処理をみてみます。以下のコードの通り基本的には添付ファイルをもとにActiveStorage::Blobインスタンスを作成し、serviceのupload処理を呼び出しています。

# https://github.com/rails/rails/blob/18707ab17fa492eb25ad2e8f9818a320dc20b823/activestorage/lib/active_storage/attached/changes/create_one.rb#L23
module ActiveStorage
  class Attached::Changes::CreateOne # :nodoc:
    def upload
      case attachable # アタッチ対象
      # 普通にformからファイルをアップロードする場合は`ActionDispatch::Http::UploadedFile`になる。
      when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile
        # ActiveStorage::Blobのインスタンスを生成し`upload_without_unfurling`を実行
        blob.upload_without_unfurling(attachable.open) 
      when Hash
        blob.upload_without_unfurling(attachable.fetch(:io))
      end
    end

# https://github.com/rails/rails/blob/18707ab17fa492eb25ad2e8f9818a320dc20b823/activestorage/app/models/active_storage/blob.rb#L246-L248
class ActiveStorage::Blob < ActiveStorage::Record
  def upload_without_unfurling(io) # :nodoc:
    service.upload key, io, checksum: checksum, **service_metadata
  end

各サービスのupload処理を実行してファイルを格納します。

# Disk
# https://github.com/rails/rails/blob/18707ab17fa492eb25ad2e8f9818a320dc20b823/activestorage/lib/active_storage/service/disk_service.rb#L19
module ActiveStorage
  class Service::DiskService < Service
    def upload(key, io, checksum: nil, **)
      instrument :upload, key: key, checksum: checksum do
        IO.copy_stream(io, make_path_for(key)) # keyで指定したfolderにファイルを作成する
        ensure_integrity_of(key, checksum) if checksum
      end
    end

# S3
# https://github.com/rails/rails/blob/18707ab17fa492eb25ad2e8f9818a320dc20b823/activestorage/lib/active_storage/service/s3_service.rb
module ActiveStorage
  class Service::S3Service < Service
    attr_reader :client, :bucket
    attr_reader :multipart_upload_threshold, :upload_options

    def initialize(bucket:, upload: {}, public: false, **options)
      @client = Aws::S3::Resource.new(**options)
      @bucket = @client.bucket(bucket)

      @multipart_upload_threshold = upload.delete(:multipart_threshold) || 100.megabytes
      @public = public

      @upload_options = upload
      @upload_options[:acl] = "public-read" if public?
    end

    def upload(key, io, checksum: nil, filename: nil, content_type: nil, disposition: nil, **)
      instrument :upload, key: key, checksum: checksum do
        content_disposition = content_disposition_with(filename: filename, type: disposition) if disposition && filename

        if io.size < multipart_upload_threshold
          # keyで指定bucketのobjectを作成しPUT
          upload_with_single_part key, io, checksum: checksum, content_type: content_type, content_disposition: content_disposition
        else
          upload_with_multipart key, io, content_type: content_type, content_disposition: content_disposition
        end
      end
    end

ファイルの参照の流れ

url_for

参照する際にはurl_forActiveStorage::Attachedインスタンスを渡すとデフォルトでは以下のようなURLが帰ってきます。

http://localhost:3000/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBCZz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--27f4ef7c0fa6e180d8384cbb4a408db03d58a344/sprite.png

上記のURLはroutes上以下の定義となっており、active_storage/blobs/redirect#showが呼ばれます。

rails_service_blob_path  GET /rails/active_storage/blobs/redirect/:signed_id/*filename(.:format) 
active_storage/blobs/redirect#show

active_storage/blobs/redirect#showでは@blob.urlにリダイレクトします。 ※リダイレクトを回避するには後述のrails_storage_proxy_pathを参照

# https://github.com/rails/rails/blob/ef65eeef081785e39da717cd9ce7888fa010d217/activestorage/app/controllers/active_storage/blobs/redirect_controller.rb
class ActiveStorage::Blobs::RedirectController < ActiveStorage::BaseController
  include ActiveStorage::SetBlob

  def show
    expires_in ActiveStorage.service_urls_expire_in # デフォルト5分
    redirect_to @blob.url(disposition: params[:disposition])
  end
end

ActiveStorage::Blob#urlはserviceのurlを呼び出します。

class ActiveStorage::Blob < ActiveStorage::Record
  def url(expires_in: ActiveStorage.service_urls_expire_in, disposition: :inline, filename: nil, **options)
    service.url key, expires_in: expires_in, filename: ActiveStorage::Filename.wrap(filename || self.filename),
      content_type: content_type_for_serving, disposition: forced_disposition_for_serving || disposition, **options
  end

下記の通りpublicオプションに応じて、各serviceのpublic_url/private_urlが呼ばれます。

# https://github.com/rails/rails/blob/ef65eeef081785e39da717cd9ce7888fa010d217/activestorage/lib/active_storage/service.rb#L112-L125
module ActiveStorage
  class Service
    def url(key, **options)
      instrument :url, key: key do |payload|
        generated_url =
          if public?
            public_url(key, **options)
          else
            private_url(key, **options)
          end

        payload[:url] = generated_url

        generated_url
      end
    end

各サービスではpublic_url/private_urlの実装は以下のとおりです。どちらでも最終的にはURLの文字列が返却されてファイルを取得出来るというわけですね。

# https://github.com/rails/rails/blob/ef65eeef081785e39da717cd9ce7888fa010d217/activestorage/lib/active_storage/service/disk_service.rb#L104-L110
# Disk
# file名をもとにrails_disk_service_pathが呼ばれる。(ex: http://localhost:3000/rails/active_storage/disk/eyJfcmFpbH...281/sprite.png)
module ActiveStorage
  class Service::DiskService < 
      def private_url(key, expires_in:, filename:, content_type:, disposition:, **)
        generate_url(key, expires_in: expires_in, filename: filename, content_type: content_type, disposition: disposition)
      end

      def public_url(key, filename:, content_type: nil, disposition: :attachment, **)
        generate_url(key, expires_in: nil, filename: filename, content_type: content_type, disposition: disposition)
      end

# https://github.com/rails/rails/blob/ef65eeef081785e39da717cd9ce7888fa010d217/activestorage/lib/active_storage/service/s3_service.rbhttps://github.com/rails/rails/blob/ef65eeef081785e39da717cd9ce7888fa010d217/activestorage/lib/active_storage/service/s3_service.rb#L99-L107
# S3
# private_url: keyをもとに取得したobjectの署名付きURL(期限付き)で取得される。
# https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/ShareObjectPreSignedURL.html
# public_url : keyをもとに取得したobjectの公開URLが取得される。
module ActiveStorage
  class Service::S3Service < Service
      def private_url(key, expires_in:, filename:, disposition:, content_type:, **client_opts)
        object_for(key).presigned_url :get, expires_in: expires_in.to_i,
          response_content_disposition: content_disposition_with(type: disposition, filename: filename),
          response_content_type: content_type, **client_opts
      end

      def public_url(key, **client_opts)
        object_for(key).public_url(**client_opts)
      end

rails_storage_proxy_path

またRails 6.1で追加されたrails_storage_proxy_path(@user.avatar)を使用するとurl_forで発生していたリダイレクト処理を防ぐことが出来ます。処理としては以下の通りactive_storage/blobs/proxy#showが実行されます。

rails_service_blob_proxy_path    GET /rails/active_storage/blobs/proxy/:signed_id/*filename(.:format)    
active_storage/blobs/proxy#show

active_storage/blobs/proxy#showはblobのダウンロードが実行され、各サービスで定義されたdownloadが呼ばれてファイルがダウンロードされ、データがブラウザに送られます。 ※リダイレクトは発生しないが、直接ファイルを閲覧できるURLが生成されるため注意

# https://github.com/rails/rails/blob/ef65eeef081785e39da717cd9ce7888fa010d217/activestorage/app/controllers/active_storage/blobs/proxy_controller.rb

class ActiveStorage::Blobs::ProxyController < ActiveStorage::BaseController
  include ActiveStorage::SetBlob

  def show
    if request.headers["Range"].present?
      send_blob_byte_range_data @blob, request.headers["Range"]
    else
      http_cache_forever public: true do
        response.headers["Accept-Ranges"] = "bytes"
        response.headers["Content-Length"] = @blob.byte_size.to_s

        send_blob_stream @blob
      end
    end
  end

# https://github.com/rails/rails/blob/ef65eeef081785e39da717cd9ce7888fa010d217/activestorage/app/controllers/concerns/active_storage/streaming.rb#L60
module ActiveStorage::
    def send_blob_stream(blob, disposition: nil) # :doc:
      send_stream(
          filename: blob.filename.sanitized,
          disposition: blob.forced_disposition_for_serving || disposition || DEFAULT_BLOB_STREAMING_DISPOSITION,
          type: blob.content_type_for_serving) do |stream|
        blob.download do |chunk| # blobのダウンロードを実行
          stream.write chunk
        end
      end
    end


# https://github.com/rails/rails/blob/ef65eeef081785e39da717cd9ce7888fa010d217/activestorage/app/models/active_storage/blob.rb#L252
class ActiveStorage::Blob < ActiveStorage::
  def download(&block)
    service.download key, &block
  end

# Disk
# https://github.com/rails/rails/blob/ef65eeef081785e39da717cd9ce7888fa010d217/activestorage/lib/active_storage/service/disk_service.rb#L26-L38
module ActiveStorage
  class Service::DiskService < Service
    def download(key, &block)
      if block_given?
        instrument :streaming_download, key: key do
          stream key, &block
        end
      else
        instrument :download, key: key do
          File.binread path_for(key) # fileをread
        rescue Errno::ENOENT
          raise ActiveStorage::FileNotFoundError
        end
      end
    end

# S3
# https://github.com/rails/rails/blob/ef65eeef081785e39da717cd9ce7888fa010d217/activestorage/lib/active_storage/service/s3_service.rb
module ActiveStorage
  class Service::S3Service < Service
    def download(key, &block)
      if block_given?
        instrument :streaming_download, key: key do
          stream(key, &block)
        end
      else
        instrument :download, key: key do
          object_for(key).get.body.string.force_encoding(Encoding::BINARY) # keyからobjectを取得しgetしてbodyを取得
        rescue Aws::S3::Errors::NoSuchKey
          raise ActiveStorage::FileNotFoundError
        end
      end
    end

CDNからファイルを配信するときのtipsは以下に記載されています。 https://edgeguides.rubyonrails.org/active_storage_overview.html#putting-a-cdn-in-front-of-active-storage

おわりに

ActiveStrageでファイルをアップロードするとてっきりアップロード先のURLを取得しストレージサービスに直接リクエストが行くのかと思っていたのですが、一度Railsを経由して配信されるんですねー(知らなかった)

確かに一度Railsを経由することでS3のprivateなバケットからファイルを取得して公開等が出来るんですね。

Railsでリクエストを受け付ける点は注意ですが、ActiveStrage便利ですね✨

参考

blog.takeyuweb.co.jp

https://edgeguides.rubyonrails.org/active_storage_overview.htm

Ruby: `standardrb`を最近使ったりしてるので使い感とかをメモ

RubyのLinterといえばrubocopだと思うのですが、個人のツールとかだと最近standardrbを使うことが多いので使い感とかをメモしておきます📝

standardrbとは?

github.com

StandardJSインスパイのRubyのLinterです。

This gem is a spiritual port of StandardJS and aims to save you (and others!) time in the same three ways:

  • No configuration. The easiest way to enforce consistent style in your project. Just drop it in.
  • Automatically format code. Just run standardrb --fix and say goodbye to messy or inconsistent code.
  • Catch style issues & programmer errors early. Save precious code review time by eliminating back-and-forth between reviewer & contributor.

記載の通り、rubocopのように.rubocop.ymlに毎回無効にするルールと記載するといった対応が不要で手軽に使えるのと、自動修正といった機能も備えています。

standardrbの使い方

基本的にはstandardrbをGemfileに追加し、bundle installを実行するだけで準備OKです。

あとは以下を実行することで静的解析を行う事ができます。

$ bundle exec standardrb

また設定不要で使えますが、特定のファイルは静的解析の対象外としたい場合には.standard.ymlを用意してあげます。

ignore:
  - 'some/file/in/particular.rb'
  - 'a/whole/directory/**/*'

おわりに

rubocopだと、以下のような日本語だと絶対に引っかかる特定のルールをまずは無効化するみたいなことをするのですが、そういうのがいらないのと、スタイル周りとか厳しすぎずストレスの感じにくい、いい感じのルールが自動で設定してくれるのがいいなぁと思っています。

standardrbはrubocopをもとに作られているのみたいですね、rubocopをベースにしているルールは以下で確認できそうです。

github.com

参考

qiita.com

stylelintを13系から14系にアップデートしたのでメモ📝

個人で運用しているサービスのstylelintを13系から14系の上げたので、そのあたりでやったこととかをメモしておきます。

stylelint.io

アップデート手順

公式で14系へのマイグレーションガイドが公開されているので、基本的にはそれをみてやっていきます。

stylelint.io

.scssを静的解析するためのライブラリの変更

We recommend extending a shared config that includes the appropriate PostCSS syntax for you. For example, if you use Stylelint to lint SCSS, you can extend the stylelint-config-standard-scss shared config.

今まではstyelint-scssstylelint-config-recommended-scssを使っていたのですが、以下のstylelint-config-standard-scssを使えば良くなったようなので、このライブラリを利用して、その他は削除するようにしました。

github.com

stylelint-config-standard-scssにはstylelint-config-recommended-scssの設定も含まれているのでextendsにはstylelint-config-standard-scssのみを設定するようにしています。

{
  "extends": ["stylelint-config-standard-scss"],
  "rules": {
    "no-empty-source": null
  }
}

stylelint-config-standard-scss/index.js at main · stylelint-scss/stylelint-config-standard-scss · GitHub

.vueを静的解析に含めるための設定

If a shared config isn't available for your perferred language or library, then you can install the appropriate PostCSS syntax yourself and use the customSyntax option, which is now available in the configuration object.

今までは.vue内のscssの記述も自動的に検査することが出来ましたが、今回からは自分で設定を追加しないといけなくなったようです。

HTML, XML and HTML-like embeds (.html, .xml, .svelte, .vue etc.) use postcss-html

.vueの場合は上述の通りpostcss-htmlをインストールして以下のようにoverrideの設定をすることで、.vueに関してはpostcss-htmlを利用するように設定を行います。

{
  "extends": ["stylelint-config-standard-scss"],
  "overrides": [
    {
      "files": ["**/*.vue"],
      "customSyntax": "postcss-html"
    }
  ],
  "rules": {
    "no-empty-source": null
  }
}

おわりに

多少overrideまわりの設定でハマってしまいましたが、公式ドキュメント通りに設定を変更することでバージョンアップができました!こういうドキュメントもアップデート時に整備してもらえるの非常にありがたいですね🙏

Ruby on Rails: js-routesを使ってフロントエンドでもRoute系のHelperを使用するMEMO

フロントエンド側から非同期でRails側にリクエストを投げたいときとかに今までは以下のような定数を集めたmoduleをフロントエンド側に用意してたりしていたのですが、

export const BLOG_API_ENDPOINT = "/api/blogs";
export const LIKE_API_ENDPOINT = "/api/likes";

js-routesというgemを使うと便利だったので使い方とかをメモしておきます📝

github.com

js-routesを導入してフロントエンド用のroutesを生成する

使い方は簡単でjs-routesGemfileに追加して

group :development do
  gem "js-routes"
end

bundle installを実行後、以下のコマンドを実行してroutes.jsおよびroutes.d.tsを生成するだけです。TypeScriptを使用しない場合は、JsRoutes.definitions!の実行は不要です。

$ bin/rails c
JsRoutes.generate! # routes.js
JsRoutes.definitions! # routes.d.ts

※READMEを見る限りrake taskも用意されているのですが、なぜかrake -Tで表示されなかった。。。

js-routesで生成したroutesを利用する

あとは簡単でimportして以下のように使用する事ができます。

import { api_blogs_path } from "@js/routes";

import Client from "@js/services/Client";

export async function postBlog(params: object): Promise<PostBlogResponse> {
  const responseData: unknown = (await Client.post(api_blogs_path(), { blog: params })).data;
  return responseData as PostBlogResponse;
}

globalにroutesが使えるようにするにはapplication.js内に以下を追加すれば良いようです。

import * as Routes from 'routes';
window.Routes = Routes;

その他

生成ファイルがGitHub上のdiffで展開されないように.gitattributesに以下のような感じで追加してあげても便利そうかなと思いました。

# Mark the js-routes generated files.
app/javascript/routes.js
app/javascript/routes.d.ts

あとはDangerとかでroutes.rbの変更があったらPRのコメントとかで更新がもれないように通知とかしてあげても良さそう👀

github.com

参考

qiita.com

Ruby on Rails: passwordlessを使ってMagicLink的な認証機能を作る

Ruby on RailsでMagic Link的な認証を作ってみたく調べてたところpasswordlessというgemを見つけて色々触ってみたので使い方とかをメモしておきます📝

github.com

Magic Linkとは?

サイトに用意された「マジックリンク」と名付けられたボタンを押します。すると、登録したメールアドレスにログインのためのURLが送られ、それをクリックすることでパスワードを入力することなく、ログイン処理が行えるのです。 ユーザーが持つ「メールアドレス」を鍵としたログインの仕組みです。

www.itmedia.co.jp

Slackで使われているアレですね👀 ユーザーがパスワードを覚えなくてもいいのも良いですが、サービスとしてパスワードを抱えなくても良いというのも運営側からするとメリットなのかなと思いました。

passwordlessの使い方

上記のMagic LinkをRailsで実現するためのgemがpasswordlessです。

github.com

Rails engineを使って作成されています。

READMEに記載の通りですが、使い方は簡単でGemfileに以下を追記後bundle installを実行し、

gem 'passwordless'

passwordlessが使用するtebleと認証したいmodel(User等)を作成します。

$ bin/rails passwordless:install:migrations
$ bin/rails generate model User email
# bin/rails db:migrate

あとはModel側にpasswordlessを使用するための設定を入れて、

class User < ApplicationRecord
  validates :email, presence: true, uniqueness: { case_sensitive: false }

  passwordless_with :email # <-- here!
end

routesでpasswordless_forを使用してengineをmountします。

Rails.application.routes.draw do
  passwordless_for :users

  # other routes
end

ref: https://github.com/mikker/passwordless/blob/master/lib/passwordless/router_helpers.rb#L19

これで準備OKです👍

実際にMagic Linkを送信する場合は以下のようにユーザーを作成したあとに、

User.crete(email: 'foo@example.com')

/sign_inに遷移し、入力フォームにfoo@example.comを入力しサブミットするとマジックリンクが記載されたメールがユーザーに送信されます📧

Here's your link: http://localhost:3000/sign_in/MAGIC_LINK_TOKEN

Tips

ログイン必須にしたい

以下のような形で出来ました。マジックリンクの送信画面もログイン必須にならないようにskip_before_action :authenticate_user!, if: :passwordless_controller?を入れています。

class ApplicationController < ActionController::Base
  include Passwordless::ControllerHelpers

  before_action :authenticate_user!
  skip_before_action :authenticate_user!, if: :passwordless_controller?
  
  helper_method :current_user

  def current_user
    @current_user ||= authenticate_by_session(User)
  end

  def authenticate_user!
    return if current_user
    redirect_to sign_in_path, flash: { alert: 'ログインしてください!' }
  end
end

マジックリンク送信時のメールの文面を変更したい

app/views/passwordless/mailer/magic_link.text.erbを作成して、オーバーライドしてあげればOKです🙆‍♂️

Custom Message!!

<%= I18n.t('passwordless.mailer.magic_link', link: @magic_link) %>

railsguides.jp

ユーザー作成と合わせてマジックを送信したい

Passwordless.after_session_saveを使うと任意のタイミングでマジックリンクのURLをメールで送信出来るのでユーザー作成後に送信してあげるようにすると良さそうです。

class UsersController < ApplicationController
  skip_before_action :authenticate_user!

  def new
    @user = User.new
  end

  def create
    @user = User.new(user_params)
    ActiveRecord::Base.transaction do
      if @user.save
        session = build_passwordless_session(@user)
        Passwordless.after_session_save.call(session, request) if session.save
        redirect_to sign_up_path, flash: { notice: 'ログイン用のリンクをメールで配信しました!' }
      else
        render :new
      end
    end
  end

  private

  def user_params
    params.require(:user).permit(:email)
  end
end

github.com

マジックリンク送信後のリダイレクト先を変えたい

先程の例と同様ですがPasswordless.after_session_saveを使うと任意のタイミングでマジックリンクのURLをメールで送信出来るので、独自のマジックリンク送信用のエンドポイントを用意してリダイレクト先を指定してあげると良さそうです。

class MagicLinksController < ApplicationController
  before_action :authenticate_user!

  def create
    session = build_passwordless_session(current_user)
    Passwordless.after_session_save.call(session, request) if session.save
    redirect_to mypage_path, flash: { notice: 'ログイン用のリンクをメールで配信しました!' }
  end
end

一度使用されたマジックリンクは無効化したい

config/initializers/passwordless.rbを用意して以下の指定をしてあげればOKです。

Passwordless.restrict_token_reuse = true

github.com

おわりに

パスワードレスコードも割と読みやすくでコアなロジックが切り出してあり、色々カスタマイズしやすい感じで良いですね✨

参考

ohbarye.hatenablog.jp

medium.com