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