はじめに
to_param
を用いて/:username
なルーティングを作る記事はよくみるのですが、/:username
なルーティングにする際に、考慮すべきことまで書いてある記事がなかったので簡単にまとめることにしました。
ステップ
1. ルーティング、to_paramを定義する
まずは、/:username
なルーティングになるようにルーティングを定義し、to_param
を対象モデルに定義します。
Rails.application.routes.draw do
resources :users, only: :show, path: '/', param: :username
end
class User < ApplicationRecord
def to_param
username
end
end
こうすることで、URLのidの部分にusernameを指定することができます。
user = User.find_by(username: 'hc0208')
user_path(user) # => "/hc0208"
2. URLが被らないようにする
ルーティングを/:username
にすると、usernameが他のルーティングの名前(例えば何かのランキングを表示するようなパス/ranking
など)とかぶってしまう可能性があるため、usernameを登録する際にそれらのルーティングの名前はusernameとして登録できないようにする必要があります。
まずは、lib以下にトップレベルのルーティングを取得し、正規表現をつくるモジュールを配置します。
module RouteRecognizer
extend self
INITIAL_SEGMENT_REGEX = %r{^\/([^\/\(:]+)}
def top_level_path
routes = Rails.application.routes.routes
routes.collect { |r| match_initial_path(r.path.spec.to_s) }.compact.uniq
end
def match_initial_path(path)
if match = INITIAL_SEGMENT_REGEX.match(path)
match[1]
end
end
end
module PathRegex
extend self
TOP_LEVEL_ROUTES = %w[404.html 500.html] # etc...
def root_namespace_path_regex
Regexp.new(Regexp.union(top_level_routes).source, Regexp::IGNORECASE)
end
def top_level_routes
TOP_LEVEL_ROUTES.push(RouteRecognizer.top_level_path).flatten.freeze
end
end
そして、カスタムバリデータを定義し対象モデルにバリデーションを追加しておきましょう。
class NamespaceValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
if value =~ PathRegex.root_namespace_path_regex
record.errors.add(attribute, "#{value}はすでに使用されています")
end
end
end
class User < ApplicationRecord
validates :username, namespace: true, uniqueness: true # 必要に応じてlengthやformatオプションなども追加
def to_param
username
end
end
これでトップレベルのルーティングをバリデーションではじくことができるようになります。
3. controllerにアクセスできないようにする
例えば/items
というパスがあった際、ルーティングの書き方によっては/:usernameのルーティングに当てはまってしまう可能性があります。/:usernameのルーティングを一番下に書けば解決するのですが、気づかずその下にルーティングを追加しまったり、そもそも一番下にはおきたくなかったりする可能性もあるので対策しておきます。
railsのルーティングにはconstraintsという機能があり、アクセスの制限をかけられるのでかけます。lib以下にurl_constrainer.rbを配置します。
require 'url_constrainer'
Rails.application.routes.draw do
constraints(UrlConstrainer.new) do
resources :users, only: :show, path: '/', param: :username
end
end
class UrlConstrainer
def matches?(request)
User.find_by(username: request.params[:username]).present?
end
end
これで完了です。
おわりに
「to_paramで/:usernameを作っておわり」ではなく、このあたりまではぜひやっておきたいところです。