diff options
Diffstat (limited to 'actionpack/lib/action_controller/routing/route_set.rb')
-rw-r--r-- | actionpack/lib/action_controller/routing/route_set.rb | 439 |
1 files changed, 439 insertions, 0 deletions
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 |