# frozen_string_literal: true module RailsBestPractices module Reviews # Review a route file to make sure all auto-generated routes have corresponding actions in controller. # # See the best practice details here https://rails-bestpractices.com/posts/2011/08/19/restrict-auto-generated-routes/ # # Implementation: # # Review process: # check all resources and resource method calls, # compare the generated routes and corresponding actions in controller, # if there is a route generated, but there is not action in that controller, # then you should restrict your routes. class RestrictAutoGeneratedRoutesReview < Review interesting_nodes :command, :command_call, :method_add_block interesting_files ROUTE_FILES url 'https://rails-bestpractices.com/posts/2011/08/19/restrict-auto-generated-routes/' def resource_methods if Prepares.configs['config.api_only'] %w[show create update destroy] else %w[show new create edit update destroy] end end def resources_methods resource_methods + ['index'] end def initialize(options = {}) super(options) @namespaces = [] @resource_controllers = [] end # check if the generated routes have the corresponding actions in controller for rails routes. add_callback :start_command, :start_command_call do |node| if node.message.to_s == 'resources' if (mod = module_option(node)) @namespaces << mod end check_resources(node) @resource_controllers << node.arguments.all.first.to_s elsif node.message.to_s == 'resource' check_resource(node) @resource_controllers << node.arguments.all.first.to_s end end add_callback :end_command do |node| if node.message.to_s == 'resources' @resource_controllers.pop @namespaces.pop if module_option(node) elsif node.message.to_s == 'resource' @resource_controllers.pop end end # remember the namespace. add_callback :start_method_add_block do |node| case node.message.to_s when 'namespace' @namespaces << node.arguments.all.first.to_s if check_method_add_block?(node) when 'resources', 'resource' @resource_controllers << node.arguments.all.first.to_s if check_method_add_block?(node) when 'scope' if check_method_add_block?(node) && (mod = module_option(node)) @namespaces << mod end end end # end of namespace call. add_callback :end_method_add_block do |node| if check_method_add_block?(node) case node.message.to_s when 'namespace' @namespaces.pop when 'resources', 'resource' @resource_controllers.pop when 'scope' if check_method_add_block?(node) && module_option(node) @namespaces.pop end end end end def check_method_add_block?(node) node[1].sexp_type == :command || (node[1].sexp_type == :command_call && node.receiver.to_s != 'map') end private # check resources call, if the routes generated by resources does not exist in the controller. def check_resources(node) _check(node, resources_methods) end # check resource call, if the routes generated by resources does not exist in the controller. def check_resource(node) _check(node, resource_methods) end # get the controller name. def controller_name(node) if option_with_hash(node) option_node = node.arguments.all[1] name = if hash_key_exist?(option_node, 'controller') option_node.hash_value('controller').to_s else node.arguments.all.first.to_s.gsub('::', '').tableize end else name = node.arguments.all.first.to_s.gsub('::', '').tableize end namespaced_class_name(name) end # get the class name with namespace. def namespaced_class_name(name) class_name = "#{name.split('/').map(&:camelize).join('::')}Controller" if @namespaces.empty? class_name else @namespaces.map { |namespace| "#{namespace.camelize}::" }.join('') + class_name end end def _check(node, methods) controller_name = controller_name(node) return unless Prepares.controllers.include? controller_name _methods = _methods(node, methods) unless _methods.all? { |meth| Prepares.controller_methods.has_method?(controller_name, meth) } prepared_method_names = Prepares.controller_methods.get_methods(controller_name).map(&:method_name) only_methods = (_methods & prepared_method_names).map { |meth| ":#{meth}" } routes_message = if only_methods.size > 3 "except: [#{(methods.map { |meth| ':' + meth } - only_methods).join(', ')}]" else "only: [#{only_methods.join(', ')}]" end add_error "restrict auto-generated routes #{friendly_route_name(node)} (#{routes_message})" end end def _methods(node, methods) if option_with_hash(node) option_node = node.arguments.all[1] if hash_key_exist?(option_node, 'only') option_node.hash_value('only').to_s == 'none' ? [] : Array(option_node.hash_value('only').to_object) elsif hash_key_exist?(option_node, 'except') if option_node.hash_value('except').to_s == 'all' [] else (methods - Array(option_node.hash_value('except').to_object)) end else methods end else methods end end def module_option(node) option_node = node.arguments[1].last if option_node && option_node.sexp_type == :bare_assoc_hash && hash_key_exist?(option_node, 'module') option_node.hash_value('module').to_s end end def option_with_hash(node) node.arguments.all.size > 1 && node.arguments.all[1].sexp_type == :bare_assoc_hash end def hash_key_exist?(node, key) node.hash_keys&.include?(key) end def friendly_route_name(node) if @resource_controllers.last == node.arguments.to_s [@namespaces.join('/'), @resource_controllers.join('/')].delete_if(&:blank?).join('/') else [@namespaces.join('/'), @resource_controllers.join('/'), node.arguments.to_s].delete_if(&:blank?).join('/') end end end end end