はじめに

to_paramを用いて/:usernameなルーティングを作る記事はよくみるのですが、/:usernameなルーティングにする際に、考慮すべきことまで書いてある記事がなかったので簡単にまとめることにしました。

ステップ

1. ルーティング、to_paramを定義する

まずは、/:usernameなルーティングになるようにルーティングを定義し、to_paramを対象モデルに定義します。

routes.rb
Rails.application.routes.draw do
  resources :users, only: :show, path: '/', param: :username
end
user.rb
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以下にトップレベルのルーティングを取得し、正規表現をつくるモジュールを配置します。

route_recognizer.rb
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
path_regex.rb
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

そして、カスタムバリデータを定義し対象モデルにバリデーションを追加しておきましょう。

namespace_validator.rb
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
user.rb
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を配置します。

routes.rb
require 'url_constrainer'

Rails.application.routes.draw do
  constraints(UrlConstrainer.new) do
    resources :users, only: :show, path: '/', param: :username
  end
end
url_constrainer.rb
class UrlConstrainer
  def matches?(request)
    User.find_by(username: request.params[:username]).present?
  end
end

これで完了です。

おわりに

「to_paramで/:usernameを作っておわり」ではなく、このあたりまではぜひやっておきたいところです。