diff options
Diffstat (limited to 'actionpack/lib/action_dispatch/routing/mapper.rb')
-rw-r--r-- | actionpack/lib/action_dispatch/routing/mapper.rb | 919 |
1 files changed, 602 insertions, 317 deletions
diff --git a/actionpack/lib/action_dispatch/routing/mapper.rb b/actionpack/lib/action_dispatch/routing/mapper.rb index 0dc6403ec0..b9e916078c 100644 --- a/actionpack/lib/action_dispatch/routing/mapper.rb +++ b/actionpack/lib/action_dispatch/routing/mapper.rb @@ -2,261 +2,361 @@ require 'active_support/core_ext/hash/except' require 'active_support/core_ext/hash/reverse_merge' require 'active_support/core_ext/hash/slice' require 'active_support/core_ext/enumerable' +require 'active_support/core_ext/array/extract_options' +require 'active_support/core_ext/module/remove_method' +require 'active_support/core_ext/string/filters' require 'active_support/inflector' require 'action_dispatch/routing/redirection' +require 'action_dispatch/routing/endpoint' +require 'active_support/deprecation' module ActionDispatch module Routing class Mapper - class Constraints #:nodoc: - def self.new(app, constraints, request = Rack::Request) - if constraints.any? - super(app, constraints, request) - else - app - end - end + URL_OPTIONS = [:protocol, :subdomain, :domain, :host, :port] + class Constraints < Endpoint #:nodoc: attr_reader :app, :constraints - def initialize(app, constraints, request) - @app, @constraints, @request = app, constraints, request - end + def initialize(app, constraints, dispatcher_p) + # Unwrap Constraints objects. I don't actually think it's possible + # to pass a Constraints object to this constructor, but there were + # multiple places that kept testing children of this object. I + # *think* they were just being defensive, but I have no idea. + if app.is_a?(self.class) + constraints += app.constraints + app = app.app + end - def matches?(env) - req = @request.new(env) + @dispatcher = dispatcher_p - @constraints.each { |constraint| - if constraint.respond_to?(:matches?) && !constraint.matches?(req) - return false - elsif constraint.respond_to?(:call) && !constraint.call(*constraint_args(constraint, req)) - return false - end - } + @app, @constraints, = app, constraints + end - return true - ensure - req.reset_parameters + def dispatcher?; @dispatcher; end + + def matches?(req) + @constraints.all? do |constraint| + (constraint.respond_to?(:matches?) && constraint.matches?(req)) || + (constraint.respond_to?(:call) && constraint.call(*constraint_args(constraint, req))) + end end - def call(env) - matches?(env) ? @app.call(env) : [ 404, {'X-Cascade' => 'pass'}, [] ] + def serve(req) + return [ 404, {'X-Cascade' => 'pass'}, [] ] unless matches?(req) + + if dispatcher? + @app.serve req + else + @app.call req.env + end end private def constraint_args(constraint, request) - constraint.arity == 1 ? [request] : [request.symbolized_path_parameters, request] + constraint.arity == 1 ? [request] : [request.path_parameters, request] end end class Mapping #:nodoc: - IGNORE_OPTIONS = [:to, :as, :via, :on, :constraints, :defaults, :only, :except, :anchor, :shallow, :shallow_path, :shallow_prefix] ANCHOR_CHARACTERS_REGEX = %r{\A(\\A|\^)|(\\Z|\\z|\$)\Z} - SHORTHAND_REGEX = %r{/[\w/]+$} - WILDCARD_PATH = %r{\*([^/\)]+)\)?$} - def initialize(set, scope, path, options) - @set, @scope = set, scope - @segment_keys = nil - @options = (@scope[:options] || {}).merge(options) - @path = normalize_path(path) - normalize_options! + attr_reader :requirements, :conditions, :defaults + attr_reader :to, :default_controller, :default_action, :as, :anchor - via_all = @options.delete(:via) if @options[:via] == :all + def self.build(scope, set, path, as, options) + options = scope[:options].merge(options) if scope[:options] - if !via_all && request_method_condition.empty? - msg = "You should not use the `match` method in your router without specifying an HTTP method.\n" \ - "If you want to expose your action to GET, use `get` in the router:\n\n" \ - " Instead of: match \"controller#action\"\n" \ - " Do: get \"controller#action\"" - raise msg - end - end + options.delete :only + options.delete :except + options.delete :shallow_path + options.delete :shallow_prefix + options.delete :shallow - def to_route - [ app, conditions, requirements, defaults, @options[:as], @options[:anchor] ] + defaults = (scope[:defaults] || {}).merge options.delete(:defaults) || {} + + new scope, set, path, defaults, as, options end - private + def initialize(scope, set, path, defaults, as, options) + @requirements, @conditions = {}, {} + @defaults = defaults + @set = set - def normalize_options! - path_without_format = @path.sub(/\(\.:format\)$/, '') + @to = options.delete :to + @default_controller = options.delete(:controller) || scope[:controller] + @default_action = options.delete(:action) || scope[:action] + @as = as + @anchor = options.delete :anchor - if using_match_shorthand?(path_without_format, @options) - to_shorthand = @options[:to].blank? - @options[:to] ||= path_without_format.gsub(/\(.*\)/, "")[1..-1].sub(%r{/([^/]*)$}, '#\1') - end + formatted = options.delete :format + via = Array(options.delete(:via) { [] }) + options_constraints = options.delete :constraints - @options.merge!(default_controller_and_action(to_shorthand)) + path = normalize_path! path, formatted + ast = path_ast path + path_params = path_params ast - requirements.each do |name, requirement| - # segment_keys.include?(k.to_s) || k == :controller - next unless Regexp === requirement && !constraints[name] + options = normalize_options!(options, formatted, path_params, ast, scope[:module]) - if requirement.source =~ ANCHOR_CHARACTERS_REGEX - raise ArgumentError, "Regexp anchor characters are not allowed in routing requirements: #{requirement.inspect}" - end - if requirement.multiline? - raise ArgumentError, "Regexp multiline option not allowed in routing requirements: #{requirement.inspect}" + + split_constraints(path_params, scope[:constraints]) if scope[:constraints] + constraints = constraints(options, path_params) + + split_constraints path_params, constraints + + @blocks = blocks(options_constraints, scope[:blocks]) + + if options_constraints.is_a?(Hash) + split_constraints path_params, options_constraints + options_constraints.each do |key, default| + if URL_OPTIONS.include?(key) && (String === default || Fixnum === default) + @defaults[key] ||= default end end + end + + normalize_format!(formatted) - if @options[:constraints].is_a?(Hash) - (@options[:defaults] ||= {}).reverse_merge!(defaults_from_constraints(@options[:constraints])) + @conditions[:path_info] = path + @conditions[:parsed_path_info] = ast + + add_request_method(via, @conditions) + normalize_defaults!(options) + end + + def to_route + [ app(@blocks), conditions, requirements, defaults, as, anchor ] + end + + private + + def normalize_path!(path, format) + path = Mapper.normalize_path(path) + + if format == true + "#{path}.:format" + elsif optional_format?(path, format) + "#{path}(.:format)" + else + path end end - # match "account/overview" - def using_match_shorthand?(path, options) - path && (options[:to] || options[:action]).nil? && path =~ SHORTHAND_REGEX + def optional_format?(path, format) + format != false && !path.include?(':format') && !path.end_with?('/') end - def normalize_path(path) - raise ArgumentError, "path is required" if path.blank? - path = Mapper.normalize_path(path) + def normalize_options!(options, formatted, path_params, path_ast, modyoule) + # Add a constraint for wildcard route to make it non-greedy and match the + # optional format part of the route by default + if formatted != false + path_ast.grep(Journey::Nodes::Star) do |node| + options[node.name.to_sym] ||= /.+?/ + end + end - if path.match(':controller') - raise ArgumentError, ":controller segment is not allowed within a namespace block" if @scope[:module] + if path_params.include?(:controller) + raise ArgumentError, ":controller segment is not allowed within a namespace block" if modyoule # Add a default constraint for :controller path segments that matches namespaced # controllers with default routes like :controller/:action/:id(.:format), e.g: # GET /admin/products/show/1 # => { controller: 'admin/products', action: 'show', id: '1' } - @options[:controller] ||= /.+?/ + options[:controller] ||= /.+?/ end - # Add a constraint for wildcard route to make it non-greedy and match the - # optional format part of the route by default - if path.match(WILDCARD_PATH) && @options[:format] != false - @options[$1.to_sym] ||= /.+?/ - end - - if @options[:format] == false - @options.delete(:format) - path - elsif path.include?(":format") || path.end_with?('/') - path - elsif @options[:format] == true - "#{path}.:format" + if to.respond_to? :call + options else - "#{path}(.:format)" + to_endpoint = split_to to + controller = to_endpoint[0] || default_controller + action = to_endpoint[1] || default_action + + controller = add_controller_module(controller, modyoule) + + options.merge! check_controller_and_action(path_params, controller, action) end end - def app - Constraints.new( - to.respond_to?(:call) ? to : Routing::RouteSet::Dispatcher.new(:defaults => defaults), - blocks, - @set.request_class - ) + def split_constraints(path_params, constraints) + constraints.each_pair do |key, requirement| + if path_params.include?(key) || key == :controller + verify_regexp_requirement(requirement) if requirement.is_a?(Regexp) + @requirements[key] = requirement + else + @conditions[key] = requirement + end + end end - def conditions - { :path_info => @path }.merge!(constraints).merge!(request_method_condition) + def normalize_format!(formatted) + if formatted == true + @requirements[:format] ||= /.+/ + elsif Regexp === formatted + @requirements[:format] = formatted + @defaults[:format] = nil + elsif String === formatted + @requirements[:format] = Regexp.compile(formatted) + @defaults[:format] = formatted + end end - def requirements - @requirements ||= (@options[:constraints].is_a?(Hash) ? @options[:constraints] : {}).tap do |requirements| - requirements.reverse_merge!(@scope[:constraints]) if @scope[:constraints] - @options.each { |k, v| requirements[k] ||= v if v.is_a?(Regexp) } + def verify_regexp_requirement(requirement) + if requirement.source =~ ANCHOR_CHARACTERS_REGEX + raise ArgumentError, "Regexp anchor characters are not allowed in routing requirements: #{requirement.inspect}" end - end - def defaults - @defaults ||= (@options[:defaults] || {}).tap do |defaults| - defaults.reverse_merge!(@scope[:defaults]) if @scope[:defaults] - @options.each { |k, v| defaults[k] = v unless v.is_a?(Regexp) || IGNORE_OPTIONS.include?(k.to_sym) } + if requirement.multiline? + raise ArgumentError, "Regexp multiline option is not allowed in routing requirements: #{requirement.inspect}" end end - def default_controller_and_action(to_shorthand=nil) - if to.respond_to?(:call) - { } - else - if to.is_a?(String) - controller, action = to.split('#') - elsif to.is_a?(Symbol) - action = to.to_s + def normalize_defaults!(options) + options.each_pair do |key, default| + unless Regexp === default + @defaults[key] = default end + end + end - controller ||= default_controller - action ||= default_action + def verify_callable_constraint(callable_constraint) + unless callable_constraint.respond_to?(:call) || callable_constraint.respond_to?(:matches?) + raise ArgumentError, "Invalid constraint: #{callable_constraint.inspect} must respond to :call or :matches?" + end + end - unless controller.is_a?(Regexp) || to_shorthand - controller = [@scope[:module], controller].compact.join("/").presence - end + def add_request_method(via, conditions) + return if via == [:all] - if controller.is_a?(String) && controller =~ %r{\A/} - raise ArgumentError, "controller name should not start with a slash" - end + if via.empty? + msg = "You should not use the `match` method in your router without specifying an HTTP method.\n" \ + "If you want to expose your action to both GET and POST, add `via: [:get, :post]` option.\n" \ + "If you want to expose your action to GET, use `get` in the router:\n" \ + " Instead of: match \"controller#action\"\n" \ + " Do: get \"controller#action\"" + raise ArgumentError, msg + end - controller = controller.to_s unless controller.is_a?(Regexp) - action = action.to_s unless action.is_a?(Regexp) + conditions[:request_method] = via.map { |m| m.to_s.dasherize.upcase } + end - if controller.blank? && segment_keys.exclude?("controller") - raise ArgumentError, "missing :controller" - end + def app(blocks) + if to.respond_to?(:call) + Constraints.new(to, blocks, false) + elsif blocks.any? + Constraints.new(dispatcher(defaults), blocks, true) + else + dispatcher(defaults) + end + end - if action.blank? && segment_keys.exclude?("action") - raise ArgumentError, "missing :action" - end + def check_controller_and_action(path_params, controller, action) + hash = check_part(:controller, controller, path_params, {}) do |part| + translate_controller(part) { + message = "'#{part}' is not a supported controller name. This can lead to potential routing problems." + message << " See http://guides.rubyonrails.org/routing.html#specifying-a-controller-to-use" - hash = {} - hash[:controller] = controller unless controller.blank? - hash[:action] = action unless action.blank? - hash + raise ArgumentError, message + } end + + check_part(:action, action, path_params, hash) { |part| + part.is_a?(Regexp) ? part : part.to_s + } end - def blocks - constraints = @options[:constraints] - if constraints.present? && !constraints.is_a?(Hash) - [constraints] + def check_part(name, part, path_params, hash) + if part + hash[name] = yield(part) else - @scope[:blocks] || [] + unless path_params.include?(name) + message = "Missing :#{name} key on routes definition, please check your routes." + raise ArgumentError, message + end + end + hash + end + + def split_to(to) + case to + when Symbol + ActiveSupport::Deprecation.warn(<<-MSG.squish) + Defining a route where `to` is a symbol is deprecated. + Please change `to: :#{to}` to `action: :#{to}`. + MSG + + [nil, to.to_s] + when /#/ then to.split('#') + when String + ActiveSupport::Deprecation.warn(<<-MSG.squish) + Defining a route where `to` is a controller without an action is deprecated. + Please change `to: :#{to}` to `controller: :#{to}`. + MSG + + [to, nil] + else + [] end end - def constraints - @constraints ||= requirements.reject { |k, v| segment_keys.include?(k.to_s) || k == :controller } - end - - def request_method_condition - if via = @options[:via] - list = Array(via).map { |m| m.to_s.dasherize.upcase } - { :request_method => list } + def add_controller_module(controller, modyoule) + if modyoule && !controller.is_a?(Regexp) + if controller =~ %r{\A/} + controller[1..-1] + else + [modyoule, controller].compact.join("/") + end else - { } + controller end end - def segment_keys - return @segment_keys if @segment_keys + def translate_controller(controller) + return controller if Regexp === controller + return controller.to_s if controller =~ /\A[a-z_0-9][a-z_0-9\/]*\z/ + + yield + end - @segment_keys = Journey::Path::Pattern.new( - Journey::Router::Strexp.compile(@path, requirements, SEPARATORS) - ).names + def blocks(options_constraints, scope_blocks) + if options_constraints && !options_constraints.is_a?(Hash) + verify_callable_constraint(options_constraints) + [options_constraints] + else + scope_blocks || [] + end end - def to - @options[:to] + def constraints(options, path_params) + constraints = {} + required_defaults = [] + options.each_pair do |key, option| + if Regexp === option + constraints[key] = option + else + required_defaults << key unless path_params.include?(key) + end + end + @conditions[:required_defaults] = required_defaults + constraints end - def default_controller - @options[:controller] || @scope[:controller] + def path_params(ast) + ast.grep(Journey::Nodes::Symbol).map { |n| n.name.to_sym } end - def default_action - @options[:action] || @scope[:action] + def path_ast(path) + parser = Journey::Parser.new + parser.parse path end - def defaults_from_constraints(constraints) - url_keys = [:protocol, :subdomain, :domain, :host, :port] - constraints.select { |k, v| url_keys.include?(k) && (v.is_a?(String) || v.is_a?(Fixnum)) } + def dispatcher(defaults) + @set.dispatcher defaults end end - # Invokes Rack::Mount::Utils.normalize path and ensure that + # Invokes Journey::Router::Utils.normalize_path and ensure that # (:locale) becomes (/:locale) instead of /(:locale). Except # for root cases, where the latter is the correct one. def self.normalize_path(path) @@ -284,44 +384,64 @@ module ActionDispatch # because this means it will be matched first. As this is the most popular route # of most Rails applications, this is beneficial. def root(options = {}) - options = { :to => options } if options.is_a?(String) match '/', { :as => :root, :via => :get }.merge!(options) end - # Matches a url pattern to one or more routes. Any symbols in a pattern - # are interpreted as url query parameters and thus available as +params+ - # in an action: + # Matches a url pattern to one or more routes. + # + # You should not use the +match+ method in your router + # without specifying an HTTP method. + # + # If you want to expose your action to both GET and POST, use: # # # sets :controller, :action and :id in params - # match ':controller/:action/:id' + # match ':controller/:action/:id', via: [:get, :post] + # + # Note that +:controller+, +:action+ and +:id+ are interpreted as url + # query parameters and thus available through +params+ in an action. + # + # If you want to expose your action to GET, use +get+ in the router: + # + # Instead of: + # + # match ":controller/:action/:id" + # + # Do: + # + # get ":controller/:action/:id" # # Two of these symbols are special, +:controller+ maps to the controller # and +:action+ to the controller's action. A pattern can also map # wildcard segments (globs) to params: # - # match 'songs/*category/:title', to: 'songs#show' + # get 'songs/*category/:title', to: 'songs#show' # # # 'songs/rock/classic/stairway-to-heaven' sets # # params[:category] = 'rock/classic' # # params[:title] = 'stairway-to-heaven' # + # To match a wildcard parameter, it must have a name assigned to it. + # Without a variable name to attach the glob parameter to, the route + # can't be parsed. + # # When a pattern points to an internal route, the route's +:action+ and # +:controller+ should be set in options or hash shorthand. Examples: # - # match 'photos/:id' => 'photos#show' - # match 'photos/:id', to: 'photos#show' - # match 'photos/:id', controller: 'photos', action: 'show' + # match 'photos/:id' => 'photos#show', via: :get + # match 'photos/:id', to: 'photos#show', via: :get + # match 'photos/:id', controller: 'photos', action: 'show', via: :get # # A pattern can also point to a +Rack+ endpoint i.e. anything that # responds to +call+: # - # match 'photos/:id', to: lambda {|hash| [200, {}, ["Coming soon"]] } - # match 'photos/:id', to: PhotoRackApp + # match 'photos/:id', to: lambda {|hash| [200, {}, ["Coming soon"]] }, via: :get + # match 'photos/:id', to: PhotoRackApp, via: :get # # Yes, controller actions are just rack endpoints - # match 'photos/:id', to: PhotosController.action(:show) + # match 'photos/:id', to: PhotosController.action(:show), via: :get # - # Because request various HTTP verbs with a single action has security - # implications, is recommendable use HttpHelpers[rdoc-ref:HttpHelpers] + # Because requesting various HTTP verbs with a single action has security + # implications, you must either specify the actions in + # the via options or use one of the HttpHelpers[rdoc-ref:HttpHelpers] # instead +match+ # # === Options @@ -334,14 +454,20 @@ module ActionDispatch # [:action] # The route's action. # + # [:param] + # Overrides the default resource identifier +:id+ (name of the + # dynamic segment used to generate the routes). + # You can access that segment from your controller using + # <tt>params[<:param>]</tt>. + # # [:path] # The path prefix for the routes. # # [:module] # The namespace for :controller. # - # match 'path', to: 'c#a', module: 'sekret', controller: 'posts' - # #=> Sekret::PostsController + # match 'path', to: 'c#a', module: 'sekret', controller: 'posts', via: :get + # # => Sekret::PostsController # # See <tt>Scoping#namespace</tt> for its scope equivalent. # @@ -359,9 +485,9 @@ module ActionDispatch # Points to a +Rack+ endpoint. Can be an object that responds to # +call+ or a string representing a controller's action. # - # match 'path', to: 'controller#action' - # match 'path', to: lambda { |env| [200, {}, ["Success!"]] } - # match 'path', to: RackApp + # match 'path', to: 'controller#action', via: :get + # match 'path', to: lambda { |env| [200, {}, ["Success!"]] }, via: :get + # match 'path', to: RackApp, via: :get # # [:on] # Shorthand for wrapping routes in a specific RESTful context. Valid @@ -381,15 +507,19 @@ module ActionDispatch # end # # [:constraints] - # Constrains parameters with a hash of regular expressions or an - # object that responds to <tt>matches?</tt> + # Constrains parameters with a hash of regular expressions + # or an object that responds to <tt>matches?</tt>. In addition, constraints + # other than path can also be specified with any object + # that responds to <tt>===</tt> (eg. String, Array, Range, etc.). + # + # match 'path/:id', constraints: { id: /[A-Z]\d{5}/ }, via: :get # - # match 'path/:id', constraints: { id: /[A-Z]\d{5}/ } + # match 'json_only', constraints: { format: 'json' }, via: :get # - # class Blacklist + # class Whitelist # def matches?(request) request.remote_ip == '1.2.3.4' end # end - # match 'path', to: 'c#a', constraints: Blacklist.new + # match 'path', to: 'c#a', constraints: Whitelist.new, via: :get # # See <tt>Scoping#constraints</tt> for more examples with its scope # equivalent. @@ -398,7 +528,7 @@ module ActionDispatch # Sets defaults for parameters # # # Sets params[:format] to 'jpg' by default - # match 'path', to: 'c#a', defaults: { format: 'jpg' } + # match 'path', to: 'c#a', defaults: { format: 'jpg' }, via: :get # # See <tt>Scoping#defaults</tt> for its scope equivalent. # @@ -407,7 +537,7 @@ module ActionDispatch # false, the pattern matches any request prefixed with the given path. # # # Matches any request starting with 'path' - # match 'path', to: 'c#a', anchor: false + # match 'path', to: 'c#a', anchor: false, via: :get # # [:format] # Allows you to specify the default value for optional +format+ @@ -443,18 +573,21 @@ module ActionDispatch end options = app - app, path = options.find { |k, v| k.respond_to?(:call) } + app, path = options.find { |k, _| k.respond_to?(:call) } options.delete(app) if app end raise "A rack application must be specified" unless path - options[:as] ||= app_name(app) + rails_app = rails_app? app + options[:as] ||= app_name(app, rails_app) + + target_as = name_for_action(options[:as], path) options[:via] ||= :all match(path, options.merge(:to => app, :anchor => false, :format => false)) - define_generate_prefix(app, options[:as]) + define_generate_prefix(app, target_as) if rails_app self end @@ -469,36 +602,42 @@ module ActionDispatch end end + # Query if the following named route was already defined. + def has_named_route?(name) + @set.named_routes.routes[name.to_sym] + end + private - def app_name(app) - return unless app.respond_to?(:routes) + def rails_app?(app) + app.is_a?(Class) && app < Rails::Railtie + end - if app.respond_to?(:railtie_name) + def app_name(app, rails_app) + if rails_app app.railtie_name - else - class_name = app.class.is_a?(Class) ? app.name : app.class.name + elsif app.is_a?(Class) + class_name = app.name ActiveSupport::Inflector.underscore(class_name).tr("/", "_") end end def define_generate_prefix(app, name) - return unless app.respond_to?(:routes) && app.routes.respond_to?(:define_mounted_helper) - - _route = @set.named_routes.routes[name.to_sym] + _route = @set.named_routes.get name _routes = @set app.routes.define_mounted_helper(name) - app.routes.singleton_class.class_eval do - define_method :mounted? do - true - end - - define_method :_generate_prefix do |options| - prefix_options = options.slice(*_route.segment_keys) - # we must actually delete prefix segment keys to avoid passing them to next url_for - _route.segment_keys.each { |k| options.delete(k) } - _routes.url_helpers.send("#{name}_path", prefix_options) + app.routes.extend Module.new { + def optimize_routes_generation?; false; end + define_method :find_script_name do |options| + if options.key? :script_name + super(options) + else + prefix_options = options.slice(*_route.segment_keys) + # we must actually delete prefix segment keys to avoid passing them to next url_for + _route.segment_keys.each { |k| options.delete(k) } + _routes.url_helpers.send("#{name}_path", prefix_options) + end end - end + } end end @@ -546,8 +685,7 @@ module ActionDispatch private def map_method(method, args, &block) options = args.extract_options! - options[:via] = method - options[:path] ||= args.first if args.first.is_a?(String) + options[:via] = method match(*args, options, &block) self end @@ -586,7 +724,7 @@ module ActionDispatch # resources :posts, module: "admin" # # If you want to route /admin/posts to +PostsController+ - # (without the Admin:: module prefix), you could use + # (without the <tt>Admin::</tt> module prefix), you could use # # scope "/admin" do # resources :posts @@ -640,34 +778,45 @@ module ActionDispatch # end def scope(*args) options = args.extract_options!.dup - recover = {} + scope = {} options[:path] = args.flatten.join('/') if args.any? options[:constraints] ||= {} + unless nested_scope? + options[:shallow_path] ||= options[:path] if options.key?(:path) + options[:shallow_prefix] ||= options[:as] if options.key?(:as) + end + if options[:constraints].is_a?(Hash) - (options[:defaults] ||= {}).reverse_merge!(defaults_from_constraints(options[:constraints])) + defaults = options[:constraints].select do + |k, v| URL_OPTIONS.include?(k) && (v.is_a?(String) || v.is_a?(Fixnum)) + end + + (options[:defaults] ||= {}).reverse_merge!(defaults) else block, options[:constraints] = options[:constraints], {} end - scope_options.each do |option| - if value = options.delete(option) - recover[option] = @scope[option] - @scope[option] = send("merge_#{option}_scope", @scope[option], value) + @scope.options.each do |option| + if option == :blocks + value = block + elsif option == :options + value = options + else + value = options.delete(option) end - end - - recover[:blocks] = @scope[:blocks] - @scope[:blocks] = merge_blocks_scope(@scope[:blocks], block) - recover[:options] = @scope[:options] - @scope[:options] = merge_options_scope(@scope[:options], options) + if value + scope[option] = send("merge_#{option}_scope", @scope[option], value) + end + end + @scope = @scope.new scope yield self ensure - @scope.merge!(recover) + @scope = @scope.parent end # Scopes routes to a specific controller @@ -720,9 +869,16 @@ module ActionDispatch # end def namespace(path, options = {}) path = path.to_s - options = { :path => path, :as => path, :module => path, - :shallow_path => path, :shallow_prefix => path }.merge!(options) - scope(options) { yield } + + defaults = { + module: path, + path: options.fetch(:path, path), + as: options.fetch(:as, path), + shallow_path: options.fetch(:path, path), + shallow_prefix: options.fetch(:as, path) + } + + scope(defaults.merge!(options)) { yield } end # === Parameter Restriction @@ -794,10 +950,6 @@ module ActionDispatch end private - def scope_options #:nodoc: - @scope_options ||= private_methods.grep(/^merge_(.+)_scope$/) { $1.to_sym } - end - def merge_path_scope(parent, child) #:nodoc: Mapper.normalize_path("#{parent}/#{child}") end @@ -822,6 +974,10 @@ module ActionDispatch child end + def merge_action_scope(parent, child) #:nodoc: + child + end + def merge_path_names_scope(parent, child) #:nodoc: merge_options_scope(parent, child) end @@ -851,11 +1007,6 @@ module ActionDispatch def override_keys(child) #:nodoc: child.key?(:only) || child.key?(:except) ? [:only, :except] : [] end - - def defaults_from_constraints(constraints) - url_keys = [:protocol, :subdomain, :domain, :host, :port] - constraints.select { |k, v| url_keys.include?(k) && (v.is_a?(String) || v.is_a?(Fixnum)) } - end end # Resource routing allows you to quickly declare all of the common routes @@ -914,6 +1065,7 @@ module ActionDispatch @as = options[:as] @param = (options[:param] || :id).to_sym @options = options + @shallow = false end def default_actions @@ -974,6 +1126,13 @@ module ActionDispatch "#{path}/:#{nested_param}" end + def shallow=(value) + @shallow = value + end + + def shallow? + @shallow + end end class SingletonResource < Resource #:nodoc: @@ -1013,18 +1172,18 @@ module ActionDispatch # a singular resource to map /profile (rather than /profile/:id) to # the show action: # - # resource :geocoder + # resource :profile # # creates six different routes in your application, all mapping to - # the +GeoCoders+ controller (note that the controller is named after + # the +Profiles+ controller (note that the controller is named after # the plural): # - # GET /geocoder/new - # POST /geocoder - # GET /geocoder - # GET /geocoder/edit - # PATCH/PUT /geocoder - # DELETE /geocoder + # GET /profile/new + # POST /profile + # GET /profile + # GET /profile/edit + # PATCH/PUT /profile + # DELETE /profile # # === Options # Takes same options as +resources+. @@ -1254,8 +1413,10 @@ module ActionDispatch end with_scope_level(:member) do - scope(parent_resource.member_scope) do - yield + if shallow? + shallow_scope(parent_resource.member_scope) { yield } + else + scope(parent_resource.member_scope) { yield } end end end @@ -1278,16 +1439,8 @@ module ActionDispatch end with_scope_level(:nested) do - if shallow? - with_exclusive_scope do - if @scope[:shallow_path].blank? - scope(parent_resource.nested_scope, nested_options) { yield } - else - scope(@scope[:shallow_path], :as => @scope[:shallow_prefix]) do - scope(parent_resource.nested_scope, nested_options) { yield } - end - end - end + if shallow? && shallow_nesting_depth >= 1 + shallow_scope(parent_resource.nested_scope, nested_options) { yield } else scope(parent_resource.nested_scope, nested_options) { yield } end @@ -1304,7 +1457,7 @@ module ActionDispatch end def shallow - scope(:shallow => true, :shallow_path => @scope[:path]) do + scope(:shallow => true) do yield end end @@ -1319,8 +1472,21 @@ module ActionDispatch def match(path, *rest) if rest.empty? && Hash === path options = path - path, to = options.find { |name, value| name.is_a?(String) } - options[:to] = to + path, to = options.find { |name, _value| name.is_a?(String) } + + case to + when Symbol + options[:action] = to + when String + if to =~ /#/ + options[:to] = to + else + options[:controller] = to + end + else + options[:to] = to + end + options.delete(path) paths = [path] else @@ -1334,15 +1500,34 @@ module ActionDispatch raise ArgumentError, "Unknown scope #{on.inspect} given to :on" end - paths.each { |_path| decomposed_match(_path, options.dup) } + if @scope[:controller] && @scope[:action] + options[:to] ||= "#{@scope[:controller]}##{@scope[:action]}" + end + + paths.each do |_path| + route_options = options.dup + route_options[:path] ||= _path if _path.is_a?(String) + + path_without_format = _path.to_s.sub(/\(\.:format\)$/, '') + if using_match_shorthand?(path_without_format, route_options) + route_options[:to] ||= path_without_format.gsub(%r{^/}, "").sub(%r{/([^/]*)$}, '#\1') + route_options[:to].tr!("-", "_") + end + + decomposed_match(_path, route_options) + end self end + def using_match_shorthand?(path, options) + path && (options[:to] || options[:action]).nil? && path =~ %r{/[\w/]+$} + end + def decomposed_match(path, options) # :nodoc: if on = options.delete(:on) send(on) { decomposed_match(path, options) } else - case @scope[:scope_level] + case @scope.scope_level when :resources nested { decomposed_match(path, options) } when :resource @@ -1355,26 +1540,37 @@ module ActionDispatch def add_route(action, options) # :nodoc: path = path_for_action(action, options.delete(:path)) + raise ArgumentError, "path is required" if path.blank? + + action = action.to_s.dup - if action.to_s =~ /^[\w\/]+$/ - options[:action] ||= action unless action.to_s.include?("/") + if action =~ /^[\w\-\/]+$/ + options[:action] ||= action.tr('-', '_') unless action.include?("/") else action = nil end - if !options.fetch(:as, true) - options.delete(:as) - else - options[:as] = name_for_action(options[:as], action) - end + as = if !options.fetch(:as, true) # if it's set to nil or false + options.delete(:as) + else + name_for_action(options.delete(:as), action) + end - mapping = Mapping.new(@set, @scope, URI.parser.escape(path), options) + mapping = Mapping.build(@scope, @set, URI.parser.escape(path), as, options) app, conditions, requirements, defaults, as, anchor = mapping.to_route @set.add_route(app, conditions, requirements, defaults, as, anchor) end - def root(options={}) - if @scope[:scope_level] == :resources + def root(path, options={}) + if path.is_a?(String) + options[:to] = path + elsif path.is_a?(Hash) and options.empty? + options = path + else + raise ArgumentError, "must be called with a path and/or options" + end + + if @scope.resources? with_scope_level(:root) do scope(parent_resource.path) do super(options) @@ -1397,6 +1593,13 @@ module ActionDispatch return true end + if options.delete(:shallow) + shallow do + send(method, resources.pop, options, &block) + end + return true + end + if resource_scope? nested { send(method, resources.pop, options, &block) } return true @@ -1434,41 +1637,47 @@ module ActionDispatch end def resource_scope? #:nodoc: - [:resource, :resources].include? @scope[:scope_level] + @scope.resource_scope? end def resource_method_scope? #:nodoc: - [:collection, :member, :new].include? @scope[:scope_level] + @scope.resource_method_scope? + end + + def nested_scope? #:nodoc: + @scope.nested? end def with_exclusive_scope begin - old_name_prefix, old_path = @scope[:as], @scope[:path] - @scope[:as], @scope[:path] = nil, nil + @scope = @scope.new(:as => nil, :path => nil) with_scope_level(:exclusive) do yield end ensure - @scope[:as], @scope[:path] = old_name_prefix, old_path + @scope = @scope.parent end end - def with_scope_level(kind, resource = parent_resource) - old, @scope[:scope_level] = @scope[:scope_level], kind - old_resource, @scope[:scope_level_resource] = @scope[:scope_level_resource], resource + def with_scope_level(kind) + @scope = @scope.new_level(kind) yield ensure - @scope[:scope_level] = old - @scope[:scope_level_resource] = old_resource + @scope = @scope.parent end def resource_scope(kind, resource) #:nodoc: - with_scope_level(kind, resource) do - scope(parent_resource.resource_scope) do - yield - end + resource.shallow = @scope[:shallow] + @scope = @scope.new(:scope_level_resource => resource) + @nesting.push(resource) + + with_scope_level(kind) do + scope(parent_resource.resource_scope) { yield } end + ensure + @nesting.pop + @scope = @scope.parent end def nested_options #:nodoc: @@ -1480,6 +1689,14 @@ module ActionDispatch options end + def nesting_depth #:nodoc: + @nesting.size + end + + def shallow_nesting_depth #:nodoc: + @nesting.select(&:shallow?).size + end + def param_constraint? #:nodoc: @scope[:constraints] && @scope[:constraints][parent_resource.param].is_a?(Regexp) end @@ -1488,22 +1705,25 @@ module ActionDispatch @scope[:constraints][parent_resource.param] end - def canonical_action?(action, flag) #:nodoc: - flag && resource_method_scope? && CANONICAL_ACTIONS.include?(action.to_s) + def canonical_action?(action) #:nodoc: + resource_method_scope? && CANONICAL_ACTIONS.include?(action.to_s) end - def shallow_scoping? #:nodoc: - shallow? && @scope[:scope_level] == :member + def shallow_scope(path, options = {}) #:nodoc: + scope = { :as => @scope[:shallow_prefix], + :path => @scope[:shallow_path] } + @scope = @scope.new scope + + scope(path, options) { yield } + ensure + @scope = @scope.parent end def path_for_action(action, path) #:nodoc: - prefix = shallow_scoping? ? - "#{@scope[:shallow_path]}/#{parent_resource.shallow_scope}" : @scope[:path] - - if canonical_action?(action, path.blank?) - prefix.to_s + if path.blank? && canonical_action?(action) + @scope[:path].to_s else - "#{prefix}/#{action_path(action, path)}" + "#{@scope[:path]}/#{action_path(action, path)}" end end @@ -1514,15 +1734,18 @@ module ActionDispatch def prefix_name_for_action(as, action) #:nodoc: if as - as.to_s - elsif !canonical_action?(action, @scope[:scope_level]) - action.to_s + prefix = as + elsif !canonical_action?(action) + prefix = action + end + + if prefix && prefix != '/' && !prefix.empty? + Mapper.normalize_name prefix.to_s.tr('-', '_') end end def name_for_action(as, action) #:nodoc: prefix = prefix_name_for_action(as, action) - prefix = Mapper.normalize_name(prefix) if prefix name_prefix = @scope[:as] if parent_resource @@ -1532,27 +1755,14 @@ module ActionDispatch member_name = parent_resource.member_name end - name = case @scope[:scope_level] - when :nested - [name_prefix, prefix] - when :collection - [prefix, name_prefix, collection_name] - when :new - [prefix, :new, name_prefix, member_name] - when :member - [prefix, shallow_scoping? ? @scope[:shallow_prefix] : name_prefix, member_name] - when :root - [name_prefix, collection_name, prefix] - else - [name_prefix, member_name, prefix] - end + name = @scope.action_name(name_prefix, prefix, collection_name, member_name) - if candidate = name.select(&:present?).join("_").presence + if candidate = name.compact.join("_").presence # If a name was not explicitly given, we check if it is valid # and return nil in case it isn't. Otherwise, we pass the invalid name # forward so the underlying router engine treats it and raises an exception. if as.nil? - candidate unless @set.routes.find { |r| r.name == candidate } || candidate !~ /\A[_a-z]/i + candidate unless candidate !~ /\A[_a-z]/i || @set.named_routes.key?(candidate) else candidate end @@ -1677,10 +1887,85 @@ module ActionDispatch end end + class Scope # :nodoc: + OPTIONS = [:path, :shallow_path, :as, :shallow_prefix, :module, + :controller, :action, :path_names, :constraints, + :shallow, :blocks, :defaults, :options] + + RESOURCE_SCOPES = [:resource, :resources] + RESOURCE_METHOD_SCOPES = [:collection, :member, :new] + + attr_reader :parent, :scope_level + + def initialize(hash, parent = {}, scope_level = nil) + @hash = hash + @parent = parent + @scope_level = scope_level + end + + def nested? + scope_level == :nested + end + + def resources? + scope_level == :resources + end + + def resource_method_scope? + RESOURCE_METHOD_SCOPES.include? scope_level + end + + def action_name(name_prefix, prefix, collection_name, member_name) + case scope_level + when :nested + [name_prefix, prefix] + when :collection + [prefix, name_prefix, collection_name] + when :new + [prefix, :new, name_prefix, member_name] + when :member + [prefix, name_prefix, member_name] + when :root + [name_prefix, collection_name, prefix] + else + [name_prefix, member_name, prefix] + end + end + + def resource_scope? + RESOURCE_SCOPES.include? scope_level + end + + def options + OPTIONS + end + + def new(hash) + self.class.new hash, self, scope_level + end + + def new_level(level) + self.class.new(self, self, level) + end + + def fetch(key, &block) + @hash.fetch(key, &block) + end + + def [](key) + @hash.fetch(key) { @parent[key] } + end + + def []=(k,v) + @hash[k] = v + end + end + def initialize(set) #:nodoc: @set = set - @scope = { :path_names => @set.resources_path_names } + @scope = Scope.new({ :path_names => @set.resources_path_names }) @concerns = {} + @nesting = [] end include Base |