diff options
author | Michael Koziarski <michael@koziarski.com> | 2008-01-17 22:45:54 +0000 |
---|---|---|
committer | Michael Koziarski <michael@koziarski.com> | 2008-01-17 22:45:54 +0000 |
commit | 3845de022b080642e87308471376911165506133 (patch) | |
tree | a31e006ca82831f725e0a782e99ab52f911e8f6e /actionpack/lib/action_controller/routing | |
parent | 3b9bcf13f9589423947a7d9598545e3ce8c3fb44 (diff) | |
download | rails-3845de022b080642e87308471376911165506133.tar.gz rails-3845de022b080642e87308471376911165506133.tar.bz2 rails-3845de022b080642e87308471376911165506133.zip |
Restructure routing into several smaller files. References #10835 [oleganza]
git-svn-id: http://svn-commit.rubyonrails.org/rails/trunk@8652 5ecf4fe2-1ee6-0310-87b1-e25e094e27de
Diffstat (limited to 'actionpack/lib/action_controller/routing')
-rw-r--r-- | actionpack/lib/action_controller/routing/builder.rb | 197 | ||||
-rw-r--r-- | actionpack/lib/action_controller/routing/optimisations.rb | 119 | ||||
-rw-r--r-- | actionpack/lib/action_controller/routing/route.rb | 240 | ||||
-rw-r--r-- | actionpack/lib/action_controller/routing/route_set.rb | 439 | ||||
-rw-r--r-- | actionpack/lib/action_controller/routing/routing_ext.rb | 46 | ||||
-rw-r--r-- | actionpack/lib/action_controller/routing/segments.rb | 268 |
6 files changed, 1309 insertions, 0 deletions
diff --git a/actionpack/lib/action_controller/routing/builder.rb b/actionpack/lib/action_controller/routing/builder.rb new file mode 100644 index 0000000000..69943d4230 --- /dev/null +++ b/actionpack/lib/action_controller/routing/builder.rb @@ -0,0 +1,197 @@ +module ActionController + module Routing + class RouteBuilder #:nodoc: + attr_accessor :separators, :optional_separators + + def initialize + self.separators = Routing::SEPARATORS + self.optional_separators = %w( / ) + end + + def separator_pattern(inverted = false) + "[#{'^' if inverted}#{Regexp.escape(separators.join)}]" + end + + def interval_regexp + Regexp.new "(.*?)(#{separators.source}|$)" + end + + # Accepts a "route path" (a string defining a route), and returns the array + # of segments that corresponds to it. Note that the segment array is only + # partially initialized--the defaults and requirements, for instance, need + # to be set separately, via the #assign_route_options method, and the + # #optional? method for each segment will not be reliable until after + # #assign_route_options is called, as well. + def segments_for_route_path(path) + rest, segments = path, [] + + until rest.empty? + segment, rest = segment_for rest + segments << segment + end + segments + end + + # A factory method that returns a new segment instance appropriate for the + # format of the given string. + def segment_for(string) + segment = case string + when /\A:(\w+)/ + key = $1.to_sym + case key + when :controller then ControllerSegment.new(key) + else DynamicSegment.new key + end + when /\A\*(\w+)/ then PathSegment.new($1.to_sym, :optional => true) + when /\A\?(.*?)\?/ + returning segment = StaticSegment.new($1) do + segment.is_optional = true + end + when /\A(#{separator_pattern(:inverted)}+)/ then StaticSegment.new($1) + when Regexp.new(separator_pattern) then + returning segment = DividerSegment.new($&) do + segment.is_optional = (optional_separators.include? $&) + end + end + [segment, $~.post_match] + end + + # Split the given hash of options into requirement and default hashes. The + # segments are passed alongside in order to distinguish between default values + # and requirements. + def divide_route_options(segments, options) + options = options.dup + + if options[:namespace] + options[:controller] = "#{options[:path_prefix]}/#{options[:controller]}" + options.delete(:path_prefix) + options.delete(:name_prefix) + options.delete(:namespace) + end + + requirements = (options.delete(:requirements) || {}).dup + defaults = (options.delete(:defaults) || {}).dup + conditions = (options.delete(:conditions) || {}).dup + + path_keys = segments.collect { |segment| segment.key if segment.respond_to?(:key) }.compact + options.each do |key, value| + hash = (path_keys.include?(key) && ! value.is_a?(Regexp)) ? defaults : requirements + hash[key] = value + end + + [defaults, requirements, conditions] + end + + # Takes a hash of defaults and a hash of requirements, and assigns them to + # the segments. Any unused requirements (which do not correspond to a segment) + # are returned as a hash. + def assign_route_options(segments, defaults, requirements) + route_requirements = {} # Requirements that do not belong to a segment + + segment_named = Proc.new do |key| + segments.detect { |segment| segment.key == key if segment.respond_to?(:key) } + end + + requirements.each do |key, requirement| + segment = segment_named[key] + if segment + raise TypeError, "#{key}: requirements on a path segment must be regular expressions" unless requirement.is_a?(Regexp) + if requirement.source =~ %r{\A(\\A|\^)|(\\Z|\\z|\$)\Z} + raise ArgumentError, "Regexp anchor characters are not allowed in routing requirements: #{requirement.inspect}" + end + segment.regexp = requirement + else + route_requirements[key] = requirement + end + end + + defaults.each do |key, default| + segment = segment_named[key] + raise ArgumentError, "#{key}: No matching segment exists; cannot assign default" unless segment + segment.is_optional = true + segment.default = default.to_param if default + end + + assign_default_route_options(segments) + ensure_required_segments(segments) + route_requirements + end + + # Assign default options, such as 'index' as a default for :action. This + # method must be run *after* user supplied requirements and defaults have + # been applied to the segments. + def assign_default_route_options(segments) + segments.each do |segment| + next unless segment.is_a? DynamicSegment + case segment.key + when :action + if segment.regexp.nil? || segment.regexp.match('index').to_s == 'index' + segment.default ||= 'index' + segment.is_optional = true + end + when :id + if segment.default.nil? && segment.regexp.nil? || segment.regexp =~ '' + segment.is_optional = true + end + end + end + end + + # Makes sure that there are no optional segments that precede a required + # segment. If any are found that precede a required segment, they are + # made required. + def ensure_required_segments(segments) + allow_optional = true + segments.reverse_each do |segment| + allow_optional &&= segment.optional? + if !allow_optional && segment.optional? + unless segment.optionality_implied? + warn "Route segment \"#{segment.to_s}\" cannot be optional because it precedes a required segment. This segment will be required." + end + segment.is_optional = false + elsif allow_optional && segment.respond_to?(:default) && segment.default + # if a segment has a default, then it is optional + segment.is_optional = true + end + end + end + + # Construct and return a route with the given path and options. + def build(path, options) + # Wrap the path with slashes + path = "/#{path}" unless path[0] == ?/ + path = "#{path}/" unless path[-1] == ?/ + + path = "/#{options[:path_prefix].to_s.gsub(/^\//,'')}#{path}" if options[:path_prefix] + + segments = segments_for_route_path(path) + defaults, requirements, conditions = divide_route_options(segments, options) + requirements = assign_route_options(segments, defaults, requirements) + + route = Route.new + + route.segments = segments + route.requirements = requirements + route.conditions = conditions + + if !route.significant_keys.include?(:action) && !route.requirements[:action] + route.requirements[:action] = "index" + route.significant_keys << :action + end + + # Routes cannot use the current string interpolation method + # if there are user-supplied :requirements as the interpolation + # code won't raise RoutingErrors when generating + if options.key?(:requirements) || route.requirements.keys.to_set != Routing::ALLOWED_REQUIREMENTS_FOR_OPTIMISATION + route.optimise = false + end + + if !route.significant_keys.include?(:controller) + raise ArgumentError, "Illegal route: the :controller must be specified!" + end + + route + end + end + end +end
\ No newline at end of file diff --git a/actionpack/lib/action_controller/routing/optimisations.rb b/actionpack/lib/action_controller/routing/optimisations.rb new file mode 100644 index 0000000000..ba4aeb4e82 --- /dev/null +++ b/actionpack/lib/action_controller/routing/optimisations.rb @@ -0,0 +1,119 @@ +module ActionController + module Routing + # Much of the slow performance from routes comes from the + # complexity of expiry, :requirements matching, defaults providing + # and figuring out which url pattern to use. With named routes + # we can avoid the expense of finding the right route. So if + # they've provided the right number of arguments, and have no + # :requirements, we can just build up a string and return it. + # + # To support building optimisations for other common cases, the + # generation code is separated into several classes + module Optimisation + def generate_optimisation_block(route, kind) + return "" unless route.optimise? + OPTIMISERS.inject("") do |memo, klazz| + memo << klazz.new(route, kind).source_code + memo + end + end + + class Optimiser + attr_reader :route, :kind + def initialize(route, kind) + @route = route + @kind = kind + end + + def guard_condition + 'false' + end + + def generation_code + 'nil' + end + + def source_code + if applicable? + "return #{generation_code} if #{guard_condition}\n" + else + "\n" + end + end + + # Temporarily disabled :url optimisation pending proper solution to + # Issues around request.host etc. + def applicable? + true + end + end + + # Given a route: + # map.person '/people/:id' + # + # If the user calls person_url(@person), we can simply + # return a string like "/people/#{@person.to_param}" + # rather than triggering the expensive logic in url_for + class PositionalArguments < Optimiser + def guard_condition + number_of_arguments = route.segment_keys.size + # if they're using foo_url(:id=>2) it's one + # argument, but we don't want to generate /foos/id2 + if number_of_arguments == 1 + "defined?(request) && request && args.size == 1 && !args.first.is_a?(Hash)" + else + "defined?(request) && request && args.size == #{number_of_arguments}" + end + end + + def generation_code + elements = [] + idx = 0 + + if kind == :url + elements << '#{request.protocol}' + elements << '#{request.host_with_port}' + end + + elements << '#{request.relative_url_root if request.relative_url_root}' + + # The last entry in route.segments appears to # *always* be a + # 'divider segment' for '/' but we have assertions to ensure that + # we don't include the trailing slashes, so skip them. + (route.segments.size == 1 ? route.segments : route.segments[0..-2]).each do |segment| + if segment.is_a?(DynamicSegment) + elements << segment.interpolation_chunk("args[#{idx}].to_param") + idx += 1 + else + elements << segment.interpolation_chunk + end + end + %("#{elements * ''}") + end + end + + # This case is mostly the same as the positional arguments case + # above, but it supports additional query parameters as the last + # argument + class PositionalArgumentsWithAdditionalParams < PositionalArguments + def guard_condition + "defined?(request) && request && args.size == #{route.segment_keys.size + 1} && !args.last.has_key?(:anchor) && !args.last.has_key?(:port) && !args.last.has_key?(:host)" + end + + # This case uses almost the same code as positional arguments, + # but add an args.last.to_query on the end + def generation_code + super.insert(-2, '?#{args.last.to_query}') + end + + # To avoid generating http://localhost/?host=foo.example.com we + # can't use this optimisation on routes without any segments + def applicable? + super && route.segment_keys.size > 0 + end + end + + OPTIMISERS = [PositionalArguments, PositionalArgumentsWithAdditionalParams] + end + end +end diff --git a/actionpack/lib/action_controller/routing/route.rb b/actionpack/lib/action_controller/routing/route.rb new file mode 100644 index 0000000000..94134a284e --- /dev/null +++ b/actionpack/lib/action_controller/routing/route.rb @@ -0,0 +1,240 @@ +module ActionController + module Routing + class Route #:nodoc: + attr_accessor :segments, :requirements, :conditions, :optimise + + def initialize + @segments = [] + @requirements = {} + @conditions = {} + @optimise = true + end + + # Indicates whether the routes should be optimised with the string interpolation + # version of the named routes methods. + def optimise? + @optimise && ActionController::Base::optimise_named_routes + end + + def segment_keys + segments.collect do |segment| + segment.key if segment.respond_to? :key + end.compact + end + + # Write and compile a +generate+ method for this Route. + def write_generation + # Build the main body of the generation + body = "expired = false\n#{generation_extraction}\n#{generation_structure}" + + # If we have conditions that must be tested first, nest the body inside an if + body = "if #{generation_requirements}\n#{body}\nend" if generation_requirements + args = "options, hash, expire_on = {}" + + # Nest the body inside of a def block, and then compile it. + raw_method = method_decl = "def generate_raw(#{args})\npath = begin\n#{body}\nend\n[path, hash]\nend" + instance_eval method_decl, "generated code (#{__FILE__}:#{__LINE__})" + + # expire_on.keys == recall.keys; in other words, the keys in the expire_on hash + # are the same as the keys that were recalled from the previous request. Thus, + # we can use the expire_on.keys to determine which keys ought to be used to build + # the query string. (Never use keys from the recalled request when building the + # query string.) + + method_decl = "def generate(#{args})\npath, hash = generate_raw(options, hash, expire_on)\nappend_query_string(path, hash, extra_keys(options))\nend" + instance_eval method_decl, "generated code (#{__FILE__}:#{__LINE__})" + + method_decl = "def generate_extras(#{args})\npath, hash = generate_raw(options, hash, expire_on)\n[path, extra_keys(options)]\nend" + instance_eval method_decl, "generated code (#{__FILE__}:#{__LINE__})" + raw_method + end + + # Build several lines of code that extract values from the options hash. If any + # of the values are missing or rejected then a return will be executed. + def generation_extraction + segments.collect do |segment| + segment.extraction_code + end.compact * "\n" + end + + # Produce a condition expression that will check the requirements of this route + # upon generation. + def generation_requirements + requirement_conditions = requirements.collect do |key, req| + if req.is_a? Regexp + value_regexp = Regexp.new "\\A#{req.source}\\Z" + "hash[:#{key}] && #{value_regexp.inspect} =~ options[:#{key}]" + else + "hash[:#{key}] == #{req.inspect}" + end + end + requirement_conditions * ' && ' unless requirement_conditions.empty? + end + + def generation_structure + segments.last.string_structure segments[0..-2] + end + + # Write and compile a +recognize+ method for this Route. + def write_recognition + # Create an if structure to extract the params from a match if it occurs. + body = "params = parameter_shell.dup\n#{recognition_extraction * "\n"}\nparams" + body = "if #{recognition_conditions.join(" && ")}\n#{body}\nend" + + # Build the method declaration and compile it + method_decl = "def recognize(path, env={})\n#{body}\nend" + instance_eval method_decl, "generated code (#{__FILE__}:#{__LINE__})" + method_decl + end + + # Plugins may override this method to add other conditions, like checks on + # host, subdomain, and so forth. Note that changes here only affect route + # recognition, not generation. + def recognition_conditions + result = ["(match = #{Regexp.new(recognition_pattern).inspect}.match(path))"] + result << "conditions[:method] === env[:method]" if conditions[:method] + result + end + + # Build the regular expression pattern that will match this route. + def recognition_pattern(wrap = true) + pattern = '' + segments.reverse_each do |segment| + pattern = segment.build_pattern pattern + end + wrap ? ("\\A" + pattern + "\\Z") : pattern + end + + # Write the code to extract the parameters from a matched route. + def recognition_extraction + next_capture = 1 + extraction = segments.collect do |segment| + x = segment.match_extraction(next_capture) + next_capture += Regexp.new(segment.regexp_chunk).number_of_captures + x + end + extraction.compact + end + + # Write the real generation implementation and then resend the message. + def generate(options, hash, expire_on = {}) + write_generation + generate options, hash, expire_on + end + + def generate_extras(options, hash, expire_on = {}) + write_generation + generate_extras options, hash, expire_on + end + + # Generate the query string with any extra keys in the hash and append + # it to the given path, returning the new path. + def append_query_string(path, hash, query_keys=nil) + return nil unless path + query_keys ||= extra_keys(hash) + "#{path}#{build_query_string(hash, query_keys)}" + end + + # Determine which keys in the given hash are "extra". Extra keys are + # those that were not used to generate a particular route. The extra + # keys also do not include those recalled from the prior request, nor + # do they include any keys that were implied in the route (like a + # :controller that is required, but not explicitly used in the text of + # the route.) + def extra_keys(hash, recall={}) + (hash || {}).keys.map { |k| k.to_sym } - (recall || {}).keys - significant_keys + end + + # Build a query string from the keys of the given hash. If +only_keys+ + # is given (as an array), only the keys indicated will be used to build + # the query string. The query string will correctly build array parameter + # values. + def build_query_string(hash, only_keys = nil) + elements = [] + + (only_keys || hash.keys).each do |key| + if value = hash[key] + elements << value.to_query(key) + end + end + + elements.empty? ? '' : "?#{elements.sort * '&'}" + end + + # Write the real recognition implementation and then resend the message. + def recognize(path, environment={}) + write_recognition + recognize path, environment + end + + # A route's parameter shell contains parameter values that are not in the + # route's path, but should be placed in the recognized hash. + # + # For example, +{:controller => 'pages', :action => 'show'} is the shell for the route: + # + # map.connect '/page/:id', :controller => 'pages', :action => 'show', :id => /\d+/ + # + def parameter_shell + @parameter_shell ||= returning({}) do |shell| + requirements.each do |key, requirement| + shell[key] = requirement unless requirement.is_a? Regexp + end + end + end + + # Return an array containing all the keys that are used in this route. This + # includes keys that appear inside the path, and keys that have requirements + # placed upon them. + def significant_keys + @significant_keys ||= returning [] do |sk| + segments.each { |segment| sk << segment.key if segment.respond_to? :key } + sk.concat requirements.keys + sk.uniq! + end + end + + # Return a hash of key/value pairs representing the keys in the route that + # have defaults, or which are specified by non-regexp requirements. + def defaults + @defaults ||= returning({}) do |hash| + segments.each do |segment| + next unless segment.respond_to? :default + hash[segment.key] = segment.default unless segment.default.nil? + end + requirements.each do |key,req| + next if Regexp === req || req.nil? + hash[key] = req + end + end + end + + def matches_controller_and_action?(controller, action) + unless defined? @matching_prepared + @controller_requirement = requirement_for(:controller) + @action_requirement = requirement_for(:action) + @matching_prepared = true + end + + (@controller_requirement.nil? || @controller_requirement === controller) && + (@action_requirement.nil? || @action_requirement === action) + end + + def to_s + @to_s ||= begin + segs = segments.inject("") { |str,s| str << s.to_s } + "%-6s %-40s %s" % [(conditions[:method] || :any).to_s.upcase, segs, requirements.inspect] + end + end + + protected + def requirement_for(key) + return requirements[key] if requirements.key? key + segments.each do |segment| + return segment.regexp if segment.respond_to?(:key) && segment.key == key + end + nil + end + + end + end +end
\ No newline at end of file diff --git a/actionpack/lib/action_controller/routing/route_set.rb b/actionpack/lib/action_controller/routing/route_set.rb new file mode 100644 index 0000000000..83903aff85 --- /dev/null +++ b/actionpack/lib/action_controller/routing/route_set.rb @@ -0,0 +1,439 @@ +module ActionController + module Routing + class RouteSet #:nodoc: + # Mapper instances are used to build routes. The object passed to the draw + # block in config/routes.rb is a Mapper instance. + # + # Mapper instances have relatively few instance methods, in order to avoid + # clashes with named routes. + class Mapper #:doc: + def initialize(set) #:nodoc: + @set = set + end + + # Create an unnamed route with the provided +path+ and +options+. See + # ActionController::Routing for an introduction to routes. + def connect(path, options = {}) + @set.add_route(path, options) + end + + # Creates a named route called "root" for matching the root level request. + def root(options = {}) + named_route("root", '', options) + end + + def named_route(name, path, options = {}) #:nodoc: + @set.add_named_route(name, path, options) + end + + # Enables the use of resources in a module by setting the name_prefix, path_prefix, and namespace for the model. + # Example: + # + # map.namespace(:admin) do |admin| + # admin.resources :products, + # :has_many => [ :tags, :images, :variants ] + # end + # + # This will create +admin_products_url+ pointing to "admin/products", which will look for an Admin::ProductsController. + # It'll also create +admin_product_tags_url+ pointing to "admin/products/#{product_id}/tags", which will look for + # Admin::TagsController. + def namespace(name, options = {}, &block) + if options[:namespace] + with_options({:path_prefix => "#{options.delete(:path_prefix)}/#{name}", :name_prefix => "#{options.delete(:name_prefix)}#{name}_", :namespace => "#{options.delete(:namespace)}#{name}/" }.merge(options), &block) + else + with_options({:path_prefix => name, :name_prefix => "#{name}_", :namespace => "#{name}/" }.merge(options), &block) + end + end + + def method_missing(route_name, *args, &proc) #:nodoc: + super unless args.length >= 1 && proc.nil? + @set.add_named_route(route_name, *args) + end + end + + # A NamedRouteCollection instance is a collection of named routes, and also + # maintains an anonymous module that can be used to install helpers for the + # named routes. + class NamedRouteCollection #:nodoc: + include Enumerable + include ActionController::Routing::Optimisation + attr_reader :routes, :helpers + + def initialize + clear! + end + + def clear! + @routes = {} + @helpers = [] + + @module ||= Module.new + @module.instance_methods.each do |selector| + @module.class_eval { remove_method selector } + end + end + + def add(name, route) + routes[name.to_sym] = route + define_named_route_methods(name, route) + end + + def get(name) + routes[name.to_sym] + end + + alias []= add + alias [] get + alias clear clear! + + def each + routes.each { |name, route| yield name, route } + self + end + + def names + routes.keys + end + + def length + routes.length + end + + def reset! + old_routes = routes.dup + clear! + old_routes.each do |name, route| + add(name, route) + end + end + + def install(destinations = [ActionController::Base, ActionView::Base], regenerate = false) + reset! if regenerate + Array(destinations).each do |dest| + dest.send! :include, @module + end + end + + private + def url_helper_name(name, kind = :url) + :"#{name}_#{kind}" + end + + def hash_access_name(name, kind = :url) + :"hash_for_#{name}_#{kind}" + end + + def define_named_route_methods(name, route) + {:url => {:only_path => false}, :path => {:only_path => true}}.each do |kind, opts| + hash = route.defaults.merge(:use_route => name).merge(opts) + define_hash_access route, name, kind, hash + define_url_helper route, name, kind, hash + end + end + + def define_hash_access(route, name, kind, options) + selector = hash_access_name(name, kind) + @module.module_eval <<-end_eval # We use module_eval to avoid leaks + def #{selector}(options = nil) + options ? #{options.inspect}.merge(options) : #{options.inspect} + end + protected :#{selector} + end_eval + helpers << selector + end + + def define_url_helper(route, name, kind, options) + selector = url_helper_name(name, kind) + # The segment keys used for positional paramters + + hash_access_method = hash_access_name(name, kind) + + # allow ordered parameters to be associated with corresponding + # dynamic segments, so you can do + # + # foo_url(bar, baz, bang) + # + # instead of + # + # foo_url(:bar => bar, :baz => baz, :bang => bang) + # + # Also allow options hash, so you can do + # + # foo_url(bar, baz, bang, :sort_by => 'baz') + # + @module.module_eval <<-end_eval # We use module_eval to avoid leaks + def #{selector}(*args) + #{generate_optimisation_block(route, kind)} + + opts = if args.empty? || Hash === args.first + args.first || {} + else + options = args.extract_options! + args = args.zip(#{route.segment_keys.inspect}).inject({}) do |h, (v, k)| + h[k] = v + h + end + options.merge(args) + end + + url_for(#{hash_access_method}(opts)) + end + protected :#{selector} + end_eval + helpers << selector + end + end + + attr_accessor :routes, :named_routes + + def initialize + self.routes = [] + self.named_routes = NamedRouteCollection.new + end + + # Subclasses and plugins may override this method to specify a different + # RouteBuilder instance, so that other route DSL's can be created. + def builder + @builder ||= RouteBuilder.new + end + + def draw + clear! + yield Mapper.new(self) + install_helpers + end + + def clear! + routes.clear + named_routes.clear + @combined_regexp = nil + @routes_by_controller = nil + end + + def install_helpers(destinations = [ActionController::Base, ActionView::Base], regenerate_code = false) + Array(destinations).each { |d| d.module_eval { include Helpers } } + named_routes.install(destinations, regenerate_code) + end + + def empty? + routes.empty? + end + + def load! + Routing.use_controllers! nil # Clear the controller cache so we may discover new ones + clear! + load_routes! + install_helpers + end + + # reload! will always force a reload whereas load checks the timestamp first + alias reload! load! + + def reload + if @routes_last_modified && defined?(RAILS_ROOT) + mtime = File.stat("#{RAILS_ROOT}/config/routes.rb").mtime + # if it hasn't been changed, then just return + return if mtime == @routes_last_modified + # if it has changed then record the new time and fall to the load! below + @routes_last_modified = mtime + end + load! + end + + def load_routes! + if defined?(RAILS_ROOT) && defined?(::ActionController::Routing::Routes) && self == ::ActionController::Routing::Routes + load File.join("#{RAILS_ROOT}/config/routes.rb") + @routes_last_modified = File.stat("#{RAILS_ROOT}/config/routes.rb").mtime + else + add_route ":controller/:action/:id" + end + end + + def add_route(path, options = {}) + route = builder.build(path, options) + routes << route + route + end + + def add_named_route(name, path, options = {}) + # TODO - is options EVER used? + name = options[:name_prefix] + name.to_s if options[:name_prefix] + named_routes[name.to_sym] = add_route(path, options) + end + + def options_as_params(options) + # If an explicit :controller was given, always make :action explicit + # too, so that action expiry works as expected for things like + # + # generate({:controller => 'content'}, {:controller => 'content', :action => 'show'}) + # + # (the above is from the unit tests). In the above case, because the + # controller was explicitly given, but no action, the action is implied to + # be "index", not the recalled action of "show". + # + # great fun, eh? + + options_as_params = options.clone + options_as_params[:action] ||= 'index' if options[:controller] + options_as_params[:action] = options_as_params[:action].to_s if options_as_params[:action] + options_as_params + end + + def build_expiry(options, recall) + recall.inject({}) do |expiry, (key, recalled_value)| + expiry[key] = (options.key?(key) && options[key].to_param != recalled_value.to_param) + expiry + end + end + + # Generate the path indicated by the arguments, and return an array of + # the keys that were not used to generate it. + def extra_keys(options, recall={}) + generate_extras(options, recall).last + end + + def generate_extras(options, recall={}) + generate(options, recall, :generate_extras) + end + + def generate(options, recall = {}, method=:generate) + named_route_name = options.delete(:use_route) + generate_all = options.delete(:generate_all) + if named_route_name + named_route = named_routes[named_route_name] + options = named_route.parameter_shell.merge(options) + end + + options = options_as_params(options) + expire_on = build_expiry(options, recall) + + if options[:controller] + options[:controller] = options[:controller].to_s + end + # if the controller has changed, make sure it changes relative to the + # current controller module, if any. In other words, if we're currently + # on admin/get, and the new controller is 'set', the new controller + # should really be admin/set. + if !named_route && expire_on[:controller] && options[:controller] && options[:controller][0] != ?/ + old_parts = recall[:controller].split('/') + new_parts = options[:controller].split('/') + parts = old_parts[0..-(new_parts.length + 1)] + new_parts + options[:controller] = parts.join('/') + end + + # drop the leading '/' on the controller name + options[:controller] = options[:controller][1..-1] if options[:controller] && options[:controller][0] == ?/ + merged = recall.merge(options) + + if named_route + path = named_route.generate(options, merged, expire_on) + if path.nil? + raise_named_route_error(options, named_route, named_route_name) + else + return path + end + else + merged[:action] ||= 'index' + options[:action] ||= 'index' + + controller = merged[:controller] + action = merged[:action] + + raise RoutingError, "Need controller and action!" unless controller && action + + if generate_all + # Used by caching to expire all paths for a resource + return routes.collect do |route| + route.send!(method, options, merged, expire_on) + end.compact + end + + # don't use the recalled keys when determining which routes to check + routes = routes_by_controller[controller][action][options.keys.sort_by { |x| x.object_id }] + + routes.each do |route| + results = route.send!(method, options, merged, expire_on) + return results if results && (!results.is_a?(Array) || results.first) + end + end + + raise RoutingError, "No route matches #{options.inspect}" + end + + # try to give a helpful error message when named route generation fails + def raise_named_route_error(options, named_route, named_route_name) + diff = named_route.requirements.diff(options) + unless diff.empty? + raise RoutingError, "#{named_route_name}_url failed to generate from #{options.inspect}, expected: #{named_route.requirements.inspect}, diff: #{named_route.requirements.diff(options).inspect}" + else + required_segments = named_route.segments.select {|seg| (!seg.optional?) && (!seg.is_a?(DividerSegment)) } + required_keys_or_values = required_segments.map { |seg| seg.key rescue seg.value } # we want either the key or the value from the segment + raise RoutingError, "#{named_route_name}_url failed to generate from #{options.inspect} - you may have ambiguous routes, or you may need to supply additional parameters for this route. content_url has the following required parameters: #{required_keys_or_values.inspect} - are they all satisfied?" + end + end + + def recognize(request) + params = recognize_path(request.path, extract_request_environment(request)) + request.path_parameters = params.with_indifferent_access + "#{params[:controller].camelize}Controller".constantize + end + + def recognize_path(path, environment={}) + routes.each do |route| + result = route.recognize(path, environment) and return result + end + + allows = HTTP_METHODS.select { |verb| routes.find { |r| r.recognize(path, :method => verb) } } + + if environment[:method] && !HTTP_METHODS.include?(environment[:method]) + raise NotImplemented.new(*allows) + elsif !allows.empty? + raise MethodNotAllowed.new(*allows) + else + raise RoutingError, "No route matches #{path.inspect} with #{environment.inspect}" + end + end + + def routes_by_controller + @routes_by_controller ||= Hash.new do |controller_hash, controller| + controller_hash[controller] = Hash.new do |action_hash, action| + action_hash[action] = Hash.new do |key_hash, keys| + key_hash[keys] = routes_for_controller_and_action_and_keys(controller, action, keys) + end + end + end + end + + def routes_for(options, merged, expire_on) + raise "Need controller and action!" unless controller && action + controller = merged[:controller] + merged = options if expire_on[:controller] + action = merged[:action] || 'index' + + routes_by_controller[controller][action][merged.keys] + end + + def routes_for_controller_and_action(controller, action) + selected = routes.select do |route| + route.matches_controller_and_action? controller, action + end + (selected.length == routes.length) ? routes : selected + end + + def routes_for_controller_and_action_and_keys(controller, action, keys) + selected = routes.select do |route| + route.matches_controller_and_action? controller, action + end + selected.sort_by do |route| + (keys - route.significant_keys).length + end + end + + # Subclasses and plugins may override this method to extract further attributes + # from the request, for use by route conditions and such. + def extract_request_environment(request) + { :method => request.method } + end + end + end +end
\ No newline at end of file diff --git a/actionpack/lib/action_controller/routing/routing_ext.rb b/actionpack/lib/action_controller/routing/routing_ext.rb new file mode 100644 index 0000000000..2ad20ee699 --- /dev/null +++ b/actionpack/lib/action_controller/routing/routing_ext.rb @@ -0,0 +1,46 @@ + +class Object + def to_param + to_s + end +end + +class TrueClass + def to_param + self + end +end + +class FalseClass + def to_param + self + end +end + +class NilClass + def to_param + self + end +end + +class Regexp #:nodoc: + def number_of_captures + Regexp.new("|#{source}").match('').captures.length + end + + class << self + def optionalize(pattern) + case unoptionalize(pattern) + when /\A(.|\(.*\))\Z/ then "#{pattern}?" + else "(?:#{pattern})?" + end + end + + def unoptionalize(pattern) + [/\A\(\?:(.*)\)\?\Z/, /\A(.|\(.*\))\?\Z/].each do |regexp| + return $1 if regexp =~ pattern + end + return pattern + end + end +end diff --git a/actionpack/lib/action_controller/routing/segments.rb b/actionpack/lib/action_controller/routing/segments.rb new file mode 100644 index 0000000000..efac2df1cb --- /dev/null +++ b/actionpack/lib/action_controller/routing/segments.rb @@ -0,0 +1,268 @@ +module ActionController + module Routing + class Segment #:nodoc: + RESERVED_PCHAR = ':@&=+$,;' + UNSAFE_PCHAR = Regexp.new("[^#{URI::REGEXP::PATTERN::UNRESERVED}#{RESERVED_PCHAR}]", false, 'N').freeze + + attr_accessor :is_optional + alias_method :optional?, :is_optional + + def initialize + self.is_optional = false + end + + def extraction_code + nil + end + + # Continue generating string for the prior segments. + def continue_string_structure(prior_segments) + if prior_segments.empty? + interpolation_statement(prior_segments) + else + new_priors = prior_segments[0..-2] + prior_segments.last.string_structure(new_priors) + end + end + + def interpolation_chunk + URI.escape(value, UNSAFE_PCHAR) + end + + # Return a string interpolation statement for this segment and those before it. + def interpolation_statement(prior_segments) + chunks = prior_segments.collect { |s| s.interpolation_chunk } + chunks << interpolation_chunk + "\"#{chunks * ''}\"#{all_optionals_available_condition(prior_segments)}" + end + + def string_structure(prior_segments) + optional? ? continue_string_structure(prior_segments) : interpolation_statement(prior_segments) + end + + # Return an if condition that is true if all the prior segments can be generated. + # If there are no optional segments before this one, then nil is returned. + def all_optionals_available_condition(prior_segments) + optional_locals = prior_segments.collect { |s| s.local_name if s.optional? && s.respond_to?(:local_name) }.compact + optional_locals.empty? ? nil : " if #{optional_locals * ' && '}" + end + + # Recognition + + def match_extraction(next_capture) + nil + end + + # Warning + + # Returns true if this segment is optional? because of a default. If so, then + # no warning will be emitted regarding this segment. + def optionality_implied? + false + end + end + + class StaticSegment < Segment #:nodoc: + attr_accessor :value, :raw + alias_method :raw?, :raw + + def initialize(value = nil) + super() + self.value = value + end + + def interpolation_chunk + raw? ? value : super + end + + def regexp_chunk + chunk = Regexp.escape(value) + optional? ? Regexp.optionalize(chunk) : chunk + end + + def build_pattern(pattern) + escaped = Regexp.escape(value) + if optional? && ! pattern.empty? + "(?:#{Regexp.optionalize escaped}\\Z|#{escaped}#{Regexp.unoptionalize pattern})" + elsif optional? + Regexp.optionalize escaped + else + escaped + pattern + end + end + + def to_s + value + end + end + + class DividerSegment < StaticSegment #:nodoc: + def initialize(value = nil) + super(value) + self.raw = true + self.is_optional = true + end + + def optionality_implied? + true + end + end + + class DynamicSegment < Segment #:nodoc: + attr_accessor :key, :default, :regexp + + def initialize(key = nil, options = {}) + super() + self.key = key + self.default = options[:default] if options.key? :default + self.is_optional = true if options[:optional] || options.key?(:default) + end + + def to_s + ":#{key}" + end + + # The local variable name that the value of this segment will be extracted to. + def local_name + "#{key}_value" + end + + def extract_value + "#{local_name} = hash[:#{key}] && hash[:#{key}].to_param #{"|| #{default.inspect}" if default}" + end + def value_check + if default # Then we know it won't be nil + "#{value_regexp.inspect} =~ #{local_name}" if regexp + elsif optional? + # If we have a regexp check that the value is not given, or that it matches. + # If we have no regexp, return nil since we do not require a condition. + "#{local_name}.nil? || #{value_regexp.inspect} =~ #{local_name}" if regexp + else # Then it must be present, and if we have a regexp, it must match too. + "#{local_name} #{"&& #{value_regexp.inspect} =~ #{local_name}" if regexp}" + end + end + def expiry_statement + "expired, hash = true, options if !expired && expire_on[:#{key}]" + end + + def extraction_code + s = extract_value + vc = value_check + s << "\nreturn [nil,nil] unless #{vc}" if vc + s << "\n#{expiry_statement}" + end + + def interpolation_chunk(value_code = "#{local_name}") + "\#{URI.escape(#{value_code}.to_s, ActionController::Routing::Segment::UNSAFE_PCHAR)}" + end + + def string_structure(prior_segments) + if optional? # We have a conditional to do... + # If we should not appear in the url, just write the code for the prior + # segments. This occurs if our value is the default value, or, if we are + # optional, if we have nil as our value. + "if #{local_name} == #{default.inspect}\n" + + continue_string_structure(prior_segments) + + "\nelse\n" + # Otherwise, write the code up to here + "#{interpolation_statement(prior_segments)}\nend" + else + interpolation_statement(prior_segments) + end + end + + def value_regexp + Regexp.new "\\A#{regexp.source}\\Z" if regexp + end + def regexp_chunk + regexp ? "(#{regexp.source})" : "([^#{Routing::SEPARATORS.join}]+)" + end + + def build_pattern(pattern) + chunk = regexp_chunk + chunk = "(#{chunk})" if Regexp.new(chunk).number_of_captures == 0 + pattern = "#{chunk}#{pattern}" + optional? ? Regexp.optionalize(pattern) : pattern + end + def match_extraction(next_capture) + # All non code-related keys (such as :id, :slug) are URI-unescaped as + # path parameters. + default_value = default ? default.inspect : nil + %[ + value = if (m = match[#{next_capture}]) + URI.unescape(m) + else + #{default_value} + end + params[:#{key}] = value if value + ] + end + + def optionality_implied? + [:action, :id].include? key + end + + end + + class ControllerSegment < DynamicSegment #:nodoc: + def regexp_chunk + possible_names = Routing.possible_controllers.collect { |name| Regexp.escape name } + "(?i-:(#{(regexp || Regexp.union(*possible_names)).source}))" + end + + # Don't URI.escape the controller name since it may contain slashes. + def interpolation_chunk(value_code = "#{local_name}") + "\#{#{value_code}.to_s}" + end + + # Make sure controller names like Admin/Content are correctly normalized to + # admin/content + def extract_value + "#{local_name} = (hash[:#{key}] #{"|| #{default.inspect}" if default}).downcase" + end + + def match_extraction(next_capture) + if default + "params[:#{key}] = match[#{next_capture}] ? match[#{next_capture}].downcase : '#{default}'" + else + "params[:#{key}] = match[#{next_capture}].downcase if match[#{next_capture}]" + end + end + end + + class PathSegment < DynamicSegment #:nodoc: + RESERVED_PCHAR = "#{Segment::RESERVED_PCHAR}/" + UNSAFE_PCHAR = Regexp.new("[^#{URI::REGEXP::PATTERN::UNRESERVED}#{RESERVED_PCHAR}]", false, 'N').freeze + + def interpolation_chunk(value_code = "#{local_name}") + "\#{URI.escape(#{value_code}.to_s, ActionController::Routing::PathSegment::UNSAFE_PCHAR)}" + end + + def default + '' + end + + def default=(path) + raise RoutingError, "paths cannot have non-empty default values" unless path.blank? + end + + def match_extraction(next_capture) + "params[:#{key}] = PathSegment::Result.new_escaped((match[#{next_capture}]#{" || " + default.inspect if default}).split('/'))#{" if match[" + next_capture + "]" if !default}" + end + + def regexp_chunk + regexp || "(.*)" + end + + def optionality_implied? + true + end + + class Result < ::Array #:nodoc: + def to_s() join '/' end + def self.new_escaped(strings) + new strings.collect {|str| URI.unescape str} + end + end + end + end +end |