So I'm building a Rails site that needs routes based on two different types
I have a Language model and a Category model
So I need to be able to go to a language route /ruby to see top ruby resources and also go to /books to see top books in all languages
I tried routes like this
get '/:language', to: "top_voted#language"
get '/:category', to: "top_voted#category"
the problem with that was the logic could not figure out the difference between the two and caused some conflicts on the back end
I also tried this
Language.all.each do |language|
get "#{language.name}", to: "top_voted#language", :language => language.name
end
Category.all.each do |category|
get "#{category.name}", to: "top_voted#category", :category => category.name
end
However the problem is Heroku where we are deploying this does not allow database calls in the routes. Is there an easier way to do this? We need to be able to dynamically generate these routes somehow.
There is a nice solution to that problem using routes constraints.
Using routes constraints
As the rails routing guide suggests, you could define routes constraints in a way that they check if a path belongs to a language or a category.
# config/routes.rb
# ...
get ':language', to: 'top_voted#language', constraints: lambda { |request| Language.where(name: request[:language]).any? }
get ':category', to: 'top_voted#category', constraints: lambda { |request| Category.where(name: request[:category]).any? }
The order defines the priority. In the above example, if a language and a category have the same name, the language wins as its route is defined above the category route.
Using a Permalink model
If you want to make sure, all paths are uniqe, an easy way would be to define a Permalink
model and using a validation there.
Generate the database table: rails generate model Permalink path:string reference_type:string reference_id:integer && rails db:migrate
And define the validation in the model:
class Permalink < ApplicationRecord
belongs_to :reference, polymorphic: true
validates :path, presence: true, uniqueness: true
end
And associate it with the other object types:
class Language < ApplicationRecord
has_many :permalinks, as: :reference, dependent: :destroy
end
This also allows you to define several permalink paths for a record.
rails_category.permalinks.create path: 'rails'
rails_category.permalinks.create path: 'ruby-on-rails'
With this solution, the routes file has to look like this:
# config/routes.rb
# ...
get ':language', to: 'top_voted#language', constraints: lambda { |request| Permalink.where(reference_type: 'Language', path: request[:language]).any? }
get ':category', to: 'top_voted#category', constraints: lambda { |request| Permalink.where(reference_type: 'Category', path: request[:category]).any? }
And, as a side note for other users using the cancan gem and load_and_authorize_resource
in the controller: You have to load the record by permalink before calling load_and_authorize_resource
:
class Category < ApplicationRecord
before_action :find_resource_by_permalink, only: :show
load_and_authorize_resource
private
def find_resource_by_permalink
@category ||= Permalink.find_by(path: params[:category]).try(:reference)
end
end
This sounds like an architecture issue. If the clean urls are important to you, here's how I would set this up:
Create a new model called Page
, which will belong to a specific resource (either a Category or a Language).
class Page < ActiveRecord::Base
belongs_to :resource, polymorphic: true
end
The database columns would be id
, resource_type
, resource_id
, path
, and whatever else you want to hang on there.
Your other models would have the reciprocal relationship:
has_many :pages, as: :resource
Now you can route using a single path, but still have access to the resources from different classes.
Router:
resources :pages, id: /[0-9a-z]/
Controller:
class PagesController
def show
@page = Page.find_by_path(params[:id])
end
end
In the view, set up partials for your resource models, and then render them in pages/show
:
=render @page.resource
An example page would be #<Page path: 'ruby', resource: #<Language name: "Ruby">>
, which would be available at /pages/ruby
. You could probably route it such that /ruby
routes to PagesController
, but then you're severely limiting the number of routes you can use elsewhere in the app.
Since I'm a few months late, you've probably already figured something out, but for the future people, constraints might be what you're looking for. You can either set up a lambda that decides based on the request object, or you can set up a class that implements a matches?
method for the router to call.
http://guides.rubyonrails.org/routing.html#advanced-constraints
Two routes get '/:language' and get '/:category' are exactly same for rails.
Rails router can't differentiate between /books
and /ruby
.
In both cases rails would just look for a route in routes.rb
which looks something like /something
, it will pick the first match and dispatches the route to the specified controller's action.
In your case,
all the requests with /something
format
would be matched to
get '/:language', to: "top_voted#language"