Ruby
Rails

RailsでQiitaみたいなランダム文字列のURLを使う方法

はじめに

今作っているWebアプリの仕様上、URLの直打ちでアクセスされたくないということがありました。
様々な方法がありそうですが、「URLをユーザーによって推測されない文字列にすることで解決できる」と判断しました。
そこで、調べてみるとfriendly_idというGemを発見!
しかし、導入している最中に予期せぬエラーが出たため、今回はfriendly_idを使わずに実装しました。
その過程で、勉強することがいくつかあったので、ご紹介します。

今回すること

スクリーンショット 2018-08-10 23.22.13.png
QiitaのURLのように、ランダムな文字列のURLを使えるようにする。

実装前の状態

DB

db/schema.rb
ActiveRecord::Schema.define(version: 20XX_XX_XX_XXXXXX) do

  create_table "rooms", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t|
    t.string   "name"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
  end

end

Model

app/model/room.rb
class Room < ApplicationRecord
  validates :name, presence: true
end

Controller

app/controller/chats_controller.rb
class ChatsController < ApplicationController
  before_action :set_room, only: %i[show destroy]

  def show; end

  def new
    @room = Room.new
  end

  def create
    @room = Room.new(room_params)
    if @room.save
      redirect_to chat_url(@room)
    else
      render :new
    end
  end

  def destroy
    @room.destroy
    redirect_to new_chat_url
  end

  private

  def room_params
    params.require(:room).permit(:name)
  end

  def set_room
    @room = Room.find(params[:id])
  end
end

ルーティング

config/routes.rb
Rails.application.routes.draw do
  resources :chats, only: %i[show new create destroy]
end

手順

①テーブルにURL用のカラムを追加する

今回は、Roomテーブルにurl_tokenというカラムを追加します。
このカラムに、URLで使用する文字列を入れていきます。

terminal
$ rails generate migration add_url_token_to_rooms url_token:string
$ rails db:migrate

②URLにパラメーターとして渡るidをurl_tokenに変更する

terminal
$ rails routes
                   Prefix Verb   URI Pattern                                                                              Controller#Action
                    chats POST   /chats(.:format)                                                                         chats#create
                 new_chat GET    /chats/new(.:format)                                                                     chats#new
                     chat GET    /chats/:id(.:format)                                                              chats#show
                          DELETE /chats/:id(.:format)                                                              chats#destroy

最初は、このようになっているはずです。
このままでは、idが表示されるので、/chats/:idとなっている部分を/chats/:url_tokenに変更します。

config/routes.rb
Rails.application.routes.draw do
  resources :chats, only: %i[show new create destroy], param: :url_token
end

chatsのresourcesに、paramオプションでurl_tokenを設定しました。

app/model/room.rb
class Room < ApplicationRecord
  validates :name,      presence: true
  validates :url_token, presence: true, uniqueness: true

  def to_param
    url_token
  end
end

次に、モデルでto_paramメソッドでurl_tokenを指定する必要があります。(参考:Railsドキュメント to_param
もう一度rails routesで確認すると、変更されています。

terminal
$ rails routes
                   Prefix Verb   URI Pattern                                                                              Controller#Action
                    chats POST   /chats(.:format)                                                                         chats#create
                 new_chat GET    /chats/new(.:format)                                                                     chats#new
                     chat GET    /chats/:url_token(.:format)                                                              chats#show
                          DELETE /chats/:url_token(.:format)                                                              chats#destroy

③コントローラーでデータの取得方法を変更する

app/controller/chats_controller.rb
class ChatsController < ApplicationController
  before_action :set_room, only: %i[show destroy]

  def set_room
    @room = Room.find(params[:id])
  end
end

色々省略しましたが、これが今の状態です。
set_roomメソッドで、idカラムでroomの検索をしています。
これを変更します。

app/controller/chats_controller.rb
class ChatsController < ApplicationController
  before_action :set_room, only: %i[show destroy]

  def set_room
    @room = Room.find_by(url_token: params[:url_token])
  end
end

findfind_byにすることによって、url_tokenで検索をできるようにします。
ここがidのままだと、このようなエラーが出るので注意です。

スクリーンショット 2018-08-11 11.10.54.png

④ランダム文字列を生成してurl_tokenに設定する

個人的には、ここがいちばんの難所でした。
url_tokenは、必ず存在していて、一意性がなければなりません。
うまく動かすために、いくつか試して見ました。

(1)コールバック関数を利用する → 失敗😅

before_validationを使えば、うまくいきそうだと思っていました。
しかし、rspecを通すとものすごく怒られた。

$ bin/rspec spec/models/room_spec.rb

...

1) Room validations when url_token is blank should validate that :url_token cannot be empty/falsy
     Failure/Error: it { is_expected.to validate_presence_of :url_token }

       Room did not properly validate that :url_token cannot be empty/falsy.
         After setting :url_token to ‹nil› -- which was read back as
         ‹"4cfe467648c14a555ab0"› -- the matcher expected the Room to be
         invalid, but it was valid instead.

         As indicated in the message above, :url_token seems to be changing
         certain values as they are set, and this could have something to do
         with why this test is failing. If you've overridden the writer method
         for this attribute, then you may need to change it to make this test
         pass, or do something else entirely.
     # ./spec/models/room_spec.rb:12:in `block (4 levels) in <main>'
     # /usr/local/bundle/gems/bootsnap-1.3.1/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:50:in `load'
     # /usr/local/bundle/gems/bootsnap-1.3.1/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:50:in `load'
     # /usr/local/bundle/gems/spring-commands-rspec-1.0.4/lib/spring/commands/rspec.rb:18:in `call'
     # -e:1:in `<main>'

  2) Room validations when url_token is not unique should validate that :url_token is case-sensitively unique
     Failure/Error: it { is_expected.to validate_uniqueness_of :url_token }

       Room did not properly validate that :url_token is case-sensitively
       unique.
         After taking the given Room, whose :url_token is
         ‹"dcabouwey8aohoc12sk6"›, and saving it as the existing record, then
         making a new Room and setting its :url_token to
         ‹"dcabouwey8aohoc12sk6"› as well, the matcher expected the new Room to
         be invalid, but it was valid instead.
     # ./spec/models/room_spec.rb:16:in `block (4 levels) in <main>'
     # /usr/local/bundle/gems/bootsnap-1.3.1/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:50:in `load'
     # /usr/local/bundle/gems/bootsnap-1.3.1/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:50:in `load'
     # /usr/local/bundle/gems/spring-commands-rspec-1.0.4/lib/spring/commands/rspec.rb:18:in `call'
     # -e:1:in `<main>'

「それなら、before_createにしよう!」
テストが通ったところまではよかったのですが、タイミングがバリデーションのあとなので、
@roomが生成されませんでした。

(2)createするときにいい感じでurl_tokenを渡したい → 失敗😨

「createアクションで、いい感じに処理したらできそうじゃないか?」

理想

terminal
[1] pry(main)> room = Room.create(name: "test", url_token: SecureRandom.hex(10))
   (0.3ms)  SAVEPOINT active_record_1
  Room Exists (0.6ms)  SELECT  1 AS one FROM `rooms` WHERE `rooms`.`url_token` = BINARY '3d0f7481395c753542c5' LIMIT 1
  Room Create (0.6ms)  INSERT INTO `rooms` (`name`, `url_token`, `created_at`, `updated_at`) VALUES ('test', '3d0f7481395c753542c5', '2018-08-10 11:50:46', '2018-08-10 11:50:46')
   (0.3ms)  RELEASE SAVEPOINT active_record_1
=> #<Room:0x000055577362f2f8
 id: 4,
 name: "test",
 url_token: "3d0f7481395c753542c5",
 created_at: Fri, 10 Aug 2018 11:50:46 UTC +00:00,
 updated_at: Fri, 10 Aug 2018 11:50:46 UTC +00:00>

恥ずかしい話なのですが、私が未熟なためにできませんでした。

(3)attributeを使う → 成功☺️

(2)がうまくいかなかったのが悔しかったので、相談してみると「attributeを使えばよいのではないか」という意見と次のコードが帰ってきました。
attribute :url_token, :string, default: SecureRandom.hex(10)
これをそのままmodelに追記すると、あら不思議、成功しました。

app/model/room.rb
class Room < ApplicationRecord
  attribute :url_token, :string, default: SecureRandom.hex(10)

  validates :name,      presence: true
  validates :url_token, presence: true, uniqueness: true

  def to_param
    url_token
  end
end

調べてみると、attributeを使えば、型を持つ属性をモデルに定義したり、更新したりできるらしい。
今回は、url_tokenをstring型としてroomモデルに定義しています。
そこに、デフォルト値としてSecureRandom.hex(10)を設定しているということです。
(参考:Rails5: ActiveRecord標準のattributes API(翻訳)

まとめ

モデルにURLで使用するランダム文字列用のカラム(ここではurl_token)を追加する。
コントローラーとルーティングを弄って、idではなくてurl_tokenを参照するようにする。
attributeを使って、url_tokenのデフォルト値を乱数を生成してくれるSecureRandom.hex(10)にする。
これで、ランダム文字列のURLを使うことができるようになります
スクリーンショット 2018-08-11 12.12.06.png

GitHubが気になる方は、こちらでどうぞ。
最後に、もしこの記事に質問や不備があればコメント欄で教えていただけると助かります。
急いで書いたからツッコミどころが多いかもしれません。

参考

Rails Hack 初心者OK!Ruby on Railsでランダムなurlを作る方法