From 60f7a5cab73fab032fdb73d1a9a8061cf20031d2 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 15 Feb 2005 01:02:37 +0000 Subject: Added routing itself.. wonder why that didnt make it through the merge git-svn-id: http://svn-commit.rubyonrails.org/rails/trunk@615 5ecf4fe2-1ee6-0310-87b1-e25e094e27de --- actionpack/lib/action_controller/routing.rb | 260 ++++++++++++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 actionpack/lib/action_controller/routing.rb diff --git a/actionpack/lib/action_controller/routing.rb b/actionpack/lib/action_controller/routing.rb new file mode 100644 index 0000000000..39ce487e62 --- /dev/null +++ b/actionpack/lib/action_controller/routing.rb @@ -0,0 +1,260 @@ +module ActionController + module Routing + ROUTE_FILE = defined?(RAILS_ROOT) ? File.expand_path(File.join(RAILS_ROOT, 'config', 'routes')) : nil + + class Route + attr_reader :defaults # The defaults hash + + def initialize(path, hash={}) + raise ArgumentError, "Second argument must be a hash!" unless hash.kind_of?(Hash) + @defaults = {} + @requirements = {} + self.items = path + hash.each do |k, v| + raise TypeError, "Hash may only contain symbols!" unless k.kind_of? Symbol + (@items.include?(k) ? @defaults : @requirements)[k] = v + end + + # Add in defaults for :action and :id. + [[:action, 'index'], [:id, nil]].each do |name, default| + @defaults[name] = default if @items.include?(name) && ! (@requirements.key?(name) || @defaults.key?(name)) + end + end + + # Generate a URL given the provided options. + # All values in options should be symbols. + # Returns the path and the unused names in a 2 element array. + # If generation fails, [nil, nil] is returned + # Generation can fail because of a missing value, or because an equality check fails. + # + # Generate urls will be as short as possible. If the last component of a url is equal to the default value, + # then that component is removed. This is applied as many times as possible. So, your index controller's + # index action will generate [] + def generate(options, defaults={}) + non_matching = @requirements.inject([]) {|a, (k, v)| ((options[k] || defaults[k]) == v) ? a : a << k} + return nil, "Options mismatch requirements: #{non_matching.join ', '}" unless non_matching.empty? + + used_names = @requirements.inject({}) {|hash, (k, v)| hash[k] = true; hash} + components = @items.collect do |item| + if item.kind_of? Symbol + used_names[item] = true + value = options[item] || defaults[item] || @defaults[item] + return nil, "#{item.inspect} was not given and has no default." if value.nil? && ! (@defaults.key?(item) && @defaults[item].nil?) # Don't leave if nil value. + defaults = {} unless defaults == {} || value == defaults[item] # Stop using defaults if this component isn't the same as the default. + value + else item + end + end + + @items.reverse_each do |item| # Remove default components from the end of the generated url. + break unless item.kind_of?(Symbol) && @defaults[item] == components.last + components.pop + end + + # If we have any nil components then we can't proceed. + # This might need to be changed. In some cases we may be able to return all componets after nil as extras. + missing = []; components.each_with_index {|c, i| missing << @items[i] if c.nil?} + return nil, "No values provided for component#{'s' if missing.length > 1} #{missing.join ', '} but values are required due to use of later components" unless missing.empty? # how wide is your screen? + + unused = (options.keys - used_names.keys).inject({}) do |unused, key| + unused[key] = options[key] if options[key] != @defaults[key] + unused + end + + components.collect! {|c| c.to_s} + components.unshift(components.shift + '/') if components.length == 1 && @items.first == :controller # Add '/' to controllers + + return components, unused + end + + # Recognize the provided path, returning a hash of recognized values, or [nil, reason] if the path isn't recognized. + # The path should be a list of component strings. + # Options is a hash of the ?k=v pairs + def recognize(components, options={}) + options = options.clone + components = components.clone + controller_class = nil + + @items.each do |item| + if item == :controller # Special case for controller + if components.empty? && @defaults[:controller] + controller_class, leftover = eat_path_to_controller(@defaults[:controller].split('/')) + raise RoutingError, "Default controller does not exist: #{@defaults[:controller]}" if controller_class.nil? || leftover.empty? == false + else + controller_class, remaining_components = eat_path_to_controller(components) + return nil, "No controller found at subpath #{components.join('/')}" if controller_class.nil? + components = remaining_components + end + options[:controller] = controller_class.controller_path + elsif item.kind_of? Symbol + value = components.shift || @defaults[item] + return nil, "No value or default for parameter #{item.inspect}" if value.nil? && ! (@defaults.key?(item) && @defaults[item].nil?) + options[item] = value + else + return nil, "No value available for component #{item.inspect}" if components.empty? + component = components.shift + return nil, "Value for component #{item.inspect} doesn't match #{component}" if component != item + end + end + + if controller_class.nil? && @requirements[:controller] # Load a default controller + controller_class, extras = eat_path_to_controller(@requirements[:controller].split('/')) + raise RoutingError, "Illegal controller path for route default: #{@requirements[:controller]}" unless controller_class && extras.empty? + options[:controller] = controller_class.controller_path + end + options = @requirements.merge(options) + + return nil, "Route recognition didn't find a controller class!" unless controller_class + return nil, "Unused components were left: #{components.join '/'}" unless components.empty? + options.delete_if {|k, v| v.nil?} # Remove nil values. + return controller_class, options + end + + def inspect + when_str = @requirements.empty? ? "" : " when #{@requirements.inspect}" + default_str = @defaults.empty? ? "" : " || #{@defaults.inspect}" + "<#{self.class.to_s} #{@items.collect{|c| c.kind_of?(String) ? c : c.inspect}.join('/').inspect}#{default_str}#{when_str}>" + end + + protected + # Find the controller given a list of path components. + # Return the controller class and the unused path components. + def eat_path_to_controller(path) + path.inject([Controllers, 1]) do |(mod, length), name| + name = name.camelize + controller_name = name + "Controller" + return mod.const_get(controller_name), path[length..-1] if mod.const_available? controller_name + return nil, nil unless mod.const_available? name + [mod.const_get(name), length + 1] + end + return nil, nil # Path ended, but no controller found. + end + + def items=(path) + items = path.split('/').collect {|c| (/^:(\w+)$/ =~ c) ? $1.intern : c} if path.kind_of?(String) # split and convert ':xyz' to symbols + items.shift if items.first == "" + items.pop if items.last == "" + @items = items + + # Verify uniqueness of each component. + @items.inject({}) do |seen, item| + if item.kind_of? Symbol + raise ArgumentError, "Illegal route path -- duplicate item #{item}\n #{path.inspect}" if seen.key? item + seen[item] = true + end + seen + end + end + end + + class RouteSet + def initialize + @routes = [] + end + + def add_route(route) + raise TypeError, "#{route.inspect} is not a Route instance!" unless route.kind_of?(Route) + @routes << route + end + def empty? + @routes.empty? + end + def each + @routes.each {|route| yield route} + end + + # Generate a path for the provided options + # Returns the path as an array of components and a hash of unused names + # Raises RoutingError if not route can handle the provided components. + # + # Note that we don't return the first generated path. We do this so that when a route + # generates a path from a subset of the available options we can keep looking for a + # route which can generate a path that uses more options. + # Note that we *do* return immediately if + def generate(options, request) + raise RoutingError, "There are no routes defined!" if @routes.empty? + options = options.symbolize_keys + defaults = request.path_parameters.symbolize_keys + expand_controller_path!(options, defaults) + + failures = [] + selected = nil + self.each do |route| + path, unused = route.generate(options, defaults) + if path.nil? + failures << [route, unused] if ActionController::Base.debug_routes + else + return path, unused if unused.empty? # Found a perfect route -- we're finished. + if selected.nil? || unused.length < selected.last.length + failures << [selected.first, "A better url than #{selected[1]} was found."] if selected + selected = [route, path, unused] + end + end + end + + return selected[1..-1] unless selected.nil? + raise RoutingError.new("Generation failure: No route for url_options #{options.inspect}, defaults: #{defaults.inspect}", failures) + end + + # Recognize the provided path. + # Raise RoutingError if the path can't be recognized. + def recognize!(request) + path = ((%r{^/?(.*)/?$} =~ request.path) ? $1 : request.path).split('/') + raise RoutingError, "There are no routes defined!" if @routes.empty? + + failures = [] + self.each do |route| + controller, options = route.recognize(path) + if controller.nil? + failures << [route, options] if ActionController::Base.debug_routes + else + options.each {|k, v| request.path_parameters[k] = CGI.unescape(v)} + return controller + end + end + + raise RoutingError.new("No route for path: #{path.join('/').inspect}", failures) + end + + def expand_controller_path!(options, defaults) + if options[:controller] + if /^\// =~ options[:controller] + options[:controller] = options[:controller][1..-1] + defaults.clear # Sending to absolute controller implies fresh defaults + else + relative_to = defaults[:controller] ? defaults[:controller].split('/')[0..-2].join('/') : '' + options[:controller] = relative_to.empty? ? options[:controller] : "#{relative_to}/#{options[:controller]}" + end + else + options[:controller] = defaults[:controller] + end + end + + def route(*args) + add_route(Route.new(*args)) + end + alias :connect :route + + def reload + begin require_dependency(ROUTE_FILE) + rescue LoadError, ScriptError => e + raise RoutingError, "Cannot load config/routes.rb:\n #{e.message}" + ensure # Ensure that there is at least one route: + connect(':controller/:action/:id', :action => 'index', :id => nil) if @routes.empty? + end + end + + def draw + @routes.clear + yield self + end + end + + def self.draw(*args, &block) + Routes.draw(*args) {|*args| block.call(*args)} + end + + Routes = RouteSet.new + #Routes.reload # Do this here, so that server will die on load if SyntaxError or whatnot. + end +end \ No newline at end of file -- cgit v1.2.3