Weâve talked about our use of the Pundit gem in a project a while back, in which Julio mentioned a menu system weâd built that incorporated Pundit. I recently received a request on Twitter to write about it, so here it goes.
The idea
The project we were working on involved a fairly standard role-based permission system, in which a User
model has_many :roles
. We then checked whether the current_user
had the necessary role in order to access a particular controller action, using Pundit policy POROs and the authorize!
controller helper.
As it didn’t make sense to render those parts of the navigation menu that the user was unable to access, we wanted to develop a simple way to describe the structure of the menu, and only render the sections to which the current user could access, without wrapping every part of the menu in an if
statement, like so:
if policy(current_user.company).show?
link_to current_user.company
end
if policy(OfficeLocation).index?
link_to office_locations_path
end
Unfortunately, Pundit only works out-of-the-box within the request-response controller cycle, since it needs to know the current controller and action (in the params
hash). We needed to find a way to generate these from a URL, so that we could construct the proper policy object, then send it the appropriate method.
Enter #recognize_path
Fortunately, Rails ships with a useful – albeit undocumented – #recognize_path
method, which is available on the instance of your app’s ActionDispatch::Routing::RouteSet
that’s returned by Rails.application.routes
.
Some examples (these are obviously dependent on the routes you’ve defined!):
routes = Rails.application.routes
routes.recognize_path("/") # => { :controller => "home", :action => "index" }
routes.recognize_path("/users/1") # => { :controller => "users", :action => "show", :id => "1" }
A menu helper
We can now write a little helper (for the purposes of illustration – you’d probably want to write a specialised class for it) for our menu partial:
module MenuHelper
def menu_item(text, url)
link_to(text, url) if menu_item_visible?(url)
end
private
def menu_item_visible?(url)
parsed_params = Rails.application.routes.recognize_path(url)
# This transforms the controller name to its corresponding model class
# For example: "users" => "User" => User
policy_class = parsed_params[:controller].classify.constantize
policy_method = "#{parsed_params[:action]}?"
policy(policy_class).send(policy_method)
end
end
In brief, we parse the URL into the relevent params, then use these params to find the correct policy for the controller, and send it the question-mark method corresponding to the controller action.
More flexibility
This relies on the usual Rails convention that your controller name is the pluralised form of a corresponding model in your app. If this isn’t the case, you can add a policy_class
class method to your controller, and derive the policy class that way (the policy_class
method is used, if available, by the policy
helper method provided by Pundit).
In our case, we defined a policy_class
method on ApplicationController
that assumes that the controller is named after a model, which can then overriden by individual controllers if needed:
# in ApplicationController
def self.policy_class
"#{controller_name.classify}Policy".constantize
end
We then change the menu helper method to provide the controller class instead of the model class:
# Previously:
policy_class = parsed_params[:controller].classify.constantize
# Now:
policy_class = "#{parsed_params[:controller].camelize}Controller".constantize
Caveats
Unfortunately, recognize_path
won’t recognize routes defined with constraints that use the request object, since at the point of menu rendering, we’re outside the normal request-response cycle. This includes the authenticated
and unauthenticated
route helpers from Devise. Such routes will raise an exception – either a ActionController::RoutingError
, or in the case of Devise, a cryptic NoMethodError: undefined method 'authenticate?' for nil:NilClass
.
In such cases, you can either remove the route constraints, or adapt the menu helper so that you can pass in the controller/action params hash manually, and rely on the link_to
helper’s delegation to url_for
to generate your URL string.
In our case, the only problem we had was the root_path
, which was routed to a different controller depending on whether the user was logged in or not. Since all (logged-in) users were able to access this route, we simply added an check_authorization
true/false flag to the menu_item
method, which skipped the policy check altogether.
The menu helper will also need adapting to handle namespaced controllers (i.e. Admin::UsersController).
Bonus
Since we have the controller and action names for each menu item, we could also use Rails’ translation system to provide the menu labels in our locale file:
# en.yml
en:
menu:
companies:
index: View my company
edit: Edit my company details
# and so on...
We can then use the t
helper method in our menu helper:
def menu_item(url)
if menu_item_visible?(url)
# ... parse params from url ...
link_to text, t("#{parsed_params[:controller]}.#{parsed_params[:action]}", scope: :menu)
end
end
Conclusion
This system gave us an easy way to separate the structure of the menu from its rendering, without having to worry about the permissions system. This came in handy as the project progressed, since we were able to move items around in the menu quickly, and gave us a easy-to-follow convention for adding new items.
For more complicated menus, you could try the SimpleNavigation gem, which has a nice DSL to declarate the structure of your menu, and adds things like nested menus and highlighting of items according to the current URL. The gem has a pluggable renderer system, so you can incorporate the menu helper above to customise whether a menu item is shown or hidden.
Photo by didmyself on Flickr