diff --git a/Gemfile b/Gemfile index 2a695df618f34..28ba2398b10de 100644 --- a/Gemfile +++ b/Gemfile @@ -96,3 +96,5 @@ end # A gem necessary for ActiveRecord tests with IBM DB gem 'ibm_db' if ENV['IBM_DB'] + +gem 'byebug' diff --git a/actionpack/lib/action_dispatch/routing/dsl/abstract_scope.rb b/actionpack/lib/action_dispatch/routing/dsl/abstract_scope.rb new file mode 100644 index 0000000000000..7d6cf84634add --- /dev/null +++ b/actionpack/lib/action_dispatch/routing/dsl/abstract_scope.rb @@ -0,0 +1,138 @@ +require 'action_dispatch/routing/dsl/normalization' + +module ActionDispatch + module Routing + module DSL + module AbstractScope + # Constants + # ========= + URL_OPTIONS = [:protocol, :subdomain, :domain, :host, :port] + SCOPE_OPTIONS = [:path, :shallow_path, :as, :shallow_prefix, :module, + :controller, :action, :path_names, :constraints, + :shallow, :blocks, :defaults, :options] + + # Accessors + # ========= + attr_accessor :set + attr_reader :controller, :action, :concerns, :parent + + def initialize(parent, *args) + if parent + @parent, @set, @concerns = parent, parent.set, parent.concerns + else + @parent, @concerns = nil, {} + end + + # Extract options out of the variable arguments + options = args.extract_options!.dup + + options[:path] = args.flatten.join('/') if args.any? + options[:constraints] ||= {} + + if options[:constraints].is_a?(Hash) + 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 option == :blocks + value = block + elsif option == :options + value = options + else + value = options.delete(option) { |_option| {} if %w(defaults path_names constraints).include?(_option.to_s) } + end + + # Set instance variables + instance_variable_set(:"@#{option}", value || nil) + end + end + + def path + parent_path = parent ? parent.path : nil + merge_with_slash(parent_path, @path) + end + + def shallow_path + parent_shallow_path = parent ? parent.shallow_path : nil + merge_with_slash(parent_shallow_path, @shallow_path) + end + + def as + parent_as = parent ? parent.as : nil + merge_with_underscore(parent_as, @as) + end + + def shallow_prefix + parent_shallow_prefix = parent ? parent.shallow_prefix : nil + merge_with_underscore(parent_shallow_prefix, @shallow_prefix) + end + + def module + if parent && parent.module + if @module + "#{parent.module}/#{@module}" + else + parent.module + end + else + @module + end + end + + def path_names + parent_path_names = parent ? parent.path_names : nil + merge_hashes(parent_path_names, @path_names) + end + + def shallow? + @shallow + end + + def blocks + parent_blocks = parent ? parent.blocks : nil + merged = parent_blocks ? parent_blocks.dup : [] + merged << @blocks if @blocks + merged + end + + def options + parent_options = parent ? parent.options : nil + merge_hashes(parent_options, @options) + end + + protected + def merge_with_slash(parent, child) + self.class.normalize_path("#{parent}/#{child}") + end + + def merge_with_underscore(parent, child) + parent ? "#{parent}_#{child}" : child + end + + def merge_hashes(parent, child) + (parent || {}).except(*override_keys(child)).merge(child) + end + + def override_keys(child) #:nodoc: + child.key?(:only) || child.key?(:except) ? [:only, :except] : [] + end + + def defaults + parent_defaults = parent ? parent.defaults : nil + merge_hashes(parent_defaults, @defaults) + end + + def constraints + parent_constraints = parent ? parent.constraints : nil + merge_hashes(parent_constraints, @constraints) + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/routing/dsl/match_route.rb b/actionpack/lib/action_dispatch/routing/dsl/match_route.rb new file mode 100644 index 0000000000000..5d39c02cc8aeb --- /dev/null +++ b/actionpack/lib/action_dispatch/routing/dsl/match_route.rb @@ -0,0 +1,74 @@ +# MatchRoute is a special scope in the sense that it has some additonal options +# like :anchor, :to, :via and :format that are specific to routes +require 'action_dispatch/routing/dsl/abstract_scope' + +module ActionDispatch + module Routing + module DSL + class MatchRoute + include AbstractScope + ROUTE_OPTIONS = [:anchor, :format, :to, :via] + + def initialize(*args) + # options_path = args.extract_options[:path] || nil + super # Let AbstractScope handle all stuff like setting ivar + # Now handle route options + + # Set anchor to true by default unless a value is supplied + @anchor = true unless options.key?(:anchor) + + # Assign options[:to] to @to and then proceed to set an approriate value if + # it evaluates to false + unless @to = options[:to] + # If we have a controller and action then set 'to' to 'controller#action' + # if it is nil + if controller && action + @to = "#{controller}##{action}" + else + # If @to is still nill then convert the path by assuming the entire + # path to represent the controller and the last segment as action e.g. + # match '/admin/controller/action' + # is tanslated as match controller: 'admin/controller', action: 'action' + # + # But before we do that we need to check if the user specified an optional + # format like (.:format) + # If so then we need to consider the path without the optional parameter + *controllers, action = @path.split('/') + action = action.to_s.sub(/\(\.:format\)$/, '') + @to = "#{controllers.select(&:present?).join('/')}##{action}" + end + end + # Change all '-' to '_' in the to + @to.tr!('-', '_') if @to.is_a? String + + # Set @path from path names if available + @path = path_names[@path.to_sym] || @path + + # If we still can't set a path then there is something wrong + raise ArgumentError, "path is required" if @path.blank? + + add_to_router + end + + protected + def add_to_router + build + setup + add_route + end + + def build + # + end + + def setup + # + end + + def add_route + # + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/routing/dsl/normalization.rb b/actionpack/lib/action_dispatch/routing/dsl/normalization.rb new file mode 100644 index 0000000000000..d2c60558420e9 --- /dev/null +++ b/actionpack/lib/action_dispatch/routing/dsl/normalization.rb @@ -0,0 +1,27 @@ +require 'action_dispatch/journey' +require 'active_support/concern' + +module ActionDispatch + module Routing + module DSL + module AbstractScope + extend ActiveSupport::Concern + + included do |base| + # 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 base.normalize_path(path) + path = Journey::Router::Utils.normalize_path(path) + path.gsub!(%r{/(\(+)/?}, '\1/') unless path =~ %r{^/\(+[^)]+\)$} + path + end + + def base.normalize_name(name) + normalize_path(name)[1..-1].tr("/", "_") + end + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/routing/dsl/resource.rb b/actionpack/lib/action_dispatch/routing/dsl/resource.rb new file mode 100644 index 0000000000000..3e76498764c74 --- /dev/null +++ b/actionpack/lib/action_dispatch/routing/dsl/resource.rb @@ -0,0 +1,60 @@ +require "action_dispatch/routing/dsl/singleton_resource" + +module ActionDispatch + module Routing + module DSL + class Resource < SingletonResource + attr_reader :param + + def initialize(parent, resource, options) + super + @param = (options[:param] || :id).to_sym + @path = @path.pluralize + @name = @name.singularize + end + + def draw + get '/', action: :index + post '/', action: :create + get '/new', action: :new + get '/edit', action: :edit + get "/:#{@param}", action: :show + patch "/:#{@param}", action: :update + put "/:#{@param}", action: :update + delete "/:#{@param}", action: :destroy + end + + def member + param = ":#{name.singularize}_#{@param}" + @path, old_path = "/#{@path}/#{param}", @path + yield + @path = old_path + end + + def decomposed_match(path, options) # :nodoc: + if on = options.delete(:on) + send(on) { decomposed_match(path, options) } + else + add_route(path, options) + end + end + + def prefixed_name(name_prefix, prefix) + if @parent.class != Scope + [prefix, @parent.name, name] + else + if prefix.blank? + if has_named_route?(name.pluralize) + [name] + else + [name.pluralize] + end + else + [prefix, name] + end + end + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/routing/dsl/scope.rb b/actionpack/lib/action_dispatch/routing/dsl/scope.rb new file mode 100644 index 0000000000000..7618dab386df5 --- /dev/null +++ b/actionpack/lib/action_dispatch/routing/dsl/scope.rb @@ -0,0 +1,38 @@ +require 'action_dispatch/routing/dsl/abstract_scope' +require 'action_dispatch/routing/redirection' + +module ActionDispatch + module Routing + module DSL + class Scope + include AbstractScope + include Redirection + + def method_missing(method, *args) + @count ||= 0 + @count += 1 + msg = "#{@count}) Missing :#{method}" + divider = "="*msg.length + puts divider, msg, divider + end + + def default_url_options=(options) + @set.default_url_options = options + end + alias_method :default_url_options, :default_url_options= + + # Query if the following named route was already defined. + def has_named_route?(name) + @set.named_routes.routes[name.to_sym] + end + end + end + end +end + +require 'action_dispatch/routing/dsl/scope/mount' +require 'action_dispatch/routing/dsl/scope/match' +require 'action_dispatch/routing/dsl/scope/http_helpers' +require 'action_dispatch/routing/dsl/scope/scoping' +require 'action_dispatch/routing/dsl/scope/concerns' +require 'action_dispatch/routing/dsl/scope/resources' diff --git a/actionpack/lib/action_dispatch/routing/dsl/scope/concerns.rb b/actionpack/lib/action_dispatch/routing/dsl/scope/concerns.rb new file mode 100644 index 0000000000000..8c4c7c9a81b90 --- /dev/null +++ b/actionpack/lib/action_dispatch/routing/dsl/scope/concerns.rb @@ -0,0 +1,111 @@ +# Routing Concerns allow you to declare common routes that can be reused +# inside others resources and routes. +# +# concern :commentable do +# resources :comments +# end +# +# concern :image_attachable do +# resources :images, only: :index +# end +# +# These concerns are used in Resources routing: +# +# resources :messages, concerns: [:commentable, :image_attachable] +# +# or in a scope or namespace: +# +# namespace :posts do +# concerns :commentable +# end +module ActionDispatch + module Routing + module DSL + class Scope + # Define a routing concern using a name. + # + # Concerns may be defined inline, using a block, or handled by + # another object, by passing that object as the second parameter. + # + # The concern object, if supplied, should respond to call, + # which will receive two parameters: + # + # * The current mapper + # * A hash of options which the concern object may use + # + # Options may also be used by concerns defined in a block by accepting + # a block parameter. So, using a block, you might do something as + # simple as limit the actions available on certain resources, passing + # standard resource options through the concern: + # + # concern :commentable do |options| + # resources :comments, options + # end + # + # resources :posts, concerns: :commentable + # resources :archived_posts do + # # Don't allow comments on archived posts + # concerns :commentable, only: [:index, :show] + # end + # + # Or, using a callable object, you might implement something more + # specific to your application, which would be out of place in your + # routes file. + # + # # purchasable.rb + # class Purchasable + # def initialize(defaults = {}) + # @defaults = defaults + # end + # + # def call(mapper, options = {}) + # options = @defaults.merge(options) + # mapper.resources :purchases + # mapper.resources :receipts + # mapper.resources :returns if options[:returnable] + # end + # end + # + # # routes.rb + # concern :purchasable, Purchasable.new(returnable: true) + # + # resources :toys, concerns: :purchasable + # resources :electronics, concerns: :purchasable + # resources :pets do + # concerns :purchasable, returnable: false + # end + # + # Any routing helpers can be used inside a concern. If using a + # callable, they're accessible from the Mapper that's passed to + # call. + def concern(name, callable = nil, &block) + callable ||= lambda { |scope, options| scope.instance_exec(options, &block) } + @concerns[name] = callable + end + + # Use the named concerns + # + # resources :posts do + # concerns :commentable + # end + # + # concerns also work in any routes helper that you want to use: + # + # namespace :posts do + # concerns :commentable + # end + def concerns(*args) + return super() if args.empty? + options = args.extract_options! + args.flatten.each do |name| + if concern = @concerns[name] + concern.call(self, options) + else + raise ArgumentError, "No concern named #{name} was found!" + end + end + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/routing/dsl/scope/http_helpers.rb b/actionpack/lib/action_dispatch/routing/dsl/scope/http_helpers.rb new file mode 100644 index 0000000000000..ee3a096b2c4e3 --- /dev/null +++ b/actionpack/lib/action_dispatch/routing/dsl/scope/http_helpers.rb @@ -0,0 +1,55 @@ +module ActionDispatch + module Routing + module DSL + class Scope + # Define a route that only recognizes HTTP GET. + # For supported arguments, see match[rdoc-ref:Base#match] + # + # get 'bacon', to: 'food#bacon' + def get(*args, &block) + map_method(:get, args, &block) + end + + # Define a route that only recognizes HTTP POST. + # For supported arguments, see match[rdoc-ref:Base#match] + # + # post 'bacon', to: 'food#bacon' + def post(*args, &block) + map_method(:post, args, &block) + end + + # Define a route that only recognizes HTTP PATCH. + # For supported arguments, see match[rdoc-ref:Base#match] + # + # patch 'bacon', to: 'food#bacon' + def patch(*args, &block) + map_method(:patch, args, &block) + end + + # Define a route that only recognizes HTTP PUT. + # For supported arguments, see match[rdoc-ref:Base#match] + # + # put 'bacon', to: 'food#bacon' + def put(*args, &block) + map_method(:put, args, &block) + end + + # Define a route that only recognizes HTTP DELETE. + # For supported arguments, see match[rdoc-ref:Base#match] + # + # delete 'broccoli', to: 'food#broccoli' + def delete(*args, &block) + map_method(:delete, args, &block) + end + + protected + def map_method(method, args, &block) + options = args.extract_options! + options[:via] = method + match(*args, options, &block) + self + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/routing/dsl/scope/mapping.rb b/actionpack/lib/action_dispatch/routing/dsl/scope/mapping.rb new file mode 100644 index 0000000000000..8f3fbef41db10 --- /dev/null +++ b/actionpack/lib/action_dispatch/routing/dsl/scope/mapping.rb @@ -0,0 +1,343 @@ +require 'action_dispatch/routing/redirection' + +module ActionDispatch + module Routing + class Constraints < Endpoint #:nodoc: + attr_reader :app, :constraints + + 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 + + @dispatcher = dispatcher_p + + @app, @constraints, = app, constraints + end + + 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 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.path_parameters, request] + end + end + + + class Mapping #:nodoc: + ANCHOR_CHARACTERS_REGEX = %r{\A(\\A|\^)|(\\Z|\\z|\$)\Z} + + attr_reader :requirements, :conditions, :defaults + attr_reader :to, :default_controller, :default_action, :as, :anchor + + def self.build(scope, path, options) + options = scope.options.merge(options) if scope.options + + options.delete :only + options.delete :except + options.delete :shallow_path + options.delete :shallow_prefix + options.delete :shallow + + defaults = (scope.defaults || {}).merge options.delete(:defaults) || {} + + new scope, path, defaults, options + end + + def initialize(scope, path, defaults, options) + @requirements, @conditions = {}, {} + @defaults = defaults + + @to = options.delete :to + @default_controller = options.delete(:controller) || scope.controller + @default_action = options.delete(:action) || scope.action + @as = options.delete(:as) || scope.as + @anchor = options.delete :anchor + @set = scope.set + + formatted = options.delete :format + via = Array(options.delete(:via) { [] }) + options_constraints = options.delete :constraints + + path = normalize_path! path, formatted + ast = path_ast path + path_params = path_params ast + + options = normalize_options!(options, formatted, path_params, ast, scope.module) + + + 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 DSL::Scope::URL_OPTIONS.include?(key) && (String === default || Fixnum === default) + @defaults[key] ||= default + end + end + end + + normalize_format!(formatted) + + @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 = DSL::Scope.normalize_path(path) + + if format == true + "#{path}.:format" + elsif optional_format?(path, format) + "#{path}(.:format)" + else + path + end + end + + def optional_format?(path, format) + format != false && !path.include?(':format') && !path.end_with?('/') + end + + 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_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] ||= /.+?/ + end + + if to.respond_to? :call + options + else + 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 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 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 verify_regexp_requirement(requirement) + 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 is not allowed in routing requirements: #{requirement.inspect}" + end + end + + def normalize_defaults!(options) + options.each_pair do |key, default| + unless Regexp === default + @defaults[key] = default + end + end + end + + 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 + + def add_request_method(via, conditions) + return if via == [:all] + + 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 + + conditions[:request_method] = via.map { |m| m.to_s.dasherize.upcase } + end + + def app(blocks) + return to if Redirect === to + + if to.respond_to?(:call) + Constraints.new(to, blocks, false) + else + if blocks.any? + Constraints.new(dispatcher, blocks, true) + else + dispatcher(defaults) + end + end + 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" + + raise ArgumentError, message + } + end + + check_part(:action, action, path_params, hash) { |part| + part.is_a?(Regexp) ? part : part.to_s + } + end + + def check_part(name, part, path_params, hash) + if part + hash[name] = yield(part) + else + 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 "defining a route where `to` is a symbol is deprecated. Please change \"to: :#{to}\" to \"action: :#{to}\"" + [nil, to.to_s] + when /#/ then to.split('#') + when String + ActiveSupport::Deprecation.warn "defining a route where `to` is a controller without an action is deprecated. Please change \"to: :#{to}\" to \"controller: :#{to}\"" + [to, nil] + else + [] + end + end + + 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 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 + + 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 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 path_params(ast) + ast.grep(Journey::Nodes::Symbol).map { |n| n.name.to_sym } + end + + def path_ast(path) + parser = Journey::Parser.new + parser.parse path + end + + def dispatcher(defaults={}) + @set.dispatcher defaults + end + end + end +end diff --git a/actionpack/lib/action_dispatch/routing/dsl/scope/match.rb b/actionpack/lib/action_dispatch/routing/dsl/scope/match.rb new file mode 100644 index 0000000000000..f438883631582 --- /dev/null +++ b/actionpack/lib/action_dispatch/routing/dsl/scope/match.rb @@ -0,0 +1,284 @@ +require 'action_dispatch/routing/dsl/scope/mapping' +# require 'action_dispatch/routing/dsl/match_route' + +module ActionDispatch + module Routing + module DSL + class Scope + # 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', 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: + # + # 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', 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"]] }, via: :get + # match 'photos/:id', to: PhotoRackApp, via: :get + # # Yes, controller actions are just rack endpoints + # match 'photos/:id', to: PhotosController.action(:show), via: :get + # + # 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 HtttpHelpers[rdoc-ref:HttpHelpers] + # instead +match+ + # + # === Options + # + # Any options not seen here are passed on as params with the url. + # + # [:controller] + # The route's controller. + # + # [: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 + # params[<:param>]. + # + # [:path] + # The path prefix for the routes. + # + # [:module] + # The namespace for :controller. + # + # match 'path', to: 'c#a', module: 'sekret', controller: 'posts', via: :get + # # => Sekret::PostsController + # + # See Scoping#namespace for its scope equivalent. + # + # [:as] + # The name used to generate routing helpers. + # + # [:via] + # Allowed HTTP verb(s) for route. + # + # match 'path', to: 'c#a', via: :get + # match 'path', to: 'c#a', via: [:get, :post] + # match 'path', to: 'c#a', via: :all + # + # [:to] + # 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', 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 + # values are +:member+, +:collection+, and +:new+. Only use within + # resource(s) block. For example: + # + # resource :bar do + # match 'foo', to: 'c#a', on: :member, via: [:get, :post] + # end + # + # Is equivalent to: + # + # resource :bar do + # member do + # match 'foo', to: 'c#a', via: [:get, :post] + # end + # end + # + # [:constraints] + # Constrains parameters with a hash of regular expressions + # or an object that responds to matches?. In addition, constraints + # other than path can also be specified with any object + # that responds to === (eg. String, Array, Range, etc.). + # + # match 'path/:id', constraints: { id: /[A-Z]\d{5}/ }, via: :get + # + # match 'json_only', constraints: { format: 'json' }, via: :get + # + # class Whitelist + # def matches?(request) request.remote_ip == '1.2.3.4' end + # end + # match 'path', to: 'c#a', constraints: Whitelist.new, via: :get + # + # See Scoping#constraints for more examples with its scope + # equivalent. + # + # [:defaults] + # Sets defaults for parameters + # + # # Sets params[:format] to 'jpg' by default + # match 'path', to: 'c#a', defaults: { format: 'jpg' }, via: :get + # + # See Scoping#defaults for its scope equivalent. + # + # [:anchor] + # Boolean to anchor a match pattern. Default is true. When set to + # false, the pattern matches any request prefixed with the given path. + # + # # Matches any request starting with 'path' + # match 'path', to: 'c#a', anchor: false, via: :get + # + # [:format] + # Allows you to specify the default value for optional +format+ + # segment or disable it by supplying +false+. + def match(*args) + *paths, options = args # Zero or more paths and a hash of options + if paths.empty? + # If we used the match shorthand, set the path and options[:to] + paths[0], to = options.find { |name, _value| name.is_a?(String) } + options.delete(paths[0]) # Delete the path from the options hash + + # Process to and merge into options + process_option_to!(to, options) + end + + options[:anchor] = true unless options.key?(:anchor) + + if controller && action + options[:to] ||= "#{controller}##{action}" + end + + # Now iterate over each path and instantiate a MatchRoute object + # Instantiation of such an object also generates the route on the + # routing table + 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 decomposed_match(path, options) # :nodoc: + add_route(path, options) + end + + # You can specify what Rails should route "/" to with the root method: + # + # root to: 'pages#main' + # + # For options, see +match+, as +root+ uses it internally. + # + # You can also pass a string which will expand + # + # root 'pages#main' + # + # You should put the root route at the top of config/routes.rb, + # because this means it will be matched first. As this is the most popular route + # of most Rails applications, this is beneficial. + 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 + + match '/', { :as => :root, :via => :get }.merge!(options) + end + + protected + + def process_option_to!(to, options) + case to + when Symbol + options[:action] = to + when String + if to =~ /#/ + options[:to] = to + else + options[:controller] = to + end + else + options[:to] = to + end + end + + def using_match_shorthand?(path, options) + path && (options[:to] || options[:action]).nil? && path =~ %r{/[\w/]+$} + end + + 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 =~ /^[\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 + + mapping = Mapping.build(self, URI.parser.escape(path), options) + app, conditions, requirements, defaults, as, anchor = mapping.to_route + set.add_route(app, conditions, requirements, defaults, as, anchor) + end + + def path_for_action(action, path) #:nodoc: + "#{self.path}/#{action_path(action, path)}" + end + + def action_path(name, path = nil) #:nodoc: + name = name.to_sym if name.is_a?(String) + path || path_names[name] || name.to_s + end + + end + end + end +end diff --git a/actionpack/lib/action_dispatch/routing/dsl/scope/mount.rb b/actionpack/lib/action_dispatch/routing/dsl/scope/mount.rb new file mode 100644 index 0000000000000..a8d380386f44b --- /dev/null +++ b/actionpack/lib/action_dispatch/routing/dsl/scope/mount.rb @@ -0,0 +1,132 @@ +module ActionDispatch + module Routing + module DSL + class Scope + # Mount a Rack-based application to be used within the application. + # + # mount SomeRackApp, at: "some_route" + # + # Alternatively: + # + # mount(SomeRackApp => "some_route") + # + # For options, see +match+, as +mount+ uses it internally. + # + # All mounted applications come with routing helpers to access them. + # These are named after the class specified, so for the above example + # the helper is either +some_rack_app_path+ or +some_rack_app_url+. + # To customize this helper's name, use the +:as+ option: + # + # mount(SomeRackApp => "some_route", as: "exciting") + # + # This will generate the +exciting_path+ and +exciting_url+ helpers + # which can be used to navigate to this mounted app. + def mount(app, options = nil) + if options + path = options.delete(:at) + else + unless Hash === app + raise ArgumentError, "must be called with mount point" + end + + options = app + 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) + 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, target_as) + self + end + + protected + def app_name(app) + return unless app.respond_to?(:routes) + + if app.respond_to?(:railtie_name) + app.railtie_name + else + class_name = app.class.is_a?(Class) ? app.name : app.class.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] + _routes = @set + app.routes.define_mounted_helper(name) + app.routes.extend Module.new { + def mounted?; true; end + define_method :find_script_name do |options| + super(options) || begin + 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 + + def prefix_name_for_action(as, action) #:nodoc: + prefix = as || action + prefix.to_s.tr('-', '_') if prefix + end + + def name_for_action(as, action) #:nodoc: + prefix = prefix_name_for_action(as, action) + prefix = self.class.normalize_name(prefix) if prefix + name_prefix = self.as + + # if parent_resource + # return nil unless as || action + + # collection_name = parent_resource.collection_name + # 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, name_prefix, member_name] + # when :root + # [name_prefix, collection_name, prefix] + # else + # [name_prefix, member_name, prefix] + # end + + name = prefixed_name name_prefix, prefix + + if candidate = name.select(&:present?).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 + else + candidate + end + end + end + + def prefixed_name(name_prefix, prefix) + [name_prefix, prefix] + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/routing/dsl/scope/resources.rb b/actionpack/lib/action_dispatch/routing/dsl/scope/resources.rb new file mode 100644 index 0000000000000..d157a230caa72 --- /dev/null +++ b/actionpack/lib/action_dispatch/routing/dsl/scope/resources.rb @@ -0,0 +1,221 @@ +require "action_dispatch/routing/dsl/resource" +require "action_dispatch/routing/dsl/singleton_resource" + +# Resource routing allows you to quickly declare all of the common routes +# for a given resourceful controller. Instead of declaring separate routes +# for your +index+, +show+, +new+, +edit+, +create+, +update+ and +destroy+ +# actions, a resourceful route declares them in a single line of code: +# +# resources :photos +# +# Sometimes, you have a resource that clients always look up without +# referencing an ID. A common example, /profile always shows the profile of +# the currently logged in user. In this case, you can use a singular resource +# to map /profile (rather than /profile/:id) to the show action. +# +# resource :profile +# +# It's common to have resources that are logically children of other +# resources: +# +# resources :magazines do +# resources :ads +# end +# +# You may wish to organize groups of controllers under a namespace. Most +# commonly, you might group a number of administrative controllers under +# an +admin+ namespace. You would place these controllers under the +# app/controllers/admin directory, and you can group them together +# in your router: +# +# namespace "admin" do +# resources :posts, :comments +# end +# +# By default the +:id+ parameter doesn't accept dots. If you need to +# use dots as part of the +:id+ parameter add a constraint which +# overrides this restriction, e.g: +# +# resources :articles, id: /[^\/]+/ +# +# This allows any character other than a slash as part of your +:id+. + +module ActionDispatch + module Routing + module DSL + class Scope + # In Rails, a resourceful route provides a mapping between HTTP verbs + # and URLs and controller actions. By convention, each action also maps + # to particular CRUD operations in a database. A single entry in the + # routing file, such as + # + # resources :photos + # + # creates seven different routes in your application, all mapping to + # the +Photos+ controller: + # + # GET /photos + # GET /photos/new + # POST /photos + # GET /photos/:id + # GET /photos/:id/edit + # PATCH/PUT /photos/:id + # DELETE /photos/:id + # + # Resources can also be nested infinitely by using this block syntax: + # + # resources :photos do + # resources :comments + # end + # + # This generates the following comments routes: + # + # GET /photos/:photo_id/comments + # GET /photos/:photo_id/comments/new + # POST /photos/:photo_id/comments + # GET /photos/:photo_id/comments/:id + # GET /photos/:photo_id/comments/:id/edit + # PATCH/PUT /photos/:photo_id/comments/:id + # DELETE /photos/:photo_id/comments/:id + # + # === Options + # Takes same options as Base#match as well as: + # + # [:path_names] + # Allows you to change the segment component of the +edit+ and +new+ actions. + # Actions not specified are not changed. + # + # resources :posts, path_names: { new: "brand_new" } + # + # The above example will now change /posts/new to /posts/brand_new + # + # [:path] + # Allows you to change the path prefix for the resource. + # + # resources :posts, path: 'postings' + # + # The resource and all segments will now route to /postings instead of /posts + # + # [:only] + # Only generate routes for the given actions. + # + # resources :cows, only: :show + # resources :cows, only: [:show, :index] + # + # [:except] + # Generate all routes except for the given actions. + # + # resources :cows, except: :show + # resources :cows, except: [:show, :index] + # + # [:shallow] + # Generates shallow routes for nested resource(s). When placed on a parent resource, + # generates shallow routes for all nested resources. + # + # resources :posts, shallow: true do + # resources :comments + # end + # + # Is the same as: + # + # resources :posts do + # resources :comments, except: [:show, :edit, :update, :destroy] + # end + # resources :comments, only: [:show, :edit, :update, :destroy] + # + # This allows URLs for resources that otherwise would be deeply nested such + # as a comment on a blog post like /posts/a-long-permalink/comments/1234 + # to be shortened to just /comments/1234. + # + # [:shallow_path] + # Prefixes nested shallow routes with the specified path. + # + # scope shallow_path: "sekret" do + # resources :posts do + # resources :comments, shallow: true + # end + # end + # + # The +comments+ resource here will have the following routes generated for it: + # + # post_comments GET /posts/:post_id/comments(.:format) + # post_comments POST /posts/:post_id/comments(.:format) + # new_post_comment GET /posts/:post_id/comments/new(.:format) + # edit_comment GET /sekret/comments/:id/edit(.:format) + # comment GET /sekret/comments/:id(.:format) + # comment PATCH/PUT /sekret/comments/:id(.:format) + # comment DELETE /sekret/comments/:id(.:format) + # + # [:shallow_prefix] + # Prefixes nested shallow route names with specified prefix. + # + # scope shallow_prefix: "sekret" do + # resources :posts do + # resources :comments, shallow: true + # end + # end + # + # The +comments+ resource here will have the following routes generated for it: + # + # post_comments GET /posts/:post_id/comments(.:format) + # post_comments POST /posts/:post_id/comments(.:format) + # new_post_comment GET /posts/:post_id/comments/new(.:format) + # edit_sekret_comment GET /comments/:id/edit(.:format) + # sekret_comment GET /comments/:id(.:format) + # sekret_comment PATCH/PUT /comments/:id(.:format) + # sekret_comment DELETE /comments/:id(.:format) + # + # [:format] + # Allows you to specify the default value for optional +format+ + # segment or disable it by supplying +false+. + # + # === Examples + # + # # routes call Admin::PostsController + # resources :posts, module: "admin" + # + # # resource actions are at /admin/posts. + # resources :posts, path: "admin/posts" + def resources(*resources, &block) + common_behaviour_for Resource, *resources, &block + end + + # Sometimes, you have a resource that clients always look up without + # referencing an ID. A common example, /profile always shows the + # profile of the currently logged in user. In this case, you can use + # a singular resource to map /profile (rather than /profile/:id) to + # the show action: + # + # resource :profile + # + # creates six different routes in your application, all mapping to + # the +Profiles+ controller (note that the controller is named after + # the plural): + # + # GET /profile/new + # POST /profile + # GET /profile + # GET /profile/edit + # PATCH/PUT /profile + # DELETE /profile + # + # === Options + # Takes same options as +resources+. + def resource(*resources, &block) + common_behaviour_for SingletonResource, *resources, &block + end + + private + def common_behaviour_for(klass, *resources, &block) + options = resources.extract_options!.dup + resources.each do |resource| + new_resource = klass.new(self, resource, options) + new_resource.instance_exec(&block) if block_given? + new_resource.draw + end + self + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/routing/dsl/scope/scoping.rb b/actionpack/lib/action_dispatch/routing/dsl/scope/scoping.rb new file mode 100644 index 0000000000000..6cfcd7cd58070 --- /dev/null +++ b/actionpack/lib/action_dispatch/routing/dsl/scope/scoping.rb @@ -0,0 +1,233 @@ +# You may wish to organize groups of controllers under a namespace. +# Most commonly, you might group a number of administrative controllers +# under an +admin+ namespace. You would place these controllers under +# the app/controllers/admin directory, and you can group them +# together in your router: +# +# namespace "admin" do +# resources :posts, :comments +# end +# +# This will create a number of routes for each of the posts and comments +# controller. For Admin::PostsController, Rails will create: +# +# GET /admin/posts +# GET /admin/posts/new +# POST /admin/posts +# GET /admin/posts/1 +# GET /admin/posts/1/edit +# PATCH/PUT /admin/posts/1 +# DELETE /admin/posts/1 +# +# If you want to route /posts (without the prefix /admin) to +# Admin::PostsController, you could use +# +# scope module: "admin" do +# resources :posts +# end +# +# or, for a single case +# +# resources :posts, module: "admin" +# +# If you want to route /admin/posts to +PostsController+ +# (without the Admin:: module prefix), you could use +# +# scope "/admin" do +# resources :posts +# end +# +# or, for a single case +# +# resources :posts, path: "/admin/posts" +# +# In each of these cases, the named routes remain the same as if you did +# not use scope. In the last case, the following paths map to +# +PostsController+: +# +# GET /admin/posts +# GET /admin/posts/new +# POST /admin/posts +# GET /admin/posts/1 +# GET /admin/posts/1/edit +# PATCH/PUT /admin/posts/1 +# DELETE /admin/posts/1 + +module ActionDispatch + module Routing + module DSL + class Scope + # Scopes a set of routes to the given default options. + # + # Take the following route definition as an example: + # + # scope path: ":account_id", as: "account" do + # resources :projects + # end + # + # This generates helpers such as +account_projects_path+, just like +resources+ does. + # The difference here being that the routes generated are like /:account_id/projects, + # rather than /accounts/:account_id/projects. + # + # === Options + # + # Takes same options as Base#match and Resources#resources. + # + # # route /posts (without the prefix /admin) to Admin::PostsController + # scope module: "admin" do + # resources :posts + # end + # + # # prefix the posts resource's requests with '/admin' + # scope path: "/admin" do + # resources :posts + # end + # + # # prefix the routing helper name: +sekret_posts_path+ instead of +posts_path+ + # scope as: "sekret" do + # resources :posts + # end + def scope(*args, &block) + Scope.new(self, *args).instance_exec(&block) + self + end + + # Scopes routes to a specific controller + # + # controller "food" do + # match "bacon", action: "bacon" + # end + def controller(controller=nil, options={}, &block) + return super() unless controller + options[:controller] = controller + scope(options, &block) + end + + # Scopes routes to a specific namespace. For example: + # + # namespace :admin do + # resources :posts + # end + # + # This generates the following routes: + # + # admin_posts GET /admin/posts(.:format) admin/posts#index + # admin_posts POST /admin/posts(.:format) admin/posts#create + # new_admin_post GET /admin/posts/new(.:format) admin/posts#new + # edit_admin_post GET /admin/posts/:id/edit(.:format) admin/posts#edit + # admin_post GET /admin/posts/:id(.:format) admin/posts#show + # admin_post PATCH/PUT /admin/posts/:id(.:format) admin/posts#update + # admin_post DELETE /admin/posts/:id(.:format) admin/posts#destroy + # + # === Options + # + # The +:path+, +:as+, +:module+, +:shallow_path+ and +:shallow_prefix+ + # options all default to the name of the namespace. + # + # For options, see Base#match. For +:shallow_path+ option, see + # Resources#resources. + # + # # accessible through /sekret/posts rather than /admin/posts + # namespace :admin, path: "sekret" do + # resources :posts + # end + # + # # maps to Sekret::PostsController rather than Admin::PostsController + # namespace :admin, module: "sekret" do + # resources :posts + # end + # + # # generates +sekret_posts_path+ rather than +admin_posts_path+ + # namespace :admin, as: "sekret" do + # resources :posts + # end + def namespace(path, options = {}, &block) + path = path.to_s + + 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), &block) + end + + # === Parameter Restriction + # Allows you to constrain the nested routes based on a set of rules. + # For instance, in order to change the routes to allow for a dot character in the +id+ parameter: + # + # constraints(id: /\d+\.\d+/) do + # resources :posts + # end + # + # Now routes such as +/posts/1+ will no longer be valid, but +/posts/1.1+ will be. + # The +id+ parameter must match the constraint passed in for this example. + # + # You may use this to also restrict other parameters: + # + # resources :posts do + # constraints(post_id: /\d+\.\d+/) do + # resources :comments + # end + # end + # + # === Restricting based on IP + # + # Routes can also be constrained to an IP or a certain range of IP addresses: + # + # constraints(ip: /192\.168\.\d+\.\d+/) do + # resources :posts + # end + # + # Any user connecting from the 192.168.* range will be able to see this resource, + # where as any user connecting outside of this range will be told there is no such route. + # + # === Dynamic request matching + # + # Requests to routes can be constrained based on specific criteria: + # + # constraints(lambda { |req| req.env["HTTP_USER_AGENT"] =~ /iPhone/ }) do + # resources :iphones + # end + # + # You are able to move this logic out into a class if it is too complex for routes. + # This class must have a +matches?+ method defined on it which either returns +true+ + # if the user should be given access to that route, or +false+ if the user should not. + # + # class Iphone + # def self.matches?(request) + # request.env["HTTP_USER_AGENT"] =~ /iPhone/ + # end + # end + # + # An expected place for this code would be +lib/constraints+. + # + # This class is then used like this: + # + # constraints(Iphone) do + # resources :iphones + # end + def constraints(constraints = nil, &block) + return super() if constraints.nil? + constraints ||= {} + scope(:constraints => constraints, &block) + end + + # Allows you to set default parameters for a route, such as this: + # defaults id: 'home' do + # match 'scoped_pages/(:id)', to: 'pages#show' + # end + # Using this, the +:id+ parameter here will default to 'home'. + def defaults(defaults = nil, &block) + return super() if defaults.nil? + defaults ||= {} + scope(:defaults => defaults, &block) + end + end + + end + end +end diff --git a/actionpack/lib/action_dispatch/routing/dsl/singleton_resource.rb b/actionpack/lib/action_dispatch/routing/dsl/singleton_resource.rb new file mode 100644 index 0000000000000..8906de312a8db --- /dev/null +++ b/actionpack/lib/action_dispatch/routing/dsl/singleton_resource.rb @@ -0,0 +1,70 @@ +module ActionDispatch + module Routing + module DSL + class SingletonResource < Scope + VALID_ON_OPTIONS = [:new, :collection, :member] + RESOURCE_OPTIONS = [:as, :controller, :path, :only, :except, :param, :concerns] + CANONICAL_ACTIONS = %w(index create new show update destroy) + RESOURCE_METHOD_SCOPES = [:collection, :member, :new] + + def initialize(parent, resource, options) + super + @name = resource.to_s + @path = (@path || @name).to_s + @controller = (@controller || @name.to_s.pluralize).to_s + end + + def name + @as || @name + end + + def draw + post '/', action: :create + get '/new', action: :new + get '/edit', action: :edit + get '/', action: :show + patch '/', action: :update + put '/', action: :update + delete '/', action: :destroy + end + + def path_for_action(action, path) #:nodoc: + if canonical_action?(action, path.blank?) + self.path.to_s + else + super + end + end + + def canonical_action?(action, flag) #:nodoc: + flag && CANONICAL_ACTIONS.include?(action.to_s) + end + + def prefixed_name(name_prefix, prefix) + if @parent.class != Scope + [prefix, @parent.name, name] + else + [prefix, name] + end + end + + def member + yield + end + + def decomposed_match(path, options) # :nodoc: + if on = options.delete(:on) + send(on) { decomposed_match(path, options) } + else + member { add_route(path, options) } + end + end + + def name_for_action(as, action) #:nodoc: + return nil unless as || action + super + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/routing/route_set.rb b/actionpack/lib/action_dispatch/routing/route_set.rb index 69535faabd78c..d9e270c1866f2 100644 --- a/actionpack/lib/action_dispatch/routing/route_set.rb +++ b/actionpack/lib/action_dispatch/routing/route_set.rb @@ -9,6 +9,7 @@ require 'action_controller/metal/exceptions' require 'action_dispatch/http/request' require 'action_dispatch/routing/endpoint' +require 'action_dispatch/routing/dsl/scope' module ActionDispatch module Routing @@ -312,12 +313,10 @@ def eval_block(block) raise "You are using the old router DSL which has been removed in Rails 3.1. " << "Please check how to update your routes file at: http://www.engineyard.com/blog/2010/the-lowdown-on-routes-in-rails-3/" end - mapper = Mapper.new(self) - if default_scope - mapper.with_default_scope(default_scope, &block) - else - mapper.instance_exec(&block) - end + scope_defaults = default_scope || { :path_names => resources_path_names } + scope = DSL::Scope.new(nil, scope_defaults) + scope.set = self + scope.instance_exec(&block) end def finalize! @@ -334,6 +333,10 @@ def clear! @prepend.each { |blk| eval_block(blk) } end + def dispatcher(defaults) + Routing::RouteSet::Dispatcher.new(defaults) + end + module MountedHelpers #:nodoc: extend ActiveSupport::Concern include UrlFor diff --git a/actionpack/test/dispatch/new_routing_test.rb b/actionpack/test/dispatch/new_routing_test.rb new file mode 100644 index 0000000000000..019a83eeffdeb --- /dev/null +++ b/actionpack/test/dispatch/new_routing_test.rb @@ -0,0 +1,528 @@ +# encoding: UTF-8 +require 'erb' +require 'abstract_unit' +require 'controller/fake_controllers' +require "byebug" + +class TestRoutingMapper < ActionDispatch::IntegrationTest + SprocketsApp = lambda { |env| + [200, {"Content-Type" => "text/html"}, ["javascripts"]] + } + + class IpRestrictor + def self.matches?(request) + request.ip =~ /192\.168\.1\.1\d\d/ + end + end + + class YoutubeFavoritesRedirector + def self.call(params, request) + "http://www.youtube.com/watch?v=#{params[:youtube_id]}" + end + end + + def test_logout + draw do + controller :sessions do + delete 'logout' => :destroy + end + end + + delete '/logout' + assert_equal 'sessions#destroy', @response.body + + assert_equal '/logout', logout_path + assert_equal '/logout', url_for(:controller => 'sessions', :action => 'destroy', :only_path => true) + end + + def test_login + draw do + default_url_options :host => "rubyonrails.org" + + controller :sessions do + get 'login' => :new + post 'login' => :create + end + end + + get '/login' + assert_equal 'sessions#new', @response.body + assert_equal '/login', login_path + + post '/login' + assert_equal 'sessions#create', @response.body + + assert_equal '/login', url_for(:controller => 'sessions', :action => 'create', :only_path => true) + assert_equal '/login', url_for(:controller => 'sessions', :action => 'new', :only_path => true) + + assert_equal 'http://rubyonrails.org/login', url_for(:controller => 'sessions', :action => 'create') + assert_equal 'http://rubyonrails.org/login', login_url + end + + def test_login_redirect + draw do + get 'account/login', :to => redirect("/login") + end + + get '/account/login' + verify_redirect 'http://www.example.com/login' + end + + def test_logout_redirect_without_to + draw do + get 'account/logout' => redirect("/logout"), :as => :logout_redirect + end + + assert_equal '/account/logout', logout_redirect_path + get '/account/logout' + verify_redirect 'http://www.example.com/logout' + end + + def test_namespace_redirect + draw do + namespace :private do + root :to => redirect('/private/index') + get "index", :to => 'private#index' + end + end + + get '/private' + verify_redirect 'http://www.example.com/private/index' + end + + def test_namespace_with_controller_segment + assert_raise(ArgumentError) do + draw do + namespace :admin do + get '/:controller(/:action(/:id(.:format)))' + end + end + end + end + + def test_namespace_without_controller_segment + draw do + namespace :admin do + get 'hello/:controllers/:action' + end + end + get '/admin/hello/foo/new' + assert_equal 'foo', @request.params["controllers"] + end + + def test_session_singleton_resource + draw do + resource :session do + get :create + post :reset + end + end + + get '/session' + assert_equal 'sessions#create', @response.body + assert_equal '/session', session_path + + post '/session' + assert_equal 'sessions#create', @response.body + + put '/session' + assert_equal 'sessions#update', @response.body + + delete '/session' + assert_equal 'sessions#destroy', @response.body + + get '/session/new' + assert_equal 'sessions#new', @response.body + assert_equal '/session/new', new_session_path + + get '/session/edit' + assert_equal 'sessions#edit', @response.body + assert_equal '/session/edit', edit_session_path + + post '/session/reset' + assert_equal 'sessions#reset', @response.body + assert_equal '/session/reset', reset_session_path + end + + def test_session_info_nested_singleton_resource + draw do + resource :session do + resource :info + end + end + + get '/session/info' + assert_equal 'infos#show', @response.body + assert_equal '/session/info', session_info_path + end + + def test_member_on_resource + draw do + resource :session do + member do + get :crush + end + end + end + + get '/session/crush' + assert_equal 'sessions#crush', @response.body + assert_equal '/session/crush', crush_session_path + end + + def test_redirect_modulo + draw do + get 'account/modulo/:name', :to => redirect("/%{name}s") + end + + get '/account/modulo/name' + verify_redirect 'http://www.example.com/names' + end + + def test_redirect_proc + draw do + get 'account/proc/:name', :to => redirect {|params, req| "/#{params[:name].pluralize}" } + end + + get '/account/proc/person' + verify_redirect 'http://www.example.com/people' + end + + def test_redirect_proc_with_request + draw do + get 'account/proc_req' => redirect {|params, req| "/#{req.method}" } + end + + get '/account/proc_req' + verify_redirect 'http://www.example.com/GET' + end + + def test_redirect_hash_with_subdomain + draw do + get 'mobile', :to => redirect(:subdomain => 'mobile') + end + + get '/mobile' + verify_redirect 'http://mobile.example.com/mobile' + end + + def test_redirect_hash_with_domain_and_path + draw do + get 'documentation', :to => redirect(:domain => 'example-documentation.com', :path => '') + end + + get '/documentation' + verify_redirect 'http://www.example-documentation.com' + end + + def test_redirect_hash_with_path + draw do + get 'new_documentation', :to => redirect(:path => '/documentation/new') + end + + get '/new_documentation' + verify_redirect 'http://www.example.com/documentation/new' + end + + def test_redirect_hash_with_host + draw do + get 'super_new_documentation', :to => redirect(:host => 'super-docs.com') + end + + get '/super_new_documentation?section=top' + verify_redirect 'http://super-docs.com/super_new_documentation?section=top' + end + + def test_redirect_hash_path_substitution + draw do + get 'stores/:name', :to => redirect(:subdomain => 'stores', :path => '/%{name}') + end + + get '/stores/iernest' + verify_redirect 'http://stores.example.com/iernest' + end + + def test_redirect_hash_path_substitution_with_catch_all + draw do + get 'stores/:name(*rest)', :to => redirect(:subdomain => 'stores', :path => '/%{name}%{rest}') + end + + get '/stores/iernest/products' + verify_redirect 'http://stores.example.com/iernest/products' + end + + def test_redirect_class + draw do + get 'youtube_favorites/:youtube_id/:name', :to => redirect(YoutubeFavoritesRedirector) + end + + get '/youtube_favorites/oHg5SJYRHA0/rick-rolld' + verify_redirect 'http://www.youtube.com/watch?v=oHg5SJYRHA0' + end + + def test_openid + draw do + match 'openid/login', :via => [:get, :post], :to => "openid#login" + end + + get '/openid/login' + assert_equal 'openid#login', @response.body + + post '/openid/login' + assert_equal 'openid#login', @response.body + end + + def test_bookmarks + draw do + scope "bookmark", :controller => "bookmarks", :as => :bookmark do + get :new, :path => "build" + post :create, :path => "create", :as => "" + put :update + get :remove, :action => :destroy, :as => :remove + end + end + + get '/bookmark/build' + assert_equal 'bookmarks#new', @response.body + assert_equal '/bookmark/build', bookmark_new_path + + post '/bookmark/create' + assert_equal 'bookmarks#create', @response.body + assert_equal '/bookmark/create', bookmark_path + + put '/bookmark/update' + assert_equal 'bookmarks#update', @response.body + assert_equal '/bookmark/update', bookmark_update_path + + get '/bookmark/remove' + assert_equal 'bookmarks#destroy', @response.body + assert_equal '/bookmark/remove', bookmark_remove_path + end + + def test_pagemarks + draw do + scope "pagemark", :controller => "pagemarks", :as => :pagemark do + get "new", :path => "build" + post "create", :as => "" + put "update" + get "remove", :action => :destroy, :as => :remove + end + end + + get '/pagemark/build' + assert_equal 'pagemarks#new', @response.body + assert_equal '/pagemark/build', pagemark_new_path + + post '/pagemark/create' + assert_equal 'pagemarks#create', @response.body + assert_equal '/pagemark/create', pagemark_path + + put '/pagemark/update' + assert_equal 'pagemarks#update', @response.body + assert_equal '/pagemark/update', pagemark_update_path + + get '/pagemark/remove' + assert_equal 'pagemarks#destroy', @response.body + assert_equal '/pagemark/remove', pagemark_remove_path + end + + def test_admin + draw do + constraints(:ip => /192\.168\.1\.\d\d\d/) do + get 'admin' => "queenbee#index" + end + + constraints ::TestRoutingMapper::IpRestrictor do + get 'admin/accounts' => "queenbee#accounts" + end + + get 'admin/passwords' => "queenbee#passwords", :constraints => ::TestRoutingMapper::IpRestrictor + end + + get '/admin', {}, {'REMOTE_ADDR' => '192.168.1.100'} + assert_equal 'queenbee#index', @response.body + + get '/admin', {}, {'REMOTE_ADDR' => '10.0.0.100'} + assert_equal 'pass', @response.headers['X-Cascade'] + + get '/admin/accounts', {}, {'REMOTE_ADDR' => '192.168.1.100'} + assert_equal 'queenbee#accounts', @response.body + + get '/admin/accounts', {}, {'REMOTE_ADDR' => '10.0.0.100'} + assert_equal 'pass', @response.headers['X-Cascade'] + + get '/admin/passwords', {}, {'REMOTE_ADDR' => '192.168.1.100'} + assert_equal 'queenbee#passwords', @response.body + + get '/admin/passwords', {}, {'REMOTE_ADDR' => '10.0.0.100'} + assert_equal 'pass', @response.headers['X-Cascade'] + end + + def test_global + draw do + controller(:global) do + get 'global/hide_notice' + get 'global/export', :action => :export, :as => :export_request + get '/export/:id/:file', :action => :export, :as => :export_download, :constraints => { :file => /.*/ } + get 'global/:action' + end + end + + get '/global/dashboard' + assert_equal 'global#dashboard', @response.body + + get '/global/export' + assert_equal 'global#export', @response.body + + get '/global/hide_notice' + assert_equal 'global#hide_notice', @response.body + + get '/export/123/foo.txt' + assert_equal 'global#export', @response.body + + assert_equal '/global/export', export_request_path + assert_equal '/global/hide_notice', global_hide_notice_path + assert_equal '/export/123/foo.txt', export_download_path(:id => 123, :file => 'foo.txt') + end + + def test_local + draw do + get "/local/:action", :controller => "local" + end + + get '/local/dashboard' + assert_equal 'local#dashboard', @response.body + end + + # tests the use of dup in url_for + def test_url_for_with_no_side_effects + draw do + get "/projects/status(.:format)" + end + + # without dup, additional (and possibly unwanted) values will be present in the options (eg. :host) + original_options = {:controller => 'projects', :action => 'status'} + options = original_options.dup + + url_for options + + # verify that the options passed in have not changed from the original ones + assert_equal original_options, options + end + + def test_url_for_does_not_modify_controller + draw do + get "/projects/status(.:format)" + end + + controller = '/projects' + options = {:controller => controller, :action => 'status', :only_path => true} + url = url_for(options) + + assert_equal '/projects/status', url + assert_equal '/projects', controller + end + + # tests the arguments modification free version of define_hash_access + def test_named_route_with_no_side_effects + draw do + resources :customers do + get "profile", :on => :member + end + end + + original_options = { :host => 'test.host' } + options = original_options.dup + + profile_customer_url("customer_model", options) + + # verify that the options passed in have not changed from the original ones + assert_equal original_options, options + end + + def test_projects_status + draw do + get "/projects/status(.:format)" + end + + assert_equal '/projects/status', url_for(:controller => 'projects', :action => 'status', :only_path => true) + assert_equal '/projects/status.json', url_for(:controller => 'projects', :action => 'status', :format => 'json', :only_path => true) + end + + def test_projects + draw do + resources :projects, :controller => :project + end + + get '/projects' + assert_equal 'project#index', @response.body + assert_equal '/projects', projects_path + + post '/projects' + assert_equal 'project#create', @response.body + + get '/projects.xml' + assert_equal 'project#index', @response.body + assert_equal '/projects.xml', projects_path(:format => 'xml') + + get '/projects/new' + assert_equal 'project#new', @response.body + assert_equal '/projects/new', new_project_path + + get '/projects/new.xml' + assert_equal 'project#new', @response.body + assert_equal '/projects/new.xml', new_project_path(:format => 'xml') + + get '/projects/1' + assert_equal 'project#show', @response.body + assert_equal '/projects/1', project_path(:id => '1') + + get '/projects/1.xml' + assert_equal 'project#show', @response.body + assert_equal '/projects/1.xml', project_path(:id => '1', :format => 'xml') + + get '/projects/1/edit' + assert_equal 'project#edit', @response.body + assert_equal '/projects/1/edit', edit_project_path(:id => '1') + end + + private + + def draw(&block) + self.class.stub_controllers do |routes| + @app = routes + @app.default_url_options = { host: 'www.example.com' } + @app.draw(&block) + end + end + + def url_for(options = {}) + @app.url_helpers.url_for(options) + end + + def method_missing(method, *args, &block) + if method.to_s =~ /_(path|url)$/ + @app.url_helpers.send(method, *args, &block) + else + super + end + end + + def with_https + old_https = https? + https! + yield + ensure + https!(old_https) + end + + def verify_redirect(url, status=301) + assert_equal status, @response.status + assert_equal url, @response.headers['Location'] + assert_equal expected_redirect_body(url), @response.body + end + + def expected_redirect_body(url) + %(You are being redirected.) + end +end