module Chusaku # Handles extracting information about the Rails project's routes. class Routes class << self # Primary method to call. # # Example output: # # { # "users" => { # "edit" => [ # { # verb: "GET", # path: "/users/:id", # name: "edit_user", # defaults: {}, # source_path: "/path/to/users_controller.rb" # } # ], # "update" => [ # { # verb: "PATCH", # path: "/users", # name: "edit_user", # defaults: {}, # source_path: "/path/to/users_controller.rb" # }, # { # verb: "PUT", # path: "/users", # name: "edit_user", # defaults: {}, # source_path: "/path/to/users_controller.rb" # } # ] # }, # "empanadas" => { # "create" => [ # { # verb: "POST", # path: "/empanadas", # name: nil, # defaults: {}, # source_path: "/path/to/empanadas_controller.rb" # } # ] # } # } # # @return [Hash] Routes hash def call routes = {} populate_routes(Rails.application, routes) backfill_routes(routes) end private # Recursively populate the routes hash with information from the given Rails # application. Accounts for Rails engines. # # @param app [Rails::Application] Result of `Rails.application` # @param routes [Hash] Collection of all route info # @return [void] def populate_routes(app, routes) app.routes.routes.each do |route| if route.app.engine? populate_routes(route.app.app, routes) next end controller, action, defaults, source_path = extract_data_from(route) routes[controller] ||= {} routes[controller][action] ||= [] add_info_for \ route: route, routes: routes, controller: controller, action: action, defaults: defaults, source_path: source_path end end # Adds formatted route info for the given param combination. # # @param route [Hash] Route info # @param routes [Hash] Collection of all route info # @param controller [String] Controller key # @param action [String] Action key # @param defaults [Hash] Default parameters for route # @param source_path [String] Path to controller file # @return [void] def add_info_for(route:, routes:, controller:, action:, defaults:, source_path:) verbs_for(route).each do |verb| routes[controller][action].push \ format( route: route, verb: verb, defaults: defaults, source_path: source_path ) routes[controller][action].uniq! end end # Extract the HTTP verbs for a Rails route. Required for older versions of # Rails that return regular expressions for a route verb which sometimes # contains multiple verbs. # # @param route [ActionDispatch::Journey::Route] Route given by Rails # @return [Array] List of HTTP verbs for the given route def verbs_for(route) route_verb = route.verb.to_s %w[GET POST PUT PATCH DELETE].select do |verb| route_verb.include?(verb) end end # Formats information for a given route. # # @param route [ActionDispatch::Journey::Route] Route given by Rails # @param verb [String] HTTP verb # @param defaults [Hash] Default parameters for route # @param source_path [String] Path to controller file # @return [Hash] { verb => String, path => String, name => String } def format(route:, verb:, defaults:, source_path:) { verb: verb, path: route.path.spec.to_s.gsub("(.:format)", ""), name: route.name, defaults: defaults, source_path: source_path } end # Given a routes hash, backfill entries that aren't already filled by # `Rails.application.routes`. # # @param routes [Hash] Routes hash generated by this class # @return [Hash] Backfilled routes hash def backfill_routes(routes) paths = {} # Map paths to their verbs and names. # # Resulting hash looks like: # # ```ruby # { # "/users/:id" => { # "GET" => "edit_user", # "PATCH" => "edit_user" # } # } # ```` routes.each do |_controller, actions| actions.each do |_action, data| data.each do |datum| paths[datum[:path]] ||= {} paths[datum[:path]][datum[:verb]] ||= datum[:name] end end end # Backfill names for routes that don't have them. # # First try to match based on the path and verb. If that doesn't work, # try to match based on the path alone. routes.each do |_controller, actions| actions.each do |_action, data| data.each do |datum| datum[:name] ||= paths.dig(datum[:path], datum[:verb]) datum[:name] ||= paths[datum[:path]]&.values&.compact&.first end end end routes end # Given a route, extract the controller & action strings as well as defaults # hash and source path. # # @param route [ActionDispatch::Journey::Route] Route instance # @return [Array] (String, String, Hash, String) def extract_data_from(route) defaults = route.defaults.dup controller = defaults.delete(:controller) action = defaults.delete(:action) controller_class = controller ? "#{controller.underscore.camelize}Controller".constantize : nil action_method_name = action&.to_sym source_path = if !action_method_name.nil? && controller_class&.method_defined?(action_method_name) controller_class.instance_method(action_method_name).source_location&.[](0) else "" end [controller, action, defaults, source_path] end end end end