aboutsummaryrefslogtreecommitdiffstats
path: root/actionpack/lib/action_controller/routing.rb
blob: c021d88ca9a08046ada5576caafc80a041fc6d77 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
module ActionController
  # See http://manuals.rubyonrails.com/read/chapter/65
  module Routing
    class Route #:nodoc:
      attr_reader :defaults # The defaults hash
      
      def initialize(path, hash={})
        raise ArgumentError, "Second argument must be a hash!" unless hash.kind_of?(Hash)
        @defaults = hash[:defaults].kind_of?(Hash) ? hash.delete(:defaults) : {}
        @requirements = hash[:requirements].kind_of?(Hash) ? hash.delete(:requirements) : {}
        self.items = path
        hash.each do |k, v|
          raise TypeError, "Hash keys must be symbols!" unless k.kind_of? Symbol
          if v.kind_of? Regexp
            raise ArgumentError, "Regexp requirement on #{k}, but #{k} is not in this route's path!" unless @items.include? k
            @requirements[k] = v
          else
            (@items.include?(k) ? @defaults : @requirements)[k] = (v.nil? ? nil : v.to_s)
          end
        end
        
        @defaults.each do |k, v|
          raise ArgumentError, "A default has been specified for #{k}, but #{k} is not in the path!" unless @items.include? k
          @defaults[k] = v.to_s unless v.kind_of?(String) || v.nil?
        end
        @requirements.each {|k, v| raise ArgumentError, "A Regexp requirement has been specified for #{k}, but #{k} is not in the path!" if v.kind_of?(Regexp) && ! @items.include?(k)}
        
        # 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.keys.select {|name| ! passes_requirements?(name, options[name] || defaults[name])}
        non_matching.collect! {|name| requirements_for(name)}
        return nil, "Mismatching option#{'s' if non_matching.length > 1}:\n   #{non_matching.join '\n   '}" unless non_matching.empty?
        
        used_names = @requirements.inject({}) {|hash, (k, v)| hash[k] = true; hash} # Mark requirements as used so they don't get put in the query params
        components = @items.collect do |item|
          if item.kind_of? Symbol
            collection = false

            if /^\*/ =~ item.to_s
              collection = true
              item = item.to_s.sub(/^\*/,"").intern
            end

            used_names[item] = true
            value = options[item] || defaults[item] || @defaults[item]
            return nil, requirements_for(item) unless passes_requirements?(item, value)

            defaults = {} unless defaults == {} || value == defaults[item] # Stop using defaults if this component isn't the same as the default.

      	    if value.nil? || item == :controller
              value
            elsif collection
	            CGI.escape(value.to_s).gsub(/%2F/, "/")
            else
              CGI.escape(value.to_s)
            end
          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}
        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
            return nil, requirements_for(:controller) unless passes_requirements?(:controller, options[:controller])
          elsif /^\*/ =~ item.to_s
            value = components.join("/") || @defaults[item]
            components = []
            options[item.to_s.sub(/^\*/,"").intern] = value.nil? ? value : CGI.unescape(value)
          elsif item.kind_of? Symbol
            value = components.shift || @defaults[item]
            return nil, requirements_for(item) unless passes_requirements?(item, value)
            options[item] = value.nil? ? value : CGI.unescape(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
        @requirements.each {|k,v| options[k] ||= v unless v.kind_of?(Regexp)}

        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
            return nil, nil unless /^[A-Z][_a-zA-Z\d]*$/ =~ name
            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 == ':' ) ? $2.intern : "*#{$2}".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
        
        # Verify that the given value passes this route's requirements
        def passes_requirements?(name, value)
          return @defaults.key?(name) && @defaults[name].nil? if value.nil? # Make sure it's there if it should be
          
          case @requirements[name]
            when nil then true
            when Regexp then
              value = value.to_s
              match = @requirements[name].match(value)
              match && match[0].length == value.length
            else
              @requirements[name] == value.to_s
          end
        end
        def requirements_for(name)
          name = name.to_s.sub(/^\*/,"").intern if (/^\*/ =~ name.inspect)
          presence = (@defaults.key?(name) && @defaults[name].nil?)
          requirement = case @requirements[name]
            when nil then nil
            when Regexp then "match #{@requirements[name].inspect}"
            else "be equal to #{@requirements[name].inspect}"
          end
          if presence && requirement then "#{name} must be present and #{requirement}"
          elsif presence || requirement then "#{name} must #{requirement || 'be present'}"
          else "#{name} has no requirements"
          end
        end
    end
    
    class RouteSet#:nodoc:
      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
        if options.empty? then options = defaults.clone # Get back the current url if no options was passed
        else expand_controller_path!(options, defaults) # Expand the supplied controller path.
        end
        defaults.delete_if {|k, v| options.key?(k) && options[k].nil?} # Remove defaults that have been manually cleared using :name => nil

        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
            request.path_parameters = options
            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]}"
            defaults.delete(:action) if options.key?(:controller)
          end
        else
          options[:controller] = defaults[:controller]
        end
      end
      
      def route(*args)
        add_route(Route.new(*args))
      end
      alias :connect :route
      
      def reload
        begin
          route_file = defined?(RAILS_ROOT) ? File.join(RAILS_ROOT, 'config', 'routes') : nil
          require_dependency(route_file) if 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) #:nodoc:
      Routes.draw(*args) {|*args| block.call(*args)}
    end
    
    Routes = RouteSet.new
  end
end