Dynamic Rails routes based on database models

2019-02-13 21:44发布

问题:

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.

回答1:

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


回答2:

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.



回答3:

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



回答4:

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"