diff options
Diffstat (limited to 'actionpack/lib/action_dispatch')
79 files changed, 4476 insertions, 953 deletions
diff --git a/actionpack/lib/action_dispatch/http/cache.rb b/actionpack/lib/action_dispatch/http/cache.rb index 5ee4c044ea..0d6015d993 100644 --- a/actionpack/lib/action_dispatch/http/cache.rb +++ b/actionpack/lib/action_dispatch/http/cache.rb @@ -1,4 +1,3 @@ -require 'active_support/core_ext/object/blank' module ActionDispatch module Http @@ -18,12 +17,21 @@ module ActionDispatch env[HTTP_IF_NONE_MATCH] end + def if_none_match_etags + (if_none_match ? if_none_match.split(/\s*,\s*/) : []).collect do |etag| + etag.gsub(/^\"|\"$/, "") + end + end + def not_modified?(modified_at) if_modified_since && modified_at && if_modified_since >= modified_at end def etag_matches?(etag) - if_none_match && if_none_match == etag + if etag + etag = etag.gsub(/^\"|\"$/, "") + if_none_match_etags.include?(etag) + end end # Check response freshness (Last-Modified and ETag) against request @@ -84,17 +92,37 @@ module ActionDispatch LAST_MODIFIED = "Last-Modified".freeze ETAG = "ETag".freeze CACHE_CONTROL = "Cache-Control".freeze + SPESHUL_KEYS = %w[extras no-cache max-age public must-revalidate] - def prepare_cache_control! - @cache_control = {} - @etag = self[ETAG] - + def cache_control_segments if cache_control = self[CACHE_CONTROL] - cache_control.split(/,\s*/).each do |segment| - first, last = segment.split("=") - @cache_control[first.to_sym] = last || true + cache_control.delete(' ').split(',') + else + [] + end + end + + def cache_control_headers + cache_control = {} + + cache_control_segments.each do |segment| + directive, argument = segment.split('=', 2) + + if SPESHUL_KEYS.include? directive + key = directive.tr('-', '_') + cache_control[key.to_sym] = argument || true + else + cache_control[:extras] ||= [] + cache_control[:extras] << segment end end + + cache_control + end + + def prepare_cache_control! + @cache_control = cache_control_headers + @etag = self[ETAG] end def handle_conditional_get! @@ -110,14 +138,24 @@ module ActionDispatch MUST_REVALIDATE = "must-revalidate".freeze def set_conditional_cache_control! - return if self[CACHE_CONTROL].present? + control = {} + cc_headers = cache_control_headers + if extras = cc_headers.delete(:extras) + @cache_control[:extras] ||= [] + @cache_control[:extras] += extras + @cache_control[:extras].uniq! + end - control = @cache_control + control.merge! cc_headers + control.merge! @cache_control if control.empty? headers[CACHE_CONTROL] = DEFAULT_CACHE_CONTROL elsif control[:no_cache] headers[CACHE_CONTROL] = NO_CACHE + if control[:extras] + headers[CACHE_CONTROL] += ", #{control[:extras].join(', ')}" + end else extras = control[:extras] max_age = control[:max_age] diff --git a/actionpack/lib/action_dispatch/http/filter_parameters.rb b/actionpack/lib/action_dispatch/http/filter_parameters.rb index 132b0c82bc..02ab49b44e 100644 --- a/actionpack/lib/action_dispatch/http/filter_parameters.rb +++ b/actionpack/lib/action_dispatch/http/filter_parameters.rb @@ -1,4 +1,3 @@ -require 'active_support/core_ext/object/blank' require 'active_support/core_ext/hash/keys' require 'active_support/core_ext/object/duplicable' @@ -10,8 +9,6 @@ module ActionDispatch # value of the params hash and all subhashes is passed to it, the value # or key can be replaced using String#replace or similar method. # - # Examples: - # # env["action_dispatch.parameter_filter"] = [:password] # => replaces the value to all keys matching /password/i with "[FILTERED]" # @@ -22,11 +19,17 @@ module ActionDispatch # v.reverse! if k =~ /secret/i # end # => reverses the value to all keys matching /secret/i - # module FilterParameters - extend ActiveSupport::Concern + ENV_MATCH = [/RAW_POST_DATA/, "rack.request.form_vars"] # :nodoc: + NULL_PARAM_FILTER = ParameterFilter.new # :nodoc: + NULL_ENV_FILTER = ParameterFilter.new ENV_MATCH # :nodoc: - @@parameter_filter_for = {} + def initialize(env) + super + @filtered_parameters = nil + @filtered_env = nil + @filtered_path = nil + end # Return a hash of parameters with all sensitive data replaced. def filtered_parameters @@ -46,15 +49,20 @@ module ActionDispatch protected def parameter_filter - parameter_filter_for(@env["action_dispatch.parameter_filter"]) + parameter_filter_for @env.fetch("action_dispatch.parameter_filter") { + return NULL_PARAM_FILTER + } end def env_filter - parameter_filter_for(Array(@env["action_dispatch.parameter_filter"]) + [/RAW_POST_DATA/, "rack.request.form_vars"]) + user_key = @env.fetch("action_dispatch.parameter_filter") { + return NULL_ENV_FILTER + } + parameter_filter_for(Array(user_key) + ENV_MATCH) end def parameter_filter_for(filters) - @@parameter_filter_for[filters] ||= ParameterFilter.new(filters) + ParameterFilter.new(filters) end KV_RE = '[^&;=]+' @@ -64,7 +72,6 @@ module ActionDispatch parameter_filter.filter([[$1, $2]]).first.join("=") end end - end end end diff --git a/actionpack/lib/action_dispatch/http/filter_redirect.rb b/actionpack/lib/action_dispatch/http/filter_redirect.rb new file mode 100644 index 0000000000..900ce1c646 --- /dev/null +++ b/actionpack/lib/action_dispatch/http/filter_redirect.rb @@ -0,0 +1,37 @@ +module ActionDispatch + module Http + module FilterRedirect + + FILTERED = '[FILTERED]'.freeze # :nodoc: + + def filtered_location + if !location_filter.empty? && location_filter_match? + FILTERED + else + location + end + end + + private + + def location_filter + if request.present? + request.env['action_dispatch.redirect_filter'] || [] + else + [] + end + end + + def location_filter_match? + location_filter.any? do |filter| + if String === filter + location.include?(filter) + elsif Regexp === filter + location.match(filter) + end + end + end + + end + end +end diff --git a/actionpack/lib/action_dispatch/http/headers.rb b/actionpack/lib/action_dispatch/http/headers.rb index 040b51e040..dc04d4577b 100644 --- a/actionpack/lib/action_dispatch/http/headers.rb +++ b/actionpack/lib/action_dispatch/http/headers.rb @@ -1,31 +1,39 @@ module ActionDispatch module Http - class Headers < ::Hash - @@env_cache = Hash.new { |h,k| h[k] = "HTTP_#{k.upcase.gsub(/-/, '_')}" } + class Headers + include Enumerable - def initialize(*args) - - if args.size == 1 && args[0].is_a?(Hash) - super() - update(args[0]) - else - super - end + def initialize(env = {}) + @headers = env end def [](header_name) - if include?(header_name) - super - else - super(env_name(header_name)) - end + @headers[env_name(header_name)] + end + + def []=(k,v); @headers[k] = v; end + def key?(k); @headers.key? k; end + alias :include? :key? + + def fetch(header_name, *args, &block) + @headers.fetch env_name(header_name), *args, &block + end + + def each(&block) + @headers.each(&block) end private - # Converts a HTTP header name to an environment variable name. - def env_name(header_name) - @@env_cache[header_name] - end + + # Converts a HTTP header name to an environment variable name if it is + # not contained within the headers hash. + def env_name(header_name) + @headers.include?(header_name) ? header_name : cgi_name(header_name) + end + + def cgi_name(k) + "HTTP_#{k.upcase.gsub(/-/, '_')}" + end end end end diff --git a/actionpack/lib/action_dispatch/http/mime_negotiation.rb b/actionpack/lib/action_dispatch/http/mime_negotiation.rb index 5c48a60469..57660e93c4 100644 --- a/actionpack/lib/action_dispatch/http/mime_negotiation.rb +++ b/actionpack/lib/action_dispatch/http/mime_negotiation.rb @@ -1,3 +1,5 @@ +require 'active_support/core_ext/module/attribute_accessors' + module ActionDispatch module Http module MimeNegotiation @@ -66,7 +68,7 @@ module ActionDispatch # that are not controlled by the extension. # # class ApplicationController < ActionController::Base - # before_filter :adjust_format_for_iphone + # before_action :adjust_format_for_iphone # # private # def adjust_format_for_iphone @@ -78,6 +80,27 @@ module ActionDispatch @env["action_dispatch.request.formats"] = [Mime::Type.lookup_by_extension(parameters[:format])] end + # Sets the \formats by string extensions. This differs from #format= by allowing you + # to set multiple, ordered formats, which is useful when you want to have a fallback. + # + # In this example, the :iphone format will be used if it's available, otherwise it'll fallback + # to the :html format. + # + # class ApplicationController < ActionController::Base + # before_action :adjust_format_for_iphone_with_html_fallback + # + # private + # def adjust_format_for_iphone_with_html_fallback + # request.formats = [ :iphone, :html ] if request.env["HTTP_USER_AGENT"][/iPhone/] + # end + # end + def formats=(extensions) + parameters[:format] = extensions.first.to_s + @env["action_dispatch.request.formats"] = extensions.collect do |extension| + Mime::Type.lookup_by_extension(extension) + end + end + # Receives an array of mimes and return the first user sent mime that # matches the order array. # diff --git a/actionpack/lib/action_dispatch/http/mime_type.rb b/actionpack/lib/action_dispatch/http/mime_type.rb index e039eb1288..f56f09c5b3 100644 --- a/actionpack/lib/action_dispatch/http/mime_type.rb +++ b/actionpack/lib/action_dispatch/http/mime_type.rb @@ -1,11 +1,11 @@ require 'set' require 'active_support/core_ext/class/attribute_accessors' -require 'active_support/core_ext/object/blank' +require 'active_support/core_ext/string/starts_ends_with' module Mime class Mimes < Array def symbols - @symbols ||= map {|m| m.to_sym } + @symbols ||= map { |m| m.to_sym } end %w(<< concat shift unshift push pop []= clear compact! collect! @@ -24,9 +24,16 @@ module Mime EXTENSION_LOOKUP = {} LOOKUP = Hash.new { |h, k| h[k] = Type.new(k) unless k.blank? } - def self.[](type) - return type if type.is_a?(Type) - Type.lookup_by_extension(type.to_s) + class << self + def [](type) + return type if type.is_a?(Type) + Type.lookup_by_extension(type) + end + + def fetch(type) + return type if type.is_a?(Type) + EXTENSION_LOOKUP.fetch(type.to_s) { |k| yield k } + end end # Encapsulates the notion of a mime type. Can be used at render time, for example, with: @@ -37,8 +44,8 @@ module Mime # # respond_to do |format| # format.html - # format.ics { render :text => post.to_ics, :mime_type => Mime::Type["text/calendar"] } - # format.xml { render :xml => @people.to_xml } + # format.ics { render text: post.to_ics, mime_type: Mime::Type["text/calendar"] } + # format.xml { render xml: @people } # end # end # end @@ -50,40 +57,97 @@ module Mime # i.e. following a link, getting an image or posting a form. CSRF protection # only needs to protect against these types. @@browser_generated_types = Set.new [:html, :url_encoded_form, :multipart_form, :text] - cattr_reader :browser_generated_types attr_reader :symbol + @register_callbacks = [] + # A simple helper class used in parsing the accept header class AcceptItem #:nodoc: - attr_accessor :order, :name, :q + attr_accessor :index, :name, :q + alias :to_s :name - def initialize(order, name, q=nil) - @order = order - @name = name.strip - q ||= 0.0 if @name == Mime::ALL # default wildcard match to end of list + def initialize(index, name, q = nil) + @index = index + @name = name + q ||= 0.0 if @name == Mime::ALL.to_s # default wildcard match to end of list @q = ((q || 1.0).to_f * 100).to_i end - def to_s - @name - end - def <=>(item) - result = item.q <=> q - result = order <=> item.order if result == 0 + result = item.q <=> @q + result = @index <=> item.index if result == 0 result end def ==(item) - name == (item.respond_to?(:name) ? item.name : item) + @name == item.to_s end end - class << self + class AcceptList < Array #:nodoc: + def assort! + sort! + + # Take care of the broken text/xml entry by renaming or deleting it + if text_xml_idx && app_xml_idx + app_xml.q = [text_xml.q, app_xml.q].max # set the q value to the max of the two + exchange_xml_items if app_xml_idx > text_xml_idx # make sure app_xml is ahead of text_xml in the list + delete_at(text_xml_idx) # delete text_xml from the list + elsif text_xml_idx + text_xml.name = Mime::XML.to_s + end + + # Look for more specific XML-based types and sort them ahead of app/xml + if app_xml_idx + idx = app_xml_idx + + while idx < length + type = self[idx] + break if type.q < app_xml.q + + if type.name.ends_with? '+xml' + self[app_xml_idx], self[idx] = self[idx], app_xml + @app_xml_idx = idx + end + idx += 1 + end + end + + map! { |i| Mime::Type.lookup(i.name) }.uniq! + to_a + end + private + def text_xml_idx + @text_xml_idx ||= index('text/xml') + end + + def app_xml_idx + @app_xml_idx ||= index(Mime::XML.to_s) + end + + def text_xml + self[text_xml_idx] + end + + def app_xml + self[app_xml_idx] + end + + def exchange_xml_items + self[app_xml_idx], self[text_xml_idx] = text_xml, app_xml + @app_xml_idx, @text_xml_idx = text_xml_idx, app_xml_idx + end + end + + class << self TRAILING_STAR_REGEXP = /(text|application)\/\*/ PARAMETER_SEPARATOR_REGEXP = /;\s*\w+="?\w+"?/ + def register_callback(&block) + @register_callbacks << block + end + def lookup(string) LOOKUP[string] end @@ -99,91 +163,51 @@ module Mime end def register(string, symbol, mime_type_synonyms = [], extension_synonyms = [], skip_lookup = false) - Mime.const_set(symbol.to_s.upcase, Type.new(string, symbol, mime_type_synonyms)) + Mime.const_set(symbol.upcase, Type.new(string, symbol, mime_type_synonyms)) - SET << Mime.const_get(symbol.to_s.upcase) + new_mime = Mime.const_get(symbol.upcase) + SET << new_mime ([string] + mime_type_synonyms).each { |str| LOOKUP[str] = SET.last } unless skip_lookup ([symbol] + extension_synonyms).each { |ext| EXTENSION_LOOKUP[ext.to_s] = SET.last } + + @register_callbacks.each do |callback| + callback.call(new_mime) + end end def parse(accept_header) if accept_header !~ /,/ accept_header = accept_header.split(PARAMETER_SEPARATOR_REGEXP).first - if accept_header =~ TRAILING_STAR_REGEXP - parse_data_with_trailing_star($1) - else - [Mime::Type.lookup(accept_header)] - end + parse_trailing_star(accept_header) || [Mime::Type.lookup(accept_header)] else - # keep track of creation order to keep the subsequent sort stable - list, index = [], 0 - accept_header.split(/,/).each do |header| + list, index = AcceptList.new, 0 + accept_header.split(',').each do |header| params, q = header.split(PARAMETER_SEPARATOR_REGEXP) if params.present? params.strip! - if params =~ TRAILING_STAR_REGEXP - parse_data_with_trailing_star($1).each do |m| - list << AcceptItem.new(index, m.to_s, q) - index += 1 - end - else - list << AcceptItem.new(index, params, q) - index += 1 - end - end - end - list.sort! - - # Take care of the broken text/xml entry by renaming or deleting it - text_xml = list.index("text/xml") - app_xml = list.index(Mime::XML.to_s) - - if text_xml && app_xml - # set the q value to the max of the two - list[app_xml].q = [list[text_xml].q, list[app_xml].q].max - - # make sure app_xml is ahead of text_xml in the list - if app_xml > text_xml - list[app_xml], list[text_xml] = list[text_xml], list[app_xml] - app_xml, text_xml = text_xml, app_xml - end - - # delete text_xml from the list - list.delete_at(text_xml) - - elsif text_xml - list[text_xml].name = Mime::XML.to_s - end + params = parse_trailing_star(params) || [params] - # Look for more specific XML-based types and sort them ahead of app/xml - - if app_xml - idx = app_xml - app_xml_type = list[app_xml] - - while(idx < list.length) - type = list[idx] - break if type.q < app_xml_type.q - if type.name =~ /\+xml$/ - list[app_xml], list[idx] = list[idx], list[app_xml] - app_xml = idx + params.each do |m| + list << AcceptItem.new(index, m.to_s, q) + index += 1 end - idx += 1 end end - - list.map! { |i| Mime::Type.lookup(i.name) }.uniq! - list + list.assort! end end - # input: 'text' - # returned value: [Mime::JSON, Mime::XML, Mime::ICS, Mime::HTML, Mime::CSS, Mime::CSV, Mime::JS, Mime::YAML, Mime::TEXT] + def parse_trailing_star(accept_header) + parse_data_with_trailing_star($1) if accept_header =~ TRAILING_STAR_REGEXP + end + + # For an input of <tt>'text'</tt>, returns <tt>[Mime::JSON, Mime::XML, Mime::ICS, + # Mime::HTML, Mime::CSS, Mime::CSV, Mime::JS, Mime::YAML, Mime::TEXT]</tt>. # - # input: 'application' - # returned value: [Mime::HTML, Mime::JS, Mime::XML, Mime::YAML, Mime::ATOM, Mime::JSON, Mime::RSS, Mime::URL_ENCODED_FORM] + # For an input of <tt>'application'</tt>, returns <tt>[Mime::HTML, Mime::JS, + # Mime::XML, Mime::YAML, Mime::ATOM, Mime::JSON, Mime::RSS, Mime::URL_ENCODED_FORM]</tt>. def parse_data_with_trailing_star(input) Mime::SET.select { |m| m =~ input } end @@ -192,9 +216,9 @@ module Mime # # Usage: # - # Mime::Type.unregister(:mobile) + # Mime::Type.unregister(:mobile) def unregister(symbol) - symbol = symbol.to_s.upcase + symbol = symbol.upcase mime = Mime.const_get(symbol) Mime.instance_eval { remove_const(symbol) } @@ -251,25 +275,36 @@ module Mime # Returns true if Action Pack should check requests using this Mime Type for possible request forgery. See # ActionController::RequestForgeryProtection. def verify_request? + ActiveSupport::Deprecation.warn "Mime::Type#verify_request? is deprecated and will be removed in Rails 4.1" @@browser_generated_types.include?(to_sym) end + def self.browser_generated_types + ActiveSupport::Deprecation.warn "Mime::Type.browser_generated_types is deprecated and will be removed in Rails 4.1" + @@browser_generated_types + end + def html? @@html_types.include?(to_sym) || @string =~ /html/ end - def respond_to?(method, include_private = false) #:nodoc: - super || method.to_s =~ /(\w+)\?$/ - end private - def method_missing(method, *args) - if method.to_s =~ /(\w+)\?$/ - $1.downcase.to_sym == to_sym - else - super - end + + def to_ary; end + def to_a; end + + def method_missing(method, *args) + if method.to_s.ends_with? '?' + method[0..-2].downcase.to_sym == to_sym + else + super end + end + + def respond_to_missing?(method, include_private = false) #:nodoc: + method.to_s.ends_with? '?' + end end end diff --git a/actionpack/lib/action_dispatch/http/parameter_filter.rb b/actionpack/lib/action_dispatch/http/parameter_filter.rb index 490b46c990..b655a54865 100644 --- a/actionpack/lib/action_dispatch/http/parameter_filter.rb +++ b/actionpack/lib/action_dispatch/http/parameter_filter.rb @@ -1,74 +1,72 @@ module ActionDispatch module Http class ParameterFilter + FILTERED = '[FILTERED]'.freeze # :nodoc: - def initialize(filters) + def initialize(filters = []) @filters = filters end def filter(params) - if enabled? - compiled_filter.call(params) - else - params.dup - end + compiled_filter.call(params) end private - def enabled? - @filters.present? + def compiled_filter + @compiled_filter ||= CompiledFilter.compile(@filters) end - FILTERED = '[FILTERED]'.freeze + class CompiledFilter # :nodoc: + def self.compile(filters) + return lambda { |params| params.dup } if filters.empty? - def compiled_filter - @compiled_filter ||= begin - regexps, blocks = compile_filter + strings, regexps, blocks = [], [], [] - lambda do |original_params| - filtered_params = {} + filters.each do |item| + case item + when Proc + blocks << item + when Regexp + regexps << item + else + strings << item.to_s + end + end - original_params.each do |key, value| - if regexps.find { |r| key =~ r } - value = FILTERED - elsif value.is_a?(Hash) - value = filter(value) - elsif value.is_a?(Array) - value = value.map { |v| v.is_a?(Hash) ? filter(v) : v } - elsif blocks.present? - key = key.dup - value = value.dup if value.duplicable? - blocks.each { |b| b.call(key, value) } - end + regexps << Regexp.new(strings.join('|'), true) unless strings.empty? + new regexps, blocks + end - filtered_params[key] = value - end + attr_reader :regexps, :blocks - filtered_params - end + def initialize(regexps, blocks) + @regexps = regexps + @blocks = blocks end - end - def compile_filter - strings, regexps, blocks = [], [], [] + def call(original_params) + filtered_params = {} + + original_params.each do |key, value| + if regexps.any? { |r| key =~ r } + value = FILTERED + elsif value.is_a?(Hash) + value = call(value) + elsif value.is_a?(Array) + value = value.map { |v| v.is_a?(Hash) ? call(v) : v } + elsif blocks.any? + key = key.dup + value = value.dup if value.duplicable? + blocks.each { |b| b.call(key, value) } + end - @filters.each do |item| - case item - when NilClass - when Proc - blocks << item - when Regexp - regexps << item - else - strings << item.to_s + filtered_params[key] = value end - end - regexps << Regexp.new(strings.join('|'), true) unless strings.empty? - [regexps, blocks] + filtered_params + end end - end end end diff --git a/actionpack/lib/action_dispatch/http/parameters.rb b/actionpack/lib/action_dispatch/http/parameters.rb index d9b63faf5e..6610315da7 100644 --- a/actionpack/lib/action_dispatch/http/parameters.rb +++ b/actionpack/lib/action_dispatch/http/parameters.rb @@ -4,10 +4,19 @@ require 'active_support/core_ext/hash/indifferent_access' module ActionDispatch module Http module Parameters + def initialize(env) + super + @symbolized_path_params = nil + end + # Returns both GET and POST \parameters in a single hash. def parameters @env["action_dispatch.request.parameters"] ||= begin - params = request_parameters.merge(query_parameters) + params = begin + request_parameters.merge(query_parameters) + rescue EOFError + query_parameters.dup + end params.merge!(path_parameters) encode_params(params).with_indifferent_access end @@ -35,6 +44,10 @@ module ActionDispatch @env["action_dispatch.request.path_parameters"] ||= {} end + def reset_parameters #:nodoc: + @env.delete("action_dispatch.request.parameters") + end + private # TODO: Validate that the characters are UTF-8. If they aren't, diff --git a/actionpack/lib/action_dispatch/http/request.rb b/actionpack/lib/action_dispatch/http/request.rb index 796e0dbc45..d60c8775af 100644 --- a/actionpack/lib/action_dispatch/http/request.rb +++ b/actionpack/lib/action_dispatch/http/request.rb @@ -17,6 +17,8 @@ module ActionDispatch include ActionDispatch::Http::Upload include ActionDispatch::Http::URL + autoload :Session, 'action_dispatch/request/session' + LOCALHOST = Regexp.union [/^127\.0\.0\.\d{1,3}$/, /^::1$/, /^0:0:0:0:0:0:0:1(%.*)?$/] ENV_METHODS = %w[ AUTH_TYPE GATEWAY_INTERFACE @@ -36,6 +38,17 @@ module ActionDispatch METHOD end + def initialize(env) + super + @method = nil + @request_method = nil + @remote_ip = nil + @original_fullpath = nil + @fullpath = nil + @ip = nil + @uuid = nil + end + def key?(key) @env.key?(key) end @@ -57,7 +70,13 @@ module ActionDispatch RFC5789 = %w(PATCH) HTTP_METHODS = RFC2616 + RFC2518 + RFC3253 + RFC3648 + RFC3744 + RFC5323 + RFC5789 - HTTP_METHOD_LOOKUP = Hash.new { |h, m| h[m] = m.underscore.to_sym if HTTP_METHODS.include?(m) } + + HTTP_METHOD_LOOKUP = {} + + # Populate the HTTP method lookup cache + HTTP_METHODS.each { |method| + HTTP_METHOD_LOOKUP[method] = method.underscore.to_sym + } # Returns the HTTP \method that the application should see. # In the case where the \method was overridden by a middleware @@ -117,9 +136,9 @@ module ActionDispatch end # Is this a HEAD request? - # Equivalent to <tt>request.method_symbol == :head</tt>. + # Equivalent to <tt>request.request_method_symbol == :head</tt>. def head? - HTTP_METHOD_LOOKUP[method] == :head + HTTP_METHOD_LOOKUP[request_method] == :head end # Provides access to the request's HTTP headers, for example: @@ -186,8 +205,9 @@ module ActionDispatch # work with raw requests directly. def raw_post unless @env.include? 'RAW_POST_DATA' - @env['RAW_POST_DATA'] = body.read(@env['CONTENT_LENGTH'].to_i) - body.rewind if body.respond_to?(:rewind) + raw_post_body = body + @env['RAW_POST_DATA'] = raw_post_body.read(@env['CONTENT_LENGTH'].to_i) + raw_post_body.rewind if raw_post_body.respond_to?(:rewind) end @env['RAW_POST_DATA'] end @@ -214,32 +234,38 @@ module ActionDispatch # TODO This should be broken apart into AD::Request::Session and probably # be included by the session middleware. def reset_session - session.destroy if session && session.respond_to?(:destroy) - self.session = {} + if session && session.respond_to?(:destroy) + session.destroy + else + self.session = {} + end @env['action_dispatch.request.flash_hash'] = nil end def session=(session) #:nodoc: - @env['rack.session'] = session + Session.set @env, session end def session_options=(options) - @env['rack.session.options'] = options + Session::Options.set @env, options end # Override Rack's GET method to support indifferent access def GET @env["action_dispatch.request.query_parameters"] ||= (normalize_parameters(super) || {}) + rescue TypeError => e + raise ActionController::BadRequest.new(:query, e) end alias :query_parameters :GET # Override Rack's POST method to support indifferent access def POST @env["action_dispatch.request.request_parameters"] ||= (normalize_parameters(super) || {}) + rescue TypeError => e + raise ActionController::BadRequest.new(:request, e) end alias :request_parameters :POST - # Returns the authorization header regardless of whether it was specified directly or through one of the # proxy alternatives. def authorization @@ -254,6 +280,27 @@ module ActionDispatch LOCALHOST =~ remote_addr && LOCALHOST =~ remote_ip end + protected + + # Remove nils from the params hash + def deep_munge(hash) + hash.each_value do |v| + case v + when Array + v.grep(Hash) { |x| deep_munge(x) } + v.compact! + when Hash + deep_munge(v) + end + end + + hash + end + + def parse_query(qs) + deep_munge(super) + end + private def check_method(name) diff --git a/actionpack/lib/action_dispatch/http/response.rb b/actionpack/lib/action_dispatch/http/response.rb index 84732085f0..0f808ac9cf 100644 --- a/actionpack/lib/action_dispatch/http/response.rb +++ b/actionpack/lib/action_dispatch/http/response.rb @@ -1,7 +1,6 @@ require 'digest/md5' -require 'active_support/core_ext/module/delegation' -require 'active_support/core_ext/object/blank' require 'active_support/core_ext/class/attribute_accessors' +require 'monitor' module ActionDispatch # :nodoc: # Represents an HTTP response generated by a controller action. Use it to @@ -29,7 +28,7 @@ module ActionDispatch # :nodoc: # class DemoControllerTest < ActionDispatch::IntegrationTest # def test_print_root_path_to_console # get('/') - # puts @response.body + # puts response.body # end # end class Response @@ -41,7 +40,7 @@ module ActionDispatch # :nodoc: alias_method :headers, :header delegate :[], :[]=, :to => :@header - delegate :each, :to => :@body + delegate :each, :to => :@stream # Sets the HTTP response's content MIME type. For example, in the controller # you could write this: @@ -51,22 +50,64 @@ module ActionDispatch # :nodoc: # If a character set has been defined for this response (see charset=) then # the character set information will also be included in the content type # information. - attr_accessor :charset, :content_type + attr_accessor :charset + attr_reader :content_type CONTENT_TYPE = "Content-Type".freeze SET_COOKIE = "Set-Cookie".freeze LOCATION = "Location".freeze - + cattr_accessor(:default_charset) { "utf-8" } + cattr_accessor(:default_headers) include Rack::Response::Helpers + include ActionDispatch::Http::FilterRedirect include ActionDispatch::Http::Cache::Response + include MonitorMixin + + class Buffer # :nodoc: + def initialize(response, buf) + @response = response + @buf = buf + @closed = false + end + + def write(string) + raise IOError, "closed stream" if closed? + + @response.commit! + @buf.push string + end + + def each(&block) + @buf.each(&block) + end + + def close + @response.commit! + @closed = true + end + + def closed? + @closed + end + end + + attr_reader :stream def initialize(status = 200, header = {}, body = []) + super() + + header = merge_default_headers(header, self.class.default_headers) + self.body, self.header, self.status = body, header, status @sending_file = false - @blank = false + @blank = false + @cv = new_cond + @committed = false + @content_type = nil + @charset = nil if content_type = self[CONTENT_TYPE] type, charset = content_type.split(/;\s*charset=/) @@ -79,10 +120,31 @@ module ActionDispatch # :nodoc: yield self if block_given? end + def await_commit + synchronize do + @cv.wait_until { @committed } + end + end + + def commit! + synchronize do + @committed = true + @cv.broadcast + end + end + + def committed? + @committed + end + def status=(status) @status = Rack::Utils.status_code(status) end + def content_type=(content_type) + @content_type = content_type.to_s + end + # The response code of the request def response_code @status @@ -100,14 +162,14 @@ module ActionDispatch # :nodoc: def respond_to?(method) if method.to_sym == :to_path - @body.respond_to?(:to_path) + stream.respond_to?(:to_path) else super end end def to_path - @body.to_path + stream.to_path end def body @@ -121,11 +183,17 @@ module ActionDispatch # :nodoc: def body=(body) @blank = true if body == EMPTY - @body = body.respond_to?(:each) ? body : [body] + if body.respond_to?(:to_path) + @stream = body + else + @stream = build_buffer self, munge_body_object(body) + end end def body_parts - @body + parts = [] + @stream.each { |x| parts << x } + parts end def set_cookie(key, value) @@ -146,21 +214,11 @@ module ActionDispatch # :nodoc: end def close - @body.close if @body.respond_to?(:close) + stream.close if stream.respond_to?(:close) end def to_a - assign_default_content_type_and_charset! - handle_conditional_get! - - @header[SET_COOKIE] = @header[SET_COOKIE].join("\n") if @header[SET_COOKIE].respond_to?(:join) - - if [204, 304].include?(@status) - @header.delete CONTENT_TYPE - [@status, @header, []] - else - [@status, @header, self] - end + rack_response @status, @header.to_hash end alias prepare! to_a alias to_ary to_a # For implicit splat on 1.9.2 @@ -184,7 +242,21 @@ module ActionDispatch # :nodoc: private - def assign_default_content_type_and_charset! + def merge_default_headers(original, default) + return original unless default.respond_to?(:merge) + + default.merge(original) + end + + def build_buffer(response, body) + Buffer.new response, body + end + + def munge_body_object(body) + body.respond_to?(:each) ? body : [body] + end + + def assign_default_content_type_and_charset!(headers) return if headers[CONTENT_TYPE].present? @content_type ||= Mime::HTML @@ -195,5 +267,19 @@ module ActionDispatch # :nodoc: headers[CONTENT_TYPE] = type end + + def rack_response(status, header) + assign_default_content_type_and_charset!(header) + handle_conditional_get! + + header[SET_COOKIE] = header[SET_COOKIE].join("\n") if header[SET_COOKIE].respond_to?(:join) + + if [204, 304].include?(@status) + header.delete CONTENT_TYPE + [status, header, []] + else + [status, header, self] + end + end end end diff --git a/actionpack/lib/action_dispatch/http/upload.rb b/actionpack/lib/action_dispatch/http/upload.rb index c4a915d1ad..79437d6e85 100644 --- a/actionpack/lib/action_dispatch/http/upload.rb +++ b/actionpack/lib/action_dispatch/http/upload.rb @@ -1,9 +1,28 @@ module ActionDispatch module Http + # Models uploaded files. + # + # The actual file is accessible via the +tempfile+ accessor, though some + # of its interface is available directly for convenience. + # + # Uploaded files are temporary files whose lifespan is one request. When + # the object is finalized Ruby unlinks the file, so there is not need to + # clean them with a separate maintenance task. class UploadedFile - attr_accessor :original_filename, :content_type, :tempfile, :headers + # The basename of the file in the client. + attr_accessor :original_filename - def initialize(hash) + # A string with the MIME type of the file. + attr_accessor :content_type + + # A +Tempfile+ object with the actual uploaded file. Note that some of + # its interface is available directly. + attr_accessor :tempfile + + # TODO. + attr_accessor :headers + + def initialize(hash) # :nodoc: @tempfile = hash[:tempfile] raise(ArgumentError, ':tempfile is required') unless @tempfile @@ -12,13 +31,39 @@ module ActionDispatch @headers = hash[:head] end - def read(*args) - @tempfile.read(*args) + # Shortcut for +tempfile.read+. + def read(length=nil, buffer=nil) + @tempfile.read(length, buffer) + end + + # Shortcut for +tempfile.open+. + def open + @tempfile.open + end + + # Shortcut for +tempfile.close+. + def close(unlink_now=false) + @tempfile.close(unlink_now) + end + + # Shortcut for +tempfile.path+. + def path + @tempfile.path + end + + # Shortcut for +tempfile.rewind+. + def rewind + @tempfile.rewind + end + + # Shortcut for +tempfile.size+. + def size + @tempfile.size end - # Delegate these methods to the tempfile. - [:open, :path, :rewind, :size].each do |method| - class_eval "def #{method}; @tempfile.#{method}; end" + # Shortcut for +tempfile.eof?+. + def eof? + @tempfile.eof? end private @@ -29,7 +74,7 @@ module ActionDispatch end end - module Upload + module Upload # :nodoc: # Convert nested Hash to HashWithIndifferentAccess and replace # file upload hash with UploadedFile objects def normalize_parameters(value) diff --git a/actionpack/lib/action_dispatch/http/url.rb b/actionpack/lib/action_dispatch/http/url.rb index f9dae5dad7..bced7d84c0 100644 --- a/actionpack/lib/action_dispatch/http/url.rb +++ b/actionpack/lib/action_dispatch/http/url.rb @@ -8,14 +8,16 @@ module ActionDispatch class << self def extract_domain(host, tld_length = @@tld_length) - return nil unless named_host?(host) - host.split('.').last(1 + tld_length).join('.') + host.split('.').last(1 + tld_length).join('.') if named_host?(host) end def extract_subdomains(host, tld_length = @@tld_length) - return [] unless named_host?(host) - parts = host.split('.') - parts[0..-(tld_length+2)] + if named_host?(host) + parts = host.split('.') + parts[0..-(tld_length + 2)] + else + [] + end end def extract_subdomain(host, tld_length = @@tld_length) @@ -23,38 +25,41 @@ module ActionDispatch end def url_for(options = {}) + path = options.delete(:script_name).to_s.chomp("/") + path << options.delete(:path).to_s + + params = options[:params].is_a?(Hash) ? options[:params] : options.slice(:params) + params.reject! { |_,v| v.to_param.nil? } + + result = build_host_url(options) + result << (options[:trailing_slash] ? path.sub(/\?|\z/) { "/" + $& } : path) + result << "?#{params.to_query}" unless params.empty? + result << "##{Journey::Router::Utils.escape_fragment(options[:anchor].to_param.to_s)}" if options[:anchor] + result + end + + private + + def build_host_url(options) if options[:host].blank? && options[:only_path].blank? raise ArgumentError, 'Missing host to link to! Please provide the :host parameter, set default_url_options[:host], or set :only_path to true' end - rewritten_url = "" + result = "" unless options[:only_path] unless options[:protocol] == false - rewritten_url << (options[:protocol] || "http") - rewritten_url << ":" unless rewritten_url.match(%r{:|//}) + result << (options[:protocol] || "http") + result << ":" unless result.match(%r{:|//}) end - rewritten_url << "//" unless rewritten_url.match("//") - rewritten_url << rewrite_authentication(options) - rewritten_url << host_or_subdomain_and_domain(options) - rewritten_url << ":#{options.delete(:port)}" if options[:port] + result << "//" unless result.match("//") + result << rewrite_authentication(options) + result << host_or_subdomain_and_domain(options) + result << ":#{options.delete(:port)}" if options[:port] end - - path = "" - path << options.delete(:script_name).to_s.chomp("/") - path << options.delete(:path).to_s - - params = options[:params] || {} - params.reject! {|k,v| v.to_param.nil? } - - rewritten_url << (options[:trailing_slash] ? path.sub(/\?|\z/) { "/" + $& } : path) - rewritten_url << "?#{params.to_query}" unless params.empty? - rewritten_url << "##{Journey::Router::Utils.escape_fragment(options[:anchor].to_param.to_s)}" if options[:anchor] - rewritten_url + result end - private - def named_host?(host) host && IP_HOST_REGEXP !~ host end @@ -82,6 +87,12 @@ module ActionDispatch end end + def initialize(env) + super + @protocol = nil + @port = nil + end + # Returns the complete URL used for this request. def url protocol + host_with_port + fullpath diff --git a/actionpack/lib/action_dispatch/journey.rb b/actionpack/lib/action_dispatch/journey.rb new file mode 100644 index 0000000000..ad42713482 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey.rb @@ -0,0 +1,5 @@ +require 'action_dispatch/journey/router' +require 'action_dispatch/journey/gtg/builder' +require 'action_dispatch/journey/gtg/simulator' +require 'action_dispatch/journey/nfa/builder' +require 'action_dispatch/journey/nfa/simulator' diff --git a/actionpack/lib/action_dispatch/journey/backwards.rb b/actionpack/lib/action_dispatch/journey/backwards.rb new file mode 100644 index 0000000000..3bd20fdf81 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/backwards.rb @@ -0,0 +1,5 @@ +module Rack # :nodoc: + Mount = ActionDispatch::Journey::Router + Mount::RouteSet = ActionDispatch::Journey::Router + Mount::RegexpWithNamedGroups = ActionDispatch::Journey::Path::Pattern +end diff --git a/actionpack/lib/action_dispatch/journey/formatter.rb b/actionpack/lib/action_dispatch/journey/formatter.rb new file mode 100644 index 0000000000..4a344f71af --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/formatter.rb @@ -0,0 +1,144 @@ +module ActionDispatch + module Journey + # The Formatter class is used for formatting URLs. For example, parameters + # passed to +url_for+ in rails will eventually call Formatter#generate. + class Formatter # :nodoc: + attr_reader :routes + + def initialize(routes) + @routes = routes + @cache = nil + end + + def generate(type, name, options, recall = {}, parameterize = nil) + constraints = recall.merge(options) + missing_keys = [] + + match_route(name, constraints) do |route| + parameterized_parts = extract_parameterized_parts(route, options, recall, parameterize) + next if !name && route.requirements.empty? && route.parts.empty? + + missing_keys = missing_keys(route, parameterized_parts) + next unless missing_keys.empty? + params = options.dup.delete_if do |key, _| + parameterized_parts.key?(key) || route.defaults.key?(key) + end + + return [route.format(parameterized_parts), params] + end + + raise Router::RoutingError.new "missing required keys: #{missing_keys}" + end + + def clear + @cache = nil + end + + private + + def extract_parameterized_parts(route, options, recall, parameterize = nil) + constraints = recall.merge(options) + data = constraints.dup + + keys_to_keep = route.parts.reverse.drop_while { |part| + !options.key?(part) || (options[part] || recall[part]).nil? + } | route.required_parts + + (data.keys - keys_to_keep).each do |bad_key| + data.delete(bad_key) + end + + parameterized_parts = data.dup + + if parameterize + parameterized_parts.each do |k, v| + parameterized_parts[k] = parameterize.call(k, v) + end + end + + parameterized_parts.keep_if { |_, v| v } + parameterized_parts + end + + def named_routes + routes.named_routes + end + + def match_route(name, options) + if named_routes.key?(name) + yield named_routes[name] + else + routes = non_recursive(cache, options.to_a) + + hash = routes.group_by { |_, r| r.score(options) } + + hash.keys.sort.reverse_each do |score| + next if score < 0 + + hash[score].sort_by { |i, _| i }.each do |_, route| + yield route + end + end + end + end + + def non_recursive(cache, options) + routes = [] + stack = [cache] + + while stack.any? + c = stack.shift + routes.concat(c[:___routes]) if c.key?(:___routes) + + options.each do |pair| + stack << c[pair] if c.key?(pair) + end + end + + routes + end + + # Returns an array populated with missing keys if any are present. + def missing_keys(route, parts) + missing_keys = [] + tests = route.path.requirements + route.required_parts.each { |key| + if tests.key?(key) + missing_keys << key unless /\A#{tests[key]}\Z/ === parts[key] + else + missing_keys << key unless parts[key] + end + } + missing_keys + end + + def possibles(cache, options, depth = 0) + cache.fetch(:___routes) { [] } + options.find_all { |pair| + cache.key?(pair) + }.map { |pair| + possibles(cache[pair], options, depth + 1) + }.flatten(1) + end + + # Returns +true+ if no missing keys are present, otherwise +false+. + def verify_required_parts!(route, parts) + missing_keys(route, parts).empty? + end + + def build_cache + root = { ___routes: [] } + routes.each_with_index do |route, i| + leaf = route.required_defaults.inject(root) do |h, tuple| + h[tuple] ||= {} + end + (leaf[:___routes] ||= []) << [i, route] + end + root + end + + def cache + @cache ||= build_cache + end + end + end +end diff --git a/actionpack/lib/action_dispatch/journey/gtg/builder.rb b/actionpack/lib/action_dispatch/journey/gtg/builder.rb new file mode 100644 index 0000000000..7d2791714b --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/gtg/builder.rb @@ -0,0 +1,162 @@ +require 'action_dispatch/journey/gtg/transition_table' + +module ActionDispatch + module Journey # :nodoc: + module GTG # :nodoc: + class Builder # :nodoc: + DUMMY = Nodes::Dummy.new + + attr_reader :root, :ast, :endpoints + + def initialize(root) + @root = root + @ast = Nodes::Cat.new root, DUMMY + @followpos = nil + end + + def transition_table + dtrans = TransitionTable.new + marked = {} + state_id = Hash.new { |h,k| h[k] = h.length } + + start = firstpos(root) + dstates = [start] + until dstates.empty? + s = dstates.shift + next if marked[s] + marked[s] = true # mark s + + s.group_by { |state| symbol(state) }.each do |sym, ps| + u = ps.map { |l| followpos(l) }.flatten + next if u.empty? + + if u.uniq == [DUMMY] + from = state_id[s] + to = state_id[Object.new] + dtrans[from, to] = sym + + dtrans.add_accepting(to) + ps.each { |state| dtrans.add_memo(to, state.memo) } + else + dtrans[state_id[s], state_id[u]] = sym + + if u.include?(DUMMY) + to = state_id[u] + + accepting = ps.find_all { |l| followpos(l).include?(DUMMY) } + + accepting.each { |accepting_state| + dtrans.add_memo(to, accepting_state.memo) + } + + dtrans.add_accepting(state_id[u]) + end + end + + dstates << u + end + end + + dtrans + end + + def nullable?(node) + case node + when Nodes::Group + true + when Nodes::Star + true + when Nodes::Or + node.children.any? { |c| nullable?(c) } + when Nodes::Cat + nullable?(node.left) && nullable?(node.right) + when Nodes::Terminal + !node.left + when Nodes::Unary + nullable?(node.left) + else + raise ArgumentError, 'unknown nullable: %s' % node.class.name + end + end + + def firstpos(node) + case node + when Nodes::Star + firstpos(node.left) + when Nodes::Cat + if nullable?(node.left) + firstpos(node.left) | firstpos(node.right) + else + firstpos(node.left) + end + when Nodes::Or + node.children.map { |c| firstpos(c) }.flatten.uniq + when Nodes::Unary + firstpos(node.left) + when Nodes::Terminal + nullable?(node) ? [] : [node] + else + raise ArgumentError, 'unknown firstpos: %s' % node.class.name + end + end + + def lastpos(node) + case node + when Nodes::Star + firstpos(node.left) + when Nodes::Or + node.children.map { |c| lastpos(c) }.flatten.uniq + when Nodes::Cat + if nullable?(node.right) + lastpos(node.left) | lastpos(node.right) + else + lastpos(node.right) + end + when Nodes::Terminal + nullable?(node) ? [] : [node] + when Nodes::Unary + lastpos(node.left) + else + raise ArgumentError, 'unknown lastpos: %s' % node.class.name + end + end + + def followpos(node) + followpos_table[node] + end + + private + + def followpos_table + @followpos ||= build_followpos + end + + def build_followpos + table = Hash.new { |h, k| h[k] = [] } + @ast.each do |n| + case n + when Nodes::Cat + lastpos(n.left).each do |i| + table[i] += firstpos(n.right) + end + when Nodes::Star + lastpos(n).each do |i| + table[i] += firstpos(n) + end + end + end + table + end + + def symbol(edge) + case edge + when Journey::Nodes::Symbol + edge.regexp + else + edge.left + end + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/journey/gtg/simulator.rb b/actionpack/lib/action_dispatch/journey/gtg/simulator.rb new file mode 100644 index 0000000000..58ad803841 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/gtg/simulator.rb @@ -0,0 +1,44 @@ +require 'strscan' + +module ActionDispatch + module Journey # :nodoc: + module GTG # :nodoc: + class MatchData # :nodoc: + attr_reader :memos + + def initialize(memos) + @memos = memos + end + end + + class Simulator # :nodoc: + attr_reader :tt + + def initialize(transition_table) + @tt = transition_table + end + + def simulate(string) + input = StringScanner.new(string) + state = [0] + while sym = input.scan(%r([/.?]|[^/.?]+)) + state = tt.move(state, sym) + end + + acceptance_states = state.find_all { |s| + tt.accepting? s + } + + return if acceptance_states.empty? + + memos = acceptance_states.map { |x| tt.memo(x) }.flatten.compact + + MatchData.new(memos) + end + + alias :=~ :simulate + alias :match :simulate + end + end + end +end diff --git a/actionpack/lib/action_dispatch/journey/gtg/transition_table.rb b/actionpack/lib/action_dispatch/journey/gtg/transition_table.rb new file mode 100644 index 0000000000..da0cddd93c --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/gtg/transition_table.rb @@ -0,0 +1,156 @@ +require 'action_dispatch/journey/nfa/dot' + +module ActionDispatch + module Journey # :nodoc: + module GTG # :nodoc: + class TransitionTable # :nodoc: + include Journey::NFA::Dot + + attr_reader :memos + + def initialize + @regexp_states = Hash.new { |h,k| h[k] = {} } + @string_states = Hash.new { |h,k| h[k] = {} } + @accepting = {} + @memos = Hash.new { |h,k| h[k] = [] } + end + + def add_accepting(state) + @accepting[state] = true + end + + def accepting_states + @accepting.keys + end + + def accepting?(state) + @accepting[state] + end + + def add_memo(idx, memo) + @memos[idx] << memo + end + + def memo(idx) + @memos[idx] + end + + def eclosure(t) + Array(t) + end + + def move(t, a) + move_string(t, a).concat(move_regexp(t, a)) + end + + def to_json + require 'json' + + simple_regexp = Hash.new { |h,k| h[k] = {} } + + @regexp_states.each do |from, hash| + hash.each do |re, to| + simple_regexp[from][re.source] = to + end + end + + JSON.dump({ + regexp_states: simple_regexp, + string_states: @string_states, + accepting: @accepting + }) + end + + def to_svg + svg = IO.popen('dot -Tsvg', 'w+') { |f| + f.write(to_dot) + f.close_write + f.readlines + } + 3.times { svg.shift } + svg.join.sub(/width="[^"]*"/, '').sub(/height="[^"]*"/, '') + end + + def visualizer(paths, title = 'FSM') + viz_dir = File.join File.dirname(__FILE__), '..', 'visualizer' + fsm_js = File.read File.join(viz_dir, 'fsm.js') + fsm_css = File.read File.join(viz_dir, 'fsm.css') + erb = File.read File.join(viz_dir, 'index.html.erb') + states = "function tt() { return #{to_json}; }" + + fun_routes = paths.shuffle.first(3).map do |ast| + ast.map { |n| + case n + when Nodes::Symbol + case n.left + when ':id' then rand(100).to_s + when ':format' then %w{ xml json }.shuffle.first + else + 'omg' + end + when Nodes::Terminal then n.symbol + else + nil + end + }.compact.join + end + + stylesheets = [fsm_css] + svg = to_svg + javascripts = [states, fsm_js] + + # Annoying hack for 1.9 warnings + fun_routes = fun_routes + stylesheets = stylesheets + svg = svg + javascripts = javascripts + + require 'erb' + template = ERB.new erb + template.result(binding) + end + + def []=(from, to, sym) + case sym + when String + @string_states[from][sym] = to + when Regexp + @regexp_states[from][sym] = to + else + raise ArgumentError, 'unknown symbol: %s' % sym.class + end + end + + def states + ss = @string_states.keys + @string_states.values.map(&:values).flatten + rs = @regexp_states.keys + @regexp_states.values.map(&:values).flatten + (ss + rs).uniq + end + + def transitions + @string_states.map { |from, hash| + hash.map { |s, to| [from, s, to] } + }.flatten(1) + @regexp_states.map { |from, hash| + hash.map { |s, to| [from, s, to] } + }.flatten(1) + end + + private + + def move_regexp(t, a) + return [] if t.empty? + + t.map { |s| + @regexp_states[s].map { |re, v| re === a ? v : nil } + }.flatten.compact.uniq + end + + def move_string(t, a) + return [] if t.empty? + + t.map { |s| @string_states[s][a] }.compact + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/journey/nfa/builder.rb b/actionpack/lib/action_dispatch/journey/nfa/builder.rb new file mode 100644 index 0000000000..ee6494c3e4 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/nfa/builder.rb @@ -0,0 +1,76 @@ +require 'action_dispatch/journey/nfa/transition_table' +require 'action_dispatch/journey/gtg/transition_table' + +module ActionDispatch + module Journey # :nodoc: + module NFA # :nodoc: + class Visitor < Visitors::Visitor # :nodoc: + def initialize(tt) + @tt = tt + @i = -1 + end + + def visit_CAT(node) + left = visit(node.left) + right = visit(node.right) + + @tt.merge(left.last, right.first) + + [left.first, right.last] + end + + def visit_GROUP(node) + from = @i += 1 + left = visit(node.left) + to = @i += 1 + + @tt.accepting = to + + @tt[from, left.first] = nil + @tt[left.last, to] = nil + @tt[from, to] = nil + + [from, to] + end + + def visit_OR(node) + from = @i += 1 + children = node.children.map { |c| visit(c) } + to = @i += 1 + + children.each do |child| + @tt[from, child.first] = nil + @tt[child.last, to] = nil + end + + @tt.accepting = to + + [from, to] + end + + def terminal(node) + from_i = @i += 1 # new state + to_i = @i += 1 # new state + + @tt[from_i, to_i] = node + @tt.accepting = to_i + @tt.add_memo(to_i, node.memo) + + [from_i, to_i] + end + end + + class Builder # :nodoc: + def initialize(ast) + @ast = ast + end + + def transition_table + tt = TransitionTable.new + Visitor.new(tt).accept(@ast) + tt + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/journey/nfa/dot.rb b/actionpack/lib/action_dispatch/journey/nfa/dot.rb new file mode 100644 index 0000000000..5c33a872e5 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/nfa/dot.rb @@ -0,0 +1,36 @@ +# encoding: utf-8 + +module ActionDispatch + module Journey # :nodoc: + module NFA # :nodoc: + module Dot # :nodoc: + def to_dot + edges = transitions.map { |from, sym, to| + " #{from} -> #{to} [label=\"#{sym || 'ε'}\"];" + } + + #memo_nodes = memos.values.flatten.map { |n| + # label = n + # if Journey::Route === n + # label = "#{n.verb.source} #{n.path.spec}" + # end + # " #{n.object_id} [label=\"#{label}\", shape=box];" + #} + #memo_edges = memos.map { |k, memos| + # (memos || []).map { |v| " #{k} -> #{v.object_id};" } + #}.flatten.uniq + + <<-eodot +digraph nfa { + rankdir=LR; + node [shape = doublecircle]; + #{accepting_states.join ' '}; + node [shape = circle]; +#{edges.join "\n"} +} + eodot + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/journey/nfa/simulator.rb b/actionpack/lib/action_dispatch/journey/nfa/simulator.rb new file mode 100644 index 0000000000..5b40da6569 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/nfa/simulator.rb @@ -0,0 +1,47 @@ +require 'strscan' + +module ActionDispatch + module Journey # :nodoc: + module NFA # :nodoc: + class MatchData # :nodoc: + attr_reader :memos + + def initialize(memos) + @memos = memos + end + end + + class Simulator # :nodoc: + attr_reader :tt + + def initialize(transition_table) + @tt = transition_table + end + + def simulate(string) + input = StringScanner.new(string) + state = tt.eclosure(0) + until input.eos? + sym = input.scan(%r([/.?]|[^/.?]+)) + + # FIXME: tt.eclosure is not needed for the GTG + state = tt.eclosure(tt.move(state, sym)) + end + + acceptance_states = state.find_all { |s| + tt.accepting?(tt.eclosure(s).sort.last) + } + + return if acceptance_states.empty? + + memos = acceptance_states.map { |x| tt.memo(x) }.flatten.compact + + MatchData.new(memos) + end + + alias :=~ :simulate + alias :match :simulate + end + end + end +end diff --git a/actionpack/lib/action_dispatch/journey/nfa/transition_table.rb b/actionpack/lib/action_dispatch/journey/nfa/transition_table.rb new file mode 100644 index 0000000000..4446a911b2 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/nfa/transition_table.rb @@ -0,0 +1,163 @@ +require 'action_dispatch/journey/nfa/dot' + +module ActionDispatch + module Journey # :nodoc: + module NFA # :nodoc: + class TransitionTable # :nodoc: + include Journey::NFA::Dot + + attr_accessor :accepting + attr_reader :memos + + def initialize + @table = Hash.new { |h,f| h[f] = {} } + @memos = {} + @accepting = nil + @inverted = nil + end + + def accepting?(state) + accepting == state + end + + def accepting_states + [accepting] + end + + def add_memo(idx, memo) + @memos[idx] = memo + end + + def memo(idx) + @memos[idx] + end + + def []=(i, f, s) + @table[f][i] = s + end + + def merge(left, right) + @memos[right] = @memos.delete(left) + @table[right] = @table.delete(left) + end + + def states + (@table.keys + @table.values.map(&:keys).flatten).uniq + end + + # Returns a generalized transition graph with reduced states. The states + # are reduced like a DFA, but the table must be simulated like an NFA. + # + # Edges of the GTG are regular expressions. + def generalized_table + gt = GTG::TransitionTable.new + marked = {} + state_id = Hash.new { |h,k| h[k] = h.length } + alphabet = self.alphabet + + stack = [eclosure(0)] + + until stack.empty? + state = stack.pop + next if marked[state] || state.empty? + + marked[state] = true + + alphabet.each do |alpha| + next_state = eclosure(following_states(state, alpha)) + next if next_state.empty? + + gt[state_id[state], state_id[next_state]] = alpha + stack << next_state + end + end + + final_groups = state_id.keys.find_all { |s| + s.sort.last == accepting + } + + final_groups.each do |states| + id = state_id[states] + + gt.add_accepting(id) + save = states.find { |s| + @memos.key?(s) && eclosure(s).sort.last == accepting + } + + gt.add_memo(id, memo(save)) + end + + gt + end + + # Returns set of NFA states to which there is a transition on ast symbol + # +a+ from some state +s+ in +t+. + def following_states(t, a) + Array(t).map { |s| inverted[s][a] }.flatten.uniq + end + + # Returns set of NFA states to which there is a transition on ast symbol + # +a+ from some state +s+ in +t+. + def move(t, a) + Array(t).map { |s| + inverted[s].keys.compact.find_all { |sym| + sym === a + }.map { |sym| inverted[s][sym] } + }.flatten.uniq + end + + def alphabet + inverted.values.map(&:keys).flatten.compact.uniq.sort_by { |x| x.to_s } + end + + # Returns a set of NFA states reachable from some NFA state +s+ in set + # +t+ on nil-transitions alone. + def eclosure(t) + stack = Array(t) + seen = {} + children = [] + + until stack.empty? + s = stack.pop + next if seen[s] + + seen[s] = true + children << s + + stack.concat(inverted[s][nil]) + end + + children.uniq + end + + def transitions + @table.map { |to, hash| + hash.map { |from, sym| [from, sym, to] } + }.flatten(1) + end + + private + + def inverted + return @inverted if @inverted + + @inverted = Hash.new { |h, from| + h[from] = Hash.new { |j, s| j[s] = [] } + } + + @table.each { |to, hash| + hash.each { |from, sym| + if sym + sym = Nodes::Symbol === sym ? sym.regexp : sym.left + end + + @inverted[from][sym] << to + } + } + + @inverted + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/journey/nodes/node.rb b/actionpack/lib/action_dispatch/journey/nodes/node.rb new file mode 100644 index 0000000000..935442ef66 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/nodes/node.rb @@ -0,0 +1,124 @@ +require 'action_dispatch/journey/visitors' + +module ActionDispatch + module Journey # :nodoc: + module Nodes # :nodoc: + class Node # :nodoc: + include Enumerable + + attr_accessor :left, :memo + + def initialize(left) + @left = left + @memo = nil + end + + def each(&block) + Visitors::Each.new(block).accept(self) + end + + def to_s + Visitors::String.new.accept(self) + end + + def to_dot + Visitors::Dot.new.accept(self) + end + + def to_sym + name.to_sym + end + + def name + left.tr '*:', '' + end + + def type + raise NotImplementedError + end + + def symbol?; false; end + def literal?; false; end + end + + class Terminal < Node # :nodoc: + alias :symbol :left + end + + class Literal < Terminal # :nodoc: + def literal?; true; end + def type; :LITERAL; end + end + + class Dummy < Literal # :nodoc: + def initialize(x = Object.new) + super + end + + def literal?; false; end + end + + %w{ Symbol Slash Dot }.each do |t| + class_eval <<-eoruby, __FILE__, __LINE__ + 1 + class #{t} < Terminal; + def type; :#{t.upcase}; end + end + eoruby + end + + class Symbol < Terminal # :nodoc: + attr_accessor :regexp + alias :symbol :regexp + + DEFAULT_EXP = /[^\.\/\?]+/ + def initialize(left) + super + @regexp = DEFAULT_EXP + end + + def default_regexp? + regexp == DEFAULT_EXP + end + + def symbol?; true; end + end + + class Unary < Node # :nodoc: + def children; [left] end + end + + class Group < Unary # :nodoc: + def type; :GROUP; end + end + + class Star < Unary # :nodoc: + def type; :STAR; end + end + + class Binary < Node # :nodoc: + attr_accessor :right + + def initialize(left, right) + super(left) + @right = right + end + + def children; [left, right] end + end + + class Cat < Binary # :nodoc: + def type; :CAT; end + end + + class Or < Node # :nodoc: + attr_reader :children + + def initialize(children) + @children = children + end + + def type; :OR; end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/journey/parser.rb b/actionpack/lib/action_dispatch/journey/parser.rb new file mode 100644 index 0000000000..bb4cbb00e2 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/parser.rb @@ -0,0 +1,206 @@ +# +# DO NOT MODIFY!!!! +# This file is automatically generated by Racc 1.4.9 +# from Racc grammer file "". +# + +require 'racc/parser.rb' + + +require 'action_dispatch/journey/parser_extras' +module ActionDispatch + module Journey # :nodoc: + class Parser < Racc::Parser # :nodoc: +##### State transition tables begin ### + +racc_action_table = [ + 17, 21, 13, 15, 14, 7, nil, 16, 8, 19, + 13, 15, 14, 7, 23, 16, 8, 19, 13, 15, + 14, 7, nil, 16, 8, 13, 15, 14, 7, nil, + 16, 8, 13, 15, 14, 7, nil, 16, 8 ] + +racc_action_check = [ + 1, 17, 1, 1, 1, 1, nil, 1, 1, 1, + 20, 20, 20, 20, 20, 20, 20, 20, 7, 7, + 7, 7, nil, 7, 7, 19, 19, 19, 19, nil, + 19, 19, 0, 0, 0, 0, nil, 0, 0 ] + +racc_action_pointer = [ + 30, 0, nil, nil, nil, nil, nil, 16, nil, nil, + nil, nil, nil, nil, nil, nil, nil, 1, nil, 23, + 8, nil, nil, nil ] + +racc_action_default = [ + -18, -18, -2, -3, -4, -5, -6, -18, -9, -10, + -11, -12, -13, -14, -15, -16, -17, -18, -1, -18, + -18, 24, -8, -7 ] + +racc_goto_table = [ + 18, 1, nil, nil, nil, nil, nil, nil, 20, nil, + nil, nil, nil, nil, nil, nil, nil, nil, 22, 18 ] + +racc_goto_check = [ + 2, 1, nil, nil, nil, nil, nil, nil, 1, nil, + nil, nil, nil, nil, nil, nil, nil, nil, 2, 2 ] + +racc_goto_pointer = [ + nil, 1, -1, nil, nil, nil, nil, nil, nil, nil, + nil ] + +racc_goto_default = [ + nil, nil, 2, 3, 4, 5, 6, 9, 10, 11, + 12 ] + +racc_reduce_table = [ + 0, 0, :racc_error, + 2, 11, :_reduce_1, + 1, 11, :_reduce_2, + 1, 11, :_reduce_none, + 1, 12, :_reduce_none, + 1, 12, :_reduce_none, + 1, 12, :_reduce_none, + 3, 15, :_reduce_7, + 3, 13, :_reduce_8, + 1, 16, :_reduce_9, + 1, 14, :_reduce_none, + 1, 14, :_reduce_none, + 1, 14, :_reduce_none, + 1, 14, :_reduce_none, + 1, 19, :_reduce_14, + 1, 17, :_reduce_15, + 1, 18, :_reduce_16, + 1, 20, :_reduce_17 ] + +racc_reduce_n = 18 + +racc_shift_n = 24 + +racc_token_table = { + false => 0, + :error => 1, + :SLASH => 2, + :LITERAL => 3, + :SYMBOL => 4, + :LPAREN => 5, + :RPAREN => 6, + :DOT => 7, + :STAR => 8, + :OR => 9 } + +racc_nt_base = 10 + +racc_use_result_var = true + +Racc_arg = [ + racc_action_table, + racc_action_check, + racc_action_default, + racc_action_pointer, + racc_goto_table, + racc_goto_check, + racc_goto_default, + racc_goto_pointer, + racc_nt_base, + racc_reduce_table, + racc_token_table, + racc_shift_n, + racc_reduce_n, + racc_use_result_var ] + +Racc_token_to_s_table = [ + "$end", + "error", + "SLASH", + "LITERAL", + "SYMBOL", + "LPAREN", + "RPAREN", + "DOT", + "STAR", + "OR", + "$start", + "expressions", + "expression", + "or", + "terminal", + "group", + "star", + "symbol", + "literal", + "slash", + "dot" ] + +Racc_debug_parser = false + +##### State transition tables end ##### + +# reduce 0 omitted + +def _reduce_1(val, _values, result) + result = Cat.new(val.first, val.last) + result +end + +def _reduce_2(val, _values, result) + result = val.first + result +end + +# reduce 3 omitted + +# reduce 4 omitted + +# reduce 5 omitted + +# reduce 6 omitted + +def _reduce_7(val, _values, result) + result = Group.new(val[1]) + result +end + +def _reduce_8(val, _values, result) + result = Or.new([val.first, val.last]) + result +end + +def _reduce_9(val, _values, result) + result = Star.new(Symbol.new(val.last)) + result +end + +# reduce 10 omitted + +# reduce 11 omitted + +# reduce 12 omitted + +# reduce 13 omitted + +def _reduce_14(val, _values, result) + result = Slash.new('/') + result +end + +def _reduce_15(val, _values, result) + result = Symbol.new(val.first) + result +end + +def _reduce_16(val, _values, result) + result = Literal.new(val.first) + result +end + +def _reduce_17(val, _values, result) + result = Dot.new(val.first) + result +end + +def _reduce_none(val, _values, result) + val[0] +end + + end # class Parser + end # module Journey + end # module ActionDispatch diff --git a/actionpack/lib/action_dispatch/journey/parser.y b/actionpack/lib/action_dispatch/journey/parser.y new file mode 100644 index 0000000000..a2e1afed32 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/parser.y @@ -0,0 +1,47 @@ +class ActionDispatch::Journey::Parser + +token SLASH LITERAL SYMBOL LPAREN RPAREN DOT STAR OR + +rule + expressions + : expressions expression { result = Cat.new(val.first, val.last) } + | expression { result = val.first } + | or + ; + expression + : terminal + | group + | star + ; + group + : LPAREN expressions RPAREN { result = Group.new(val[1]) } + ; + or + : expressions OR expression { result = Or.new([val.first, val.last]) } + ; + star + : STAR { result = Star.new(Symbol.new(val.last)) } + ; + terminal + : symbol + | literal + | slash + | dot + ; + slash + : SLASH { result = Slash.new('/') } + ; + symbol + : SYMBOL { result = Symbol.new(val.first) } + ; + literal + : LITERAL { result = Literal.new(val.first) } + dot + : DOT { result = Dot.new(val.first) } + ; + +end + +---- header + +require 'action_dispatch/journey/parser_extras' diff --git a/actionpack/lib/action_dispatch/journey/parser_extras.rb b/actionpack/lib/action_dispatch/journey/parser_extras.rb new file mode 100644 index 0000000000..14892f4321 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/parser_extras.rb @@ -0,0 +1,23 @@ +require 'action_dispatch/journey/scanner' +require 'action_dispatch/journey/nodes/node' + +module ActionDispatch + module Journey # :nodoc: + class Parser < Racc::Parser # :nodoc: + include Journey::Nodes + + def initialize + @scanner = Scanner.new + end + + def parse(string) + @scanner.scan_setup(string) + do_parse + end + + def next_token + @scanner.next_token + end + end + end +end diff --git a/actionpack/lib/action_dispatch/journey/path/pattern.rb b/actionpack/lib/action_dispatch/journey/path/pattern.rb new file mode 100644 index 0000000000..4a571ec546 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/path/pattern.rb @@ -0,0 +1,196 @@ +module ActionDispatch + module Journey # :nodoc: + module Path # :nodoc: + class Pattern # :nodoc: + attr_reader :spec, :requirements, :anchored + + def initialize(strexp) + parser = Journey::Parser.new + + @anchored = true + + case strexp + when String + @spec = parser.parse(strexp) + @requirements = {} + @separators = "/.?" + when Router::Strexp + @spec = parser.parse(strexp.path) + @requirements = strexp.requirements + @separators = strexp.separators.join + @anchored = strexp.anchor + else + raise "wtf bro: #{strexp}" + end + + @names = nil + @optional_names = nil + @required_names = nil + @re = nil + @offsets = nil + end + + def ast + @spec.grep(Nodes::Symbol).each do |node| + re = @requirements[node.to_sym] + node.regexp = re if re + end + + @spec.grep(Nodes::Star).each do |node| + node = node.left + node.regexp = @requirements[node.to_sym] || /(.+)/ + end + + @spec + end + + def names + @names ||= spec.grep(Nodes::Symbol).map { |n| n.name } + end + + def required_names + @required_names ||= names - optional_names + end + + def optional_names + @optional_names ||= spec.grep(Nodes::Group).map { |group| + group.grep(Nodes::Symbol) + }.flatten.map { |n| n.name }.uniq + end + + class RegexpOffsets < Journey::Visitors::Visitor # :nodoc: + attr_reader :offsets + + def initialize(matchers) + @matchers = matchers + @capture_count = [0] + end + + def visit(node) + super + @capture_count + end + + def visit_SYMBOL(node) + node = node.to_sym + + if @matchers.key?(node) + re = /#{@matchers[node]}|/ + @capture_count.push((re.match('').length - 1) + (@capture_count.last || 0)) + else + @capture_count << (@capture_count.last || 0) + end + end + end + + class AnchoredRegexp < Journey::Visitors::Visitor # :nodoc: + def initialize(separator, matchers) + @separator = separator + @matchers = matchers + @separator_re = "([^#{separator}]+)" + super() + end + + def accept(node) + %r{\A#{visit node}\Z} + end + + def visit_CAT(node) + [visit(node.left), visit(node.right)].join + end + + def visit_SYMBOL(node) + node = node.to_sym + + return @separator_re unless @matchers.key?(node) + + re = @matchers[node] + "(#{re})" + end + + def visit_GROUP(node) + "(?:#{visit node.left})?" + end + + def visit_LITERAL(node) + Regexp.escape(node.left) + end + alias :visit_DOT :visit_LITERAL + + def visit_SLASH(node) + node.left + end + + def visit_STAR(node) + re = @matchers[node.left.to_sym] || '.+' + "(#{re})" + end + end + + class UnanchoredRegexp < AnchoredRegexp # :nodoc: + def accept(node) + %r{\A#{visit node}} + end + end + + class MatchData # :nodoc: + attr_reader :names + + def initialize(names, offsets, match) + @names = names + @offsets = offsets + @match = match + end + + def captures + (length - 1).times.map { |i| self[i + 1] } + end + + def [](x) + idx = @offsets[x - 1] + x + @match[idx] + end + + def length + @offsets.length + end + + def post_match + @match.post_match + end + + def to_s + @match.to_s + end + end + + def match(other) + return unless match = to_regexp.match(other) + MatchData.new(names, offsets, match) + end + alias :=~ :match + + def source + to_regexp.source + end + + def to_regexp + @re ||= regexp_visitor.new(@separators, @requirements).accept spec + end + + private + + def regexp_visitor + @anchored ? AnchoredRegexp : UnanchoredRegexp + end + + def offsets + return @offsets if @offsets + + viz = RegexpOffsets.new(@requirements) + @offsets = viz.accept(spec) + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/journey/route.rb b/actionpack/lib/action_dispatch/journey/route.rb new file mode 100644 index 0000000000..d18efd863a --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/route.rb @@ -0,0 +1,94 @@ +module ActionDispatch + module Journey # :nodoc: + class Route # :nodoc: + attr_reader :app, :path, :verb, :defaults, :ip, :name + + attr_reader :constraints + alias :conditions :constraints + + attr_accessor :precedence + + ## + # +path+ is a path constraint. + # +constraints+ is a hash of constraints to be applied to this route. + def initialize(name, app, path, constraints, defaults = {}) + constraints = constraints.dup + @name = name + @app = app + @path = path + @verb = constraints[:request_method] || // + @ip = constraints.delete(:ip) || // + + @constraints = constraints + @constraints.keep_if { |_,v| Regexp === v || String === v } + @defaults = defaults + @required_defaults = nil + @required_parts = nil + @parts = nil + @decorated_ast = nil + @precedence = 0 + end + + def ast + return @decorated_ast if @decorated_ast + + @decorated_ast = path.ast + @decorated_ast.grep(Nodes::Terminal).each { |n| n.memo = self } + @decorated_ast + end + + def requirements # :nodoc: + # needed for rails `rake routes` + path.requirements.merge(@defaults).delete_if { |_,v| + /.+?/ == v + } + end + + def segments + @path.names + end + + def required_keys + path.required_names.map { |x| x.to_sym } + required_defaults.keys + end + + def score(constraints) + required_keys = path.required_names + supplied_keys = constraints.map { |k,v| v && k.to_s }.compact + + return -1 unless (required_keys - supplied_keys).empty? + + score = (supplied_keys & path.names).length + score + (required_defaults.length * 2) + end + + def parts + @parts ||= segments.map { |n| n.to_sym } + end + alias :segment_keys :parts + + def format(path_options) + path_options.delete_if do |key, value| + value.to_s == defaults[key].to_s && !required_parts.include?(key) + end + + Visitors::Formatter.new(path_options).accept(path.spec) + end + + def optional_parts + path.optional_names.map { |n| n.to_sym } + end + + def required_parts + @required_parts ||= path.required_names.map { |n| n.to_sym } + end + + def required_defaults + @required_defaults ||= begin + matches = parts + @defaults.dup.delete_if { |k,_| matches.include?(k) } + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/journey/router.rb b/actionpack/lib/action_dispatch/journey/router.rb new file mode 100644 index 0000000000..1fc45a2109 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/router.rb @@ -0,0 +1,168 @@ +require 'action_dispatch/journey/router/utils' +require 'action_dispatch/journey/router/strexp' +require 'action_dispatch/journey/routes' +require 'action_dispatch/journey/formatter' + +before = $-w +$-w = false +require 'action_dispatch/journey/parser' +$-w = before + +require 'action_dispatch/journey/route' +require 'action_dispatch/journey/path/pattern' + +module ActionDispatch + module Journey # :nodoc: + class Router # :nodoc: + class RoutingError < ::StandardError # :nodoc: + end + + # :nodoc: + VERSION = '2.0.0' + + class NullReq # :nodoc: + attr_reader :env + def initialize(env) + @env = env + end + + def request_method + env['REQUEST_METHOD'] + end + + def path_info + env['PATH_INFO'] + end + + def ip + env['REMOTE_ADDR'] + end + + def [](k); env[k]; end + end + + attr_reader :request_class, :formatter + attr_accessor :routes + + def initialize(routes, options) + @options = options + @params_key = options[:parameters_key] + @request_class = options[:request_class] || NullReq + @routes = routes + end + + def call(env) + env['PATH_INFO'] = Utils.normalize_path(env['PATH_INFO']) + + find_routes(env).each do |match, parameters, route| + script_name, path_info, set_params = env.values_at('SCRIPT_NAME', + 'PATH_INFO', + @params_key) + + unless route.path.anchored + env['SCRIPT_NAME'] = (script_name.to_s + match.to_s).chomp('/') + env['PATH_INFO'] = match.post_match + end + + env[@params_key] = (set_params || {}).merge parameters + + status, headers, body = route.app.call(env) + + if 'pass' == headers['X-Cascade'] + env['SCRIPT_NAME'] = script_name + env['PATH_INFO'] = path_info + env[@params_key] = set_params + next + end + + return [status, headers, body] + end + + return [404, {'X-Cascade' => 'pass'}, ['Not Found']] + end + + def recognize(req) + find_routes(req.env).each do |match, parameters, route| + unless route.path.anchored + req.env['SCRIPT_NAME'] = match.to_s + req.env['PATH_INFO'] = match.post_match.sub(/^([^\/])/, '/\1') + end + + yield(route, nil, parameters) + end + end + + def visualizer + tt = GTG::Builder.new(ast).transition_table + groups = partitioned_routes.first.map(&:ast).group_by { |a| a.to_s } + asts = groups.values.map { |v| v.first } + tt.visualizer(asts) + end + + private + + def partitioned_routes + routes.partitioned_routes + end + + def ast + routes.ast + end + + def simulator + routes.simulator + end + + def custom_routes + partitioned_routes.last + end + + def filter_routes(path) + return [] unless ast + data = simulator.match(path) + data ? data.memos : [] + end + + def find_routes env + req = request_class.new(env) + + routes = filter_routes(req.path_info).concat custom_routes.find_all { |r| + r.path.match(req.path_info) + } + routes.concat get_routes_as_head(routes) + + routes.sort_by!(&:precedence).select! { |r| + r.constraints.all? { |k, v| v === req.send(k) } && + r.verb === req.request_method + } + routes.reject! { |r| req.ip && !(r.ip === req.ip) } + + routes.map! { |r| + match_data = r.path.match(req.path_info) + match_names = match_data.names.map { |n| n.to_sym } + match_values = match_data.captures.map { |v| v && Utils.unescape_uri(v) } + info = Hash[match_names.zip(match_values).find_all { |_, y| y }] + + [match_data, r.defaults.merge(info), r] + } + end + + def get_routes_as_head(routes) + precedence = (routes.map(&:precedence).max || 0) + 1 + routes = routes.select { |r| + r.verb === "GET" && !(r.verb === "HEAD") + }.map! { |r| + Route.new(r.name, + r.app, + r.path, + r.conditions.merge(request_method: "HEAD"), + r.defaults).tap do |route| + route.precedence = r.precedence + precedence + end + } + routes.flatten! + routes + end + end + end +end diff --git a/actionpack/lib/action_dispatch/journey/router/strexp.rb b/actionpack/lib/action_dispatch/journey/router/strexp.rb new file mode 100644 index 0000000000..f97f1a223e --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/router/strexp.rb @@ -0,0 +1,24 @@ +module ActionDispatch + module Journey # :nodoc: + class Router # :nodoc: + class Strexp # :nodoc: + class << self + alias :compile :new + end + + attr_reader :path, :requirements, :separators, :anchor + + def initialize(path, requirements, separators, anchor = true) + @path = path + @requirements = requirements + @separators = separators + @anchor = anchor + end + + def names + @path.scan(/:\w+/).map { |s| s.tr(':', '') } + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/journey/router/utils.rb b/actionpack/lib/action_dispatch/journey/router/utils.rb new file mode 100644 index 0000000000..462f1a122d --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/router/utils.rb @@ -0,0 +1,54 @@ +require 'uri' + +module ActionDispatch + module Journey # :nodoc: + class Router # :nodoc: + class Utils # :nodoc: + # Normalizes URI path. + # + # Strips off trailing slash and ensures there is a leading slash. + # + # normalize_path("/foo") # => "/foo" + # normalize_path("/foo/") # => "/foo" + # normalize_path("foo") # => "/foo" + # normalize_path("") # => "/" + def self.normalize_path(path) + path = "/#{path}" + path.squeeze!('/') + path.sub!(%r{/+\Z}, '') + path = '/' if path == '' + path + end + + # URI path and fragment escaping + # http://tools.ietf.org/html/rfc3986 + module UriEscape # :nodoc: + # Symbol captures can generate multiple path segments, so include /. + reserved_segment = '/' + reserved_fragment = '/?' + reserved_pchar = ':@&=+$,;%' + + safe_pchar = "#{URI::REGEXP::PATTERN::UNRESERVED}#{reserved_pchar}" + safe_segment = "#{safe_pchar}#{reserved_segment}" + safe_fragment = "#{safe_pchar}#{reserved_fragment}" + UNSAFE_SEGMENT = Regexp.new("[^#{safe_segment}]", false).freeze + UNSAFE_FRAGMENT = Regexp.new("[^#{safe_fragment}]", false).freeze + end + + Parser = URI.const_defined?(:Parser) ? URI::Parser.new : URI + + def self.escape_path(path) + Parser.escape(path.to_s, UriEscape::UNSAFE_SEGMENT) + end + + def self.escape_fragment(fragment) + Parser.escape(fragment.to_s, UriEscape::UNSAFE_FRAGMENT) + end + + def self.unescape_uri(uri) + Parser.unescape(uri) + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/journey/routes.rb b/actionpack/lib/action_dispatch/journey/routes.rb new file mode 100644 index 0000000000..32829a1f20 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/routes.rb @@ -0,0 +1,76 @@ +module ActionDispatch + module Journey # :nodoc: + # The Routing table. Contains all routes for a system. Routes can be + # added to the table by calling Routes#add_route. + class Routes # :nodoc: + include Enumerable + + attr_reader :routes, :named_routes + + def initialize + @routes = [] + @named_routes = {} + @ast = nil + @partitioned_routes = nil + @simulator = nil + end + + def length + @routes.length + end + alias :size :length + + def last + @routes.last + end + + def each(&block) + routes.each(&block) + end + + def clear + routes.clear + end + + def partitioned_routes + @partitioned_routes ||= routes.partition { |r| + r.path.anchored && r.ast.grep(Nodes::Symbol).all? { |n| n.default_regexp? } + } + end + + def ast + return @ast if @ast + return if partitioned_routes.first.empty? + + asts = partitioned_routes.first.map { |r| r.ast } + @ast = Nodes::Or.new(asts) + end + + def simulator + return @simulator if @simulator + + gtg = GTG::Builder.new(ast).transition_table + @simulator = GTG::Simulator.new(gtg) + end + + # Add a route to the routing table. + def add_route(app, path, conditions, defaults, name = nil) + route = Route.new(name, app, path, conditions, defaults) + + route.precedence = routes.length + routes << route + named_routes[name] = route if name && !named_routes[name] + clear_cache! + route + end + + private + + def clear_cache! + @ast = nil + @partitioned_routes = nil + @simulator = nil + end + end + end +end diff --git a/actionpack/lib/action_dispatch/journey/scanner.rb b/actionpack/lib/action_dispatch/journey/scanner.rb new file mode 100644 index 0000000000..633be11a2d --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/scanner.rb @@ -0,0 +1,61 @@ +require 'strscan' + +module ActionDispatch + module Journey # :nodoc: + class Scanner # :nodoc: + def initialize + @ss = nil + end + + def scan_setup(str) + @ss = StringScanner.new(str) + end + + def eos? + @ss.eos? + end + + def pos + @ss.pos + end + + def pre_match + @ss.pre_match + end + + def next_token + return if @ss.eos? + + until token = scan || @ss.eos?; end + token + end + + private + + def scan + case + # / + when text = @ss.scan(/\//) + [:SLASH, text] + when text = @ss.scan(/\*\w+/) + [:STAR, text] + when text = @ss.scan(/\(/) + [:LPAREN, text] + when text = @ss.scan(/\)/) + [:RPAREN, text] + when text = @ss.scan(/\|/) + [:OR, text] + when text = @ss.scan(/\./) + [:DOT, text] + when text = @ss.scan(/:\w+/) + [:SYMBOL, text] + when text = @ss.scan(/[\w%\-~]+/) + [:LITERAL, text] + # any char + when text = @ss.scan(/./) + [:LITERAL, text] + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/journey/visitors.rb b/actionpack/lib/action_dispatch/journey/visitors.rb new file mode 100644 index 0000000000..46bd58c178 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/visitors.rb @@ -0,0 +1,189 @@ +# encoding: utf-8 +module ActionDispatch + module Journey # :nodoc: + module Visitors # :nodoc: + class Visitor # :nodoc: + DISPATCH_CACHE = Hash.new { |h,k| + h[k] = "visit_#{k}" + } + + def accept(node) + visit(node) + end + + private + + def visit node + send(DISPATCH_CACHE[node.type], node) + end + + def binary(node) + visit(node.left) + visit(node.right) + end + def visit_CAT(n); binary(n); end + + def nary(node) + node.children.each { |c| visit(c) } + end + def visit_OR(n); nary(n); end + + def unary(node) + visit(node.left) + end + def visit_GROUP(n); unary(n); end + def visit_STAR(n); unary(n); end + + def terminal(node); end + %w{ LITERAL SYMBOL SLASH DOT }.each do |t| + class_eval %{ def visit_#{t}(n); terminal(n); end }, __FILE__, __LINE__ + end + end + + # Loop through the requirements AST + class Each < Visitor # :nodoc: + attr_reader :block + + def initialize(block) + @block = block + end + + def visit(node) + super + block.call(node) + end + end + + class String < Visitor # :nodoc: + private + + def binary(node) + [visit(node.left), visit(node.right)].join + end + + def nary(node) + node.children.map { |c| visit(c) }.join '|' + end + + def terminal(node) + node.left + end + + def visit_GROUP(node) + "(#{visit(node.left)})" + end + end + + # Used for formatting urls (url_for) + class Formatter < Visitor # :nodoc: + attr_reader :options, :consumed + + def initialize(options) + @options = options + @consumed = {} + end + + private + + def visit_GROUP(node) + if consumed == options + nil + else + route = visit(node.left) + route.include?("\0") ? nil : route + end + end + + def terminal(node) + node.left + end + + def binary(node) + [visit(node.left), visit(node.right)].join + end + + def nary(node) + node.children.map { |c| visit(c) }.join + end + + def visit_SYMBOL(node) + key = node.to_sym + + if value = options[key] + consumed[key] = value + Router::Utils.escape_path(value) + else + "\0" + end + end + end + + class Dot < Visitor # :nodoc: + def initialize + @nodes = [] + @edges = [] + end + + def accept(node) + super + <<-eodot + digraph parse_tree { + size="8,5" + node [shape = none]; + edge [dir = none]; + #{@nodes.join "\n"} + #{@edges.join("\n")} + } + eodot + end + + private + + def binary(node) + node.children.each do |c| + @edges << "#{node.object_id} -> #{c.object_id};" + end + super + end + + def nary(node) + node.children.each do |c| + @edges << "#{node.object_id} -> #{c.object_id};" + end + super + end + + def unary(node) + @edges << "#{node.object_id} -> #{node.left.object_id};" + super + end + + def visit_GROUP(node) + @nodes << "#{node.object_id} [label=\"()\"];" + super + end + + def visit_CAT(node) + @nodes << "#{node.object_id} [label=\"○\"];" + super + end + + def visit_STAR(node) + @nodes << "#{node.object_id} [label=\"*\"];" + super + end + + def visit_OR(node) + @nodes << "#{node.object_id} [label=\"|\"];" + super + end + + def terminal(node) + value = node.left + + @nodes << "#{node.object_id} [label=\"#{value}\"];" + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/journey/visualizer/fsm.css b/actionpack/lib/action_dispatch/journey/visualizer/fsm.css new file mode 100644 index 0000000000..50caebaa18 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/visualizer/fsm.css @@ -0,0 +1,34 @@ +body { + font-family: "Helvetica Neue", Helvetica, Arial, Sans-Serif; + margin: 0; +} + +h1 { + font-size: 2.0em; font-weight: bold; text-align: center; + color: white; background-color: black; + padding: 5px 0; + margin: 0 0 20px; +} + +h2 { + text-align: center; + display: none; + font-size: 0.5em; +} + +div#chart-2 { + height: 350px; +} + +.clearfix {display: inline-block; } +.input { overflow: show;} +.instruction { color: #666; padding: 0 30px 20px; font-size: 0.9em} +.instruction p { padding: 0 0 5px; } +.instruction li { padding: 0 10px 5px; } + +.form { background: #EEE; padding: 20px 30px; border-radius: 5px; margin-left: auto; margin-right: auto; width: 500px; margin-bottom: 20px} +.form p, .form form { text-align: center } +.form form {padding: 0 10px 5px; } +.form .fun_routes { font-size: 0.9em;} +.form .fun_routes a { margin: 0 5px 0 0; } + diff --git a/actionpack/lib/action_dispatch/journey/visualizer/fsm.js b/actionpack/lib/action_dispatch/journey/visualizer/fsm.js new file mode 100644 index 0000000000..d9bcaef928 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/visualizer/fsm.js @@ -0,0 +1,134 @@ +function tokenize(input, callback) { + while(input.length > 0) { + callback(input.match(/^[\/\.\?]|[^\/\.\?]+/)[0]); + input = input.replace(/^[\/\.\?]|[^\/\.\?]+/, ''); + } +} + +var graph = d3.select("#chart-2 svg"); +var svg_edges = {}; +var svg_nodes = {}; + +graph.selectAll("g.edge").each(function() { + var node = d3.select(this); + var index = node.select("title").text().split("->"); + var left = parseInt(index[0]); + var right = parseInt(index[1]); + + if(!svg_edges[left]) { svg_edges[left] = {} } + svg_edges[left][right] = node; +}); + +graph.selectAll("g.node").each(function() { + var node = d3.select(this); + var index = parseInt(node.select("title").text()); + svg_nodes[index] = node; +}); + +function reset_graph() { + for(var key in svg_edges) { + for(var mkey in svg_edges[key]) { + var node = svg_edges[key][mkey]; + var path = node.select("path"); + var arrow = node.select("polygon"); + path.style("stroke", "black"); + arrow.style("stroke", "black").style("fill", "black"); + } + } + + for(var key in svg_nodes) { + var node = svg_nodes[key]; + node.select('ellipse').style("fill", "white"); + node.select('polygon').style("fill", "white"); + } + return false; +} + +function highlight_edge(from, to) { + var node = svg_edges[from][to]; + var path = node.select("path"); + var arrow = node.select("polygon"); + + path + .transition().duration(500) + .style("stroke", "green"); + + arrow + .transition().duration(500) + .style("stroke", "green").style("fill", "green"); +} + +function highlight_state(index, color) { + if(!color) { color = "green"; } + + svg_nodes[index].select('ellipse') + .style("fill", "white") + .transition().duration(500) + .style("fill", color); +} + +function highlight_finish(index) { + svg_nodes[index].select('polygon') + .style("fill", "while") + .transition().duration(500) + .style("fill", "blue"); +} + +function match(input) { + reset_graph(); + var table = tt(); + var states = [0]; + var regexp_states = table['regexp_states']; + var string_states = table['string_states']; + var accepting = table['accepting']; + + highlight_state(0); + + tokenize(input, function(token) { + var new_states = []; + for(var key in states) { + var state = states[key]; + + if(string_states[state] && string_states[state][token]) { + var new_state = string_states[state][token]; + highlight_edge(state, new_state); + highlight_state(new_state); + new_states.push(new_state); + } + + if(regexp_states[state]) { + for(var key in regexp_states[state]) { + var re = new RegExp("^" + key + "$"); + if(re.test(token)) { + var new_state = regexp_states[state][key]; + highlight_edge(state, new_state); + highlight_state(new_state); + new_states.push(new_state); + } + } + } + } + + if(new_states.length == 0) { + return; + } + states = new_states; + }); + + for(var key in states) { + var state = states[key]; + if(accepting[state]) { + for(var mkey in svg_edges[state]) { + if(!regexp_states[mkey] && !string_states[mkey]) { + highlight_edge(state, mkey); + highlight_finish(mkey); + } + } + } else { + highlight_state(state, "red"); + } + } + + return false; +} + diff --git a/actionpack/lib/action_dispatch/journey/visualizer/index.html.erb b/actionpack/lib/action_dispatch/journey/visualizer/index.html.erb new file mode 100644 index 0000000000..6aff10956a --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/visualizer/index.html.erb @@ -0,0 +1,52 @@ +<!DOCTYPE html> +<html> + <head> + <title><%= title %></title> + <link rel="stylesheet" href="https://raw.github.com/gist/1706081/af944401f75ea20515a02ddb3fb43d23ecb8c662/reset.css" type="text/css"> + <style> + <% stylesheets.each do |style| %> + <%= style %> + <% end %> + </style> + <script src="https://raw.github.com/gist/1706081/df464722a05c3c2bec450b7b5c8240d9c31fa52d/d3.min.js" type="text/javascript"></script> + </head> + <body> + <div id="wrapper"> + <h1>Routes FSM with NFA simulation</h1> + <div class="instruction form"> + <p> + Type a route in to the box and click "simulate". + </p> + <form onsubmit="return match(this.route.value);"> + <input type="text" size="30" name="route" value="/articles/new" /> + <button>simulate</button> + <input type="reset" value="reset" onclick="return reset_graph();"/> + </form> + <p class="fun_routes"> + Some fun routes to try: + <% fun_routes.each do |path| %> + <a href="#" onclick="document.forms[0].elements[0].value=this.text.replace(/^\s+|\s+$/g,''); return match(this.text.replace(/^\s+|\s+$/g,''));"> + <%= path %> + </a> + <% end %> + </p> + </div> + <div class='chart' id='chart-2'> + <%= svg %> + </div> + <div class="instruction"> + <p> + This is a FSM for a system that has the following routes: + </p> + <ul> + <% paths.each do |route| %> + <li><%= route %></li> + <% end %> + </ul> + </div> + </div> + <% javascripts.each do |js| %> + <script><%= js %></script> + <% end %> + </body> +</html> diff --git a/actionpack/lib/action_dispatch/middleware/best_standards_support.rb b/actionpack/lib/action_dispatch/middleware/best_standards_support.rb index 69adcc419f..d338996240 100644 --- a/actionpack/lib/action_dispatch/middleware/best_standards_support.rb +++ b/actionpack/lib/action_dispatch/middleware/best_standards_support.rb @@ -15,7 +15,13 @@ module ActionDispatch def call(env) status, headers, body = @app.call(env) - headers["X-UA-Compatible"] = @header + + if headers["X-UA-Compatible"] && @header + headers["X-UA-Compatible"] << "," << @header.to_s + else + headers["X-UA-Compatible"] = @header + end + [status, headers, body] end end diff --git a/actionpack/lib/action_dispatch/middleware/callbacks.rb b/actionpack/lib/action_dispatch/middleware/callbacks.rb index 338b116940..852f1cf6f5 100644 --- a/actionpack/lib/action_dispatch/middleware/callbacks.rb +++ b/actionpack/lib/action_dispatch/middleware/callbacks.rb @@ -1,4 +1,3 @@ -require 'active_support/core_ext/module/delegation' module ActionDispatch # Provide callbacks to be executed before and after the request dispatch. diff --git a/actionpack/lib/action_dispatch/middleware/cookies.rb b/actionpack/lib/action_dispatch/middleware/cookies.rb index 25f1db8228..2f148752cb 100644 --- a/actionpack/lib/action_dispatch/middleware/cookies.rb +++ b/actionpack/lib/action_dispatch/middleware/cookies.rb @@ -1,8 +1,9 @@ -require 'active_support/core_ext/object/blank' require 'active_support/core_ext/hash/keys' +require 'active_support/core_ext/module/attribute_accessors' +require 'active_support/message_verifier' module ActionDispatch - class Request + class Request < Rack::Request def cookie_jar env['action_dispatch.cookies'] ||= Cookies::CookieJar.build(self) end @@ -24,11 +25,11 @@ module ActionDispatch # cookies[:lat_lon] = [47.68, -122.37] # # # Sets a cookie that expires in 1 hour. - # cookies[:login] = { :value => "XJ-122", :expires => 1.hour.from_now } + # cookies[:login] = { value: "XJ-122", expires: 1.hour.from_now } # - # # Sets a signed cookie, which prevents a user from tampering with its value. - # # The cookie is signed by your app's <tt>config.secret_token</tt> value. - # # Rails generates this value by default when you create a new Rails app. + # # Sets a signed cookie, which prevents users from tampering with its value. + # # The cookie is signed by your app's <tt>config.secret_key_base</tt> value. + # # It can be read using the signed method <tt>cookies.signed[:key]</tt> # cookies.signed[:user_id] = current_user.id # # # Sets a "permanent" cookie (which expires in 20 years from now). @@ -39,9 +40,10 @@ module ActionDispatch # # Examples for reading: # - # cookies[:user_name] # => "david" - # cookies.size # => 2 - # cookies[:lat_lon] # => [47.68, -122.37] + # cookies[:user_name] # => "david" + # cookies.size # => 2 + # cookies[:lat_lon] # => [47.68, -122.37] + # cookies.signed[:login] # => "XJ-122" # # Example for deleting: # @@ -50,12 +52,12 @@ module ActionDispatch # Please note that if you specify a :domain when setting a cookie, you must also specify the domain when deleting the cookie: # # cookies[:key] = { - # :value => 'a yummy cookie', - # :expires => 1.year.from_now, - # :domain => 'domain.com' + # value: 'a yummy cookie', + # expires: 1.year.from_now, + # domain: 'domain.com' # } # - # cookies.delete(:key, :domain => 'domain.com') + # cookies.delete(:key, domain: 'domain.com') # # The option symbols for setting cookies are: # @@ -68,8 +70,8 @@ module ActionDispatch # to <tt>:all</tt>. Make sure to specify the <tt>:domain</tt> option with # <tt>:all</tt> again when deleting keys. # - # :domain => nil # Does not sets cookie domain. (default) - # :domain => :all # Allow the cookie for the top most level + # domain: nil # Does not sets cookie domain. (default) + # domain: :all # Allow the cookie for the top most level # domain and subdomains. # # * <tt>:expires</tt> - The time at which this cookie expires, as a \Time object. @@ -78,11 +80,15 @@ module ActionDispatch # * <tt>:httponly</tt> - Whether this cookie is accessible via scripting or # only HTTP. Defaults to +false+. class Cookies - HTTP_HEADER = "Set-Cookie".freeze + HTTP_HEADER = "Set-Cookie".freeze + GENERATOR_KEY = "action_dispatch.key_generator".freeze + SIGNED_COOKIE_SALT = "action_dispatch.signed_cookie_salt".freeze + ENCRYPTED_COOKIE_SALT = "action_dispatch.encrypted_cookie_salt".freeze + ENCRYPTED_SIGNED_COOKIE_SALT = "action_dispatch.encrypted_signed_cookie_salt".freeze TOKEN_KEY = "action_dispatch.secret_token".freeze # Raised when storing more than 4K of session data. - class CookieOverflow < StandardError; end + CookieOverflow = Class.new StandardError class CookieJar #:nodoc: include Enumerable @@ -102,22 +108,28 @@ module ActionDispatch DOMAIN_REGEXP = /[^.]*\.([^.]*|..\...|...\...)$/ def self.build(request) - secret = request.env[TOKEN_KEY] + env = request.env + key_generator = env[GENERATOR_KEY] + options = { signed_cookie_salt: env[SIGNED_COOKIE_SALT], + encrypted_cookie_salt: env[ENCRYPTED_COOKIE_SALT], + encrypted_signed_cookie_salt: env[ENCRYPTED_SIGNED_COOKIE_SALT], + token_key: env[TOKEN_KEY] } + host = request.host secure = request.ssl? - new(secret, host, secure).tap do |hash| + new(key_generator, host, secure, options).tap do |hash| hash.update(request.cookies) end end - def initialize(secret = nil, host = nil, secure = false) - @secret = secret + def initialize(key_generator, host = nil, secure = false, options = {}) + @key_generator = key_generator @set_cookies = {} @delete_cookies = {} @host = host @secure = secure - @closed = false + @options = options @cookies = {} end @@ -154,7 +166,7 @@ module ActionDispatch end elsif options[:domain].is_a? Array # if host matches one of the supplied domains without a dot in front of it - options[:domain] = options[:domain].find {|domain| @host.include? domain[/^\.?(.*)$/, 1] } + options[:domain] = options[:domain].find {|domain| @host.include? domain.sub(/^\./, '') } end end @@ -169,12 +181,14 @@ module ActionDispatch options = { :value => value } end - @cookies[key.to_s] = value - handle_options(options) - @set_cookies[key.to_s] = options - @delete_cookies.delete(key.to_s) + if @cookies[key.to_s] != value or options[:expires] + @cookies[key.to_s] = value + @set_cookies[key.to_s] = options + @delete_cookies.delete(key.to_s) + end + value end @@ -182,8 +196,9 @@ module ActionDispatch # and setting its expiration date into the past. Like <tt>[]=</tt>, you can pass in # an options hash to delete cookies with extra data such as a <tt>:path</tt>. def delete(key, options = {}) - options.symbolize_keys! + return unless @cookies.has_key? key.to_s + options.symbolize_keys! handle_options(options) value = @cookies.delete(key.to_s) @@ -217,7 +232,7 @@ module ActionDispatch # cookies.permanent.signed[:remember_me] = current_user.id # # => Set-Cookie: remember_me=BAhU--848956038e692d7046deab32b7131856ab20e14e; path=/; expires=Sun, 16-Dec-2029 03:24:16 GMT def permanent - @permanent ||= PermanentCookieJar.new(self, @secret) + @permanent ||= PermanentCookieJar.new(self, @key_generator, @options) end # Returns a jar that'll automatically generate a signed representation of cookie value and verify it when reading from @@ -225,7 +240,7 @@ module ActionDispatch # cookie was tampered with by the user (or a 3rd party), an ActiveSupport::MessageVerifier::InvalidSignature exception will # be raised. # - # This jar requires that you set a suitable secret for the verification on your app's config.secret_token. + # This jar requires that you set a suitable secret for the verification on your app's +config.secret_key_base+. # # Example: # @@ -234,7 +249,28 @@ module ActionDispatch # # cookies.signed[:discount] # => 45 def signed - @signed ||= SignedCookieJar.new(self, @secret) + @signed ||= SignedCookieJar.new(self, @key_generator, @options) + end + + # Only needed for supporting the +UpgradeSignatureToEncryptionCookieStore+, users and plugin authors should not use this + def signed_using_old_secret #:nodoc: + @signed_using_old_secret ||= SignedCookieJar.new(self, ActiveSupport::DummyKeyGenerator.new(@options[:token_key]), @options) + end + + # Returns a jar that'll automatically encrypt cookie values before sending them to the client and will decrypt them for read. + # If the cookie was tampered with by the user (or a 3rd party), an ActiveSupport::MessageVerifier::InvalidSignature exception + # will be raised. + # + # This jar requires that you set a suitable secret for the verification on your app's +config.secret_key_base+. + # + # Example: + # + # cookies.encrypted[:discount] = 45 + # # => Set-Cookie: discount=ZS9ZZ1R4cG1pcUJ1bm80anhQang3dz09LS1mbDZDSU5scGdOT3ltQ2dTdlhSdWpRPT0%3D--ab54663c9f4e3bc340c790d6d2b71e92f5b60315; path=/ + # + # cookies.encrypted[:discount] # => 45 + def encrypted + @encrypted ||= EncryptedCookieJar.new(self, @key_generator, @options) end def write(headers) @@ -258,8 +294,10 @@ module ActionDispatch end class PermanentCookieJar < CookieJar #:nodoc: - def initialize(parent_jar, secret) - @parent_jar, @secret = parent_jar, secret + def initialize(parent_jar, key_generator, options = {}) + @parent_jar = parent_jar + @key_generator = key_generator + @options = options end def []=(key, options) @@ -273,10 +311,6 @@ module ActionDispatch @parent_jar[key] = options end - def signed - @signed ||= SignedCookieJar.new(self, @secret) - end - def method_missing(method, *arguments, &block) @parent_jar.send(method, *arguments, &block) end @@ -284,11 +318,11 @@ module ActionDispatch class SignedCookieJar < CookieJar #:nodoc: MAX_COOKIE_SIZE = 4096 # Cookies can typically store 4096 bytes. - SECRET_MIN_LENGTH = 30 # Characters - def initialize(parent_jar, secret) - ensure_secret_secure(secret) + def initialize(parent_jar, key_generator, options = {}) @parent_jar = parent_jar + @options = options + secret = key_generator.generate_key(@options[:signed_cookie_salt]) @verifier = ActiveSupport::MessageVerifier.new(secret) end @@ -315,26 +349,41 @@ module ActionDispatch def method_missing(method, *arguments, &block) @parent_jar.send(method, *arguments, &block) end + end - protected - - # To prevent users from using something insecure like "Password" we make sure that the - # secret they've provided is at least 30 characters in length. - def ensure_secret_secure(secret) - if secret.blank? - raise ArgumentError, "A secret is required to generate an " + - "integrity hash for cookie session data. Use " + - "config.secret_token = \"some secret phrase of at " + - "least #{SECRET_MIN_LENGTH} characters\"" + - "in config/initializers/secret_token.rb" + class EncryptedCookieJar < SignedCookieJar #:nodoc: + def initialize(parent_jar, key_generator, options = {}) + if ActiveSupport::DummyKeyGenerator === key_generator + raise "Encrypted Cookies must be used in conjunction with config.secret_key_base." + + "Set config.secret_key_base in config/initializers/secret_token.rb" end - if secret.length < SECRET_MIN_LENGTH - raise ArgumentError, "Secret should be something secure, " + - "like \"#{SecureRandom.hex(16)}\". The value you " + - "provided, \"#{secret}\", is shorter than the minimum length " + - "of #{SECRET_MIN_LENGTH} characters" + @parent_jar = parent_jar + @options = options + secret = key_generator.generate_key(@options[:encrypted_cookie_salt]) + sign_secret = key_generator.generate_key(@options[:encrypted_signed_cookie_salt]) + @encryptor = ActiveSupport::MessageEncryptor.new(secret, sign_secret) + end + + def [](name) + if encrypted_message = @parent_jar[name] + @encryptor.decrypt_and_verify(encrypted_message) + end + rescue ActiveSupport::MessageVerifier::InvalidSignature, + ActiveSupport::MessageVerifier::InvalidMessage + nil + end + + def []=(key, options) + if options.is_a?(Hash) + options.symbolize_keys! + else + options = { :value => options } end + options[:value] = @encryptor.encrypt_and_sign(options[:value]) + + raise CookieOverflow if options[:value].size > MAX_COOKIE_SIZE + @parent_jar[key] = options end end @@ -343,7 +392,6 @@ module ActionDispatch end def call(env) - cookie_jar = nil status, headers, body = @app.call(env) if cookie_jar = env['action_dispatch.cookies'] diff --git a/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb b/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb index b903f98761..1dc51d62e0 100644 --- a/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb +++ b/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb @@ -1,14 +1,17 @@ require 'action_dispatch/http/request' require 'action_dispatch/middleware/exception_wrapper' +require 'action_dispatch/routing/inspector' + module ActionDispatch # This middleware is responsible for logging exceptions and # showing a debugging page in case the request is local. class DebugExceptions - RESCUES_TEMPLATE_PATH = File.join(File.dirname(__FILE__), 'templates') + RESCUES_TEMPLATE_PATH = File.expand_path('../templates', __FILE__) - def initialize(app) - @app = app + def initialize(app, routes_app = nil) + @app = app + @routes_app = routes_app end def call(env) @@ -39,7 +42,8 @@ module ActionDispatch :exception => wrapper.exception, :application_trace => wrapper.application_trace, :framework_trace => wrapper.framework_trace, - :full_trace => wrapper.full_trace + :full_trace => wrapper.full_trace, + :routes => formatted_routes(exception) ) file = "rescues/#{wrapper.rescue_template}" @@ -78,5 +82,13 @@ module ActionDispatch def stderr_logger @stderr_logger ||= ActiveSupport::Logger.new($stderr) end + + def formatted_routes(exception) + return false unless @routes_app.respond_to?(:routes) + if exception.is_a?(ActionController::RoutingError) || exception.is_a?(ActionView::Template::Error) + inspector = ActionDispatch::Routing::RoutesInspector.new + inspector.collect_routes(@routes_app.routes.routes) + end + end end end diff --git a/actionpack/lib/action_dispatch/middleware/exception_wrapper.rb b/actionpack/lib/action_dispatch/middleware/exception_wrapper.rb index c0532c80c4..ae38c56a67 100644 --- a/actionpack/lib/action_dispatch/middleware/exception_wrapper.rb +++ b/actionpack/lib/action_dispatch/middleware/exception_wrapper.rb @@ -1,5 +1,6 @@ require 'action_controller/metal/exceptions' require 'active_support/core_ext/exception' +require 'active_support/core_ext/class/attribute_accessors' module ActionDispatch class ExceptionWrapper @@ -10,7 +11,9 @@ module ActionDispatch 'AbstractController::ActionNotFound' => :not_found, 'ActionController::MethodNotAllowed' => :method_not_allowed, 'ActionController::NotImplemented' => :not_implemented, - 'ActionController::InvalidAuthenticityToken' => :unprocessable_entity + 'ActionController::UnknownFormat' => :not_acceptable, + 'ActionController::InvalidAuthenticityToken' => :unprocessable_entity, + 'ActionController::BadRequest' => :bad_request ) cattr_accessor :rescue_templates @@ -34,7 +37,7 @@ module ActionDispatch end def status_code - Rack::Utils.status_code(@@rescue_responses[@exception.class.name]) + self.class.status_code_for_exception(@exception.class.name) end def application_trace @@ -49,6 +52,10 @@ module ActionDispatch clean_backtrace(:all) end + def self.status_code_for_exception(class_name) + Rack::Utils.status_code(@@rescue_responses[class_name]) + end + private def original_exception(exception) @@ -75,4 +82,4 @@ module ActionDispatch @backtrace_cleaner ||= @env['action_dispatch.backtrace_cleaner'] end end -end
\ No newline at end of file +end diff --git a/actionpack/lib/action_dispatch/middleware/flash.rb b/actionpack/lib/action_dispatch/middleware/flash.rb index cff0877030..f24e9b8e18 100644 --- a/actionpack/lib/action_dispatch/middleware/flash.rb +++ b/actionpack/lib/action_dispatch/middleware/flash.rb @@ -1,23 +1,23 @@ module ActionDispatch - class Request + class Request < Rack::Request # Access the contents of the flash. Use <tt>flash["notice"]</tt> to # read a notice you put there or <tt>flash["notice"] = "hello"</tt> # to put a new one. def flash - @env[Flash::KEY] ||= (session["flash"] || Flash::FlashHash.new) + @env[Flash::KEY] ||= Flash::FlashHash.from_session_value(session["flash"]) end end # The flash provides a way to pass temporary objects between actions. Anything you place in the flash will be exposed # to the very next action and then cleared out. This is a great way of doing notices and alerts, such as a create # action that sets <tt>flash[:notice] = "Post successfully created"</tt> before redirecting to a display action that can - # then expose the flash to its template. Actually, that exposure is automatically done. Example: + # then expose the flash to its template. Actually, that exposure is automatically done. # # class PostsController < ActionController::Base # def create # # save post # flash[:notice] = "Post successfully created" - # redirect_to posts_path(@post) + # redirect_to @post # end # # def show @@ -70,17 +70,30 @@ module ActionDispatch end end - # Implementation detail: please do not change the signature of the - # FlashHash class. Doing that will likely affect all Rails apps in - # production as the FlashHash currently stored in their sessions will - # become invalid. class FlashHash include Enumerable - def initialize #:nodoc: - @discard = Set.new - @closed = false - @flashes = {} + def self.from_session_value(value) + flash = case value + when FlashHash # Rails 3.1, 3.2 + new(value.instance_variable_get(:@flashes), value.instance_variable_get(:@used)) + when Hash # Rails 4.0 + new(value['flashes'], value['discard']) + else + new + end + + flash.tap(&:sweep) + end + + def to_session_value + return nil if empty? + {'discard' => @discard.to_a, 'flashes' => @flashes} + end + + def initialize(flashes = {}, discard = []) #:nodoc: + @discard = Set.new(discard) + @flashes = flashes @now = nil end @@ -92,7 +105,7 @@ module ActionDispatch super end - def []=(k, v) #:nodoc: + def []=(k, v) @discard.delete k @flashes[k] = v end @@ -217,18 +230,14 @@ module ActionDispatch end def call(env) - if (session = env['rack.session']) && (flash = session['flash']) - flash.sweep - end - @app.call(env) ensure - session = env['rack.session'] || {} + session = Request::Session.find(env) || {} flash_hash = env[KEY] if flash_hash if !flash_hash.empty? || session.key?('flash') - session["flash"] = flash_hash + session["flash"] = flash_hash.to_session_value new_hash = flash_hash.dup else new_hash = flash_hash @@ -237,7 +246,8 @@ module ActionDispatch env[KEY] = new_hash end - if session.key?('flash') && session['flash'].empty? + if (!session.respond_to?(:loaded?) || session.loaded?) && # (reset_session uses {}, which doesn't implement #loaded?) + session.key?('flash') && session['flash'].nil? session.delete('flash') end end diff --git a/actionpack/lib/action_dispatch/middleware/head.rb b/actionpack/lib/action_dispatch/middleware/head.rb deleted file mode 100644 index f1906a3ab3..0000000000 --- a/actionpack/lib/action_dispatch/middleware/head.rb +++ /dev/null @@ -1,18 +0,0 @@ -module ActionDispatch - class Head - def initialize(app) - @app = app - end - - def call(env) - if env["REQUEST_METHOD"] == "HEAD" - env["REQUEST_METHOD"] = "GET" - env["rack.methodoverride.original_method"] = "HEAD" - status, headers, _ = @app.call(env) - [status, headers, []] - else - @app.call(env) - end - end - end -end diff --git a/actionpack/lib/action_dispatch/middleware/params_parser.rb b/actionpack/lib/action_dispatch/middleware/params_parser.rb index 1cb803ffb9..2c98ca03a8 100644 --- a/actionpack/lib/action_dispatch/middleware/params_parser.rb +++ b/actionpack/lib/action_dispatch/middleware/params_parser.rb @@ -4,6 +4,15 @@ require 'active_support/core_ext/hash/indifferent_access' module ActionDispatch class ParamsParser + class ParseError < StandardError + attr_reader :original_exception + + def initialize(message, original_exception) + super(message) + @original_exception = original_exception + end + end + DEFAULT_PARSERS = { Mime::XML => :xml_simple, Mime::JSON => :json @@ -38,14 +47,12 @@ module ActionDispatch when Proc strategy.call(request.raw_post) when :xml_simple, :xml_node - data = Hash.from_xml(request.body.read) || {} - request.body.rewind if request.body.respond_to?(:rewind) + data = Hash.from_xml(request.raw_post) || {} data.with_indifferent_access when :yaml YAML.load(request.raw_post) when :json - data = ActiveSupport::JSON.decode(request.body) - request.body.rewind if request.body.respond_to?(:rewind) + data = ActiveSupport::JSON.decode(request.raw_post) data = {:_json => data} unless data.is_a?(Hash) data.with_indifferent_access else @@ -54,7 +61,7 @@ module ActionDispatch rescue Exception => e # YAML, XML or Ruby code block errors logger(env).debug "Error occurred while parsing request parameters.\nContents:\n\n#{request.raw_post}" - raise e + raise ParseError.new(e.message, e) end def content_type_from_legacy_post_data_format_header(env) diff --git a/actionpack/lib/action_dispatch/middleware/public_exceptions.rb b/actionpack/lib/action_dispatch/middleware/public_exceptions.rb index 85b8d178bf..53bedaa40a 100644 --- a/actionpack/lib/action_dispatch/middleware/public_exceptions.rb +++ b/actionpack/lib/action_dispatch/middleware/public_exceptions.rb @@ -1,5 +1,4 @@ module ActionDispatch - # A simple Rack application that renders exceptions in the given public path. class PublicExceptions attr_accessor :public_path @@ -8,23 +7,41 @@ module ActionDispatch end def call(env) - status = env["PATH_INFO"][1..-1] - locale_path = "#{public_path}/#{status}.#{I18n.locale}.html" if I18n.locale - path = "#{public_path}/#{status}.html" - - if locale_path && File.exist?(locale_path) - render(status, File.read(locale_path)) - elsif File.exist?(path) - render(status, File.read(path)) + exception = env["action_dispatch.exception"] + status = env["PATH_INFO"][1..-1] + request = ActionDispatch::Request.new(env) + content_type = request.formats.first + body = { :status => status, :error => exception.message } + + render(status, content_type, body) + end + + private + + def render(status, content_type, body) + format = content_type && "to_#{content_type.to_sym}" + if format && body.respond_to?(format) + render_format(status, content_type, body.public_send(format)) else - [404, { "X-Cascade" => "pass" }, []] + render_html(status) end end - private + def render_format(status, content_type, body) + [status, {'Content-Type' => "#{content_type}; charset=#{ActionDispatch::Response.default_charset}", + 'Content-Length' => body.bytesize.to_s}, [body]] + end + + def render_html(status) + found = false + path = "#{public_path}/#{status}.#{I18n.locale}.html" if I18n.locale + path = "#{public_path}/#{status}.html" unless path && (found = File.exist?(path)) - def render(status, body) - [status, {'Content-Type' => "text/html; charset=#{Response.default_charset}", 'Content-Length' => body.bytesize.to_s}, [body]] + if found || File.exist?(path) + render_format(status, 'text/html', File.read(path)) + else + [404, { "X-Cascade" => "pass" }, []] + end end end -end
\ No newline at end of file +end diff --git a/actionpack/lib/action_dispatch/middleware/reloader.rb b/actionpack/lib/action_dispatch/middleware/reloader.rb index a0388e0e13..2f6968eb2e 100644 --- a/actionpack/lib/action_dispatch/middleware/reloader.rb +++ b/actionpack/lib/action_dispatch/middleware/reloader.rb @@ -18,10 +18,10 @@ module ActionDispatch # classes before they are unloaded. # # By default, ActionDispatch::Reloader is included in the middleware stack - # only in the development environment; specifically, when config.cache_classes + # only in the development environment; specifically, when +config.cache_classes+ # is false. Callbacks may be registered even when it is not included in the - # middleware stack, but are executed only when +ActionDispatch::Reloader.prepare!+ - # or +ActionDispatch::Reloader.cleanup!+ are called manually. + # middleware stack, but are executed only when <tt>ActionDispatch::Reloader.prepare!</tt> + # or <tt>ActionDispatch::Reloader.cleanup!</tt> are called manually. # class Reloader include ActiveSupport::Callbacks diff --git a/actionpack/lib/action_dispatch/middleware/remote_ip.rb b/actionpack/lib/action_dispatch/middleware/remote_ip.rb index d924f21fad..5abf8f2802 100644 --- a/actionpack/lib/action_dispatch/middleware/remote_ip.rb +++ b/actionpack/lib/action_dispatch/middleware/remote_ip.rb @@ -5,11 +5,14 @@ module ActionDispatch # IP addresses that are "trusted proxies" that can be stripped from # the comma-delimited list in the X-Forwarded-For header. See also: # http://en.wikipedia.org/wiki/Private_network#Private_IPv4_address_spaces + # http://en.wikipedia.org/wiki/Private_network#Private_IPv6_addresses. TRUSTED_PROXIES = %r{ ^127\.0\.0\.1$ | # localhost + ^::1$ | ^(10 | # private IP 10.x.x.x 172\.(1[6-9]|2[0-9]|3[0-1]) | # private IP in the range 172.16.0.0 .. 172.31.255.255 - 192\.168 # private IP 192.168.x.x + 192\.168 | # private IP 192.168.x.x + fc00:: # private IP fc00 )\. }x @@ -19,13 +22,13 @@ module ActionDispatch @app = app @check_ip = check_ip_spoofing @proxies = case custom_proxies - when Regexp - custom_proxies - when nil - TRUSTED_PROXIES - else - Regexp.union(TRUSTED_PROXIES, custom_proxies) - end + when Regexp + custom_proxies + when nil + TRUSTED_PROXIES + else + Regexp.union(TRUSTED_PROXIES, custom_proxies) + end end def call(env) @@ -34,49 +37,90 @@ module ActionDispatch end class GetIp + + # IP v4 and v6 (with compression) validation regexp + # https://gist.github.com/1289635 + VALID_IP = %r{ + (^(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[0-9]{1,2})(\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[0-9]{1,2})){3}$) | # ip v4 + (^( + (([0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}) | # ip v6 not abbreviated + (([0-9A-Fa-f]{1,4}:){6}:[0-9A-Fa-f]{1,4}) | # ip v6 with double colon in the end + (([0-9A-Fa-f]{1,4}:){5}:([0-9A-Fa-f]{1,4}:)?[0-9A-Fa-f]{1,4}) | # - ip addresses v6 + (([0-9A-Fa-f]{1,4}:){4}:([0-9A-Fa-f]{1,4}:){0,2}[0-9A-Fa-f]{1,4}) | # - with + (([0-9A-Fa-f]{1,4}:){3}:([0-9A-Fa-f]{1,4}:){0,3}[0-9A-Fa-f]{1,4}) | # - double colon + (([0-9A-Fa-f]{1,4}:){2}:([0-9A-Fa-f]{1,4}:){0,4}[0-9A-Fa-f]{1,4}) | # - in the middle + (([0-9A-Fa-f]{1,4}:){6} ((\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b)\.){3} (\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b)) | # ip v6 with compatible to v4 + (([0-9A-Fa-f]{1,4}:){1,5}:((\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b)\.){3}(\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b)) | # ip v6 with compatible to v4 + (([0-9A-Fa-f]{1,4}:){1}:([0-9A-Fa-f]{1,4}:){0,4}((\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b)\.){3}(\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b)) | # ip v6 with compatible to v4 + (([0-9A-Fa-f]{1,4}:){0,2}:([0-9A-Fa-f]{1,4}:){0,3}((\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b)\.){3}(\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b)) | # ip v6 with compatible to v4 + (([0-9A-Fa-f]{1,4}:){0,3}:([0-9A-Fa-f]{1,4}:){0,2}((\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b)\.){3}(\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b)) | # ip v6 with compatible to v4 + (([0-9A-Fa-f]{1,4}:){0,4}:([0-9A-Fa-f]{1,4}:){1}((\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b)\.){3}(\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b)) | # ip v6 with compatible to v4 + (::([0-9A-Fa-f]{1,4}:){0,5}((\b((25[0-5])|(1\d{2})|(2[0-4]\d) |(\d{1,2}))\b)\.){3}(\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b)) | # ip v6 with compatible to v4 + ([0-9A-Fa-f]{1,4}::([0-9A-Fa-f]{1,4}:){0,5}[0-9A-Fa-f]{1,4}) | # ip v6 with compatible to v4 + (::([0-9A-Fa-f]{1,4}:){0,6}[0-9A-Fa-f]{1,4}) | # ip v6 with double colon at the begining + (([0-9A-Fa-f]{1,4}:){1,7}:) # ip v6 without ending + )$) + }x + def initialize(env, middleware) - @env = env - @middleware = middleware - @calculated_ip = false + @env = env + @middleware = middleware + @ip = nil end # Determines originating IP address. REMOTE_ADDR is the standard # but will be wrong if the user is behind a proxy. Proxies will set # HTTP_CLIENT_IP and/or HTTP_X_FORWARDED_FOR, so we prioritize those. # HTTP_X_FORWARDED_FOR may be a comma-delimited list in the case of - # multiple chained proxies. The last address which is not a known proxy - # will be the originating IP. + # multiple chained proxies. The first address which is in this list + # if it's not a known proxy will be the originating IP. + # Format of HTTP_X_FORWARDED_FOR: + # client_ip, proxy_ip1, proxy_ip2... + # http://en.wikipedia.org/wiki/X-Forwarded-For def calculate_ip - client_ip = @env['HTTP_CLIENT_IP'] - forwarded_ips = ips_from('HTTP_X_FORWARDED_FOR') - remote_addrs = ips_from('REMOTE_ADDR') + client_ip = @env['HTTP_CLIENT_IP'] + forwarded_ip = ips_from('HTTP_X_FORWARDED_FOR').first + remote_addrs = ips_from('REMOTE_ADDR') check_ip = client_ip && @middleware.check_ip - if check_ip && !forwarded_ips.include?(client_ip) + if check_ip && forwarded_ip != client_ip # We don't know which came from the proxy, and which from the user raise IpSpoofAttackError, "IP spoofing attack?!" \ "HTTP_CLIENT_IP=#{@env['HTTP_CLIENT_IP'].inspect}" \ "HTTP_X_FORWARDED_FOR=#{@env['HTTP_X_FORWARDED_FOR'].inspect}" end - not_proxy = client_ip || forwarded_ips.first || remote_addrs.first - - # Return first REMOTE_ADDR if there are no other options - not_proxy || ips_from('REMOTE_ADDR', :allow_proxies).first + client_ips = remove_proxies [client_ip, forwarded_ip, remote_addrs].flatten + if client_ips.present? + client_ips.first + else + # If there is no client ip we can return first valid proxy ip from REMOTE_ADDR + remote_addrs.find { |ip| valid_ip? ip } + end end def to_s - return @ip if @calculated_ip - @calculated_ip = true - @ip = calculate_ip + @ip ||= calculate_ip end - protected + private - def ips_from(header, allow_proxies = false) - ips = @env[header] ? @env[header].strip.split(/[,\s]+/) : [] - allow_proxies ? ips : ips.reject{|ip| ip =~ @middleware.proxies } + def ips_from(header) + @env[header] ? @env[header].strip.split(/[,\s]+/) : [] end + + def valid_ip?(ip) + ip =~ VALID_IP + end + + def not_a_proxy?(ip) + ip !~ @middleware.proxies + end + + def remove_proxies(ips) + ips.select { |ip| valid_ip?(ip) && not_a_proxy?(ip) } + end + end end diff --git a/actionpack/lib/action_dispatch/middleware/request_id.rb b/actionpack/lib/action_dispatch/middleware/request_id.rb index 6fff94707c..44290445d4 100644 --- a/actionpack/lib/action_dispatch/middleware/request_id.rb +++ b/actionpack/lib/action_dispatch/middleware/request_id.rb @@ -1,6 +1,5 @@ require 'securerandom' require 'active_support/core_ext/string/access' -require 'active_support/core_ext/object/blank' module ActionDispatch # Makes a unique request id available to the action_dispatch.request_id env variable (which is then accessible through diff --git a/actionpack/lib/action_dispatch/middleware/session/abstract_store.rb b/actionpack/lib/action_dispatch/middleware/session/abstract_store.rb index 6a8e690d18..7c12590c49 100644 --- a/actionpack/lib/action_dispatch/middleware/session/abstract_store.rb +++ b/actionpack/lib/action_dispatch/middleware/session/abstract_store.rb @@ -2,26 +2,22 @@ require 'rack/utils' require 'rack/request' require 'rack/session/abstract/id' require 'action_dispatch/middleware/cookies' -require 'active_support/core_ext/object/blank' +require 'action_dispatch/request/session' module ActionDispatch module Session class SessionRestoreError < StandardError #:nodoc: - end + attr_reader :original_exception + + def initialize(const_error) + @original_exception = const_error - module DestroyableSession - def destroy - clear - options = @env[Rack::Session::Abstract::ENV_SESSION_OPTIONS_KEY] if @env - options ||= {} - @by.send(:destroy_session, @env, options[:id], options) if @by - options[:id] = nil - @loaded = false + super("Session contains objects whose class definition isn't available.\n" + + "Remember to require the classes for all objects kept in the session.\n" + + "(Original exception: #{const_error.message} [#{const_error.class}])\n") end end - ::Rack::Session::Abstract::SessionHash.send :include, DestroyableSession - module Compatibility def initialize(app, options = {}) options[:key] ||= '_session_id' @@ -58,11 +54,8 @@ module ActionDispatch begin # Note that the regexp does not allow $1 to end with a ':' $1.constantize - rescue LoadError, NameError => const_error - raise ActionDispatch::Session::SessionRestoreError, - "Session contains objects whose class definition isn't available.\n" + - "Remember to require the classes for all objects kept in the session.\n" + - "(Original exception: #{const_error.message} [#{const_error.class}])\n" + rescue LoadError, NameError => e + raise ActionDispatch::Session::SessionRestoreError, e, e.backtrace end retry else @@ -71,9 +64,27 @@ module ActionDispatch end end + module SessionObject # :nodoc: + def prepare_session(env) + Request::Session.create(self, env, @default_options) + end + + def loaded_session?(session) + !session.is_a?(Request::Session) || session.loaded? + end + end + class AbstractStore < Rack::Session::Abstract::ID include Compatibility include StaleSessionCheck + include SessionObject + + private + + def set_cookie(env, session_id, cookie) + request = ActionDispatch::Request.new(env) + request.cookie_jar[key] = cookie + end end end end diff --git a/actionpack/lib/action_dispatch/middleware/session/cookie_store.rb b/actionpack/lib/action_dispatch/middleware/session/cookie_store.rb index 8ebf870b95..ce5f89ee5b 100644 --- a/actionpack/lib/action_dispatch/middleware/session/cookie_store.rb +++ b/actionpack/lib/action_dispatch/middleware/session/cookie_store.rb @@ -1,5 +1,4 @@ require 'active_support/core_ext/hash/keys' -require 'active_support/core_ext/object/blank' require 'action_dispatch/middleware/session/abstract_store' require 'rack/session/cookie' @@ -27,10 +26,10 @@ module ActionDispatch # CGI::Session instance as an argument. It's important that the secret # is not vulnerable to a dictionary attack. Therefore, you should choose # a secret consisting of random numbers and letters and more than 30 - # characters. Examples: + # characters. # - # :secret => '449fe2e7daee471bffae2fd8dc02313d' - # :secret => Proc.new { User.current_user.secret_key } + # secret: '449fe2e7daee471bffae2fd8dc02313d' + # secret: Proc.new { User.current_user.secret_key } # # * <tt>:digest</tt>: The message digest algorithm used to verify session # integrity defaults to 'SHA1' but may be any digest provided by OpenSSL, @@ -43,14 +42,22 @@ module ActionDispatch class CookieStore < Rack::Session::Cookie include Compatibility include StaleSessionCheck + include SessionObject + + # Override rack's method + def destroy_session(env, session_id, options) + new_sid = super + # Reset hash and Assign the new session id + env["action_dispatch.request.unsigned_session_cookie"] = new_sid ? { "session_id" => new_sid } : {} + new_sid + end private def unpacked_cookie_data(env) env["action_dispatch.request.unsigned_session_cookie"] ||= begin stale_session_check! do - request = ActionDispatch::Request.new(env) - if data = request.cookie_jar.signed[@key] + if data = get_cookie(env) data.stringify_keys! end data || {} @@ -59,12 +66,56 @@ module ActionDispatch end def set_session(env, sid, session_data, options) - session_data.merge("session_id" => sid) + session_data["session_id"] = sid + session_data end def set_cookie(env, session_id, cookie) + cookie_jar(env)[@key] = cookie + end + + def get_cookie(env) + cookie_jar(env)[@key] + end + + def cookie_jar(env) + request = ActionDispatch::Request.new(env) + request.cookie_jar.signed + end + end + + class EncryptedCookieStore < CookieStore + + private + + def cookie_jar(env) + request = ActionDispatch::Request.new(env) + request.cookie_jar.encrypted + end + end + + # This cookie store helps you upgrading apps that use +CookieStore+ to the new default +EncryptedCookieStore+ + # To use this CookieStore set + # + # Myapp::Application.config.session_store :upgrade_signature_to_encryption_cookie_store, key: '_myapp_session' + # + # in your config/initializers/session_store.rb + # + # You will also need to add + # + # Myapp::Application.config.secret_key_base = 'some secret' + # + # in your config/initializers/secret_token.rb, but do not remove +Myapp::Application.config.secret_token = 'some secret'+ + class UpgradeSignatureToEncryptionCookieStore < EncryptedCookieStore + private + + def get_cookie(env) + signed_using_old_secret_cookie_jar(env)[@key] || cookie_jar(env)[@key] + end + + def signed_using_old_secret_cookie_jar(env) request = ActionDispatch::Request.new(env) - request.cookie_jar.signed[@key] = cookie + request.cookie_jar.signed_using_old_secret end end end diff --git a/actionpack/lib/action_dispatch/middleware/session/mem_cache_store.rb b/actionpack/lib/action_dispatch/middleware/session/mem_cache_store.rb index 4dd9a946c2..b4d6629c35 100644 --- a/actionpack/lib/action_dispatch/middleware/session/mem_cache_store.rb +++ b/actionpack/lib/action_dispatch/middleware/session/mem_cache_store.rb @@ -1,14 +1,19 @@ require 'action_dispatch/middleware/session/abstract_store' -require 'rack/session/memcache' +begin + require 'rack/session/dalli' +rescue LoadError => e + $stderr.puts "You don't have dalli installed in your application. Please add it to your Gemfile and run bundle install" + raise e +end module ActionDispatch module Session - class MemCacheStore < Rack::Session::Memcache + class MemCacheStore < Rack::Session::Dalli include Compatibility include StaleSessionCheck + include SessionObject def initialize(app, options = {}) - require 'memcache' options[:expire_after] ||= options[:expires] super end diff --git a/actionpack/lib/action_dispatch/middleware/show_exceptions.rb b/actionpack/lib/action_dispatch/middleware/show_exceptions.rb index 836136eb95..2b37a8d026 100644 --- a/actionpack/lib/action_dispatch/middleware/show_exceptions.rb +++ b/actionpack/lib/action_dispatch/middleware/show_exceptions.rb @@ -6,20 +6,20 @@ module ActionDispatch # and calls an exceptions app that will wrap it in a format for the end user. # # The exceptions app should be passed as parameter on initialization - # of ShowExceptions. Everytime there is an exception, ShowExceptions will + # of ShowExceptions. Every time there is an exception, ShowExceptions will # store the exception in env["action_dispatch.exception"], rewrite the # PATH_INFO to the exception status code and call the rack app. - # + # # If the application returns a "X-Cascade" pass response, this middleware # will send an empty response as result with the correct status code. # If any exception happens inside the exceptions app, this middleware # catches the exceptions and returns a FAILSAFE_RESPONSE. class ShowExceptions - FAILSAFE_RESPONSE = [500, {'Content-Type' => 'text/html'}, - ["<html><body><h1>500 Internal Server Error</h1>" << + FAILSAFE_RESPONSE = [500, { 'Content-Type' => 'text/plain' }, + ["500 Internal Server Error\n" << "If you are the administrator of this website, then please read this web " << "application's log file and/or the web server's log file to find out what " << - "went wrong.</body></html>"]] + "went wrong."]] def initialize(app, exceptions_app) @app = app @@ -28,7 +28,7 @@ module ActionDispatch def call(env) begin - response = @app.call(env) + response = @app.call(env) rescue Exception => exception raise exception if env['action_dispatch.show_exceptions'] == false end diff --git a/actionpack/lib/action_dispatch/middleware/ssl.rb b/actionpack/lib/action_dispatch/middleware/ssl.rb new file mode 100644 index 0000000000..9098f4e170 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/ssl.rb @@ -0,0 +1,70 @@ +module ActionDispatch + class SSL + YEAR = 31536000 + + def self.default_hsts_options + { :expires => YEAR, :subdomains => false } + end + + def initialize(app, options = {}) + @app = app + + @hsts = options.fetch(:hsts, {}) + @hsts = {} if @hsts == true + @hsts = self.class.default_hsts_options.merge(@hsts) if @hsts + + @host = options[:host] + @port = options[:port] + end + + def call(env) + request = Request.new(env) + + if request.ssl? + status, headers, body = @app.call(env) + headers = hsts_headers.merge(headers) + flag_cookies_as_secure!(headers) + [status, headers, body] + else + redirect_to_https(request) + end + end + + private + def redirect_to_https(request) + url = URI(request.url) + url.scheme = "https" + url.host = @host if @host + url.port = @port if @port + headers = hsts_headers.merge('Content-Type' => 'text/html', + 'Location' => url.to_s) + + [301, headers, []] + end + + # http://tools.ietf.org/html/draft-hodges-strict-transport-sec-02 + def hsts_headers + if @hsts + value = "max-age=#{@hsts[:expires]}" + value += "; includeSubDomains" if @hsts[:subdomains] + { 'Strict-Transport-Security' => value } + else + {} + end + end + + def flag_cookies_as_secure!(headers) + if cookies = headers['Set-Cookie'] + cookies = cookies.split("\n") + + headers['Set-Cookie'] = cookies.map { |cookie| + if cookie !~ /;\s+secure(;|$)/ + "#{cookie}; secure" + else + cookie + end + }.join("\n") + end + end + end +end diff --git a/actionpack/lib/action_dispatch/middleware/stack.rb b/actionpack/lib/action_dispatch/middleware/stack.rb index 28e8fbdab8..bbf734f103 100644 --- a/actionpack/lib/action_dispatch/middleware/stack.rb +++ b/actionpack/lib/action_dispatch/middleware/stack.rb @@ -75,6 +75,11 @@ module ActionDispatch middlewares[i] end + def unshift(*args, &block) + middleware = self.class::Middleware.new(*args, &block) + middlewares.unshift(middleware) + end + def initialize_copy(other) self.middlewares = other.middlewares.dup end @@ -110,7 +115,7 @@ module ActionDispatch def build(app = nil, &block) app ||= block raise "MiddlewareStack#build requires an app" unless app - middlewares.reverse.inject(app) { |a, e| e.build(a) } + middlewares.freeze.reverse.inject(app) { |a, e| e.build(a) } end protected diff --git a/actionpack/lib/action_dispatch/middleware/static.rb b/actionpack/lib/action_dispatch/middleware/static.rb index 63b7422287..e3b15b43b9 100644 --- a/actionpack/lib/action_dispatch/middleware/static.rb +++ b/actionpack/lib/action_dispatch/middleware/static.rb @@ -29,7 +29,7 @@ module ActionDispatch def ext @ext ||= begin - ext = ::ActionController::Base.page_cache_extension + ext = ::ActionController::Base.default_static_extension "{,#{ext},/index#{ext}}" end end @@ -39,6 +39,7 @@ module ActionDispatch end def escape_glob_chars(path) + path.force_encoding('binary') if path.respond_to? :force_encoding path.gsub(/[*?{}\[\]]/, "\\\\\\&") end end diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/_request_and_response.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/_request_and_response.erb index 0c5bafa666..823f5d25b6 100644 --- a/actionpack/lib/action_dispatch/middleware/templates/rescues/_request_and_response.erb +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/_request_and_response.erb @@ -12,8 +12,8 @@ request_dump = clean_params.empty? ? 'None' : clean_params.inspect.gsub(',', ",\n") - def debug_hash(hash) - hash.sort_by { |k, v| k.to_s }.map { |k, v| "#{k}: #{v.inspect rescue $!.message}" }.join("\n") + def debug_hash(object) + object.to_hash.sort_by { |k, v| k.to_s }.map { |k, v| "#{k}: #{v.inspect rescue $!.message}" }.join("\n") end unless self.class.method_defined?(:debug_hash) %> diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/diagnostics.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/diagnostics.erb index 4b9d3141d5..c5043c5e7b 100644 --- a/actionpack/lib/action_dispatch/middleware/templates/rescues/diagnostics.erb +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/diagnostics.erb @@ -6,5 +6,5 @@ </h1> <pre><%=h @exception.message %></pre> -<%= render :template => "rescues/_trace" %> -<%= render :template => "rescues/_request_and_response" %> +<%= render template: "rescues/_trace" %> +<%= render template: "rescues/_request_and_response" %> diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/routing_error.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/routing_error.erb index f06c07daa5..6c903d6a17 100644 --- a/actionpack/lib/action_dispatch/middleware/templates/rescues/routing_error.erb +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/routing_error.erb @@ -10,6 +10,16 @@ </ol> </p> <% end %> +<%= render template: "rescues/_trace" %> + +<h2> + Routes +</h2> + <p> - Try running <code>rake routes</code> for more information on available routes. -</p>
\ No newline at end of file + Routes match in priority from top to bottom +</p> + +<%= render layout: "routes/route_wrapper" do %> + <%= render partial: "routes/route", collection: @routes %> +<% end %> diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/template_error.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/template_error.erb index c658559be9..a1b377f68c 100644 --- a/actionpack/lib/action_dispatch/middleware/templates/rescues/template_error.erb +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/template_error.erb @@ -13,5 +13,5 @@ <p><%=h @exception.sub_template_message %></p> -<%= render :template => "rescues/_trace" %> -<%= render :template => "rescues/_request_and_response" %> +<%= render template: "rescues/_trace" %> +<%= render template: "rescues/_request_and_response" %> diff --git a/actionpack/lib/action_dispatch/middleware/templates/routes/_route.html.erb b/actionpack/lib/action_dispatch/middleware/templates/routes/_route.html.erb new file mode 100644 index 0000000000..400ae97d22 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/templates/routes/_route.html.erb @@ -0,0 +1,16 @@ +<tr class='route_row' data-helper='path'> + <td data-route-name='<%= route[:name] %>'> + <% if route[:name].present? %> + <%= route[:name] %><span class='helper'>_path</span> + <% end %> + </td> + <td data-route-verb='<%= route[:verb] %>'> + <%= route[:verb] %> + </td> + <td data-route-path='<%= route[:path] %>'> + <%= route[:path] %> + </td> + <td data-route-reqs='<%= route[:reqs] %>'> + <%= route[:reqs] %> + </td> +</tr> diff --git a/actionpack/lib/action_dispatch/middleware/templates/routes/_route_wrapper.html.erb b/actionpack/lib/action_dispatch/middleware/templates/routes/_route_wrapper.html.erb new file mode 100644 index 0000000000..dc17cb77ef --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/templates/routes/_route_wrapper.html.erb @@ -0,0 +1,56 @@ +<style type='text/css'> + #route_table td {padding: 0 30px;} + #route_table {margin: 0 auto 0;} +</style> + +<table id='route_table' class='route_table'> + <thead> + <tr> + <th>Helper<br /> + <%= link_to "Path", "#", 'data-route-helper' => '_path', + title: "Returns a relative path (without the http or domain)" %> / + <%= link_to "Url", "#", 'data-route-helper' => '_url', + title: "Returns an absolute url (with the http and domain)" %> + </th> + <th>HTTP Verb</th> + <th>Path</th> + <th>Controller#Action</th> + </tr> + </thead> + <tbody> + <%= yield %> + </tbody> +</table> + +<script type='text/javascript'> + function each(elems, func) { + if (!elems instanceof Array) { elems = [elems]; } + for (var i = elems.length; i--; ) { + func(elems[i]); + } + } + + function setValOn(elems, val) { + each(elems, function(elem) { + elem.innerHTML = val; + }); + } + + function onClick(elems, func) { + each(elems, function(elem) { + elem.onclick = func; + }); + } + + // Enables functionality to toggle between `_path` and `_url` helper suffixes + function setupRouteToggleHelperLinks() { + var toggleLinks = document.querySelectorAll('#route_table [data-route-helper]'); + onClick(toggleLinks, function(){ + var helperTxt = this.getAttribute("data-route-helper"); + var helperElems = document.querySelectorAll('[data-route-name] span.helper'); + setValOn(helperElems, helperTxt); + }); + } + + setupRouteToggleHelperLinks(); +</script> diff --git a/actionpack/lib/action_dispatch/railtie.rb b/actionpack/lib/action_dispatch/railtie.rb index 62f906219c..5a835ae439 100644 --- a/actionpack/lib/action_dispatch/railtie.rb +++ b/actionpack/lib/action_dispatch/railtie.rb @@ -1,7 +1,7 @@ require "action_dispatch" module ActionDispatch - class Railtie < Rails::Railtie + class Railtie < Rails::Railtie # :nodoc: config.action_dispatch = ActiveSupport::OrderedOptions.new config.action_dispatch.x_sendfile_header = nil config.action_dispatch.ip_spoofing_check = true @@ -12,17 +12,25 @@ module ActionDispatch config.action_dispatch.rescue_templates = { } config.action_dispatch.rescue_responses = { } config.action_dispatch.default_charset = nil + config.action_dispatch.rack_cache = false + config.action_dispatch.http_auth_salt = 'http authentication' + config.action_dispatch.signed_cookie_salt = 'signed cookie' + config.action_dispatch.encrypted_cookie_salt = 'encrypted cookie' + config.action_dispatch.encrypted_signed_cookie_salt = 'signed encrypted cookie' - config.action_dispatch.rack_cache = { - :metastore => "rails:/", - :entitystore => "rails:/", - :verbose => false + config.action_dispatch.default_headers = { + 'X-Frame-Options' => 'SAMEORIGIN', + 'X-XSS-Protection' => '1; mode=block', + 'X-Content-Type-Options' => 'nosniff' } + config.eager_load_namespaces << ActionDispatch + initializer "action_dispatch.configure" do |app| ActionDispatch::Http::URL.tld_length = app.config.action_dispatch.tld_length ActionDispatch::Request.ignore_accept_header = app.config.action_dispatch.ignore_accept_header ActionDispatch::Response.default_charset = app.config.action_dispatch.default_charset || app.config.encoding + ActionDispatch::Response.default_headers = app.config.action_dispatch.default_headers ActionDispatch::ExceptionWrapper.rescue_responses.merge!(config.action_dispatch.rescue_responses) ActionDispatch::ExceptionWrapper.rescue_templates.merge!(config.action_dispatch.rescue_templates) diff --git a/actionpack/lib/action_dispatch/request/session.rb b/actionpack/lib/action_dispatch/request/session.rb new file mode 100644 index 0000000000..a05a23d953 --- /dev/null +++ b/actionpack/lib/action_dispatch/request/session.rb @@ -0,0 +1,177 @@ +require 'rack/session/abstract/id' + +module ActionDispatch + class Request < Rack::Request + # Session is responsible for lazily loading the session from store. + class Session # :nodoc: + ENV_SESSION_KEY = Rack::Session::Abstract::ENV_SESSION_KEY # :nodoc: + ENV_SESSION_OPTIONS_KEY = Rack::Session::Abstract::ENV_SESSION_OPTIONS_KEY # :nodoc: + + def self.create(store, env, default_options) + session_was = find env + session = Request::Session.new(store, env) + session.merge! session_was if session_was + + set(env, session) + Options.set(env, Request::Session::Options.new(store, env, default_options)) + session + end + + def self.find(env) + env[ENV_SESSION_KEY] + end + + def self.set(env, session) + env[ENV_SESSION_KEY] = session + end + + class Options #:nodoc: + def self.set(env, options) + env[ENV_SESSION_OPTIONS_KEY] = options + end + + def self.find(env) + env[ENV_SESSION_OPTIONS_KEY] + end + + def initialize(by, env, default_options) + @by = by + @env = env + @delegate = default_options.dup + end + + def [](key) + if key == :id + @delegate.fetch(key) { + @delegate[:id] = @by.send(:extract_session_id, @env) + } + else + @delegate[key] + end + end + + def []=(k,v); @delegate[k] = v; end + def to_hash; @delegate.dup; end + def values_at(*args); @delegate.values_at(*args); end + end + + def initialize(by, env) + @by = by + @env = env + @delegate = {} + @loaded = false + @exists = nil # we haven't checked yet + end + + def options + Options.find @env + end + + def destroy + clear + options = self.options || {} + new_sid = @by.send(:destroy_session, @env, options[:id], options) + options[:id] = new_sid # Reset session id with a new value or nil + + # Load the new sid to be written with the response + @loaded = false + load_for_write! + end + + def [](key) + load_for_read! + @delegate[key.to_s] + end + + def has_key?(key) + load_for_read! + @delegate.key?(key.to_s) + end + alias :key? :has_key? + alias :include? :has_key? + + def keys + @delegate.keys + end + + def values + @delegate.values + end + + def []=(key, value) + load_for_write! + @delegate[key.to_s] = value + end + + def clear + load_for_write! + @delegate.clear + end + + def to_hash + load_for_read! + @delegate.dup.delete_if { |_,v| v.nil? } + end + + def update(hash) + load_for_write! + @delegate.update stringify_keys(hash) + end + + def delete(key) + load_for_write! + @delegate.delete key.to_s + end + + def inspect + if loaded? + super + else + "#<#{self.class}:0x#{(object_id << 1).to_s(16)} not yet loaded>" + end + end + + def exists? + return @exists unless @exists.nil? + @exists = @by.send(:session_exists?, @env) + end + + def loaded? + @loaded + end + + def empty? + load_for_read! + @delegate.empty? + end + + def merge!(other) + load_for_write! + @delegate.merge!(other) + end + + private + + def load_for_read! + load! if !loaded? && exists? + end + + def load_for_write! + load! unless loaded? + end + + def load! + id, session = @by.load_session @env + options[:id] = id + @delegate.replace(stringify_keys(session)) + @loaded = true + end + + def stringify_keys(other) + other.each_with_object({}) { |(key, value), hash| + hash[key.to_s] = value + } + end + end + end +end diff --git a/actionpack/lib/action_dispatch/routing.rb b/actionpack/lib/action_dispatch/routing.rb index 38a0270151..d55eb8109a 100644 --- a/actionpack/lib/action_dispatch/routing.rb +++ b/actionpack/lib/action_dispatch/routing.rb @@ -1,3 +1,4 @@ +# encoding: UTF-8 require 'active_support/core_ext/object/to_param' require 'active_support/core_ext/regexp' @@ -60,7 +61,7 @@ module ActionDispatch # directory by using +scope+. +scope+ takes additional options which # apply to all enclosed routes. # - # scope :path => "/cpanel", :as => 'admin' do + # scope path: "/cpanel", as: 'admin' do # resources :posts, :comments # end # @@ -77,22 +78,22 @@ module ActionDispatch # Example: # # # In routes.rb - # match '/login' => 'accounts#login', :as => 'login' + # match '/login' => 'accounts#login', as: 'login' # # # With render, redirect_to, tests, etc. # redirect_to login_url # # Arguments can be passed as well. # - # redirect_to show_item_path(:id => 25) + # redirect_to show_item_path(id: 25) # # Use <tt>root</tt> as a shorthand to name a route for the root path "/". # # # In routes.rb - # root :to => 'blogs#index' + # root to: 'blogs#index' # # # would recognize http://www.example.com/ as - # params = { :controller => 'blogs', :action => 'index' } + # params = { controller: 'blogs', action: 'index' } # # # and provide these named routes # root_url # => 'http://www.example.com/' @@ -109,43 +110,43 @@ module ActionDispatch # end # # # provides named routes for show, delete, and edit - # link_to @article.title, show_path(:id => @article.id) + # link_to @article.title, show_path(id: @article.id) # # == Pretty URLs # # Routes can generate pretty URLs. For example: # - # match '/articles/:year/:month/:day' => 'articles#find_by_id', :constraints => { - # :year => /\d{4}/, - # :month => /\d{1,2}/, - # :day => /\d{1,2}/ + # match '/articles/:year/:month/:day' => 'articles#find_by_id', constraints: { + # year: /\d{4}/, + # month: /\d{1,2}/, + # day: /\d{1,2}/ # } # # Using the route above, the URL "http://localhost:3000/articles/2005/11/06" # maps to # - # params = {:year => '2005', :month => '11', :day => '06'} + # params = {year: '2005', month: '11', day: '06'} # # == Regular Expressions and parameters # You can specify a regular expression to define a format for a parameter. # # controller 'geocode' do - # match 'geocode/:postalcode' => :show, :constraints => { - # :postalcode => /\d{5}(-\d{4})?/ + # match 'geocode/:postalcode' => :show, constraints: { + # postalcode: /\d{5}(-\d{4})?/ # } # # Constraints can include the 'ignorecase' and 'extended syntax' regular # expression modifiers: # # controller 'geocode' do - # match 'geocode/:postalcode' => :show, :constraints => { - # :postalcode => /hx\d\d\s\d[a-z]{2}/i + # match 'geocode/:postalcode' => :show, constraints: { + # postalcode: /hx\d\d\s\d[a-z]{2}/i # } # end # # controller 'geocode' do - # match 'geocode/:postalcode' => :show, :constraints => { - # :postalcode => /# Postcode format + # match 'geocode/:postalcode' => :show, constraints: { + # postalcode: /# Postcode format # \d{5} #Prefix # (-\d{4})? #Suffix # /x @@ -171,9 +172,9 @@ module ActionDispatch # Suppose you get an incoming request for <tt>/blog/edit/22</tt>, you'll end # up with: # - # params = { :controller => 'blog', - # :action => 'edit', - # :id => '22' + # params = { controller: 'blog', + # action: 'edit', + # id: '22' # } # # By not relying on default routes, you improve the security of your @@ -190,10 +191,8 @@ module ActionDispatch # <tt>:any</tt> which means that the route will respond to any of the HTTP # methods. # - # Examples: - # - # match 'post/:id' => 'posts#show', :via => :get - # match 'post/:id' => 'posts#create_comment', :via => :post + # match 'post/:id' => 'posts#show', via: :get + # match 'post/:id' => 'posts#create_comment', via: :post # # Now, if you POST to <tt>/posts/:id</tt>, it will route to the <tt>create_comment</tt> action. A GET on the same # URL will route to the <tt>show</tt> action. @@ -203,8 +202,6 @@ module ActionDispatch # An alternative method of specifying which HTTP method a route should respond to is to use the helper # methods <tt>get</tt>, <tt>post</tt>, <tt>patch</tt>, <tt>put</tt> and <tt>delete</tt>. # - # Examples: - # # get 'post/:id' => 'posts#show' # post 'post/:id' => 'posts#create_comment' # @@ -218,6 +215,12 @@ module ActionDispatch # # match "/stories" => redirect("/posts") # + # == Unicode character routes + # + # You can specify unicode character routes in your router: + # + # match "こんにちは" => "welcome#index" + # # == Routing to Rack Applications # # Instead of a String, like <tt>posts#index</tt>, which corresponds to the @@ -242,7 +245,7 @@ module ActionDispatch # === +assert_routing+ # # def test_movie_route_properly_splits - # opts = {:controller => "plugin", :action => "checkout", :id => "2"} + # opts = {controller: "plugin", action: "checkout", id: "2"} # assert_routing "plugin/checkout/2", opts # end # @@ -251,7 +254,7 @@ module ActionDispatch # === +assert_recognizes+ # # def test_route_has_options - # opts = {:controller => "plugin", :action => "show", :id => "12"} + # opts = {controller: "plugin", action: "show", id: "12"} # assert_recognizes opts, "/plugins/show/12" # end # diff --git a/actionpack/lib/action_dispatch/routing/inspector.rb b/actionpack/lib/action_dispatch/routing/inspector.rb new file mode 100644 index 0000000000..8d7461ecc3 --- /dev/null +++ b/actionpack/lib/action_dispatch/routing/inspector.rb @@ -0,0 +1,121 @@ +require 'delegate' + +module ActionDispatch + module Routing + class RouteWrapper < SimpleDelegator + def endpoint + rack_app ? rack_app.inspect : "#{controller}##{action}" + end + + def constraints + requirements.except(:controller, :action) + end + + def rack_app(app = self.app) + @rack_app ||= begin + class_name = app.class.name.to_s + if class_name == "ActionDispatch::Routing::Mapper::Constraints" + rack_app(app.app) + elsif ActionDispatch::Routing::Redirect === app || class_name !~ /^ActionDispatch::Routing/ + app + end + end + end + + def verb + super.source.gsub(/[$^]/, '') + end + + def path + super.spec.to_s + end + + def name + super.to_s + end + + def reqs + @reqs ||= begin + reqs = endpoint + reqs += " #{constraints.to_s}" unless constraints.empty? + reqs + end + end + + def controller + requirements[:controller] || ':controller' + end + + def action + requirements[:action] || ':action' + end + + def internal? + controller =~ %r{\Arails/(info|welcome)} || path =~ %r{\A#{Rails.application.config.assets.prefix}} + end + + def engine? + rack_app && rack_app.respond_to?(:routes) + end + end + + ## + # This class is just used for displaying route information when someone + # executes `rake routes`. People should not use this class. + class RoutesInspector # :nodoc: + def initialize + @engines = Hash.new + end + + def format(all_routes, filter = nil) + if filter + all_routes = all_routes.select{ |route| route.defaults[:controller] == filter } + end + + routes = collect_routes(all_routes) + + formatted_routes(routes) + + formatted_routes_for_engines + end + + def collect_routes(routes) + routes = routes.collect do |route| + RouteWrapper.new(route) + end.reject do |route| + route.internal? + end.collect do |route| + collect_engine_routes(route) + + {:name => route.name, :verb => route.verb, :path => route.path, :reqs => route.reqs } + end + end + + def collect_engine_routes(route) + name = route.endpoint + return unless route.engine? + return if @engines[name] + + routes = route.rack_app.routes + if routes.is_a?(ActionDispatch::Routing::RouteSet) + @engines[name] = collect_routes(routes.routes) + end + end + + def formatted_routes_for_engines + @engines.map do |name, routes| + ["\nRoutes for #{name}:"] + formatted_routes(routes) + end.flatten + end + + def formatted_routes(routes) + name_width = routes.map{ |r| r[:name].length }.max + verb_width = routes.map{ |r| r[:verb].length }.max + path_width = routes.map{ |r| r[:path].length }.max + + routes.map do |r| + "#{r[:name].rjust(name_width)} #{r[:verb].ljust(verb_width)} #{r[:path].ljust(path_width)} #{r[:reqs]}" + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/routing/mapper.rb b/actionpack/lib/action_dispatch/routing/mapper.rb index cd215034dc..3c99932e72 100644 --- a/actionpack/lib/action_dispatch/routing/mapper.rb +++ b/actionpack/lib/action_dispatch/routing/mapper.rb @@ -1,6 +1,7 @@ require 'active_support/core_ext/hash/except' -require 'active_support/core_ext/object/blank' -require 'active_support/core_ext/object/inclusion' +require 'active_support/core_ext/hash/reverse_merge' +require 'active_support/core_ext/hash/slice' +require 'active_support/core_ext/enumerable' require 'active_support/inflector' require 'action_dispatch/routing/redirection' @@ -34,6 +35,8 @@ module ActionDispatch } return true + ensure + req.reset_parameters end def call(env) @@ -58,6 +61,16 @@ module ActionDispatch @options = (@scope[:options] || {}).merge(options) @path = normalize_path(path) normalize_options! + + via_all = @options.delete(:via) if @options[:via] == :all + + if !via_all && request_method_condition.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 GET, use `get` in the router:\n\n" \ + " Instead of: match \"controller#action\"\n" \ + " Do: get \"controller#action\"" + raise msg + end end def to_route @@ -87,6 +100,10 @@ module ActionDispatch raise ArgumentError, "Regexp multiline option not allowed in routing requirements: #{requirement.inspect}" end end + + if @options[:constraints].is_a?(Hash) + (@options[:defaults] ||= {}).reverse_merge!(defaults_from_constraints(@options[:constraints])) + end end # match "account/overview" @@ -104,7 +121,7 @@ module ActionDispatch # 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' } + # => { controller: 'admin/products', action: 'show', id: '1' } @options[:controller] ||= /.+?/ end @@ -135,13 +152,13 @@ module ActionDispatch end def conditions - { :path_info => @path }.merge(constraints).merge(request_method_condition) + { :path_info => @path }.merge!(constraints).merge!(request_method_condition) end def requirements @requirements ||= (@options[:constraints].is_a?(Hash) ? @options[:constraints] : {}).tap do |requirements| requirements.reverse_merge!(@scope[:constraints]) if @scope[:constraints] - @options.each { |k, v| requirements[k] = v if v.is_a?(Regexp) } + @options.each { |k, v| requirements[k] ||= v if v.is_a?(Regexp) } end end @@ -232,6 +249,11 @@ module ActionDispatch def default_action @options[:action] || @scope[:action] end + + def defaults_from_constraints(constraints) + url_keys = [:protocol, :subdomain, :domain, :host, :port] + constraints.select { |k, v| url_keys.include?(k) && (v.is_a?(String) || v.is_a?(Fixnum)) } + end end # Invokes Rack::Mount::Utils.normalize path and ensure that @@ -239,18 +261,18 @@ module ActionDispatch # for root cases, where the latter is the correct one. def self.normalize_path(path) path = Journey::Router::Utils.normalize_path(path) - path.gsub!(%r{/(\(+)/?}, '\1/') unless path =~ %r{^/\(+[^/]+\)$} + path.gsub!(%r{/(\(+)/?}, '\1/') unless path =~ %r{^/\(+[^)]+\)$} path end def self.normalize_name(name) - normalize_path(name)[1..-1].gsub("/", "_") + normalize_path(name)[1..-1].tr("/", "_") end module Base # You can specify what Rails should route "/" to with the root method: # - # root :to => 'pages#main' + # root to: 'pages#main' # # For options, see +match+, as +root+ uses it internally. # @@ -263,7 +285,7 @@ module ActionDispatch # of most Rails applications, this is beneficial. def root(options = {}) options = { :to => options } if options.is_a?(String) - match '/', { :as => :root }.merge(options) + match '/', { :as => :root, :via => :get }.merge!(options) end # Matches a url pattern to one or more routes. Any symbols in a pattern @@ -277,7 +299,7 @@ module ActionDispatch # and +:action+ to the controller's action. A pattern can also map # wildcard segments (globs) to params: # - # match 'songs/*category/:title' => 'songs#show' + # match 'songs/*category/:title', to: 'songs#show' # # # 'songs/rock/classic/stairway-to-heaven' sets # # params[:category] = 'rock/classic' @@ -287,16 +309,20 @@ module ActionDispatch # +:controller+ should be set in options or hash shorthand. Examples: # # match 'photos/:id' => 'photos#show' - # match 'photos/:id', :to => 'photos#show' - # match 'photos/:id', :controller => 'photos', :action => 'show' + # match 'photos/:id', to: 'photos#show' + # match 'photos/:id', controller: 'photos', action: 'show' # # A pattern can also point to a +Rack+ endpoint i.e. anything that # responds to +call+: # - # match 'photos/:id' => lambda {|hash| [200, {}, "Coming soon"] } - # match 'photos/:id' => PhotoRackApp + # match 'photos/:id', to: lambda {|hash| [200, {}, "Coming soon"] } + # match 'photos/:id', to: PhotoRackApp # # Yes, controller actions are just rack endpoints - # match 'photos/:id' => PhotosController.action(:show) + # match 'photos/:id', to: PhotosController.action(:show) + # + # Because request various HTTP verbs with a single action has security + # implications, is recommendable use HttpHelpers[rdoc-ref:HttpHelpers] + # instead +match+ # # === Options # @@ -314,7 +340,7 @@ module ActionDispatch # [:module] # The namespace for :controller. # - # match 'path' => 'c#a', :module => 'sekret', :controller => 'posts' + # match 'path', to: 'c#a', module: 'sekret', controller: 'posts' # #=> Sekret::PostsController # # See <tt>Scoping#namespace</tt> for its scope equivalent. @@ -325,16 +351,17 @@ module ActionDispatch # [:via] # Allowed HTTP verb(s) for route. # - # match 'path' => 'c#a', :via => :get - # match 'path' => 'c#a', :via => [:get, :post] + # 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' - # match 'path', :to => lambda { |env| [200, {}, "Success!"] } - # match 'path', :to => RackApp + # match 'path', to: 'controller#action' + # match 'path', to: lambda { |env| [200, {}, "Success!"] } + # match 'path', to: RackApp # # [:on] # Shorthand for wrapping routes in a specific RESTful context. Valid @@ -342,14 +369,14 @@ module ActionDispatch # <tt>resource(s)</tt> block. For example: # # resource :bar do - # match 'foo' => 'c#a', :on => :member, :via => [:get, :post] + # match 'foo', to: 'c#a', on: :member, via: [:get, :post] # end # # Is equivalent to: # # resource :bar do # member do - # match 'foo' => 'c#a', :via => [:get, :post] + # match 'foo', to: 'c#a', via: [:get, :post] # end # end # @@ -357,12 +384,12 @@ module ActionDispatch # Constrains parameters with a hash of regular expressions or an # object that responds to <tt>matches?</tt> # - # match 'path/:id', :constraints => { :id => /[A-Z]\d{5}/ } + # match 'path/:id', constraints: { id: /[A-Z]\d{5}/ } # # class Blacklist # def matches?(request) request.remote_ip == '1.2.3.4' end # end - # match 'path' => 'c#a', :constraints => Blacklist.new + # match 'path', to: 'c#a', constraints: Blacklist.new # # See <tt>Scoping#constraints</tt> for more examples with its scope # equivalent. @@ -371,7 +398,7 @@ module ActionDispatch # Sets defaults for parameters # # # Sets params[:format] to 'jpg' by default - # match 'path' => 'c#a', :defaults => { :format => 'jpg' } + # match 'path', to: 'c#a', defaults: { format: 'jpg' } # # See <tt>Scoping#defaults</tt> for its scope equivalent. # @@ -380,13 +407,17 @@ module ActionDispatch # false, the pattern matches any request prefixed with the given path. # # # Matches any request starting with 'path' - # match 'path' => 'c#a', :anchor => false + # match 'path', to: 'c#a', anchor: false + # + # [:format] + # Allows you to specify the default value for optional +format+ + # segment or disable it by supplying +false+. def match(path, options=nil) end # Mount a Rack-based application to be used within the application. # - # mount SomeRackApp, :at => "some_route" + # mount SomeRackApp, at: "some_route" # # Alternatively: # @@ -399,7 +430,7 @@ module ActionDispatch # 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") + # 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. @@ -407,6 +438,10 @@ module ActionDispatch 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, v| k.respond_to?(:call) } options.delete(app) if app @@ -414,7 +449,8 @@ module ActionDispatch raise "A rack application must be specified" unless path - options[:as] ||= app_name(app) + options[:as] ||= app_name(app) + options[:via] ||= :all match(path, options.merge(:to => app, :anchor => false, :format => false)) @@ -441,7 +477,7 @@ module ActionDispatch app.railtie_name else class_name = app.class.is_a?(Class) ? app.name : app.class.name - ActiveSupport::Inflector.underscore(class_name).gsub("/", "_") + ActiveSupport::Inflector.underscore(class_name).tr("/", "_") end end @@ -460,9 +496,7 @@ module ActionDispatch 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) } - prefix = _routes.url_helpers.send("#{name}_path", prefix_options) - prefix = '' if prefix == '/' - prefix + _routes.url_helpers.send("#{name}_path", prefix_options) end end end @@ -470,51 +504,41 @@ module ActionDispatch module HttpHelpers # Define a route that only recognizes HTTP GET. - # For supported arguments, see <tt>Base#match</tt>. + # For supported arguments, see match[rdoc-ref:Base#match] # - # Example: - # - # get 'bacon', :to => 'food#bacon' + # 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 <tt>Base#match</tt>. - # - # Example: + # For supported arguments, see match[rdoc-ref:Base#match] # - # post 'bacon', :to => 'food#bacon' + # 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 <tt>Base#match</tt>. - # - # Example: + # For supported arguments, see match[rdoc-ref:Base#match] # - # patch 'bacon', :to => 'food#bacon' + # 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 <tt>Base#match</tt>. + # For supported arguments, see match[rdoc-ref:Base#match] # - # Example: - # - # put 'bacon', :to => 'food#bacon' + # 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 <tt>Base#match</tt>. - # - # Example: + # For supported arguments, see match[rdoc-ref:Base#match] # - # delete 'broccoli', :to => 'food#broccoli' + # delete 'broccoli', to: 'food#broccoli' def delete(*args, &block) map_method(:delete, args, &block) end @@ -522,7 +546,8 @@ module ActionDispatch private def map_method(method, args, &block) options = args.extract_options! - options[:via] = method + options[:via] = method + options[:path] ||= args.first if args.first.is_a?(String) match(*args, options, &block) self end @@ -552,13 +577,13 @@ module ActionDispatch # If you want to route /posts (without the prefix /admin) to # <tt>Admin::PostsController</tt>, you could use # - # scope :module => "admin" do + # scope module: "admin" do # resources :posts # end # # or, for a single case # - # resources :posts, :module => "admin" + # resources :posts, module: "admin" # # If you want to route /admin/posts to +PostsController+ # (without the Admin:: module prefix), you could use @@ -569,7 +594,7 @@ module ActionDispatch # # or, for a single case # - # resources :posts, :path => "/admin/posts" + # 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 @@ -587,7 +612,7 @@ module ActionDispatch # # Take the following route definition as an example: # - # scope :path => ":account_id", :as => "account" do + # scope path: ":account_id", as: "account" do # resources :projects # end # @@ -599,31 +624,30 @@ module ActionDispatch # # Takes same options as <tt>Base#match</tt> and <tt>Resources#resources</tt>. # - # === Examples - # # # route /posts (without the prefix /admin) to <tt>Admin::PostsController</tt> - # scope :module => "admin" do + # scope module: "admin" do # resources :posts # end # # # prefix the posts resource's requests with '/admin' - # scope :path => "/admin" do + # scope path: "/admin" do # resources :posts # end # # # prefix the routing helper name: +sekret_posts_path+ instead of +posts_path+ - # scope :as => "sekret" do + # scope as: "sekret" do # resources :posts # end def scope(*args) - options = args.extract_options! - options = options.dup - - options[:path] = args.first if args.first.is_a?(String) + options = args.extract_options!.dup recover = {} + options[:path] = args.flatten.join('/') if args.any? options[:constraints] ||= {} - unless options[:constraints].is_a?(Hash) + + if options[:constraints].is_a?(Hash) + (options[:defaults] ||= {}).reverse_merge!(defaults_from_constraints(options[:constraints])) + else block, options[:constraints] = options[:constraints], {} end @@ -634,8 +658,8 @@ module ActionDispatch end end - recover[:block] = @scope[:blocks] - @scope[:blocks] = merge_blocks_scope(@scope[:blocks], block) + recover[:blocks] = @scope[:blocks] + @scope[:blocks] = merge_blocks_scope(@scope[:blocks], block) recover[:options] = @scope[:options] @scope[:options] = merge_options_scope(@scope[:options], options) @@ -643,19 +667,13 @@ module ActionDispatch yield self ensure - scope_options.each do |option| - @scope[option] = recover[option] if recover.has_key?(option) - end - - @scope[:options] = recover[:options] - @scope[:blocks] = recover[:block] + @scope.merge!(recover) end # Scopes routes to a specific controller # - # Example: # controller "food" do - # match "bacon", :action => "bacon" + # match "bacon", action: "bacon" # end def controller(controller, options={}) options[:controller] = controller @@ -686,20 +704,18 @@ module ActionDispatch # For options, see <tt>Base#match</tt>. For +:shallow_path+ option, see # <tt>Resources#resources</tt>. # - # === Examples - # # # accessible through /sekret/posts rather than /admin/posts - # namespace :admin, :path => "sekret" do + # namespace :admin, path: "sekret" do # resources :posts # end # # # maps to <tt>Sekret::PostsController</tt> rather than <tt>Admin::PostsController</tt> - # namespace :admin, :module => "sekret" do + # namespace :admin, module: "sekret" do # resources :posts # end # # # generates +sekret_posts_path+ rather than +admin_posts_path+ - # namespace :admin, :as => "sekret" do + # namespace :admin, as: "sekret" do # resources :posts # end def namespace(path, options = {}) @@ -713,7 +729,7 @@ module ActionDispatch # 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 + # constraints(id: /\d+\.\d+/) do # resources :posts # end # @@ -723,7 +739,7 @@ module ActionDispatch # You may use this to also restrict other parameters: # # resources :posts do - # constraints(:post_id => /\d+\.\d+/) do + # constraints(post_id: /\d+\.\d+/) do # resources :comments # end # end @@ -732,7 +748,7 @@ module ActionDispatch # # Routes can also be constrained to an IP or a certain range of IP addresses: # - # constraints(:ip => /192.168.\d+.\d+/) do + # constraints(ip: /192\.168\.\d+\.\d+/) do # resources :posts # end # @@ -769,8 +785,8 @@ module ActionDispatch end # Allows you to set default parameters for a route, such as this: - # defaults :id => 'home' do - # match 'scoped_pages/(:id)', :to => 'pages#show' + # 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 = {}) @@ -825,7 +841,7 @@ module ActionDispatch end def merge_options_scope(parent, child) #:nodoc: - (parent || {}).except(*override_keys(child)).merge(child) + (parent || {}).except(*override_keys(child)).merge!(child) end def merge_shallow_scope(parent, child) #:nodoc: @@ -835,6 +851,11 @@ module ActionDispatch def override_keys(child) #:nodoc: child.key?(:only) || child.key?(:except) ? [:only, :except] : [] end + + def defaults_from_constraints(constraints) + url_keys = [:protocol, :subdomain, :domain, :host, :port] + constraints.select { |k, v| url_keys.include?(k) && (v.is_a?(String) || v.is_a?(Fixnum)) } + end end # Resource routing allows you to quickly declare all of the common routes @@ -872,7 +893,7 @@ module ActionDispatch # use dots as part of the +:id+ parameter add a constraint which # overrides this restriction, e.g: # - # resources :articles, :id => /[^\/]+/ + # resources :articles, id: /[^\/]+/ # # This allows any character other than a slash as part of your +:id+. # @@ -880,17 +901,18 @@ module ActionDispatch # CANONICAL_ACTIONS holds all actions that does not need a prefix or # a path appended since they fit properly in their scope level. VALID_ON_OPTIONS = [:new, :collection, :member] - RESOURCE_OPTIONS = [:as, :controller, :path, :only, :except] + RESOURCE_OPTIONS = [:as, :controller, :path, :only, :except, :param, :concerns] CANONICAL_ACTIONS = %w(index create new show update destroy) class Resource #:nodoc: - attr_reader :controller, :path, :options + attr_reader :controller, :path, :options, :param def initialize(entities, options = {}) @name = entities.to_s @path = (options[:path] || @name).to_s @controller = (options[:controller] || @name).to_s @as = options[:as] + @param = (options[:param] || :id).to_sym @options = options end @@ -935,15 +957,21 @@ module ActionDispatch alias :collection_scope :path def member_scope - "#{path}/:id" + "#{path}/:#{param}" end + alias :shallow_scope :member_scope + def new_scope(new_path) "#{path}/#{new_path}" end + def nested_param + :"#{singular}_#{param}" + end + def nested_scope - "#{path}/:#{singular}_id" + "#{path}/:#{nested_param}" end end @@ -1001,7 +1029,7 @@ module ActionDispatch # === Options # Takes same options as +resources+. def resource(*resources, &block) - options = resources.extract_options! + options = resources.extract_options!.dup if apply_common_behavior_for(:resource, resources, options, &block) return self @@ -1010,6 +1038,8 @@ module ActionDispatch resource_scope(:resource, SingletonResource.new(resources.pop, options)) do yield if block_given? + concerns(options[:concerns]) if options[:concerns] + collection do post :create end if parent_resource.actions.include?(:create) @@ -1018,15 +1048,7 @@ module ActionDispatch get :new end if parent_resource.actions.include?(:new) - member do - get :edit if parent_resource.actions.include?(:edit) - get :show if parent_resource.actions.include?(:show) - if parent_resource.actions.include?(:update) - patch :update - put :update - end - delete :destroy if parent_resource.actions.include?(:destroy) - end + set_member_mappings_for_resource end self @@ -1073,43 +1095,43 @@ module ActionDispatch # 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" } + # 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' + # 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] + # 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] + # 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 :posts, shallow: true do # resources :comments # end # # Is the same as: # # resources :posts do - # resources :comments, :except => [:show, :edit, :update, :destroy] + # resources :comments, except: [:show, :edit, :update, :destroy] # end - # resources :comments, :only => [:show, :edit, :update, :destroy] + # 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 <tt>/posts/a-long-permalink/comments/1234</tt> @@ -1118,9 +1140,9 @@ module ActionDispatch # [:shallow_path] # Prefixes nested shallow routes with the specified path. # - # scope :shallow_path => "sekret" do + # scope shallow_path: "sekret" do # resources :posts do - # resources :comments, :shallow => true + # resources :comments, shallow: true # end # end # @@ -1134,15 +1156,38 @@ module ActionDispatch # 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 <tt>Admin::PostsController</tt> - # resources :posts, :module => "admin" + # resources :posts, module: "admin" # # # resource actions are at /admin/posts. - # resources :posts, :path => "admin/posts" + # resources :posts, path: "admin/posts" def resources(*resources, &block) - options = resources.extract_options! + options = resources.extract_options!.dup if apply_common_behavior_for(:resources, resources, options, &block) return self @@ -1151,6 +1196,8 @@ module ActionDispatch resource_scope(:resources, Resource.new(resources.pop, options)) do yield if block_given? + concerns(options[:concerns]) if options[:concerns] + collection do get :index if parent_resource.actions.include?(:index) post :create if parent_resource.actions.include?(:create) @@ -1160,15 +1207,7 @@ module ActionDispatch get :new end if parent_resource.actions.include?(:new) - member do - get :edit if parent_resource.actions.include?(:edit) - get :show if parent_resource.actions.include?(:show) - if parent_resource.actions.include?(:update) - patch :update - put :update - end - delete :destroy if parent_resource.actions.include?(:destroy) - end + set_member_mappings_for_resource end self @@ -1329,7 +1368,7 @@ module ActionDispatch options[:as] = name_for_action(options[:as], action) end - mapping = Mapping.new(@set, @scope, path, options) + mapping = Mapping.new(@set, @scope, URI.parser.escape(path), options) app, conditions, requirements, defaults, as, anchor = mapping.to_route @set.add_route(app, conditions, requirements, defaults, as, anchor) end @@ -1435,18 +1474,18 @@ module ActionDispatch def nested_options #:nodoc: options = { :as => parent_resource.member_name } options[:constraints] = { - :"#{parent_resource.singular}_id" => id_constraint - } if id_constraint? + parent_resource.nested_param => param_constraint + } if param_constraint? options end - def id_constraint? #:nodoc: - @scope[:constraints] && @scope[:constraints][:id].is_a?(Regexp) + def param_constraint? #:nodoc: + @scope[:constraints] && @scope[:constraints][parent_resource.param].is_a?(Regexp) end - def id_constraint #:nodoc: - @scope[:constraints][:id] + def param_constraint #:nodoc: + @scope[:constraints][parent_resource.param] end def canonical_action?(action, flag) #:nodoc: @@ -1459,9 +1498,9 @@ module ActionDispatch def path_for_action(action, path) #:nodoc: prefix = shallow_scoping? ? - "#{@scope[:shallow_path]}/#{parent_resource.path}/:id" : @scope[:path] + "#{@scope[:shallow_path]}/#{parent_resource.shallow_scope}" : @scope[:path] - path = if canonical_action?(action, path.blank?) + if canonical_action?(action, path.blank?) prefix.to_s else "#{prefix}/#{action_path(action, path)}" @@ -1519,17 +1558,136 @@ module ActionDispatch end end end + + def set_member_mappings_for_resource + member do + get :edit if parent_resource.actions.include?(:edit) + get :show if parent_resource.actions.include?(:show) + if parent_resource.actions.include?(:update) + patch :update + put :update + end + delete :destroy if parent_resource.actions.include?(:destroy) + end + end + end + + # 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 Concerns + # 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 <tt>call</tt>, + # 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 + # <tt>call</tt>. + def concern(name, callable = nil, &block) + callable ||= lambda { |mapper, options| mapper.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) + 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 def initialize(set) #:nodoc: @set = set @scope = { :path_names => @set.resources_path_names } + @concerns = {} end include Base include HttpHelpers include Redirection include Scoping + include Concerns include Resources end end diff --git a/actionpack/lib/action_dispatch/routing/polymorphic_routes.rb b/actionpack/lib/action_dispatch/routing/polymorphic_routes.rb index 013cf93dbc..6d3f8da932 100644 --- a/actionpack/lib/action_dispatch/routing/polymorphic_routes.rb +++ b/actionpack/lib/action_dispatch/routing/polymorphic_routes.rb @@ -1,3 +1,5 @@ +require 'action_controller/model_naming' + module ActionDispatch module Routing # Polymorphic URL helpers are methods for smart resolution to a named route call when @@ -32,7 +34,7 @@ module ActionDispatch # == Prefixed polymorphic helpers # # In addition to <tt>polymorphic_url</tt> and <tt>polymorphic_path</tt> methods, a - # number of prefixed helpers are available as a shorthand to <tt>:action => "..."</tt> + # number of prefixed helpers are available as a shorthand to <tt>action: "..."</tt> # in options. Those are: # # * <tt>edit_polymorphic_url</tt>, <tt>edit_polymorphic_path</tt> @@ -41,20 +43,20 @@ module ActionDispatch # Example usage: # # edit_polymorphic_path(@post) # => "/posts/1/edit" - # polymorphic_path(@post, :format => :pdf) # => "/posts/1.pdf" - # - # == Using with mounted engines + # polymorphic_path(@post, format: :pdf) # => "/posts/1.pdf" # - # If you use mounted engine, there is a possibility that you will need to use - # polymorphic_url pointing at engine's routes. To do that, just pass proxy used - # to reach engine's routes as a first argument: + # == Usage with mounted engines # - # For example: + # If you are using a mounted engine and you need to use a polymorphic_url + # pointing at the engine's routes, pass in the engine's route proxy as the first + # argument to the method. For example: # - # polymorphic_url([blog, @post]) # it will call blog.post_path(@post) - # form_for([blog, @post]) # => "/blog/posts/1 + # polymorphic_url([blog, @post]) # calls blog.post_path(@post) + # form_for([blog, @post]) # => "/blog/posts/1" # module PolymorphicRoutes + include ActionController::ModelNaming + # Constructs a call to a named RESTful route for the given record and returns the # resulting URL string. For example: # @@ -72,8 +74,6 @@ module ActionDispatch # * <tt>:routing_type</tt> - Allowed values are <tt>:path</tt> or <tt>:url</tt>. # Default is <tt>:url</tt>. # - # ==== Examples - # # # an Article record # polymorphic_url(record) # same as article_url(record) # @@ -97,7 +97,7 @@ module ActionDispatch end record = extract_record(record_or_hash_or_array) - record = record.to_model if record.respond_to?(:to_model) + record = convert_to_model(record) args = Array === record_or_hash_or_array ? record_or_hash_or_array.dup : @@ -124,11 +124,13 @@ module ActionDispatch args.last.kind_of?(Hash) ? args.last.merge!(url_options) : args << url_options end + args.collect! { |a| convert_to_model(a) } + (proxy || self).send(named_route, *args) end # Returns the path component of a URL for the given record. It uses - # <tt>polymorphic_url</tt> with <tt>:routing_type => :path</tt>. + # <tt>polymorphic_url</tt> with <tt>routing_type: :path</tt>. def polymorphic_path(record_or_hash_or_array, options = {}) polymorphic_url(record_or_hash_or_array, options.merge(:routing_type => :path)) end @@ -165,7 +167,7 @@ module ActionDispatch if parent.is_a?(Symbol) || parent.is_a?(String) parent else - ActiveModel::Naming.singular_route_key(parent) + model_name_from_record_or_class(parent).singular_route_key end end else @@ -177,9 +179,9 @@ module ActionDispatch route << record elsif record if inflection == :singular - route << ActiveModel::Naming.singular_route_key(record) + route << model_name_from_record_or_class(record).singular_route_key else - route << ActiveModel::Naming.route_key(record) + route << model_name_from_record_or_class(record).route_key end else raise ArgumentError, "Nil location provided. Can't build URI." diff --git a/actionpack/lib/action_dispatch/routing/redirection.rb b/actionpack/lib/action_dispatch/routing/redirection.rb index 617b24b46a..d751e04e6a 100644 --- a/actionpack/lib/action_dispatch/routing/redirection.rb +++ b/actionpack/lib/action_dispatch/routing/redirection.rb @@ -1,4 +1,8 @@ require 'action_dispatch/http/request' +require 'active_support/core_ext/uri' +require 'active_support/core_ext/array/extract_options' +require 'rack/utils' +require 'action_controller/metal/exceptions' module ActionDispatch module Routing @@ -13,6 +17,14 @@ module ActionDispatch def call(env) req = Request.new(env) + # If any of the path parameters has a invalid encoding then + # raise since it's likely to trigger errors further on. + req.symbolized_path_parameters.each do |key, value| + unless value.valid_encoding? + raise ActionController::BadRequest, "Invalid parameter: #{key} => #{value}" + end + end + uri = URI.parse(path(req.symbolized_path_parameters, req)) uri.scheme ||= req.scheme uri.host ||= req.host @@ -32,6 +44,25 @@ module ActionDispatch def path(params, request) block.call params, request end + + def inspect + "redirect(#{status})" + end + end + + class PathRedirect < Redirect + def path(params, request) + (params.empty? || !block.match(/%\{\w*\}/)) ? block : (block % escape(params)) + end + + def inspect + "redirect(#{status}, #{block})" + end + + private + def escape(params) + Hash[params.map{ |k,v| [k, Rack::Utils.escape(v)] }] + end end class OptionRedirect < Redirect # :nodoc: @@ -44,21 +75,34 @@ module ActionDispatch :port => request.optional_port, :path => request.path, :params => request.query_parameters - }.merge options + }.merge! options + + if !params.empty? && url_options[:path].match(/%\{\w*\}/) + url_options[:path] = (url_options[:path] % escape_path(params)) + end ActionDispatch::Http::URL.url_for url_options end + + def inspect + "redirect(#{status}, #{options.map{ |k,v| "#{k}: #{v}" }.join(', ')})" + end + + private + def escape_path(params) + Hash[params.map{ |k,v| [k, URI.parser.escape(v)] }] + end end module Redirection # Redirect any path to another path: # - # match "/stories" => redirect("/posts") + # get "/stories" => redirect("/posts") # # You can also use interpolation in the supplied redirect argument: # - # match 'docs/:article', :to => redirect('/wiki/%{article}') + # get 'docs/:article', to: redirect('/wiki/%{article}') # # Alternatively you can use one of the other syntaxes: # @@ -67,34 +111,33 @@ module ActionDispatch # params, depending of how many arguments your block accepts. A string is required as a # return value. # - # match 'jokes/:number', :to => redirect do |params, request| - # path = (params[:number].to_i.even? ? "/wheres-the-beef" : "/i-love-lamp") + # get 'jokes/:number', to: redirect { |params, request| + # path = (params[:number].to_i.even? ? "wheres-the-beef" : "i-love-lamp") # "http://#{request.host_with_port}/#{path}" - # end + # } + # + # Note that the +do end+ syntax for the redirect block wouldn't work, as Ruby would pass + # the block to +get+ instead of +redirect+. Use <tt>{ ... }</tt> instead. # # The options version of redirect allows you to supply only the parts of the url which need # to change, it also supports interpolation of the path similar to the first example. # - # match 'stores/:name', :to => redirect(:subdomain => 'stores', :path => '/%{name}') - # match 'stores/:name(*all)', :to => redirect(:subdomain => 'stores', :path => '/%{name}%{all}') + # get 'stores/:name', to: redirect(subdomain: 'stores', path: '/%{name}') + # get 'stores/:name(*all)', to: redirect(subdomain: 'stores', path: '/%{name}%{all}') # # Finally, an object which responds to call can be supplied to redirect, allowing you to reuse # common redirect routes. The call method must accept two arguments, params and request, and return # a string. # - # match 'accounts/:name' => redirect(SubdomainRedirector.new('api')) + # get 'accounts/:name' => redirect(SubdomainRedirector.new('api')) # def redirect(*args, &block) - options = args.last.is_a?(Hash) ? args.pop : {} + options = args.extract_options! status = options.delete(:status) || 301 + path = args.shift return OptionRedirect.new(status, options) if options.any? - - path = args.shift - - block = lambda { |params, request| - (params.empty? || !path.match(/%\{\w*\}/)) ? path : (path % params) - } if String === path + return PathRedirect.new(status, path) if String === path block = path if path.respond_to? :call raise ArgumentError, "redirection argument not supported" unless block diff --git a/actionpack/lib/action_dispatch/routing/route_set.rb b/actionpack/lib/action_dispatch/routing/route_set.rb index 30e9e5634b..b1959e388c 100644 --- a/actionpack/lib/action_dispatch/routing/route_set.rb +++ b/actionpack/lib/action_dispatch/routing/route_set.rb @@ -1,6 +1,6 @@ -require 'journey' +require 'action_dispatch/journey' require 'forwardable' -require 'active_support/core_ext/object/blank' +require 'thread_safe' require 'active_support/core_ext/object/to_query' require 'active_support/core_ext/hash/slice' require 'active_support/core_ext/module/remove_method' @@ -21,11 +21,20 @@ module ActionDispatch def initialize(options={}) @defaults = options[:defaults] @glob_param = options.delete(:glob) - @controllers = {} + @controller_class_names = ThreadSafe::Cache.new end def call(env) params = env[PARAMETERS_KEY] + + # If any of the path parameters has a invalid encoding then + # raise since it's likely to trigger errors further on. + params.each do |key, value| + unless value.valid_encoding? + raise ActionController::BadRequest, "Invalid parameter: #{key} => #{value}" + end + end + prepare_params!(params) # Just raise undefined constant errors if a controller was specified as default. @@ -60,13 +69,8 @@ module ActionDispatch private def controller_reference(controller_param) - controller_name = "#{controller_param.camelize}Controller" - - unless controller = @controllers[controller_param] - controller = @controllers[controller_param] = - ActiveSupport::Dependencies.reference(controller_name) - end - controller.get(controller_name) + const_name = @controller_class_names[controller_param] ||= "#{controller_param.camelize}Controller" + ActiveSupport::Dependencies.constantize(const_name) end def dispatch(controller, action, env) @@ -96,7 +100,25 @@ module ActionDispatch def initialize @routes = {} @helpers = [] - @module = Module.new + @module = Module.new do + protected + + def handle_positional_args(args, options, segment_keys) + inner_options = args.extract_options! + result = options.dup + + if args.size > 0 + keys = segment_keys + if args.size < keys.size - 1 # take format into account + keys -= self.url_options.keys if self.respond_to?(:url_options) + keys -= options.keys + end + result.merge!(Hash[keys.zip(args)]) + end + + result.merge!(inner_options) + end + end end def helper_names @@ -104,6 +126,12 @@ module ActionDispatch end def clear! + @helpers.each do |helper| + @module.module_eval do + remove_possible_method helper + end + end + @routes.clear @helpers.clear end @@ -135,43 +163,12 @@ module ActionDispatch 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 do - remove_possible_method selector - - define_method(selector) do |*args| - inner_options = args.extract_options! - result = options.dup - - if args.any? - result[:_positional_args] = args - result[:_positional_keys] = route.segment_keys - end - - result.merge(inner_options) - end - - protected selector - end - helpers << selector + define_url_helper route, :"#{name}_path", + route.defaults.merge(:use_route => name, :only_path => true) + define_url_helper route, :"#{name}_url", + route.defaults.merge(:use_route => name, :only_path => false) end # Create a url helper allowing ordered parameters to be associated @@ -181,44 +178,33 @@ module ActionDispatch # # Instead of: # - # foo_url(:bar => bar, :baz => baz, :bang => bang) + # foo_url(bar: bar, baz: baz, bang: bang) # # Also allow options hash, so you can do: # - # foo_url(bar, baz, bang, :sort_by => 'baz') + # foo_url(bar, baz, bang, sort_by: 'baz') # - def define_url_helper(route, name, kind, options) - selector = url_helper_name(name, kind) - hash_access_method = hash_access_name(name, kind) - - if optimize_helper?(route) - @module.module_eval <<-END_EVAL, __FILE__, __LINE__ + 1 - remove_possible_method :#{selector} - def #{selector}(*args) - if args.size == #{route.required_parts.size} && !args.last.is_a?(Hash) && optimize_routes_generation? - options = #{options.inspect}.merge!(url_options) - options[:path] = "#{optimized_helper(route)}" - ActionDispatch::Http::URL.url_for(options) - else - url_for(#{hash_access_method}(*args)) - end + def define_url_helper(route, name, options) + @module.module_eval <<-END_EVAL, __FILE__, __LINE__ + 1 + remove_possible_method :#{name} + def #{name}(*args) + if #{optimize_helper?(route)} && args.size == #{route.required_parts.size} && !args.last.is_a?(Hash) && optimize_routes_generation? + options = #{options.inspect} + options.merge!(url_options) if respond_to?(:url_options) + options[:path] = "#{optimized_helper(route)}" + ActionDispatch::Http::URL.url_for(options) + else + url_for(handle_positional_args(args, #{options.inspect}, #{route.segment_keys.inspect})) end - END_EVAL - else - @module.module_eval <<-END_EVAL, __FILE__, __LINE__ + 1 - remove_possible_method :#{selector} - def #{selector}(*args) - url_for(#{hash_access_method}(*args)) - end - END_EVAL - end + end + END_EVAL - helpers << selector + helpers << name end # Clause check about when we need to generate an optimized helper. def optimize_helper?(route) #:nodoc: - route.ast.grep(Journey::Nodes::Star).empty? && route.requirements.except(:controller, :action).empty? + route.requirements.except(:controller, :action).empty? end # Generates the interpolation to be used in the optimized helper. @@ -230,7 +216,10 @@ module ActionDispatch end route.required_parts.each_with_index do |part, i| - string_route.gsub!(part.inspect, "\#{Journey::Router::Utils.escape_fragment(args[#{i}].to_param)}") + # Replace each route parameter + # e.g. :id for regular parameter or *path for globbing + # with ruby string interpolation code + string_route.gsub!(/(\*|:)#{part}/, "\#{Journey::Router::Utils.escape_fragment(args[#{i}].to_param)}") end string_route @@ -239,7 +228,7 @@ module ActionDispatch attr_accessor :formatter, :set, :named_routes, :default_scope, :router attr_accessor :disable_clear_and_finalize, :resources_path_names - attr_accessor :default_url_options, :request_class, :valid_conditions + attr_accessor :default_url_options, :request_class alias :routes :set @@ -251,17 +240,7 @@ module ActionDispatch self.named_routes = NamedRouteCollection.new self.resources_path_names = self.class.default_resources_path_names.dup self.default_url_options = {} - self.request_class = request_class - @valid_conditions = {} - - request_class.public_instance_methods.each { |m| - @valid_conditions[m.to_sym] = true - } - @valid_conditions[:controller] = true - @valid_conditions[:action] = true - - self.valid_conditions.delete(:id) @append = [] @prepend = [] @@ -340,9 +319,9 @@ module ActionDispatch end end - MountedHelpers.class_eval <<-RUBY + MountedHelpers.class_eval(<<-RUBY, __FILE__, __LINE__ + 1) def #{name} - @#{name} ||= _#{name} + @_#{name} ||= _#{name} end RUBY end @@ -392,7 +371,7 @@ module ActionDispatch raise ArgumentError, "Invalid route name: '#{name}'" unless name.blank? || name.to_s.match(/^[_a-z]\w*$/i) path = build_path(conditions.delete(:path_info), requirements, SEPARATORS, anchor) - conditions = build_conditions(conditions, valid_conditions, path.names.map { |x| x.to_sym }) + conditions = build_conditions(conditions, path.names.map { |x| x.to_sym }) route = @set.add_route(app, path, conditions, defaults, name) named_routes[name] = route if name && !named_routes[name] @@ -429,21 +408,22 @@ module ActionDispatch end private :build_path - def build_conditions(current_conditions, req_predicates, path_values) + def build_conditions(current_conditions, path_values) conditions = current_conditions.dup - verbs = conditions[:request_method] || [] - # Rack-Mount requires that :request_method be a regular expression. # :request_method represents the HTTP verb that matches this route. # # Here we munge values before they get sent on to rack-mount. + verbs = conditions[:request_method] || [] unless verbs.empty? conditions[:request_method] = %r[^#{verbs.join('|')}$] end - conditions.delete_if { |k,v| !(req_predicates.include?(k) || path_values.include?(k)) } - conditions + conditions.keep_if do |k, _| + k == :action || k == :controller || + @request_class.public_method_defined?(k) || path_values.include?(k) + end end private :build_conditions @@ -460,22 +440,21 @@ module ActionDispatch attr_reader :options, :recall, :set, :named_route - def initialize(options, recall, set, extras = false) + def initialize(options, recall, set) @named_route = options.delete(:use_route) @options = options.dup @recall = recall.dup @set = set - @extras = extras normalize_options! normalize_controller_action_id! use_relative_controller! - controller.sub!(%r{^/}, '') if controller + normalize_controller! handle_nil_action! end def controller - @controller ||= @options[:controller] + @options[:controller] end def current_controller @@ -484,9 +463,7 @@ module ActionDispatch def use_recall_for(key) if @recall[key] && (!@options.key?(key) || @options[key] == @recall[key]) - if named_route_exists? - @options[key] = @recall.delete(key) if segment_keys.include?(key) - else + if !named_route_exists? || segment_keys.include?(key) @options[key] = @recall.delete(key) end end @@ -496,7 +473,7 @@ module ActionDispatch # 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'}) + # 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 @@ -525,19 +502,24 @@ module ActionDispatch use_recall_for(:id) end - # if the current controller is "foo/bar/baz" and :controller => "baz/bat" + # if the current controller is "foo/bar/baz" and controller: "baz/bat" # is specified, the controller becomes "foo/baz/bat" def use_relative_controller! if !named_route && different_controller? && !controller.start_with?("/") old_parts = current_controller.split('/') size = controller.count("/") + 1 parts = old_parts[0...-size] << controller - @controller = @options[:controller] = parts.join("/") + @options[:controller] = parts.join("/") end end - # This handles the case of :action => nil being explicitly passed. - # It is identical to :action => "index" + # Remove leading slashes from controllers + def normalize_controller! + @options[:controller] = controller.sub(%r{^/}, '') if controller + end + + # This handles the case of action: nil being explicitly passed. + # It is identical to action: "index" def handle_nil_action! if options.has_key?(:action) && options[:action].nil? options[:action] = 'index' @@ -545,20 +527,12 @@ module ActionDispatch recall[:action] = options.delete(:action) if options[:action] == 'index' end + # Generates a path from routes, returns [path, params] + # if no path is returned the formatter will raise Journey::Router::RoutingError def generate - path, params = @set.formatter.generate(:path_info, named_route, options, recall, PARAMETERIZE) - - raise_routing_error unless path - - return [path, params.keys] if @extras - - [path, params] - rescue Journey::Router::RoutingError - raise_routing_error - end - - def raise_routing_error - raise ActionController::RoutingError, "No route matches #{options.inspect}" + @set.formatter.generate(:path_info, named_route, options, recall, PARAMETERIZE) + rescue Journey::Router::RoutingError => e + raise ActionController::UrlGenerationError, "No route matches #{options.inspect} #{e.message}" end def different_controller? @@ -583,15 +557,17 @@ module ActionDispatch end def generate_extras(options, recall={}) - generate(options, recall, true) + path, params = generate(options, recall) + return path, params.keys end - def generate(options, recall = {}, extras = false) - Generator.new(options, recall, self, extras).generate + def generate(options, recall = {}) + Generator.new(options, recall, self).generate end RESERVED_OPTIONS = [:host, :protocol, :port, :subdomain, :domain, :tld_length, - :trailing_slash, :anchor, :params, :only_path, :script_name] + :trailing_slash, :anchor, :params, :only_path, :script_name, + :original_script_name] def mounted? false @@ -605,19 +581,24 @@ module ActionDispatch nil end + # The +options+ argument must be +nil+ or a hash whose keys are *symbols*. def url_for(options) - options = (options || {}).reverse_merge!(default_url_options) - - handle_positional_args(options) + options = default_url_options.merge(options || {}) user, password = extract_authentication(options) - path_segments = options.delete(:_path_segments) - script_name = options.delete(:script_name).presence || _generate_prefix(options) + recall = options.delete(:_recall) + + original_script_name = options.delete(:original_script_name).presence + script_name = options.delete(:script_name).presence || _generate_prefix(options) + + if script_name && original_script_name + script_name = original_script_name + script_name + end path_options = options.except(*RESERVED_OPTIONS) path_options = yield(path_options) if block_given? - path, params = generate(path_options, path_segments || {}) + path, params = generate(path_options, recall || {}) params.merge!(options[:params] || {}) ActionDispatch::Http::URL.url_for(options.merge!({ @@ -660,9 +641,13 @@ module ActionDispatch dispatcher = dispatcher.app end - if dispatcher.is_a?(Dispatcher) && dispatcher.controller(params, false) - dispatcher.prepare_params!(params) - return params + if dispatcher.is_a?(Dispatcher) + if dispatcher.controller(params, false) + dispatcher.prepare_params!(params) + return params + else + raise ActionController::RoutingError, "A route matches #{path.inspect}, but references missing controller: #{params[:controller].camelize}Controller" + end end end @@ -679,16 +664,6 @@ module ActionDispatch end end - def handle_positional_args(options) - return unless args = options.delete(:_positional_args) - - keys = options.delete(:_positional_keys) - keys -= options.keys if args.size < keys.size - 1 # take format into account - - # Tell url_for to skip default_url_options - options.merge!(Hash[args.zip(keys).map { |v, k| [k, v] }]) - end - end end end diff --git a/actionpack/lib/action_dispatch/routing/url_for.rb b/actionpack/lib/action_dispatch/routing/url_for.rb index 94db36ce1f..76311c423a 100644 --- a/actionpack/lib/action_dispatch/routing/url_for.rb +++ b/actionpack/lib/action_dispatch/routing/url_for.rb @@ -18,8 +18,8 @@ module ActionDispatch # of parameters. For example, you've probably had the chance to write code # like this in one of your views: # - # <%= link_to('Click here', :controller => 'users', - # :action => 'new', :message => 'Welcome!') %> + # <%= link_to('Click here', controller: 'users', + # action: 'new', message: 'Welcome!') %> # # => "/users/new?message=Welcome%21" # # link_to, and all other functions that require URL generation functionality, @@ -28,22 +28,22 @@ module ActionDispatch # the same path as the above example by using the following code: # # include UrlFor - # url_for(:controller => 'users', - # :action => 'new', - # :message => 'Welcome!', - # :only_path => true) + # url_for(controller: 'users', + # action: 'new', + # message: 'Welcome!', + # only_path: true) # # => "/users/new?message=Welcome%21" # - # Notice the <tt>:only_path => true</tt> part. This is because UrlFor has no + # Notice the <tt>only_path: true</tt> part. This is because UrlFor has no # information about the website hostname that your Rails app is serving. So if you # want to include the hostname as well, then you must also pass the <tt>:host</tt> # argument: # # include UrlFor - # url_for(:controller => 'users', - # :action => 'new', - # :message => 'Welcome!', - # :host => 'www.example.com') + # url_for(controller: 'users', + # action: 'new', + # message: 'Welcome!', + # host: 'www.example.com') # # => "http://www.example.com/users/new?message=Welcome%21" # # By default, all controllers and views have access to a special version of url_for, @@ -68,7 +68,7 @@ module ActionDispatch # This generates, among other things, the method <tt>users_path</tt>. By default, # this method is accessible from your controllers, views and mailers. If you need # to access this auto-generated method from other places (such as a model), then - # you can do that by including ActionController::UrlFor in your class: + # you can do that by including Rails.application.routes.url_helpers in your class: # # class User < ActiveRecord::Base # include Rails.application.routes.url_helpers @@ -95,6 +95,8 @@ module ActionDispatch self.default_url_options = {} end + + include(*_url_for_modules) if respond_to?(:_url_for_modules) end def initialize(*) @@ -102,7 +104,7 @@ module ActionDispatch super end - # Hook overriden in controller to add request information + # Hook overridden in controller to add request information # with `default_url_options`. Application logic should not # go into url_options. def url_options @@ -132,22 +134,22 @@ module ActionDispatch # Any other key (<tt>:controller</tt>, <tt>:action</tt>, etc.) given to # +url_for+ is forwarded to the Routes module. # - # Examples: - # - # url_for :controller => 'tasks', :action => 'testing', :host => 'somehost.org', :port => '8080' + # url_for controller: 'tasks', action: 'testing', host: 'somehost.org', port: '8080' # # => 'http://somehost.org:8080/tasks/testing' - # url_for :controller => 'tasks', :action => 'testing', :host => 'somehost.org', :anchor => 'ok', :only_path => true + # url_for controller: 'tasks', action: 'testing', host: 'somehost.org', anchor: 'ok', only_path: true # # => '/tasks/testing#ok' - # url_for :controller => 'tasks', :action => 'testing', :trailing_slash => true + # url_for controller: 'tasks', action: 'testing', trailing_slash: true # # => 'http://somehost.org/tasks/testing/' - # url_for :controller => 'tasks', :action => 'testing', :host => 'somehost.org', :number => '33' + # url_for controller: 'tasks', action: 'testing', host: 'somehost.org', number: '33' # # => 'http://somehost.org/tasks/testing?number=33' def url_for(options = nil) case options + when nil + _routes.url_for(url_options.symbolize_keys) + when Hash + _routes.url_for(options.symbolize_keys.reverse_merge!(url_options)) when String options - when nil, Hash - _routes.url_for((options || {}).symbolize_keys.reverse_merge!(url_options)) else polymorphic_url(options) end diff --git a/actionpack/lib/action_dispatch/testing/assertions/dom.rb b/actionpack/lib/action_dispatch/testing/assertions/dom.rb index edea6dab39..6c61d4e61a 100644 --- a/actionpack/lib/action_dispatch/testing/assertions/dom.rb +++ b/actionpack/lib/action_dispatch/testing/assertions/dom.rb @@ -1,15 +1,12 @@ -require 'action_controller/vendor/html-scanner' +require 'action_view/vendor/html-scanner' module ActionDispatch module Assertions module DomAssertions # \Test two HTML strings for equivalency (e.g., identical up to reordering of attributes) # - # ==== Examples - # # # assert that the referenced method generates the appropriate HTML string # assert_dom_equal '<a href="http://www.example.com">Apples</a>', link_to("Apples", "http://www.example.com") - # def assert_dom_equal(expected, actual, message = "") expected_dom = HTML::Document.new(expected).root actual_dom = HTML::Document.new(actual).root @@ -18,11 +15,8 @@ module ActionDispatch # The negated form of +assert_dom_equivalent+. # - # ==== Examples - # # # assert that the referenced method does not generate the specified HTML string # assert_dom_not_equal '<a href="http://www.example.com">Apples</a>', link_to("Oranges", "http://www.example.com") - # def assert_dom_not_equal(expected, actual, message = "") expected_dom = HTML::Document.new(expected).root actual_dom = HTML::Document.new(actual).root diff --git a/actionpack/lib/action_dispatch/testing/assertions/response.rb b/actionpack/lib/action_dispatch/testing/assertions/response.rb index 094cfbfc76..44ed0ac1f3 100644 --- a/actionpack/lib/action_dispatch/testing/assertions/response.rb +++ b/actionpack/lib/action_dispatch/testing/assertions/response.rb @@ -1,14 +1,11 @@ -require 'active_support/core_ext/object/inclusion' module ActionDispatch module Assertions # A small suite of assertions that test responses from \Rails applications. module ResponseAssertions - extend ActiveSupport::Concern - # Asserts that the response is one of the following types: # - # * <tt>:success</tt> - Status code was 200 + # * <tt>:success</tt> - Status code was in the 200-299 range # * <tt>:redirect</tt> - Status code was in the 300-399 range # * <tt>:missing</tt> - Status code was 404 # * <tt>:error</tt> - Status code was in the 500-599 range @@ -17,14 +14,11 @@ module ActionDispatch # or its symbolic equivalent <tt>assert_response(:not_implemented)</tt>. # See Rack::Utils::SYMBOL_TO_STATUS_CODE for a full list. # - # ==== Examples - # # # assert that the response was a redirection # assert_response :redirect # # # assert that the response code was status code 401 (unauthorized) # assert_response 401 - # def assert_response(type, message = nil) message ||= "Expected response to be a <#{type}>, but was <#{@response.response_code}>" @@ -33,7 +27,7 @@ module ActionDispatch assert @response.send("#{type}?"), message else code = Rack::Utils::SYMBOL_TO_STATUS_CODE[type] - assert_equal @response.response_code, code, message + assert_equal code, @response.response_code, message end else assert_equal type, @response.response_code, message @@ -41,13 +35,11 @@ module ActionDispatch end # Assert that the redirection options passed in match those of the redirect called in the latest action. - # This match can be partial, such that <tt>assert_redirected_to(:controller => "weblog")</tt> will also - # match the redirection of <tt>redirect_to(:controller => "weblog", :action => "show")</tt> and so on. - # - # ==== Examples + # This match can be partial, such that <tt>assert_redirected_to(controller: "weblog")</tt> will also + # match the redirection of <tt>redirect_to(controller: "weblog", action: "show")</tt> and so on. # # # assert that the redirection was to the "index" action on the WeblogController - # assert_redirected_to :controller => "weblog", :action => "index" + # assert_redirected_to controller: "weblog", action: "index" # # # assert that the redirection was to the named route login_url # assert_redirected_to login_url @@ -55,15 +47,17 @@ module ActionDispatch # # assert that the redirection was to the url for @customer # assert_redirected_to @customer # + # # asserts that the redirection matches the regular expression + # assert_redirected_to %r(\Ahttp://example.org) def assert_redirected_to(options = {}, message=nil) assert_response(:redirect, message) - return true if options == @response.location + return true if options === @response.location redirect_is = normalize_argument_to_redirection(@response.location) redirect_expected = normalize_argument_to_redirection(options) message ||= "Expected response to be a redirect to <#{redirect_expected}> but was a redirect to <#{redirect_is}>" - assert_equal redirect_expected, redirect_is, message + assert_operator redirect_expected, :===, redirect_is, message end private @@ -73,17 +67,21 @@ module ActionDispatch end def normalize_argument_to_redirection(fragment) - case fragment - when %r{^\w[A-Za-z\d+.-]*:.*} - fragment - when String - @request.protocol + @request.host_with_port + fragment - when :back - raise RedirectBackError unless refer = @request.headers["Referer"] - refer - else - @controller.url_for(fragment) - end.gsub(/[\r\n]/, '') + normalized = case fragment + when Regexp + fragment + when %r{^\w[A-Za-z\d+.-]*:.*} + fragment + when String + @request.protocol + @request.host_with_port + fragment + when :back + raise RedirectBackError unless refer = @request.headers["Referer"] + refer + else + @controller.url_for(fragment) + end + + normalized.respond_to?(:delete) ? normalized.delete("\0\r\n") : normalized end end end diff --git a/actionpack/lib/action_dispatch/testing/assertions/routing.rb b/actionpack/lib/action_dispatch/testing/assertions/routing.rb index 1f4b905d18..79dff7d121 100644 --- a/actionpack/lib/action_dispatch/testing/assertions/routing.rb +++ b/actionpack/lib/action_dispatch/testing/assertions/routing.rb @@ -1,5 +1,4 @@ require 'uri' -require 'active_support/core_ext/hash/diff' require 'active_support/core_ext/hash/indifferent_access' require 'action_controller/metal/exceptions' @@ -15,40 +14,41 @@ module ActionDispatch # and a :method containing the required HTTP verb. # # # assert that POSTing to /items will call the create action on ItemsController - # assert_recognizes({:controller => 'items', :action => 'create'}, {:path => 'items', :method => :post}) + # assert_recognizes({controller: 'items', action: 'create'}, {path: 'items', method: :post}) # # You can also pass in +extras+ with a hash containing URL parameters that would normally be in the query string. This can be used # to assert that values in the query string string will end up in the params hash correctly. To test query strings you must use the # extras argument, appending the query string on the path directly will not work. For example: # # # assert that a path of '/items/list/1?view=print' returns the correct options - # assert_recognizes({:controller => 'items', :action => 'list', :id => '1', :view => 'print'}, 'items/list/1', { :view => "print" }) + # assert_recognizes({controller: 'items', action: 'list', id: '1', view: 'print'}, 'items/list/1', { view: "print" }) # # The +message+ parameter allows you to pass in an error message that is displayed upon failure. # - # ==== Examples # # Check the default route (i.e., the index action) - # assert_recognizes({:controller => 'items', :action => 'index'}, 'items') + # assert_recognizes({controller: 'items', action: 'index'}, 'items') # # # Test a specific action - # assert_recognizes({:controller => 'items', :action => 'list'}, 'items/list') + # assert_recognizes({controller: 'items', action: 'list'}, 'items/list') # # # Test an action with a parameter - # assert_recognizes({:controller => 'items', :action => 'destroy', :id => '1'}, 'items/destroy/1') + # assert_recognizes({controller: 'items', action: 'destroy', id: '1'}, 'items/destroy/1') # # # Test a custom route - # assert_recognizes({:controller => 'items', :action => 'show', :id => '1'}, 'view/item1') - def assert_recognizes(expected_options, path, extras={}, message=nil) + # assert_recognizes({controller: 'items', action: 'show', id: '1'}, 'view/item1') + def assert_recognizes(expected_options, path, extras={}, msg=nil) request = recognized_request_for(path, extras) expected_options = expected_options.clone expected_options.stringify_keys! - # FIXME: minitest does object diffs, do we need to have our own? - message ||= sprintf("The recognized options <%s> did not match <%s>, difference: <%s>", - request.path_parameters, expected_options, expected_options.diff(request.path_parameters)) - assert_equal(expected_options, request.path_parameters, message) + msg = message(msg, "") { + sprintf("The recognized options <%s> did not match <%s>, difference:", + request.path_parameters, expected_options) + } + + assert_equal(expected_options, request.path_parameters, msg) end # Asserts that the provided options can be used to generate the provided path. This is the inverse of +assert_recognizes+. @@ -57,25 +57,22 @@ module ActionDispatch # # The +defaults+ parameter is unused. # - # ==== Examples # # Asserts that the default action is generated for a route with no action - # assert_generates "/items", :controller => "items", :action => "index" + # assert_generates "/items", controller: "items", action: "index" # # # Tests that the list action is properly routed - # assert_generates "/items/list", :controller => "items", :action => "list" + # assert_generates "/items/list", controller: "items", action: "list" # # # Tests the generation of a route with a parameter - # assert_generates "/items/list/1", { :controller => "items", :action => "list", :id => "1" } + # assert_generates "/items/list/1", { controller: "items", action: "list", id: "1" } # # # Asserts that the generated route gives us our custom route - # assert_generates "changesets/12", { :controller => 'scm', :action => 'show_diff', :revision => "12" } + # assert_generates "changesets/12", { controller: 'scm', action: 'show_diff', revision: "12" } def assert_generates(expected_path, options, defaults={}, extras = {}, message=nil) if expected_path =~ %r{://} - begin + fail_on(URI::InvalidURIError) do uri = URI.parse(expected_path) expected_path = uri.path.to_s.empty? ? "/" : uri.path - rescue URI::InvalidURIError => e - raise ActionController::RoutingError, e.message end else expected_path = "/#{expected_path}" unless expected_path.first == '/' @@ -100,21 +97,20 @@ module ActionDispatch # The +extras+ hash allows you to specify options that would normally be provided as a query string to the action. The # +message+ parameter allows you to specify a custom error message to display upon failure. # - # ==== Examples # # Assert a basic route: a controller with the default action (index) - # assert_routing '/home', :controller => 'home', :action => 'index' + # assert_routing '/home', controller: 'home', action: 'index' # # # Test a route generated with a specific controller, action, and parameter (id) - # assert_routing '/entries/show/23', :controller => 'entries', :action => 'show', :id => 23 + # assert_routing '/entries/show/23', controller: 'entries', action: 'show', id: 23 # # # Assert a basic route (controller + default action), with an error message if it fails - # assert_routing '/store', { :controller => 'store', :action => 'index' }, {}, {}, 'Route for store index not generated properly' + # assert_routing '/store', { controller: 'store', action: 'index' }, {}, {}, 'Route for store index not generated properly' # # # Tests a route, providing a defaults hash - # assert_routing 'controller/action/9', {:id => "9", :item => "square"}, {:controller => "controller", :action => "action"}, {}, {:item => "square"} + # assert_routing 'controller/action/9', {id: "9", item: "square"}, {controller: "controller", action: "action"}, {}, {item: "square"} # # # Tests a route with a HTTP method - # assert_routing({ :method => 'put', :path => '/product/321' }, { :controller => "product", :action => "update", :id => "321" }) + # assert_routing({ method: 'put', path: '/product/321' }, { controller: "product", action: "update", id: "321" }) def assert_routing(path, options, defaults={}, extras={}, message=nil) assert_recognizes(options, path, extras, message) @@ -132,16 +128,13 @@ module ActionDispatch # with a new RouteSet instance. # # The new instance is yielded to the passed block. Typically the block - # will create some routes using <tt>map.draw { map.connect ... }</tt>: + # will create some routes using <tt>set.draw { match ... }</tt>: # # with_routing do |set| - # set.draw do |map| - # map.connect ':controller/:action/:id' - # assert_equal( - # ['/content/10/show', {}], - # map.generate(:controller => 'content', :id => 10, :action => 'show') - # end + # set.draw do + # resources :users # end + # assert_equal "/users", users_path # end # def with_routing @@ -192,14 +185,12 @@ module ActionDispatch request = ActionController::TestRequest.new if path =~ %r{://} - begin + fail_on(URI::InvalidURIError) do uri = URI.parse(path) request.env["rack.url_scheme"] = uri.scheme || "http" request.host = uri.host if uri.host request.port = uri.port if uri.port request.path = uri.path.to_s.empty? ? "/" : uri.path - rescue URI::InvalidURIError => e - raise ActionController::RoutingError, e.message end else path = "/#{path}" unless path.first == "/" @@ -208,11 +199,21 @@ module ActionDispatch request.request_method = method if method - params = @routes.recognize_path(path, { :method => method, :extras => extras }) + params = fail_on(ActionController::RoutingError) do + @routes.recognize_path(path, { :method => method, :extras => extras }) + end request.path_parameters = params.with_indifferent_access request end + + def fail_on(exception_class) + begin + yield + rescue exception_class => e + raise MiniTest::Assertion, e.message + end + end end end end diff --git a/actionpack/lib/action_dispatch/testing/assertions/selector.rb b/actionpack/lib/action_dispatch/testing/assertions/selector.rb index 8eed85bce2..e481f3b245 100644 --- a/actionpack/lib/action_dispatch/testing/assertions/selector.rb +++ b/actionpack/lib/action_dispatch/testing/assertions/selector.rb @@ -1,4 +1,4 @@ -require 'action_controller/vendor/html-scanner' +require 'action_view/vendor/html-scanner' require 'active_support/core_ext/object/inclusion' #-- @@ -39,7 +39,6 @@ module ActionDispatch # The selector may be a CSS selector expression (String), an expression # with substitution values (Array) or an HTML::Selector object. # - # ==== Examples # # Selects all div tags # divs = css_select("div") # @@ -58,7 +57,6 @@ module ActionDispatch # inputs = css_select(form, "input") # ... # end - # def css_select(*args) # See assert_select to understand what's going on here. arg = args.shift @@ -157,8 +155,6 @@ module ActionDispatch # If the method is called with a block, once all equality tests are # evaluated the block is called with an array of all matched elements. # - # ==== Examples - # # # At least one form element # assert_select "form" # @@ -169,7 +165,7 @@ module ActionDispatch # assert_select "title", "Welcome" # # # Page title is "Welcome" and there is only one title element - # assert_select "title", {:count => 1, :text => "Welcome"}, + # assert_select "title", {count: 1, text: "Welcome"}, # "Wrong title or more than one title element" # # # Page contains no forms @@ -269,6 +265,7 @@ module ActionDispatch end end text.strip! unless NO_STRIP.include?(match.name) + text.sub!(/\A\n/, '') if match.name == "textarea" unless match_with.is_a?(Regexp) ? (text =~ match_with) : (text == match_with.to_s) content_mismatch ||= sprintf("<%s> expected but was\n<%s>.", match_with, text) true @@ -339,7 +336,6 @@ module ActionDispatch # The content of each element is un-encoded, and wrapped in the root # element +encoded+. It then calls the block with all un-encoded elements. # - # ==== Examples # # Selects all bold tags from within the title of an Atom feed's entries (perhaps to nab a section name prefix) # assert_select "feed[xmlns='http://www.w3.org/2005/Atom']" do # # Select each entry item and then the title item @@ -400,8 +396,6 @@ module ActionDispatch # You must enable deliveries for this assertion to work, use: # ActionMailer::Base.perform_deliveries = true # - # ==== Examples - # # assert_select_email do # assert_select "h1", "Email alert" # end @@ -412,7 +406,6 @@ module ActionDispatch # # Work with items here... # end # end - # def assert_select_email(&block) deliveries = ActionMailer::Base.deliveries assert !deliveries.empty?, "No e-mail in delivery list" diff --git a/actionpack/lib/action_dispatch/testing/assertions/tag.rb b/actionpack/lib/action_dispatch/testing/assertions/tag.rb index 5c735e61b2..e5fe30ba82 100644 --- a/actionpack/lib/action_dispatch/testing/assertions/tag.rb +++ b/actionpack/lib/action_dispatch/testing/assertions/tag.rb @@ -1,4 +1,4 @@ -require 'action_controller/vendor/html-scanner' +require 'action_view/vendor/html-scanner' module ActionDispatch module Assertions @@ -48,47 +48,45 @@ module ActionDispatch # * if the condition is +true+, the value must not be +nil+. # * if the condition is +false+ or +nil+, the value must be +nil+. # - # === Examples - # # # Assert that there is a "span" tag - # assert_tag :tag => "span" + # assert_tag tag: "span" # # # Assert that there is a "span" tag with id="x" - # assert_tag :tag => "span", :attributes => { :id => "x" } + # assert_tag tag: "span", attributes: { id: "x" } # # # Assert that there is a "span" tag using the short-hand # assert_tag :span # # # Assert that there is a "span" tag with id="x" using the short-hand - # assert_tag :span, :attributes => { :id => "x" } + # assert_tag :span, attributes: { id: "x" } # # # Assert that there is a "span" inside of a "div" - # assert_tag :tag => "span", :parent => { :tag => "div" } + # assert_tag tag: "span", parent: { tag: "div" } # # # Assert that there is a "span" somewhere inside a table - # assert_tag :tag => "span", :ancestor => { :tag => "table" } + # assert_tag tag: "span", ancestor: { tag: "table" } # # # Assert that there is a "span" with at least one "em" child - # assert_tag :tag => "span", :child => { :tag => "em" } + # assert_tag tag: "span", child: { tag: "em" } # # # Assert that there is a "span" containing a (possibly nested) # # "strong" tag. - # assert_tag :tag => "span", :descendant => { :tag => "strong" } + # assert_tag tag: "span", descendant: { tag: "strong" } # # # Assert that there is a "span" containing between 2 and 4 "em" tags # # as immediate children - # assert_tag :tag => "span", - # :children => { :count => 2..4, :only => { :tag => "em" } } + # assert_tag tag: "span", + # children: { count: 2..4, only: { tag: "em" } } # # # Get funky: assert that there is a "div", with an "ul" ancestor # # and an "li" parent (with "class" = "enum"), and containing a # # "span" descendant that contains text matching /hello world/ - # assert_tag :tag => "div", - # :ancestor => { :tag => "ul" }, - # :parent => { :tag => "li", - # :attributes => { :class => "enum" } }, - # :descendant => { :tag => "span", - # :child => /hello world/ } + # assert_tag tag: "div", + # ancestor: { tag: "ul" }, + # parent: { tag: "li", + # attributes: { class: "enum" } }, + # descendant: { tag: "span", + # child: /hello world/ } # # <b>Please note</b>: +assert_tag+ and +assert_no_tag+ only work # with well-formed XHTML. They recognize a few tags as implicitly self-closing @@ -104,17 +102,16 @@ module ActionDispatch # Identical to +assert_tag+, but asserts that a matching tag does _not_ # exist. (See +assert_tag+ for a full discussion of the syntax.) # - # === Examples # # Assert that there is not a "div" containing a "p" - # assert_no_tag :tag => "div", :descendant => { :tag => "p" } + # assert_no_tag tag: "div", descendant: { tag: "p" } # # # Assert that an unordered list is empty - # assert_no_tag :tag => "ul", :descendant => { :tag => "li" } + # assert_no_tag tag: "ul", descendant: { tag: "li" } # # # Assert that there is not a "p" tag with between 1 to 3 "img" tags # # as immediate children - # assert_no_tag :tag => "p", - # :children => { :count => 1..3, :only => { :tag => "img" } } + # assert_no_tag tag: "p", + # children: { count: 1..3, only: { tag: "img" } } def assert_no_tag(*opts) opts = opts.size > 1 ? opts.last.merge({ :tag => opts.first.to_s }) : opts.first tag = find_tag(opts) diff --git a/actionpack/lib/action_dispatch/testing/integration.rb b/actionpack/lib/action_dispatch/testing/integration.rb index 69d54f6981..95cd89a166 100644 --- a/actionpack/lib/action_dispatch/testing/integration.rb +++ b/actionpack/lib/action_dispatch/testing/integration.rb @@ -1,9 +1,9 @@ require 'stringio' require 'uri' require 'active_support/core_ext/kernel/singleton_class' -require 'active_support/core_ext/object/inclusion' require 'active_support/core_ext/object/try' require 'rack/test' +require 'minitest/unit' module ActionDispatch module Integration #:nodoc: @@ -17,10 +17,10 @@ module ActionDispatch # a Hash, or a String that is appropriately encoded # (<tt>application/x-www-form-urlencoded</tt> or # <tt>multipart/form-data</tt>). - # - +headers+: Additional HTTP headers to pass, as a Hash. The keys will - # automatically be upcased, with the prefix 'HTTP_' added if needed. + # - +headers+: Additional headers to pass, as a Hash. The headers will be + # merged into the Rack env hash. # - # This method returns an Response object, which one can use to + # This method returns a Response object, which one can use to # inspect the details of the response. Furthermore, if this method was # called from an ActionDispatch::IntegrationTest object, then that # object's <tt>@response</tt> instance variable will point to the same @@ -73,8 +73,7 @@ module ActionDispatch # # The request_method is +:get+, +:post+, +:patch+, +:put+, +:delete+ or # +:head+; the parameters are +nil+, a hash, or a url-encoded or multipart - # string; the headers are a hash. Keys are automatically upcased and - # prefixed with 'HTTP_' if not already. + # string; the headers are a hash. def xml_http_request(request_method, path, parameters = nil, headers = nil) headers ||= {} headers['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest' @@ -194,16 +193,26 @@ module ActionDispatch # If the app is a Rails app, make url_helpers available on the session # This makes app.url_for and app.foo_path available in the console - if app.respond_to?(:routes) && app.routes.respond_to?(:url_helpers) - singleton_class.class_eval { include app.routes.url_helpers } + if app.respond_to?(:routes) + singleton_class.class_eval do + include app.routes.url_helpers if app.routes.respond_to?(:url_helpers) + include app.routes.mounted_helpers if app.routes.respond_to?(:mounted_helpers) + end end reset! end - remove_method :default_url_options - def default_url_options - { :host => host, :protocol => https? ? "https" : "http" } + def url_options + @url_options ||= default_url_options.dup.tap do |url_options| + url_options.reverse_merge!(controller.url_options) if controller + + if @app.respond_to?(:routes) && @app.routes.respond_to?(:default_url_options) + url_options.reverse_merge!(@app.routes.default_url_options) + end + + url_options.reverse_merge!(:host => host, :protocol => https? ? "https" : "http") + end end # Resets the instance. This can be used to reset the state information @@ -216,6 +225,7 @@ module ActionDispatch @controller = @request = @response = nil @_mock_session = nil @request_count = 0 + @url_options = nil self.host = DEFAULT_HOST self.remote_addr = "127.0.0.1" @@ -310,6 +320,7 @@ module ActionDispatch response = _mock_session.last_response @response = ActionDispatch::TestResponse.new(response.status, response.headers, response.body) @html_document = nil + @url_options = nil @controller = session.last_request.env['action_controller.instance'] @@ -335,7 +346,7 @@ module ActionDispatch define_method(method) do |*args| reset! unless integration_session # reset the html_document variable, but only for new get/post calls - @html_document = nil unless method.in?(["cookies", "assigns"]) + @html_document = nil unless method == 'cookies' || method == 'assigns' integration_session.__send__(method, *args).tap do copy_session_variables! end @@ -367,12 +378,14 @@ module ActionDispatch end end - extend ActiveSupport::Concern - include ActionDispatch::Routing::UrlFor + def default_url_options + reset! unless integration_session + integration_session.default_url_options + end - def url_options + def default_url_options=(options) reset! unless integration_session - integration_session.url_options + integration_session.default_url_options = options end def respond_to?(method, include_private = false) @@ -417,8 +430,8 @@ module ActionDispatch # assert_equal 200, status # # # post the login and follow through to the home page - # post "/login", :username => people(:jamis).username, - # :password => people(:jamis).password + # post "/login", username: people(:jamis).username, + # password: people(:jamis).password # follow_redirect! # assert_equal 200, status # assert_equal "/home", path @@ -451,13 +464,13 @@ module ActionDispatch # module CustomAssertions # def enter(room) # # reference a named route, for maximum internal consistency! - # get(room_url(:id => room.id)) + # get(room_url(id: room.id)) # assert(...) # ... # end # # def speak(room, message) - # xml_http_request "/say/#{room.id}", :message => message + # xml_http_request "/say/#{room.id}", message: message # assert(...) # ... # end @@ -467,8 +480,8 @@ module ActionDispatch # open_session do |sess| # sess.extend(CustomAssertions) # who = people(who) - # sess.post "/login", :username => who.username, - # :password => who.password + # sess.post "/login", username: who.username, + # password: who.password # assert(...) # end # end @@ -476,13 +489,16 @@ module ActionDispatch class IntegrationTest < ActiveSupport::TestCase include Integration::Runner include ActionController::TemplateAssertions + include ActionDispatch::Routing::UrlFor + + # Use AD::IntegrationTest for acceptance tests + register_spec_type(/(Acceptance|Integration) ?Test\z/i, self) @@app = nil def self.app if !@@app && !ActionDispatch.test_app - ActiveSupport::Deprecation.warn "Rails application fallback is deprecated " \ - "and no longer works, please set ActionDispatch.test_app", caller + ActiveSupport::Deprecation.warn "Rails application fallback is deprecated and no longer works, please set ActionDispatch.test_app" end @@app || ActionDispatch.test_app @@ -495,5 +511,10 @@ module ActionDispatch def app super || self.class.app end + + def url_options + reset! unless integration_session + integration_session.url_options + end end end diff --git a/actionpack/lib/action_dispatch/testing/test_process.rb b/actionpack/lib/action_dispatch/testing/test_process.rb index 3a6d081721..e657283cec 100644 --- a/actionpack/lib/action_dispatch/testing/test_process.rb +++ b/actionpack/lib/action_dispatch/testing/test_process.rb @@ -26,17 +26,19 @@ module ActionDispatch @response.redirect_url end - # Shortcut for <tt>Rack::Test::UploadedFile.new(ActionController::TestCase.fixture_path + path, type)</tt>: + # Shortcut for <tt>Rack::Test::UploadedFile.new(File.join(ActionController::TestCase.fixture_path, path), type)</tt>: # - # post :change_avatar, :avatar => fixture_file_upload('/files/spongebob.png', 'image/png') + # post :change_avatar, avatar: fixture_file_upload('files/spongebob.png', 'image/png') # # To upload binary files on Windows, pass <tt>:binary</tt> as the last parameter. # This will not affect other platforms: # - # post :change_avatar, :avatar => fixture_file_upload('/files/spongebob.png', 'image/png', :binary) + # post :change_avatar, avatar: fixture_file_upload('files/spongebob.png', 'image/png', :binary) def fixture_file_upload(path, mime_type = nil, binary = false) - fixture_path = self.class.fixture_path if self.class.respond_to?(:fixture_path) - Rack::Test::UploadedFile.new("#{fixture_path}#{path}", mime_type, binary) + if self.class.respond_to?(:fixture_path) && self.class.fixture_path + path = File.join(self.class.fixture_path, path) + end + Rack::Test::UploadedFile.new(path, mime_type, binary) end end end diff --git a/actionpack/lib/action_dispatch/testing/test_request.rb b/actionpack/lib/action_dispatch/testing/test_request.rb index 7280e9a93b..c63778f870 100644 --- a/actionpack/lib/action_dispatch/testing/test_request.rb +++ b/actionpack/lib/action_dispatch/testing/test_request.rb @@ -1,6 +1,4 @@ -require 'active_support/core_ext/object/blank' require 'active_support/core_ext/hash/indifferent_access' -require 'active_support/core_ext/hash/reverse_merge' require 'rack/utils' module ActionDispatch @@ -12,8 +10,8 @@ module ActionDispatch end def initialize(env = {}) - env = Rails.application.env_config.merge(env) if defined?(Rails.application) - super(DEFAULT_ENV.merge(env)) + env = Rails.application.env_config.merge(env) if defined?(Rails.application) && Rails.application + super(default_env.merge(env)) self.host = 'test.host' self.remote_addr = '0.0.0.0' @@ -70,5 +68,11 @@ module ActionDispatch def cookies @cookies ||= {}.with_indifferent_access end + + private + + def default_env + DEFAULT_ENV + end end end |