diff options
Diffstat (limited to 'actionpack/lib/action_dispatch')
97 files changed, 13175 insertions, 0 deletions
diff --git a/actionpack/lib/action_dispatch/http/cache.rb b/actionpack/lib/action_dispatch/http/cache.rb new file mode 100644 index 0000000000..63a3cbc90b --- /dev/null +++ b/actionpack/lib/action_dispatch/http/cache.rb @@ -0,0 +1,175 @@ + +module ActionDispatch + module Http + module Cache + module Request + + HTTP_IF_MODIFIED_SINCE = 'HTTP_IF_MODIFIED_SINCE'.freeze + HTTP_IF_NONE_MATCH = 'HTTP_IF_NONE_MATCH'.freeze + + def if_modified_since + if since = env[HTTP_IF_MODIFIED_SINCE] + Time.rfc2822(since) rescue nil + end + end + + def if_none_match + 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 etag + etag = etag.gsub(/^\"|\"$/, "") + if_none_match_etags.include?(etag) + end + end + + # Check response freshness (Last-Modified and ETag) against request + # If-Modified-Since and If-None-Match conditions. If both headers are + # supplied, both must match, or the request is not considered fresh. + def fresh?(response) + last_modified = if_modified_since + etag = if_none_match + + return false unless last_modified || etag + + success = true + success &&= not_modified?(response.last_modified) if last_modified + success &&= etag_matches?(response.etag) if etag + success + end + end + + module Response + attr_reader :cache_control, :etag + alias :etag? :etag + + def last_modified + if last = headers[LAST_MODIFIED] + Time.httpdate(last) + end + end + + def last_modified? + headers.include?(LAST_MODIFIED) + end + + def last_modified=(utc_time) + headers[LAST_MODIFIED] = utc_time.httpdate + end + + def date + if date_header = headers['Date'] + Time.httpdate(date_header) + end + end + + def date? + headers.include?('Date') + end + + def date=(utc_time) + headers['Date'] = utc_time.httpdate + end + + def etag=(etag) + key = ActiveSupport::Cache.expand_cache_key(etag) + @etag = self[ETAG] = %("#{Digest::MD5.hexdigest(key)}") + end + + private + + LAST_MODIFIED = "Last-Modified".freeze + ETAG = "ETag".freeze + CACHE_CONTROL = "Cache-Control".freeze + SPECIAL_KEYS = Set.new(%w[extras no-cache max-age public must-revalidate]) + + def cache_control_segments + if cache_control = self[CACHE_CONTROL] + 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 SPECIAL_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! + if etag? || last_modified? || !@cache_control.empty? + set_conditional_cache_control! + end + end + + DEFAULT_CACHE_CONTROL = "max-age=0, private, must-revalidate".freeze + NO_CACHE = "no-cache".freeze + PUBLIC = "public".freeze + PRIVATE = "private".freeze + MUST_REVALIDATE = "must-revalidate".freeze + + def set_conditional_cache_control! + 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.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] + + options = [] + options << "max-age=#{max_age.to_i}" if max_age + options << (control[:public] ? PUBLIC : PRIVATE) + options << MUST_REVALIDATE if control[:must_revalidate] + options.concat(extras) if extras + + headers[CACHE_CONTROL] = options.join(", ") + end + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/http/filter_parameters.rb b/actionpack/lib/action_dispatch/http/filter_parameters.rb new file mode 100644 index 0000000000..2b851cc28d --- /dev/null +++ b/actionpack/lib/action_dispatch/http/filter_parameters.rb @@ -0,0 +1,78 @@ +require 'active_support/core_ext/hash/keys' +require 'active_support/core_ext/object/duplicable' +require 'action_dispatch/http/parameter_filter' + +module ActionDispatch + module Http + # Allows you to specify sensitive parameters which will be replaced from + # the request log by looking in the query string of the request and all + # sub-hashes of the params hash to filter. If a block is given, each key and + # value of the params hash and all sub-hashes is passed to it, the value + # or key can be replaced using String#replace or similar method. + # + # env["action_dispatch.parameter_filter"] = [:password] + # => replaces the value to all keys matching /password/i with "[FILTERED]" + # + # env["action_dispatch.parameter_filter"] = [:foo, "bar"] + # => replaces the value to all keys matching /foo|bar/i with "[FILTERED]" + # + # env["action_dispatch.parameter_filter"] = lambda do |k,v| + # v.reverse! if k =~ /secret/i + # end + # => reverses the value to all keys matching /secret/i + module FilterParameters + ENV_MATCH = [/RAW_POST_DATA/, "rack.request.form_vars"] # :nodoc: + NULL_PARAM_FILTER = ParameterFilter.new # :nodoc: + NULL_ENV_FILTER = ParameterFilter.new ENV_MATCH # :nodoc: + + 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 + @filtered_parameters ||= parameter_filter.filter(parameters) + end + + # Return a hash of request.env with all sensitive data replaced. + def filtered_env + @filtered_env ||= env_filter.filter(@env) + end + + # Reconstructed a path with all sensitive GET parameters replaced. + def filtered_path + @filtered_path ||= query_string.empty? ? path : "#{path}?#{filtered_query_string}" + end + + protected + + def parameter_filter + parameter_filter_for @env.fetch("action_dispatch.parameter_filter") { + return NULL_PARAM_FILTER + } + end + + def env_filter + 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) + ParameterFilter.new(filters) + end + + KV_RE = '[^&;=]+' + PAIR_RE = %r{(#{KV_RE})=(#{KV_RE})} + def filtered_query_string + query_string.gsub(PAIR_RE) do |_| + 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..cd603649c3 --- /dev/null +++ b/actionpack/lib/action_dispatch/http/filter_redirect.rb @@ -0,0 +1,38 @@ +module ActionDispatch + module Http + module FilterRedirect + + FILTERED = '[FILTERED]'.freeze # :nodoc: + + def filtered_location + filters = location_filter + if !filters.empty? && location_filter_match?(filters) + FILTERED + else + location + end + end + + private + + def location_filter + if request + request.env['action_dispatch.redirect_filter'] || [] + else + [] + end + end + + def location_filter_match?(filters) + filters.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 new file mode 100644 index 0000000000..bc5410dc38 --- /dev/null +++ b/actionpack/lib/action_dispatch/http/headers.rb @@ -0,0 +1,99 @@ +module ActionDispatch + module Http + # Provides access to the request's HTTP headers from the environment. + # + # env = { "CONTENT_TYPE" => "text/plain" } + # headers = ActionDispatch::Http::Headers.new(env) + # headers["Content-Type"] # => "text/plain" + class Headers + CGI_VARIABLES = Set.new(%W[ + AUTH_TYPE + CONTENT_LENGTH + CONTENT_TYPE + GATEWAY_INTERFACE + HTTPS + PATH_INFO + PATH_TRANSLATED + QUERY_STRING + REMOTE_ADDR + REMOTE_HOST + REMOTE_IDENT + REMOTE_USER + REQUEST_METHOD + SCRIPT_NAME + SERVER_NAME + SERVER_PORT + SERVER_PROTOCOL + SERVER_SOFTWARE + ]).freeze + + HTTP_HEADER = /\A[A-Za-z0-9-]+\z/ + + include Enumerable + attr_reader :env + + def initialize(env = {}) # :nodoc: + @env = env + end + + # Returns the value for the given key mapped to @env. + def [](key) + @env[env_name(key)] + end + + # Sets the given value for the key mapped to @env. + def []=(key, value) + @env[env_name(key)] = value + end + + def key?(key) + @env.key? env_name(key) + end + alias :include? :key? + + # Returns the value for the given key mapped to @env. + # + # If the key is not found and an optional code block is not provided, + # raises a <tt>KeyError</tt> exception. + # + # If the code block is provided, then it will be run and + # its result returned. + def fetch(key, *args, &block) + @env.fetch env_name(key), *args, &block + end + + def each(&block) + @env.each(&block) + end + + # Returns a new Http::Headers instance containing the contents of + # <tt>headers_or_env</tt> and the original instance. + def merge(headers_or_env) + headers = Http::Headers.new(env.dup) + headers.merge!(headers_or_env) + headers + end + + # Adds the contents of <tt>headers_or_env</tt> to original instance + # entries; duplicate keys are overwritten with the values from + # <tt>headers_or_env</tt>. + def merge!(headers_or_env) + headers_or_env.each do |key, value| + self[env_name(key)] = value + end + end + + private + # Converts a HTTP header name to an environment variable name if it is + # not contained within the headers hash. + def env_name(key) + key = key.to_s + if key =~ HTTP_HEADER + key = key.upcase.tr('-', '_') + key = "HTTP_" + key unless CGI_VARIABLES.include?(key) + end + key + end + end + end +end diff --git a/actionpack/lib/action_dispatch/http/mime_negotiation.rb b/actionpack/lib/action_dispatch/http/mime_negotiation.rb new file mode 100644 index 0000000000..9c8f65deac --- /dev/null +++ b/actionpack/lib/action_dispatch/http/mime_negotiation.rb @@ -0,0 +1,155 @@ +require 'active_support/core_ext/module/attribute_accessors' + +module ActionDispatch + module Http + module MimeNegotiation + extend ActiveSupport::Concern + + included do + mattr_accessor :ignore_accept_header + self.ignore_accept_header = false + end + + attr_reader :variant + + # The MIME type of the HTTP request, such as Mime::XML. + # + # For backward compatibility, the post \format is extracted from the + # X-Post-Data-Format HTTP header if present. + def content_mime_type + @env["action_dispatch.request.content_type"] ||= begin + if @env['CONTENT_TYPE'] =~ /^([^,\;]*)/ + Mime::Type.lookup($1.strip.downcase) + else + nil + end + end + end + + def content_type + content_mime_type && content_mime_type.to_s + end + + # Returns the accepted MIME type for the request. + def accepts + @env["action_dispatch.request.accepts"] ||= begin + header = @env['HTTP_ACCEPT'].to_s.strip + + if header.empty? + [content_mime_type] + else + Mime::Type.parse(header) + end + end + end + + # Returns the MIME type for the \format used in the request. + # + # GET /posts/5.xml | request.format => Mime::XML + # GET /posts/5.xhtml | request.format => Mime::HTML + # GET /posts/5 | request.format => Mime::HTML or MIME::JS, or request.accepts.first + # + def format(view_path = []) + formats.first || Mime::NullType.instance + end + + def formats + @env["action_dispatch.request.formats"] ||= begin + params_readable = begin + parameters[:format] + rescue ActionController::BadRequest + false + end + + if params_readable + Array(Mime[parameters[:format]]) + elsif use_accept_header && valid_accept_header + accepts + elsif xhr? + [Mime::JS] + else + [Mime::HTML] + end + end + end + # Sets the \variant for template. + def variant=(variant) + if variant.is_a?(Symbol) + @variant = [variant] + elsif variant.is_a?(Array) && variant.any? && variant.all?{ |v| v.is_a?(Symbol) } + @variant = variant + else + raise ArgumentError, "request.variant must be set to a Symbol or an Array of Symbols, not a #{variant.class}. " \ + "For security reasons, never directly set the variant to a user-provided value, " \ + "like params[:variant].to_sym. Check user-provided value against a whitelist first, " \ + "then set the variant: request.variant = :tablet if params[:variant] == 'tablet'" + end + end + + # Sets the \format by string extension, which can be used to force custom formats + # that are not controlled by the extension. + # + # class ApplicationController < ActionController::Base + # before_action :adjust_format_for_iphone + # + # private + # def adjust_format_for_iphone + # request.format = :iphone if request.env["HTTP_USER_AGENT"][/iPhone/] + # end + # end + def format=(extension) + parameters[:format] = extension.to_s + @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. + # + def negotiate_mime(order) + formats.each do |priority| + if priority == Mime::ALL + return order.first + elsif order.include?(priority) + return priority + end + end + + order.include?(Mime::ALL) ? format : nil + end + + protected + + BROWSER_LIKE_ACCEPTS = /,\s*\*\/\*|\*\/\*\s*,/ + + def valid_accept_header + (xhr? && (accept.present? || content_mime_type)) || + (accept.present? && accept !~ BROWSER_LIKE_ACCEPTS) + end + + def use_accept_header + !self.class.ignore_accept_header + end + end + end +end diff --git a/actionpack/lib/action_dispatch/http/mime_type.rb b/actionpack/lib/action_dispatch/http/mime_type.rb new file mode 100644 index 0000000000..9450be838c --- /dev/null +++ b/actionpack/lib/action_dispatch/http/mime_type.rb @@ -0,0 +1,315 @@ +require 'set' +require 'singleton' +require 'active_support/core_ext/module/attribute_accessors' +require 'active_support/core_ext/string/starts_ends_with' + +module Mime + class Mimes < Array + def symbols + @symbols ||= map { |m| m.to_sym } + end + + %w(<< concat shift unshift push pop []= clear compact! collect! + delete delete_at delete_if flatten! map! insert reject! reverse! + replace slice! sort! uniq!).each do |method| + module_eval <<-CODE, __FILE__, __LINE__ + 1 + def #{method}(*) + @symbols = nil + super + end + CODE + end + end + + SET = Mimes.new + EXTENSION_LOOKUP = {} + LOOKUP = Hash.new { |h, k| h[k] = Type.new(k) unless k.blank? } + + 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: + # + # class PostsController < ActionController::Base + # def show + # @post = Post.find(params[:id]) + # + # respond_to do |format| + # format.html + # format.ics { render text: post.to_ics, mime_type: Mime::Type["text/calendar"] } + # format.xml { render xml: @people } + # end + # end + # end + class Type + @@html_types = Set.new [:html, :all] + cattr_reader :html_types + + attr_reader :symbol + + @register_callbacks = [] + + # A simple helper class used in parsing the accept header + class AcceptItem #:nodoc: + attr_accessor :index, :name, :q + alias :to_s :name + + 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 <=>(item) + result = item.q <=> @q + result = @index <=> item.index if result == 0 + result + end + + def ==(item) + @name == item.to_s + end + end + + 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 + + def lookup_by_extension(extension) + EXTENSION_LOOKUP[extension.to_s] + end + + # Registers an alias that's not used on mime type lookup, but can be referenced directly. Especially useful for + # rendering different HTML versions depending on the user agent, like an iPhone. + def register_alias(string, symbol, extension_synonyms = []) + register(string, symbol, [], extension_synonyms, true) + end + + def register(string, symbol, mime_type_synonyms = [], extension_synonyms = [], skip_lookup = false) + Mime.const_set(symbol.upcase, Type.new(string, symbol, mime_type_synonyms)) + + 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.include?(',') + accept_header = accept_header.split(PARAMETER_SEPARATOR_REGEXP).first + parse_trailing_star(accept_header) || [Mime::Type.lookup(accept_header)].compact + else + list, index = AcceptList.new, 0 + accept_header.split(',').each do |header| + params, q = header.split(PARAMETER_SEPARATOR_REGEXP) + if params.present? + params.strip! + + params = parse_trailing_star(params) || [params] + + params.each do |m| + list << AcceptItem.new(index, m.to_s, q) + index += 1 + end + end + end + list.assort! + end + end + + 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>. + # + # 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 + + # This method is opposite of register method. + # + # Usage: + # + # Mime::Type.unregister(:mobile) + def unregister(symbol) + symbol = symbol.upcase + mime = Mime.const_get(symbol) + Mime.instance_eval { remove_const(symbol) } + + SET.delete_if { |v| v.eql?(mime) } + LOOKUP.delete_if { |_,v| v.eql?(mime) } + EXTENSION_LOOKUP.delete_if { |_,v| v.eql?(mime) } + end + end + + def initialize(string, symbol = nil, synonyms = []) + @symbol, @synonyms = symbol, synonyms + @string = string + end + + def to_s + @string + end + + def to_str + to_s + end + + def to_sym + @symbol + end + + def ref + to_sym || to_s + end + + def ===(list) + if list.is_a?(Array) + (@synonyms + [ self ]).any? { |synonym| list.include?(synonym) } + else + super + end + end + + def ==(mime_type) + return false if mime_type.blank? + (@synonyms + [ self ]).any? do |synonym| + synonym.to_s == mime_type.to_s || synonym.to_sym == mime_type.to_sym + end + end + + def =~(mime_type) + return false if mime_type.blank? + regexp = Regexp.new(Regexp.quote(mime_type.to_s)) + (@synonyms + [ self ]).any? do |synonym| + synonym.to_s =~ regexp + end + end + + def html? + @@html_types.include?(to_sym) || @string =~ /html/ + end + + + private + + 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 + + class NullType + include Singleton + + def nil? + true + end + + def ref; end + + def respond_to_missing?(method, include_private = false) + method.to_s.ends_with? '?' + end + + private + def method_missing(method, *args) + false if method.to_s.ends_with? '?' + end + end +end + +require 'action_dispatch/http/mime_types' diff --git a/actionpack/lib/action_dispatch/http/mime_types.rb b/actionpack/lib/action_dispatch/http/mime_types.rb new file mode 100644 index 0000000000..0e4da36038 --- /dev/null +++ b/actionpack/lib/action_dispatch/http/mime_types.rb @@ -0,0 +1,36 @@ +# Build list of Mime types for HTTP responses +# http://www.iana.org/assignments/media-types/ + +Mime::Type.register "text/html", :html, %w( application/xhtml+xml ), %w( xhtml ) +Mime::Type.register "text/plain", :text, [], %w(txt) +Mime::Type.register "text/javascript", :js, %w( application/javascript application/x-javascript ) +Mime::Type.register "text/css", :css +Mime::Type.register "text/calendar", :ics +Mime::Type.register "text/csv", :csv +Mime::Type.register "text/vcard", :vcf + +Mime::Type.register "image/png", :png, [], %w(png) +Mime::Type.register "image/jpeg", :jpeg, [], %w(jpg jpeg jpe pjpeg) +Mime::Type.register "image/gif", :gif, [], %w(gif) +Mime::Type.register "image/bmp", :bmp, [], %w(bmp) +Mime::Type.register "image/tiff", :tiff, [], %w(tif tiff) + +Mime::Type.register "video/mpeg", :mpeg, [], %w(mpg mpeg mpe) + +Mime::Type.register "application/xml", :xml, %w( text/xml application/x-xml ) +Mime::Type.register "application/rss+xml", :rss +Mime::Type.register "application/atom+xml", :atom +Mime::Type.register "application/x-yaml", :yaml, %w( text/yaml ) + +Mime::Type.register "multipart/form-data", :multipart_form +Mime::Type.register "application/x-www-form-urlencoded", :url_encoded_form + +# http://www.ietf.org/rfc/rfc4627.txt +# http://www.json.org/JSONRequest.html +Mime::Type.register "application/json", :json, %w( text/x-json application/jsonrequest ) + +Mime::Type.register "application/pdf", :pdf, [], %w(pdf) +Mime::Type.register "application/zip", :zip, [], %w(zip) + +# Create Mime::ALL but do not add it to the SET. +Mime::ALL = Mime::Type.new("*/*", :all, []) diff --git a/actionpack/lib/action_dispatch/http/parameter_filter.rb b/actionpack/lib/action_dispatch/http/parameter_filter.rb new file mode 100644 index 0000000000..b655a54865 --- /dev/null +++ b/actionpack/lib/action_dispatch/http/parameter_filter.rb @@ -0,0 +1,72 @@ +module ActionDispatch + module Http + class ParameterFilter + FILTERED = '[FILTERED]'.freeze # :nodoc: + + def initialize(filters = []) + @filters = filters + end + + def filter(params) + compiled_filter.call(params) + end + + private + + def compiled_filter + @compiled_filter ||= CompiledFilter.compile(@filters) + end + + class CompiledFilter # :nodoc: + def self.compile(filters) + return lambda { |params| params.dup } if filters.empty? + + strings, regexps, blocks = [], [], [] + + filters.each do |item| + case item + when Proc + blocks << item + when Regexp + regexps << item + else + strings << item.to_s + end + end + + regexps << Regexp.new(strings.join('|'), true) unless strings.empty? + new regexps, blocks + end + + attr_reader :regexps, :blocks + + def initialize(regexps, blocks) + @regexps = regexps + @blocks = blocks + end + + 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 + + filtered_params[key] = value + end + + 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 new file mode 100644 index 0000000000..20ae48d458 --- /dev/null +++ b/actionpack/lib/action_dispatch/http/parameters.rb @@ -0,0 +1,67 @@ +require 'active_support/core_ext/hash/keys' +require 'active_support/core_ext/hash/indifferent_access' +require 'active_support/deprecation' + +module ActionDispatch + module Http + module Parameters + PARAMETERS_KEY = 'action_dispatch.request.path_parameters' + + # Returns both GET and POST \parameters in a single hash. + def parameters + @env["action_dispatch.request.parameters"] ||= begin + params = begin + request_parameters.merge(query_parameters) + rescue EOFError + query_parameters.dup + end + params.merge!(path_parameters) + end + end + alias :params :parameters + + def path_parameters=(parameters) #:nodoc: + @env.delete('action_dispatch.request.parameters') + @env[PARAMETERS_KEY] = parameters + end + + def symbolized_path_parameters + ActiveSupport::Deprecation.warn( + "`symbolized_path_parameters` is deprecated. Please use `path_parameters`" + ) + path_parameters + end + + # Returns a hash with the \parameters used to form the \path of the request. + # Returned hash keys are strings: + # + # {'action' => 'my_action', 'controller' => 'my_controller'} + def path_parameters + @env[PARAMETERS_KEY] ||= {} + end + + private + + # Convert nested Hash to HashWithIndifferentAccess. + # + def normalize_encode_params(params) + case params + when Hash + if params.has_key?(:tempfile) + UploadedFile.new(params) + else + params.each_with_object({}) do |(key, val), new_hash| + new_hash[key] = if val.is_a?(Array) + val.map! { |el| normalize_encode_params(el) } + else + normalize_encode_params(val) + end + end.with_indifferent_access + end + else + params + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/http/rack_cache.rb b/actionpack/lib/action_dispatch/http/rack_cache.rb new file mode 100644 index 0000000000..003ae4029d --- /dev/null +++ b/actionpack/lib/action_dispatch/http/rack_cache.rb @@ -0,0 +1,61 @@ +require "rack/cache" +require "rack/cache/context" +require "active_support/cache" + +module ActionDispatch + class RailsMetaStore < Rack::Cache::MetaStore + def self.resolve(uri) + new + end + + def initialize(store = Rails.cache) + @store = store + end + + def read(key) + if data = @store.read(key) + Marshal.load(data) + else + [] + end + end + + def write(key, value) + @store.write(key, Marshal.dump(value)) + end + + ::Rack::Cache::MetaStore::RAILS = self + end + + class RailsEntityStore < Rack::Cache::EntityStore + def self.resolve(uri) + new + end + + def initialize(store = Rails.cache) + @store = store + end + + def exist?(key) + @store.exist?(key) + end + + def open(key) + @store.read(key) + end + + def read(key) + body = open(key) + body.join if body + end + + def write(body) + buf = [] + key, size = slurp(body) { |part| buf << part } + @store.write(key, buf) + [key, size] + end + + ::Rack::Cache::EntityStore::RAILS = self + end +end diff --git a/actionpack/lib/action_dispatch/http/request.rb b/actionpack/lib/action_dispatch/http/request.rb new file mode 100644 index 0000000000..a519d6c1fc --- /dev/null +++ b/actionpack/lib/action_dispatch/http/request.rb @@ -0,0 +1,342 @@ +require 'stringio' + +require 'active_support/inflector' +require 'action_dispatch/http/headers' +require 'action_controller/metal/exceptions' +require 'rack/request' +require 'action_dispatch/http/cache' +require 'action_dispatch/http/mime_negotiation' +require 'action_dispatch/http/parameters' +require 'action_dispatch/http/filter_parameters' +require 'action_dispatch/http/upload' +require 'action_dispatch/http/url' +require 'active_support/core_ext/array/conversions' + +module ActionDispatch + class Request < Rack::Request + include ActionDispatch::Http::Cache::Request + include ActionDispatch::Http::MimeNegotiation + include ActionDispatch::Http::Parameters + include ActionDispatch::Http::FilterParameters + include ActionDispatch::Http::URL + + autoload :Session, 'action_dispatch/request/session' + autoload :Utils, 'action_dispatch/request/utils' + + LOCALHOST = Regexp.union [/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, /^::1$/, /^0:0:0:0:0:0:0:1(%.*)?$/] + + ENV_METHODS = %w[ AUTH_TYPE GATEWAY_INTERFACE + PATH_TRANSLATED REMOTE_HOST + REMOTE_IDENT REMOTE_USER REMOTE_ADDR + SERVER_NAME SERVER_PROTOCOL + + HTTP_ACCEPT HTTP_ACCEPT_CHARSET HTTP_ACCEPT_ENCODING + HTTP_ACCEPT_LANGUAGE HTTP_CACHE_CONTROL HTTP_FROM + HTTP_NEGOTIATE HTTP_PRAGMA ].freeze + + ENV_METHODS.each do |env| + class_eval <<-METHOD, __FILE__, __LINE__ + 1 + def #{env.sub(/^HTTP_/n, '').downcase} # def accept_charset + @env["#{env}"] # @env["HTTP_ACCEPT_CHARSET"] + end # end + 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 check_path_parameters! + # If any of the path parameters has an invalid encoding then + # raise since it's likely to trigger errors further on. + path_parameters.each do |key, value| + next unless value.respond_to?(:valid_encoding?) + unless value.valid_encoding? + raise ActionController::BadRequest, "Invalid parameter: #{key} => #{value}" + end + end + end + + def key?(key) + @env.key?(key) + end + + # List of HTTP request methods from the following RFCs: + # Hypertext Transfer Protocol -- HTTP/1.1 (http://www.ietf.org/rfc/rfc2616.txt) + # HTTP Extensions for Distributed Authoring -- WEBDAV (http://www.ietf.org/rfc/rfc2518.txt) + # Versioning Extensions to WebDAV (http://www.ietf.org/rfc/rfc3253.txt) + # Ordered Collections Protocol (WebDAV) (http://www.ietf.org/rfc/rfc3648.txt) + # Web Distributed Authoring and Versioning (WebDAV) Access Control Protocol (http://www.ietf.org/rfc/rfc3744.txt) + # Web Distributed Authoring and Versioning (WebDAV) SEARCH (http://www.ietf.org/rfc/rfc5323.txt) + # Calendar Extensions to WebDAV (http://www.ietf.org/rfc/rfc4791.txt) + # PATCH Method for HTTP (http://www.ietf.org/rfc/rfc5789.txt) + RFC2616 = %w(OPTIONS GET HEAD POST PUT DELETE TRACE CONNECT) + RFC2518 = %w(PROPFIND PROPPATCH MKCOL COPY MOVE LOCK UNLOCK) + RFC3253 = %w(VERSION-CONTROL REPORT CHECKOUT CHECKIN UNCHECKOUT MKWORKSPACE UPDATE LABEL MERGE BASELINE-CONTROL MKACTIVITY) + RFC3648 = %w(ORDERPATCH) + RFC3744 = %w(ACL) + RFC5323 = %w(SEARCH) + RFC4791 = %w(MKCALENDAR) + RFC5789 = %w(PATCH) + + HTTP_METHODS = RFC2616 + RFC2518 + RFC3253 + RFC3648 + RFC3744 + RFC5323 + RFC4791 + RFC5789 + + 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 + # (for instance, if a HEAD request was converted to a GET, + # or if a _method parameter was used to determine the \method + # the application should use), this \method returns the overridden + # value, not the original. + def request_method + @request_method ||= check_method(env["REQUEST_METHOD"]) + end + + # Returns a symbol form of the #request_method + def request_method_symbol + HTTP_METHOD_LOOKUP[request_method] + end + + # Returns the original value of the environment's REQUEST_METHOD, + # even if it was overridden by middleware. See #request_method for + # more information. + def method + @method ||= check_method(env["rack.methodoverride.original_method"] || env['REQUEST_METHOD']) + end + + # Returns a symbol form of the #method + def method_symbol + HTTP_METHOD_LOOKUP[method] + end + + # Is this a GET (or HEAD) request? + # Equivalent to <tt>request.request_method_symbol == :get</tt>. + def get? + HTTP_METHOD_LOOKUP[request_method] == :get + end + + # Is this a POST request? + # Equivalent to <tt>request.request_method_symbol == :post</tt>. + def post? + HTTP_METHOD_LOOKUP[request_method] == :post + end + + # Is this a PATCH request? + # Equivalent to <tt>request.request_method == :patch</tt>. + def patch? + HTTP_METHOD_LOOKUP[request_method] == :patch + end + + # Is this a PUT request? + # Equivalent to <tt>request.request_method_symbol == :put</tt>. + def put? + HTTP_METHOD_LOOKUP[request_method] == :put + end + + # Is this a DELETE request? + # Equivalent to <tt>request.request_method_symbol == :delete</tt>. + def delete? + HTTP_METHOD_LOOKUP[request_method] == :delete + end + + # Is this a HEAD request? + # Equivalent to <tt>request.request_method_symbol == :head</tt>. + def head? + HTTP_METHOD_LOOKUP[request_method] == :head + end + + # Provides access to the request's HTTP headers, for example: + # + # request.headers["Content-Type"] # => "text/plain" + def headers + Http::Headers.new(@env) + end + + # Returns a +String+ with the last requested path including their params. + # + # # get '/foo' + # request.original_fullpath # => '/foo' + # + # # get '/foo?bar' + # request.original_fullpath # => '/foo?bar' + def original_fullpath + @original_fullpath ||= (env["ORIGINAL_FULLPATH"] || fullpath) + end + + # Returns the +String+ full path including params of the last URL requested. + # + # # get "/articles" + # request.fullpath # => "/articles" + # + # # get "/articles?page=2" + # request.fullpath # => "/articles?page=2" + def fullpath + @fullpath ||= super + end + + # Returns the original request URL as a +String+. + # + # # get "/articles?page=2" + # request.original_url # => "http://www.example.com/articles?page=2" + def original_url + base_url + original_fullpath + end + + # The +String+ MIME type of the request. + # + # # get "/articles" + # request.media_type # => "application/x-www-form-urlencoded" + def media_type + content_mime_type.to_s + end + + # Returns the content length of the request as an integer. + def content_length + super.to_i + end + + # Returns true if the "X-Requested-With" header contains "XMLHttpRequest" + # (case-insensitive), which may need to be manually added depending on the + # choice of JavaScript libraries and frameworks. + def xml_http_request? + @env['HTTP_X_REQUESTED_WITH'] =~ /XMLHttpRequest/i + end + alias :xhr? :xml_http_request? + + def ip + @ip ||= super + end + + # Originating IP address, usually set by the RemoteIp middleware. + def remote_ip + @remote_ip ||= (@env["action_dispatch.remote_ip"] || ip).to_s + end + + # Returns the unique request id, which is based off either the X-Request-Id header that can + # be generated by a firewall, load balancer, or web server or by the RequestId middleware + # (which sets the action_dispatch.request_id environment variable). + # + # This unique ID is useful for tracing a request from end-to-end as part of logging or debugging. + # This relies on the rack variable set by the ActionDispatch::RequestId middleware. + def uuid + @uuid ||= env["action_dispatch.request_id"] + end + + # Returns the lowercase name of the HTTP server software. + def server_software + (@env['SERVER_SOFTWARE'] && /^([a-zA-Z]+)/ =~ @env['SERVER_SOFTWARE']) ? $1.downcase : nil + end + + # Read the request \body. This is useful for web services that need to + # work with raw requests directly. + def raw_post + unless @env.include? 'RAW_POST_DATA' + raw_post_body = body + @env['RAW_POST_DATA'] = raw_post_body.read(content_length) + raw_post_body.rewind if raw_post_body.respond_to?(:rewind) + end + @env['RAW_POST_DATA'] + end + + # The request body is an IO input stream. If the RAW_POST_DATA environment + # variable is already set, wrap it in a StringIO. + def body + if raw_post = @env['RAW_POST_DATA'] + raw_post.force_encoding(Encoding::BINARY) + StringIO.new(raw_post) + else + @env['rack.input'] + end + end + + def form_data? + FORM_DATA_MEDIA_TYPES.include?(content_mime_type.to_s) + end + + def body_stream #:nodoc: + @env['rack.input'] + end + + # TODO This should be broken apart into AD::Request::Session and probably + # be included by the session middleware. + def reset_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: + Session.set @env, session + end + + def 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"] ||= Utils.deep_munge(normalize_encode_params(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"] ||= Utils.deep_munge(normalize_encode_params(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 + @env['HTTP_AUTHORIZATION'] || + @env['X-HTTP_AUTHORIZATION'] || + @env['X_HTTP_AUTHORIZATION'] || + @env['REDIRECT_X_HTTP_AUTHORIZATION'] + end + + # True if the request came from localhost, 127.0.0.1. + def local? + LOCALHOST =~ remote_addr && LOCALHOST =~ remote_ip + end + + # Extracted into ActionDispatch::Request::Utils.deep_munge, but kept here for backwards compatibility. + def deep_munge(hash) + ActiveSupport::Deprecation.warn( + "This method has been extracted into ActionDispatch::Request::Utils.deep_munge. Please start using that instead." + ) + + Utils.deep_munge(hash) + end + + protected + def parse_query(qs) + Utils.deep_munge(super) + end + + private + def check_method(name) + HTTP_METHOD_LOOKUP[name] || raise(ActionController::UnknownHttpMethod, "#{name}, accepted HTTP methods are #{HTTP_METHODS.to_sentence(:locale => :en)}") + name + end + end +end diff --git a/actionpack/lib/action_dispatch/http/response.rb b/actionpack/lib/action_dispatch/http/response.rb new file mode 100644 index 0000000000..2fab6be1a5 --- /dev/null +++ b/actionpack/lib/action_dispatch/http/response.rb @@ -0,0 +1,388 @@ +require 'active_support/core_ext/module/attribute_accessors' +require 'action_dispatch/http/filter_redirect' +require 'monitor' + +module ActionDispatch # :nodoc: + # Represents an HTTP response generated by a controller action. Use it to + # retrieve the current state of the response, or customize the response. It can + # either represent a real HTTP response (i.e. one that is meant to be sent + # back to the web browser) or a TestResponse (i.e. one that is generated + # from integration tests). + # + # \Response is mostly a Ruby on \Rails framework implementation detail, and + # should never be used directly in controllers. Controllers should use the + # methods defined in ActionController::Base instead. For example, if you want + # to set the HTTP response's content MIME type, then use + # ActionControllerBase#headers instead of Response#headers. + # + # Nevertheless, integration tests may want to inspect controller responses in + # more detail, and that's when \Response can be useful for application + # developers. Integration test methods such as + # ActionDispatch::Integration::Session#get and + # ActionDispatch::Integration::Session#post return objects of type + # TestResponse (which are of course also of type \Response). + # + # For example, the following demo integration test prints the body of the + # controller response to the console: + # + # class DemoControllerTest < ActionDispatch::IntegrationTest + # def test_print_root_path_to_console + # get('/') + # puts response.body + # end + # end + class Response + # The request that the response is responding to. + attr_accessor :request + + # The HTTP status code. + attr_reader :status + + attr_writer :sending_file + + # Get and set headers for this response. + attr_accessor :header + + alias_method :headers=, :header= + alias_method :headers, :header + + delegate :[], :[]=, :to => :@header + delegate :each, :to => :@stream + + # Sets the HTTP response's content MIME type. For example, in the controller + # you could write this: + # + # response.content_type = "text/plain" + # + # 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_reader :content_type + + # The charset of the response. HTML wants to know the encoding of the + # content you're giving them, so we need to send that along. + attr_accessor :charset + + CONTENT_TYPE = "Content-Type".freeze + SET_COOKIE = "Set-Cookie".freeze + LOCATION = "Location".freeze + NO_CONTENT_CODES = [204, 304] + + 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) + @response.sending! + x = @buf.each(&block) + @response.sent! + x + end + + def abort + end + + def close + @response.commit! + @closed = true + end + + def closed? + @closed + end + end + + # The underlying body, as a streamable object. + 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 + @cv = new_cond + @committed = false + @sending = false + @sent = false + @content_type = nil + @charset = nil + + if content_type = self[CONTENT_TYPE] + type, charset = content_type.split(/;\s*charset=/) + @content_type = Mime::Type.lookup(type) + @charset = charset || self.class.default_charset + end + + prepare_cache_control! + + yield self if block_given? + end + + def await_commit + synchronize do + @cv.wait_until { @committed } + end + end + + def await_sent + synchronize { @cv.wait_until { @sent } } + end + + def commit! + synchronize do + before_committed + @committed = true + @cv.broadcast + end + end + + def sending! + synchronize do + before_sending + @sending = true + @cv.broadcast + end + end + + def sent! + synchronize do + @sent = true + @cv.broadcast + end + end + + def sending?; synchronize { @sending }; end + def committed?; synchronize { @committed }; end + def sent?; synchronize { @sent }; end + + # Sets the HTTP status code. + def status=(status) + @status = Rack::Utils.status_code(status) + end + + # Sets the HTTP content type. + def content_type=(content_type) + @content_type = content_type.to_s + end + + # The response code of the request. + def response_code + @status + end + + # Returns a string to ensure compatibility with <tt>Net::HTTPResponse</tt>. + def code + @status.to_s + end + + # Returns the corresponding message for the current HTTP status code: + # + # response.status = 200 + # response.message # => "OK" + # + # response.status = 404 + # response.message # => "Not Found" + # + def message + Rack::Utils::HTTP_STATUS_CODES[@status] + end + alias_method :status_message, :message + + # Returns the content of the response as a string. This contains the contents + # of any calls to <tt>render</tt>. + def body + strings = [] + each { |part| strings << part.to_s } + strings.join + end + + EMPTY = " " + + # Allows you to manually set or override the response body. + def body=(body) + @blank = true if body == EMPTY + + if body.respond_to?(:to_path) + @stream = body + else + synchronize do + @stream = build_buffer self, munge_body_object(body) + end + end + end + + def body_parts + parts = [] + @stream.each { |x| parts << x } + parts + end + + def set_cookie(key, value) + ::Rack::Utils.set_cookie_header!(header, key, value) + end + + def delete_cookie(key, value={}) + ::Rack::Utils.delete_cookie_header!(header, key, value) + end + + # The location header we'll be responding with. + def location + headers[LOCATION] + end + alias_method :redirect_url, :location + + # Sets the location header we'll be responding with. + def location=(url) + headers[LOCATION] = url + end + + def close + stream.close if stream.respond_to?(:close) + end + + def abort + if stream.respond_to?(:abort) + stream.abort + elsif stream.respond_to?(:close) + # `stream.close` should really be reserved for a close from the + # other direction, but we must fall back to it for + # compatibility. + stream.close + end + end + + # Turns the Response into a Rack-compatible array of the status, headers, + # and body. + def to_a + rack_response @status, @header.to_hash + end + alias prepare! to_a + alias to_ary to_a + + # Returns the response cookies, converted to a Hash of (name => value) pairs + # + # assert_equal 'AuthorOfNewPage', r.cookies['author'] + def cookies + cookies = {} + if header = self[SET_COOKIE] + header = header.split("\n") if header.respond_to?(:to_str) + header.each do |cookie| + if pair = cookie.split(';').first + key, value = pair.split("=").map { |v| Rack::Utils.unescape(v) } + cookies[key] = value + end + end + end + cookies + end + + def _status_code + @status + end + private + + def before_committed + end + + def before_sending + end + + 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 + @charset ||= self.class.default_charset unless @charset == false + + type = @content_type.to_s.dup + type << "; charset=#{@charset}" if append_charset? + + headers[CONTENT_TYPE] = type + end + + def append_charset? + !@sending_file && @charset != false + end + + class RackBody + def initialize(response) + @response = response + end + + def each(*args, &block) + @response.each(*args, &block) + end + + def close + # Rack "close" maps to Response#abort, and *not* Response#close + # (which is used when the controller's finished writing) + @response.abort + end + + def body + @response.body + end + + def respond_to?(method, include_private = false) + if method.to_s == 'to_path' + @response.stream.respond_to?(method) + else + super + end + end + + def to_path + @response.stream.to_path + end + 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 NO_CONTENT_CODES.include?(@status) + header.delete CONTENT_TYPE + [status, header, []] + else + [status, header, RackBody.new(self)] + end + end + end +end diff --git a/actionpack/lib/action_dispatch/http/upload.rb b/actionpack/lib/action_dispatch/http/upload.rb new file mode 100644 index 0000000000..540e11a4a0 --- /dev/null +++ b/actionpack/lib/action_dispatch/http/upload.rb @@ -0,0 +1,72 @@ +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 no need to + # clean them with a separate maintenance task. + class UploadedFile + # The basename of the file in the client. + attr_accessor :original_filename + + # 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 + alias :to_io :tempfile + + # A string with the headers of the multipart request. + attr_accessor :headers + + def initialize(hash) # :nodoc: + @tempfile = hash[:tempfile] + raise(ArgumentError, ':tempfile is required') unless @tempfile + + @original_filename = hash[:filename] + @original_filename &&= @original_filename.encode "UTF-8" + @content_type = hash[:type] + @headers = hash[:head] + end + + # 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 + + # Shortcut for +tempfile.eof?+. + def eof? + @tempfile.eof? + end + end + end +end diff --git a/actionpack/lib/action_dispatch/http/url.rb b/actionpack/lib/action_dispatch/http/url.rb new file mode 100644 index 0000000000..6b8dcaf497 --- /dev/null +++ b/actionpack/lib/action_dispatch/http/url.rb @@ -0,0 +1,266 @@ +require 'active_support/core_ext/module/attribute_accessors' +require 'active_support/core_ext/hash/slice' + +module ActionDispatch + module Http + module URL + IP_HOST_REGEXP = /\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/ + HOST_REGEXP = /(^[^:]+:\/\/)?([^:]+)(?::(\d+$))?/ + PROTOCOL_REGEXP = /^([^:]+)(:)?(\/\/)?$/ + + mattr_accessor :tld_length + self.tld_length = 1 + + class << self + def extract_domain(host, tld_length) + extract_domain_from(host, tld_length) if named_host?(host) + end + + def extract_subdomains(host, tld_length) + if named_host?(host) + extract_subdomains_from(host, tld_length) + else + [] + end + end + + def extract_subdomain(host, tld_length) + extract_subdomains(host, tld_length).join('.') + end + + def url_for(options) + if options[:only_path] + path_for options + else + full_url_for options + end + end + + def full_url_for(options) + host = options[:host] + protocol = options[:protocol] + port = options[:port] + + unless host + raise ArgumentError, 'Missing host to link to! Please provide the :host parameter, set default_url_options[:host], or set :only_path to true' + end + + build_host_url(host, port, protocol, options, path_for(options)) + end + + def path_for(options) + path = options[:script_name].to_s.chomp("/") + path << options[:path] if options.key?(:path) + + add_trailing_slash(path) if options[:trailing_slash] + add_params(path, options[:params]) if options.key?(:params) + add_anchor(path, options[:anchor]) if options.key?(:anchor) + + path + end + + private + + def add_params(path, params) + params = { params: params } unless params.is_a?(Hash) + params.reject! { |_,v| v.to_param.nil? } + path << "?#{params.to_query}" unless params.empty? + end + + def add_anchor(path, anchor) + path << "##{Journey::Router::Utils.escape_fragment(anchor.to_param.to_s)}" + end + + def extract_domain_from(host, tld_length) + host.split('.').last(1 + tld_length).join('.') + end + + def extract_subdomains_from(host, tld_length) + parts = host.split('.') + parts[0..-(tld_length + 2)] + end + + def add_trailing_slash(path) + # includes querysting + if path.include?('?') + path.sub!(/\?/, '/\&') + # does not have a .format + elsif !path.include?(".") + path.sub!(/[^\/]\z|\A\z/, '\&/') + end + end + + def build_host_url(host, port, protocol, options, path) + if match = host.match(HOST_REGEXP) + protocol ||= match[1] unless protocol == false + host = match[2] + port = match[3] unless options.key? :port + end + + protocol = normalize_protocol protocol + host = normalize_host(host, options) + + result = protocol.dup + + if options[:user] && options[:password] + result << "#{Rack::Utils.escape(options[:user])}:#{Rack::Utils.escape(options[:password])}@" + end + + result << host + normalize_port(port, protocol) { |normalized_port| + result << ":#{normalized_port}" + } + + result.concat path + end + + def named_host?(host) + IP_HOST_REGEXP !~ host + end + + def normalize_protocol(protocol) + case protocol + when nil + "http://" + when false, "//" + "//" + when PROTOCOL_REGEXP + "#{$1}://" + else + raise ArgumentError, "Invalid :protocol option: #{protocol.inspect}" + end + end + + def normalize_host(_host, options) + return _host unless named_host?(_host) + + tld_length = options[:tld_length] || @@tld_length + subdomain = options.fetch :subdomain, true + domain = options[:domain] + + host = "" + if subdomain == true + return _host if domain.nil? + + host << extract_subdomains_from(_host, tld_length).join('.') + elsif subdomain + host << subdomain.to_param + end + host << "." unless host.empty? + host << (domain || extract_domain_from(_host, tld_length)) + host + end + + def normalize_port(port, protocol) + return unless port + + case protocol + when "//" then yield port + when "https://" + yield port unless port.to_i == 443 + else + yield port unless port.to_i == 80 + end + 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 + end + + # Returns 'https://' if this is an SSL request and 'http://' otherwise. + def protocol + @protocol ||= ssl? ? 'https://' : 'http://' + end + + # Returns the \host for this request, such as "example.com". + def raw_host_with_port + if forwarded = env["HTTP_X_FORWARDED_HOST"] + forwarded.split(/,\s?/).last + else + env['HTTP_HOST'] || "#{env['SERVER_NAME'] || env['SERVER_ADDR']}:#{env['SERVER_PORT']}" + end + end + + # Returns the host for this request, such as example.com. + def host + raw_host_with_port.sub(/:\d+$/, '') + end + + # Returns a \host:\port string for this request, such as "example.com" or + # "example.com:8080". + def host_with_port + "#{host}#{port_string}" + end + + # Returns the port number of this request as an integer. + def port + @port ||= begin + if raw_host_with_port =~ /:(\d+)$/ + $1.to_i + else + standard_port + end + end + end + + # Returns the standard \port number for this request's protocol. + def standard_port + case protocol + when 'https://' then 443 + else 80 + end + end + + # Returns whether this request is using the standard port + def standard_port? + port == standard_port + end + + # Returns a number \port suffix like 8080 if the \port number of this request + # is not the default HTTP \port 80 or HTTPS \port 443. + def optional_port + standard_port? ? nil : port + end + + # Returns a string \port suffix, including colon, like ":8080" if the \port + # number of this request is not the default HTTP \port 80 or HTTPS \port 443. + def port_string + standard_port? ? '' : ":#{port}" + end + + def server_port + @env['SERVER_PORT'].to_i + end + + # Returns the \domain part of a \host, such as "rubyonrails.org" in "www.rubyonrails.org". You can specify + # a different <tt>tld_length</tt>, such as 2 to catch rubyonrails.co.uk in "www.rubyonrails.co.uk". + def domain(tld_length = @@tld_length) + ActionDispatch::Http::URL.extract_domain(host, tld_length) + end + + # Returns all the \subdomains as an array, so <tt>["dev", "www"]</tt> would be + # returned for "dev.www.rubyonrails.org". You can specify a different <tt>tld_length</tt>, + # such as 2 to catch <tt>["www"]</tt> instead of <tt>["www", "rubyonrails"]</tt> + # in "www.rubyonrails.co.uk". + def subdomains(tld_length = @@tld_length) + ActionDispatch::Http::URL.extract_subdomains(host, tld_length) + end + + # Returns all the \subdomains as a string, so <tt>"dev.www"</tt> would be + # returned for "dev.www.rubyonrails.org". You can specify a different <tt>tld_length</tt>, + # such as 2 to catch <tt>"www"</tt> instead of <tt>"www.rubyonrails"</tt> + # in "www.rubyonrails.co.uk". + def subdomain(tld_length = @@tld_length) + ActionDispatch::Http::URL.extract_subdomain(host, tld_length) + end + end + end +end 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..59b353b1b7 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/formatter.rb @@ -0,0 +1,151 @@ +require 'action_controller/metal/exceptions' + +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(name, options, path_parameters, parameterize = nil) + constraints = path_parameters.merge(options) + missing_keys = [] + + match_route(name, constraints) do |route| + parameterized_parts = extract_parameterized_parts(route, options, path_parameters, parameterize) + + # Skip this route unless a name has been provided or it is a + # standard Rails route since we can't determine whether an options + # hash passed to url_for matches a Rack application or a redirect. + next unless name || route.dispatcher? + + 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 + + defaults = route.defaults + required_parts = route.required_parts + parameterized_parts.delete_if do |key, value| + value.to_s == defaults[key].to_s && !required_parts.include?(key) + end + + return [route.format(parameterized_parts), params] + end + + message = "No route matches #{Hash[constraints.sort].inspect}" + message << " missing required keys: #{missing_keys.sort.inspect}" if name + + raise ActionController::UrlGenerationError, message + end + + def clear + @cache = nil + end + + private + + def extract_parameterized_parts(route, options, recall, parameterize = nil) + parameterized_parts = recall.merge(options) + + keys_to_keep = route.parts.reverse.drop_while { |part| + !options.key?(part) || (options[part] || recall[part]).nil? + } | route.required_parts + + (parameterized_parts.keys - keys_to_keep).each do |bad_key| + parameterized_parts.delete(bad_key) + end + + 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) + + hash = routes.group_by { |_, r| r.score(options) } + + hash.keys.sort.reverse_each do |score| + break if score < 0 + + hash[score].sort_by { |i, _| i }.each do |_, route| + yield route + end + end + end + end + + def non_recursive(cache, options) + routes = [] + queue = [cache] + + while queue.any? + c = queue.shift + routes.concat(c[:___routes]) if c.key?(:___routes) + + options.each do |pair| + queue << 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) + }.flat_map { |pair| + possibles(cache[pair], options, depth + 1) + } + 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..450588cda6 --- /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.flat_map { |l| followpos(l) } + 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.flat_map { |c| firstpos(c) }.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.flat_map { |c| lastpos(c) }.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..94b0a24344 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/gtg/simulator.rb @@ -0,0 +1,47 @@ +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) + ms = memos(string) { return } + MatchData.new(ms) + end + + alias :=~ :simulate + alias :match :simulate + + def memos(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 yield if acceptance_states.empty? + + acceptance_states.flat_map { |x| tt.memo(x) }.compact + end + 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..990d2127ee --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/gtg/transition_table.rb @@ -0,0 +1,157 @@ +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 = {} + @string_states = {} + @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) + return [] if t.empty? + + regexps = [] + + t.map { |s| + if states = @regexp_states[s] + regexps.concat states.map { |re, v| re === a ? v : nil } + end + + if states = @string_states[s] + states[a] + end + }.compact.concat regexps + end + + def as_json(options = nil) + 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 + + { + 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) + to_mappings = states_hash_for(sym)[from] ||= {} + to_mappings[sym] = to + end + + def states + ss = @string_states.keys + @string_states.values.flat_map(&:values) + rs = @regexp_states.keys + @regexp_states.values.flat_map(&:values) + (ss + rs).uniq + end + + def transitions + @string_states.flat_map { |from, hash| + hash.map { |s, to| [from, s, to] } + } + @regexp_states.flat_map { |from, hash| + hash.map { |s, to| [from, s, to] } + } + end + + private + + def states_hash_for(sym) + case sym + when String + @string_states + when Regexp + @regexp_states + else + raise ArgumentError, 'unknown symbol: %s' % sym.class + end + 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..47bf76bdbf --- /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.flat_map { |k, memos| + # (memos || []).map { |v| " #{k} -> #{v.object_id};" } + #}.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..b23270db3c --- /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.flat_map { |x| tt.memo(x) }.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..66e414213a --- /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.flat_map(&:keys)).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).flat_map { |s| inverted[s][a] }.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.flat_map(&:keys).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.flat_map { |to, hash| + hash.map { |from, sym| [from, sym, to] } + } + 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..bb01c087bc --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/nodes/node.rb @@ -0,0 +1,128 @@ +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 + + def name + left.name.tr '*:', '' + 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..9012297400 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/parser.rb @@ -0,0 +1,198 @@ +# +# DO NOT MODIFY!!!! +# This file is automatically generated by Racc 1.4.11 +# from Racc grammer file "". +# + +require 'racc/parser.rb' + + +require 'action_dispatch/journey/parser_extras' +module ActionDispatch + module Journey + class Parser < Racc::Parser +##### State transition tables begin ### + +racc_action_table = [ + 13, 15, 14, 7, 21, 16, 8, 19, 13, 15, + 14, 7, 17, 16, 8, 13, 15, 14, 7, 24, + 16, 8, 13, 15, 14, 7, 19, 16, 8 ] + +racc_action_check = [ + 2, 2, 2, 2, 17, 2, 2, 2, 0, 0, + 0, 0, 1, 0, 0, 19, 19, 19, 19, 20, + 19, 19, 7, 7, 7, 7, 22, 7, 7 ] + +racc_action_pointer = [ + 6, 12, -2, nil, nil, nil, nil, 20, nil, nil, + nil, nil, nil, nil, nil, nil, nil, 4, nil, 13, + 13, nil, 17, nil, nil ] + +racc_action_default = [ + -19, -19, -2, -3, -4, -5, -6, -19, -10, -11, + -12, -13, -14, -15, -16, -17, -18, -19, -1, -19, + -19, 25, -8, -9, -7 ] + +racc_goto_table = [ + 1, 22, 18, 23, nil, nil, nil, 20 ] + +racc_goto_check = [ + 1, 2, 1, 3, nil, nil, nil, 1 ] + +racc_goto_pointer = [ + nil, 0, -18, -16, 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, + 3, 13, :_reduce_9, + 1, 16, :_reduce_10, + 1, 14, :_reduce_none, + 1, 14, :_reduce_none, + 1, 14, :_reduce_none, + 1, 14, :_reduce_none, + 1, 19, :_reduce_15, + 1, 17, :_reduce_16, + 1, 18, :_reduce_17, + 1, 20, :_reduce_18 ] + +racc_reduce_n = 19 + +racc_shift_n = 25 + +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 = false + +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) + Cat.new(val.first, val.last) +end + +def _reduce_2(val, _values) + val.first +end + +# reduce 3 omitted + +# reduce 4 omitted + +# reduce 5 omitted + +# reduce 6 omitted + +def _reduce_7(val, _values) + Group.new(val[1]) +end + +def _reduce_8(val, _values) + Or.new([val.first, val.last]) +end + +def _reduce_9(val, _values) + Or.new([val.first, val.last]) +end + +def _reduce_10(val, _values) + Star.new(Symbol.new(val.last)) +end + +# reduce 11 omitted + +# reduce 12 omitted + +# reduce 13 omitted + +# reduce 14 omitted + +def _reduce_15(val, _values) + Slash.new('/') +end + +def _reduce_16(val, _values) + Symbol.new(val.first) +end + +def _reduce_17(val, _values) + Literal.new(val.first) +end + +def _reduce_18(val, _values) + Dot.new(val.first) +end + +def _reduce_none(val, _values) + 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..d3f7c4d765 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/parser.y @@ -0,0 +1,49 @@ +class ActionDispatch::Journey::Parser + options no_result_var +token SLASH LITERAL SYMBOL LPAREN RPAREN DOT STAR OR + +rule + expressions + : expression expressions { Cat.new(val.first, val.last) } + | expression { val.first } + | or + ; + expression + : terminal + | group + | star + ; + group + : LPAREN expressions RPAREN { Group.new(val[1]) } + ; + or + : expression OR expression { Or.new([val.first, val.last]) } + | expression OR or { Or.new([val.first, val.last]) } + ; + star + : STAR { Star.new(Symbol.new(val.last)) } + ; + terminal + : symbol + | literal + | slash + | dot + ; + slash + : SLASH { Slash.new('/') } + ; + symbol + : SYMBOL { Symbol.new(val.first) } + ; + literal + : LITERAL { Literal.new(val.first) } + ; + dot + : DOT { 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..3af940a02f --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/path/pattern.rb @@ -0,0 +1,193 @@ +require 'action_dispatch/journey/router/strexp' + +module ActionDispatch + module Journey # :nodoc: + module Path # :nodoc: + class Pattern # :nodoc: + attr_reader :spec, :requirements, :anchored + + def self.from_string string + new Journey::Router::Strexp.build(string, {}, ["/.?"], true) + end + + def initialize(strexp) + @spec = strexp.ast + @requirements = strexp.requirements + @separators = strexp.separators.join + @anchored = strexp.anchor + + @names = nil + @optional_names = nil + @required_names = nil + @re = nil + @offsets = nil + end + + def build_formatter + Visitors::FormatBuilder.new.accept(spec) + 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).flat_map { |group| + group.grep(Nodes::Symbol) + }.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..9f0a3af902 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/route.rb @@ -0,0 +1,125 @@ +module ActionDispatch + module Journey # :nodoc: + class Route # :nodoc: + attr_reader :app, :path, :defaults, :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 = {}) + @name = name + @app = app + @path = path + + @constraints = constraints + @defaults = defaults + @required_defaults = nil + @required_parts = nil + @parts = nil + @decorated_ast = nil + @precedence = 0 + @path_formatter = @path.build_formatter + end + + def ast + @decorated_ast ||= begin + decorated_ast = path.ast + decorated_ast.grep(Nodes::Terminal).each { |n| n.memo = self } + decorated_ast + end + 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 + required_parts + 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_formatter.evaluate path_options + 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_default?(key) + (constraints[:required_defaults] || []).include?(key) + end + + def required_defaults + @required_defaults ||= @defaults.dup.delete_if do |k,_| + parts.include?(k) || !required_default?(k) + end + end + + def glob? + !path.spec.grep(Nodes::Star).empty? + end + + def dispatcher? + @app.dispatcher? + end + + def matches?(request) + constraints.all? do |method, value| + next true unless request.respond_to?(method) + + case value + when Regexp, String + value === request.send(method).to_s + when Array + value.include?(request.send(method)) + when TrueClass + request.send(method).present? + when FalseClass + request.send(method).blank? + else + value === request.send(method) + end + end + end + + def ip + constraints[:ip] || // + end + + def verb + constraints[:request_method] || // + 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..21817b374c --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/router.rb @@ -0,0 +1,137 @@ +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' + + attr_accessor :routes + + def initialize(routes) + @routes = routes + end + + def serve(req) + find_routes(req).each do |match, parameters, route| + set_params = req.path_parameters + path_info = req.path_info + script_name = req.script_name + + unless route.path.anchored + req.script_name = (script_name.to_s + match.to_s).chomp('/') + req.path_info = match.post_match + req.path_info = "/" + req.path_info unless req.path_info.start_with? "/" + end + + req.path_parameters = set_params.merge parameters + + status, headers, body = route.app.serve(req) + + if 'pass' == headers['X-Cascade'] + req.script_name = script_name + req.path_info = path_info + req.path_parameters = set_params + next + end + + return [status, headers, body] + end + + return [404, {'X-Cascade' => 'pass'}, ['Not Found']] + end + + def recognize(rails_req) + find_routes(rails_req).each do |match, parameters, route| + unless route.path.anchored + rails_req.script_name = match.to_s + rails_req.path_info = match.post_match.sub(/^([^\/])/, '/\1') + end + + yield(route, 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 + simulator.memos(path) { [] } + end + + def find_routes req + routes = filter_routes(req.path_info).concat custom_routes.find_all { |r| + r.path.match(req.path_info) + } + + if req.env["REQUEST_METHOD"] === "HEAD" + routes.concat get_routes_as_head(routes) + end + + routes.select! { |r| r.matches?(req) } + routes.sort_by!(&:precedence) + + routes.map! { |r| + match_data = r.path.match(req.path_info) + path_parameters = r.defaults.dup + match_data.names.zip(match_data.captures) { |name,val| + path_parameters[name.to_sym] = Utils.unescape_uri(val) if val + } + [match_data, path_parameters, r] + } + end + + def get_routes_as_head(routes) + precedence = (routes.map(&:precedence).max || 0) + 1 + 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 + } + 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..4b7738f335 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/router/strexp.rb @@ -0,0 +1,27 @@ +module ActionDispatch + module Journey # :nodoc: + class Router # :nodoc: + class Strexp # :nodoc: + class << self + alias :compile :new + end + + attr_reader :path, :requirements, :separators, :anchor, :ast + + def self.build(path, requirements, separators, anchor = true) + parser = Journey::Parser.new + ast = parser.parse path + new ast, path, requirements, separators, anchor + end + + def initialize(ast, path, requirements, separators, anchor = true) + @ast = ast + @path = path + @requirements = requirements + @separators = separators + @anchor = anchor + 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..2b0a6575d4 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/router/utils.rb @@ -0,0 +1,93 @@ +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. + # Also converts downcase url encoded string to uppercase. + # + # normalize_path("/foo") # => "/foo" + # normalize_path("/foo/") # => "/foo" + # normalize_path("foo") # => "/foo" + # normalize_path("") # => "/" + # normalize_path("/%ab") # => "/%AB" + def self.normalize_path(path) + path = "/#{path}" + path.squeeze!('/') + path.sub!(%r{/+\Z}, '') + path.gsub!(/(%[a-f0-9]{2})/) { $1.upcase } + path = '/' if path == '' + path + end + + # URI path and fragment escaping + # http://tools.ietf.org/html/rfc3986 + class UriEncoder # :nodoc: + ENCODE = "%%%02X".freeze + US_ASCII = Encoding::US_ASCII + UTF_8 = Encoding::UTF_8 + EMPTY = "".force_encoding(US_ASCII).freeze + DEC2HEX = (0..255).to_a.map{ |i| ENCODE % i }.map{ |s| s.force_encoding(US_ASCII) } + + ALPHA = "a-zA-Z".freeze + DIGIT = "0-9".freeze + UNRESERVED = "#{ALPHA}#{DIGIT}\\-\\._~".freeze + SUB_DELIMS = "!\\$&'\\(\\)\\*\\+,;=".freeze + + ESCAPED = /%[a-zA-Z0-9]{2}/.freeze + + FRAGMENT = /[^#{UNRESERVED}#{SUB_DELIMS}:@\/\?]/.freeze + SEGMENT = /[^#{UNRESERVED}#{SUB_DELIMS}:@]/.freeze + PATH = /[^#{UNRESERVED}#{SUB_DELIMS}:@\/]/.freeze + + def escape_fragment(fragment) + escape(fragment, FRAGMENT) + end + + def escape_path(path) + escape(path, PATH) + end + + def escape_segment(segment) + escape(segment, SEGMENT) + end + + def unescape_uri(uri) + encoding = uri.encoding == US_ASCII ? UTF_8 : uri.encoding + uri.gsub(ESCAPED) { [$&[1, 2].hex].pack('C') }.force_encoding(encoding) + end + + protected + def escape(component, pattern) + component.gsub(pattern){ |unsafe| percent_encode(unsafe) }.force_encoding(US_ASCII) + end + + def percent_encode(unsafe) + safe = EMPTY.dup + unsafe.each_byte { |b| safe << DEC2HEX[b] } + safe + end + end + + ENCODER = UriEncoder.new + + def self.escape_path(path) + ENCODER.escape_path(path.to_s) + end + + def self.escape_segment(segment) + ENCODER.escape_segment(segment.to_s) + end + + def self.escape_fragment(fragment) + ENCODER.escape_fragment(fragment.to_s) + end + + def self.unescape_uri(uri) + ENCODER.unescape_uri(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..80e3818ccd --- /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 + named_routes.clear + end + + def partitioned_routes + @partitioned_routes ||= routes.partition do |r| + r.path.anchored && r.ast.grep(Nodes::Symbol).all?(&:default_regexp?) + end + end + + def ast + @ast ||= begin + asts = partitioned_routes.first.map(&:ast) + Nodes::Or.new(asts) unless asts.empty? + end + end + + def simulator + @simulator ||= begin + gtg = GTG::Builder.new(ast).transition_table + GTG::Simulator.new(gtg) + end + 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..52b4c8b489 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/visitors.rb @@ -0,0 +1,221 @@ +# encoding: utf-8 + +module ActionDispatch + module Journey # :nodoc: + class Format + ESCAPE_PATH = ->(value) { Router::Utils.escape_path(value) } + ESCAPE_SEGMENT = ->(value) { Router::Utils.escape_segment(value) } + + class Parameter < Struct.new(:name, :escaper) + def escape(value); escaper.call value; end + end + + def self.required_path(symbol) + Parameter.new symbol, ESCAPE_PATH + end + + def self.required_segment(symbol) + Parameter.new symbol, ESCAPE_SEGMENT + end + + def initialize(parts) + @parts = parts + @children = [] + @parameters = [] + + parts.each_with_index do |object,i| + case object + when Journey::Format + @children << i + when Parameter + @parameters << i + end + end + end + + def evaluate(hash) + parts = @parts.dup + + @parameters.each do |index| + param = parts[index] + value = hash[param.name] + return ''.freeze unless value + parts[index] = param.escape value + end + + @children.each { |index| parts[index] = parts[index].evaluate(hash) } + + parts.join + end + end + + module Visitors # :nodoc: + class Visitor # :nodoc: + DISPATCH_CACHE = {} + + 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 + def visit_LITERAL(n); terminal(n); end + def visit_SYMBOL(n); terminal(n); end + def visit_SLASH(n); terminal(n); end + def visit_DOT(n); terminal(n); end + + private_instance_methods(false).each do |pim| + next unless pim =~ /^visit_(.*)$/ + DISPATCH_CACHE[$1.to_sym] = pim + end + end + + class FormatBuilder < Visitor # :nodoc: + def accept(node); Journey::Format.new(super); end + def terminal(node); [node.left]; end + + def binary(node) + visit(node.left) + visit(node.right) + end + + def visit_GROUP(n); [Journey::Format.new(unary(n))]; end + + def visit_STAR(n) + [Journey::Format.required_path(n.left.to_sym)] + end + + def visit_SYMBOL(n) + symbol = n.to_sym + if symbol == :controller + [Journey::Format.required_path(symbol)] + else + [Journey::Format.required_segment(symbol)] + end + end + end + + # Loop through the requirements AST + class Each < Visitor # :nodoc: + attr_reader :block + + def initialize(block) + @block = block + end + + def visit(node) + block.call(node) + super + 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 + + 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..9b28a65200 --- /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://cdnjs.cloudflare.com/ajax/libs/meyer-reset/2.0/reset.css" type="text/css"> + <style> + <% stylesheets.each do |style| %> + <%= style %> + <% end %> + </style> + <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.4.8/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/callbacks.rb b/actionpack/lib/action_dispatch/middleware/callbacks.rb new file mode 100644 index 0000000000..baf9d5779e --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/callbacks.rb @@ -0,0 +1,37 @@ + +module ActionDispatch + # Provide callbacks to be executed before and after the request dispatch. + class Callbacks + include ActiveSupport::Callbacks + + define_callbacks :call + + class << self + delegate :to_prepare, :to_cleanup, :to => "ActionDispatch::Reloader" + + def before(*args, &block) + set_callback(:call, :before, *args, &block) + end + + def after(*args, &block) + set_callback(:call, :after, *args, &block) + end + end + + def initialize(app) + @app = app + end + + def call(env) + error = nil + result = run_callbacks :call do + begin + @app.call(env) + rescue => error + end + end + raise error if error + result + end + end +end diff --git a/actionpack/lib/action_dispatch/middleware/cookies.rb b/actionpack/lib/action_dispatch/middleware/cookies.rb new file mode 100644 index 0000000000..ac9e5effe2 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/cookies.rb @@ -0,0 +1,574 @@ +require 'active_support/core_ext/hash/keys' +require 'active_support/core_ext/module/attribute_accessors' +require 'active_support/core_ext/object/blank' +require 'active_support/key_generator' +require 'active_support/message_verifier' + +module ActionDispatch + class Request < Rack::Request + def cookie_jar + env['action_dispatch.cookies'] ||= Cookies::CookieJar.build(self) + end + end + + # \Cookies are read and written through ActionController#cookies. + # + # The cookies being read are the ones received along with the request, the cookies + # being written will be sent out with the response. Reading a cookie does not get + # the cookie object itself back, just the value it holds. + # + # Examples of writing: + # + # # Sets a simple session cookie. + # # This cookie will be deleted when the user's browser is closed. + # cookies[:user_name] = "david" + # + # # Cookie values are String based. Other data types need to be serialized. + # cookies[:lat_lon] = JSON.generate([47.68, -122.37]) + # + # # Sets a cookie that expires in 1 hour. + # cookies[:login] = { value: "XJ-122", expires: 1.hour.from_now } + # + # # Sets a signed cookie, which prevents users from tampering with its value. + # # The cookie is signed by your app's `secrets.secret_key_base` value. + # # It can be read using the signed method `cookies.signed[:name]` + # cookies.signed[:user_id] = current_user.id + # + # # Sets a "permanent" cookie (which expires in 20 years from now). + # cookies.permanent[:login] = "XJ-122" + # + # # You can also chain these methods: + # cookies.permanent.signed[:login] = "XJ-122" + # + # Examples of reading: + # + # cookies[:user_name] # => "david" + # cookies.size # => 2 + # JSON.parse(cookies[:lat_lon]) # => [47.68, -122.37] + # cookies.signed[:login] # => "XJ-122" + # + # Example for deleting: + # + # cookies.delete :user_name + # + # Please note that if you specify a :domain when setting a cookie, you must also specify the domain when deleting the cookie: + # + # cookies[:name] = { + # value: 'a yummy cookie', + # expires: 1.year.from_now, + # domain: 'domain.com' + # } + # + # cookies.delete(:name, domain: 'domain.com') + # + # The option symbols for setting cookies are: + # + # * <tt>:value</tt> - The cookie's value. + # * <tt>:path</tt> - The path for which this cookie applies. Defaults to the root + # of the application. + # * <tt>:domain</tt> - The domain for which this cookie applies so you can + # restrict to the domain level. If you use a schema like www.example.com + # and want to share session with user.example.com set <tt>:domain</tt> + # to <tt>:all</tt>. Make sure to specify the <tt>:domain</tt> option with + # <tt>:all</tt> again when deleting cookies. + # + # 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. + # * <tt>:secure</tt> - Whether this cookie is only transmitted to HTTPS servers. + # Default is +false+. + # * <tt>:httponly</tt> - Whether this cookie is accessible via scripting or + # only HTTP. Defaults to +false+. + class Cookies + 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 + SECRET_TOKEN = "action_dispatch.secret_token".freeze + SECRET_KEY_BASE = "action_dispatch.secret_key_base".freeze + COOKIES_SERIALIZER = "action_dispatch.cookies_serializer".freeze + + # Cookies can typically store 4096 bytes. + MAX_COOKIE_SIZE = 4096 + + # Raised when storing more than 4K of session data. + CookieOverflow = Class.new StandardError + + # Include in a cookie jar to allow chaining, e.g. cookies.permanent.signed + module ChainedCookieJars + # Returns a jar that'll automatically set the assigned cookies to have an expiration date 20 years from now. Example: + # + # cookies.permanent[:prefers_open_id] = true + # # => Set-Cookie: prefers_open_id=true; path=/; expires=Sun, 16-Dec-2029 03:24:16 GMT + # + # This jar is only meant for writing. You'll read permanent cookies through the regular accessor. + # + # This jar allows chaining with the signed jar as well, so you can set permanent, signed cookies. Examples: + # + # 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, @key_generator, @options) + end + + # Returns a jar that'll automatically generate a signed representation of cookie value and verify it when reading from + # the cookie again. This is useful for creating cookies with values that the user is not supposed to change. If a signed + # cookie was tampered with by the user (or a 3rd party), nil will be returned. + # + # If +secrets.secret_key_base+ and +config.secret_token+ (deprecated) are both set, + # legacy cookies signed with the old key generator will be transparently upgraded. + # + # This jar requires that you set a suitable secret for the verification on your app's +secrets.secret_key_base+. + # + # Example: + # + # cookies.signed[:discount] = 45 + # # => Set-Cookie: discount=BAhpMg==--2c1c6906c90a3bc4fd54a51ffb41dffa4bf6b5f7; path=/ + # + # cookies.signed[:discount] # => 45 + def signed + @signed ||= + if @options[:upgrade_legacy_signed_cookies] + UpgradeLegacySignedCookieJar.new(self, @key_generator, @options) + else + SignedCookieJar.new(self, @key_generator, @options) + end + 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), nil will be returned. + # + # If +secrets.secret_key_base+ and +config.secret_token+ (deprecated) are both set, + # legacy cookies signed with the old key generator will be transparently upgraded. + # + # This jar requires that you set a suitable secret for the verification on your app's +secrets.secret_key_base+. + # + # Example: + # + # cookies.encrypted[:discount] = 45 + # # => Set-Cookie: discount=ZS9ZZ1R4cG1pcUJ1bm80anhQang3dz09LS1mbDZDSU5scGdOT3ltQ2dTdlhSdWpRPT0%3D--ab54663c9f4e3bc340c790d6d2b71e92f5b60315; path=/ + # + # cookies.encrypted[:discount] # => 45 + def encrypted + @encrypted ||= + if @options[:upgrade_legacy_signed_cookies] + UpgradeLegacyEncryptedCookieJar.new(self, @key_generator, @options) + else + EncryptedCookieJar.new(self, @key_generator, @options) + end + end + + # Returns the +signed+ or +encrypted+ jar, preferring +encrypted+ if +secret_key_base+ is set. + # Used by ActionDispatch::Session::CookieStore to avoid the need to introduce new cookie stores. + def signed_or_encrypted + @signed_or_encrypted ||= + if @options[:secret_key_base].present? + encrypted + else + signed + end + end + end + + module VerifyAndUpgradeLegacySignedMessage + def initialize(*args) + super + @legacy_verifier = ActiveSupport::MessageVerifier.new(@options[:secret_token], serializer: NullSerializer) + end + + def verify_and_upgrade_legacy_signed_message(name, signed_message) + deserialize(name, @legacy_verifier.verify(signed_message)).tap do |value| + self[name] = { value: value } + end + rescue ActiveSupport::MessageVerifier::InvalidSignature + nil + end + end + + class CookieJar #:nodoc: + include Enumerable, ChainedCookieJars + + # This regular expression is used to split the levels of a domain. + # The top level domain can be any string without a period or + # **.**, ***.** style TLDs like co.uk or com.au + # + # www.example.co.uk gives: + # $& => example.co.uk + # + # example.com gives: + # $& => example.com + # + # lots.of.subdomains.example.local gives: + # $& => example.local + DOMAIN_REGEXP = /[^.]*\.([^.]*|..\...|...\...)$/ + + def self.options_for_env(env) #:nodoc: + { signed_cookie_salt: env[SIGNED_COOKIE_SALT] || '', + encrypted_cookie_salt: env[ENCRYPTED_COOKIE_SALT] || '', + encrypted_signed_cookie_salt: env[ENCRYPTED_SIGNED_COOKIE_SALT] || '', + secret_token: env[SECRET_TOKEN], + secret_key_base: env[SECRET_KEY_BASE], + upgrade_legacy_signed_cookies: env[SECRET_TOKEN].present? && env[SECRET_KEY_BASE].present?, + serializer: env[COOKIES_SERIALIZER] + } + end + + def self.build(request) + env = request.env + key_generator = env[GENERATOR_KEY] + options = options_for_env env + + host = request.host + secure = request.ssl? + + new(key_generator, host, secure, options).tap do |hash| + hash.update(request.cookies) + end + end + + def initialize(key_generator, host = nil, secure = false, options = {}) + @key_generator = key_generator + @set_cookies = {} + @delete_cookies = {} + @host = host + @secure = secure + @options = options + @cookies = {} + @committed = false + end + + def committed?; @committed; end + + def commit! + @committed = true + @set_cookies.freeze + @delete_cookies.freeze + end + + def each(&block) + @cookies.each(&block) + end + + # Returns the value of the cookie by +name+, or +nil+ if no such cookie exists. + def [](name) + @cookies[name.to_s] + end + + def fetch(name, *args, &block) + @cookies.fetch(name.to_s, *args, &block) + end + + def key?(name) + @cookies.key?(name.to_s) + end + alias :has_key? :key? + + def update(other_hash) + @cookies.update other_hash.stringify_keys + self + end + + def handle_options(options) #:nodoc: + options[:path] ||= "/" + + if options[:domain] == :all + # if there is a provided tld length then we use it otherwise default domain regexp + domain_regexp = options[:tld_length] ? /([^.]+\.?){#{options[:tld_length]}}$/ : DOMAIN_REGEXP + + # if host is not ip and matches domain regexp + # (ip confirms to domain regexp so we explicitly check for ip) + options[:domain] = if (@host !~ /^[\d.]+$/) && (@host =~ domain_regexp) + ".#{$&}" + 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.sub(/^\./, '') } + end + end + + # Sets the cookie named +name+. The second argument may be the cookie's + # value or a hash of options as documented above. + def []=(name, options) + if options.is_a?(Hash) + options.symbolize_keys! + value = options[:value] + else + value = options + options = { :value => value } + end + + handle_options(options) + + if @cookies[name.to_s] != value or options[:expires] + @cookies[name.to_s] = value + @set_cookies[name.to_s] = options + @delete_cookies.delete(name.to_s) + end + + value + end + + # Removes the cookie on the client machine by setting the value to an empty string + # and the expiration date in 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(name, options = {}) + return unless @cookies.has_key? name.to_s + + options.symbolize_keys! + handle_options(options) + + value = @cookies.delete(name.to_s) + @delete_cookies[name.to_s] = options + value + end + + # Whether the given cookie is to be deleted by this CookieJar. + # Like <tt>[]=</tt>, you can pass in an options hash to test if a + # deletion applies to a specific <tt>:path</tt>, <tt>:domain</tt> etc. + def deleted?(name, options = {}) + options.symbolize_keys! + handle_options(options) + @delete_cookies[name.to_s] == options + end + + # Removes all cookies on the client machine by calling <tt>delete</tt> for each cookie + def clear(options = {}) + @cookies.each_key{ |k| delete(k, options) } + end + + def write(headers) + @set_cookies.each { |k, v| ::Rack::Utils.set_cookie_header!(headers, k, v) if write_cookie?(v) } + @delete_cookies.each { |k, v| ::Rack::Utils.delete_cookie_header!(headers, k, v) } + end + + def recycle! #:nodoc: + @set_cookies = {} + @delete_cookies = {} + end + + mattr_accessor :always_write_cookie + self.always_write_cookie = false + + private + def write_cookie?(cookie) + @secure || !cookie[:secure] || always_write_cookie + end + end + + class PermanentCookieJar #:nodoc: + include ChainedCookieJars + + def initialize(parent_jar, key_generator, options = {}) + @parent_jar = parent_jar + @key_generator = key_generator + @options = options + end + + def [](name) + @parent_jar[name.to_s] + end + + def []=(name, options) + if options.is_a?(Hash) + options.symbolize_keys! + else + options = { :value => options } + end + + options[:expires] = 20.years.from_now + @parent_jar[name] = options + end + end + + class JsonSerializer + def self.load(value) + JSON.parse(value, quirks_mode: true) + end + + def self.dump(value) + JSON.generate(value, quirks_mode: true) + end + end + + # Passing the NullSerializer downstream to the Message{Encryptor,Verifier} + # allows us to handle the (de)serialization step within the cookie jar, + # which gives us the opportunity to detect and migrate legacy cookies. + class NullSerializer + def self.load(value) + value + end + + def self.dump(value) + value + end + end + + module SerializedCookieJars + MARSHAL_SIGNATURE = "\x04\x08".freeze + + protected + def needs_migration?(value) + @options[:serializer] == :hybrid && value.start_with?(MARSHAL_SIGNATURE) + end + + def serialize(name, value) + serializer.dump(value) + end + + def deserialize(name, value) + if value + if needs_migration?(value) + Marshal.load(value).tap do |v| + self[name] = { value: v } + end + else + serializer.load(value) + end + end + end + + def serializer + serializer = @options[:serializer] || :marshal + case serializer + when :marshal + Marshal + when :json, :hybrid + JsonSerializer + else + serializer + end + end + end + + class SignedCookieJar #:nodoc: + include ChainedCookieJars + include SerializedCookieJars + + 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, serializer: NullSerializer) + end + + def [](name) + if signed_message = @parent_jar[name] + deserialize name, verify(signed_message) + end + end + + def []=(name, options) + if options.is_a?(Hash) + options.symbolize_keys! + options[:value] = @verifier.generate(serialize(name, options[:value])) + else + options = { :value => @verifier.generate(serialize(name, options)) } + end + + raise CookieOverflow if options[:value].bytesize > MAX_COOKIE_SIZE + @parent_jar[name] = options + end + + private + def verify(signed_message) + @verifier.verify(signed_message) + rescue ActiveSupport::MessageVerifier::InvalidSignature + nil + end + end + + # UpgradeLegacySignedCookieJar is used instead of SignedCookieJar if + # config.secret_token and secrets.secret_key_base are both set. It reads + # legacy cookies signed with the old dummy key generator and re-saves + # them using the new key generator to provide a smooth upgrade path. + class UpgradeLegacySignedCookieJar < SignedCookieJar #:nodoc: + include VerifyAndUpgradeLegacySignedMessage + + def [](name) + if signed_message = @parent_jar[name] + deserialize(name, verify(signed_message)) || verify_and_upgrade_legacy_signed_message(name, signed_message) + end + end + end + + class EncryptedCookieJar #:nodoc: + include ChainedCookieJars + include SerializedCookieJars + + def initialize(parent_jar, key_generator, options = {}) + if ActiveSupport::LegacyKeyGenerator === key_generator + raise "You didn't set secrets.secret_key_base, which is required for this cookie jar. " + + "Read the upgrade documentation to learn more about this new config option." + end + + @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, serializer: NullSerializer) + end + + def [](name) + if encrypted_message = @parent_jar[name] + deserialize name, decrypt_and_verify(encrypted_message) + end + end + + def []=(name, options) + if options.is_a?(Hash) + options.symbolize_keys! + else + options = { :value => options } + end + + options[:value] = @encryptor.encrypt_and_sign(serialize(name, options[:value])) + + raise CookieOverflow if options[:value].bytesize > MAX_COOKIE_SIZE + @parent_jar[name] = options + end + + private + def decrypt_and_verify(encrypted_message) + @encryptor.decrypt_and_verify(encrypted_message) + rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveSupport::MessageEncryptor::InvalidMessage + nil + end + end + + # UpgradeLegacyEncryptedCookieJar is used by ActionDispatch::Session::CookieStore + # instead of EncryptedCookieJar if config.secret_token and secrets.secret_key_base + # are both set. It reads legacy cookies signed with the old dummy key generator and + # encrypts and re-saves them using the new key generator to provide a smooth upgrade path. + class UpgradeLegacyEncryptedCookieJar < EncryptedCookieJar #:nodoc: + include VerifyAndUpgradeLegacySignedMessage + + def [](name) + if encrypted_or_signed_message = @parent_jar[name] + deserialize(name, decrypt_and_verify(encrypted_or_signed_message)) || verify_and_upgrade_legacy_signed_message(name, encrypted_or_signed_message) + end + end + end + + def initialize(app) + @app = app + end + + def call(env) + status, headers, body = @app.call(env) + + if cookie_jar = env['action_dispatch.cookies'] + unless cookie_jar.committed? + cookie_jar.write(headers) + if headers[HTTP_HEADER].respond_to?(:join) + headers[HTTP_HEADER] = headers[HTTP_HEADER].join("\n") + end + end + end + + [status, headers, body] + end + end +end diff --git a/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb b/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb new file mode 100644 index 0000000000..274f6f2f22 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb @@ -0,0 +1,128 @@ +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.expand_path('../templates', __FILE__) + + def initialize(app, routes_app = nil) + @app = app + @routes_app = routes_app + end + + def call(env) + _, headers, body = response = @app.call(env) + + if headers['X-Cascade'] == 'pass' + body.close if body.respond_to?(:close) + raise ActionController::RoutingError, "No route matches [#{env['REQUEST_METHOD']}] #{env['PATH_INFO'].inspect}" + end + + response + rescue Exception => exception + raise exception if env['action_dispatch.show_exceptions'] == false + render_exception(env, exception) + end + + private + + def render_exception(env, exception) + wrapper = ExceptionWrapper.new(env, exception) + log_error(env, wrapper) + + if env['action_dispatch.show_detailed_exceptions'] + request = Request.new(env) + template = ActionView::Base.new([RESCUES_TEMPLATE_PATH], + request: request, + exception: wrapper.exception, + traces: traces_from_wrapper(wrapper), + routes_inspector: routes_inspector(exception), + source_extract: wrapper.source_extract, + line_number: wrapper.line_number, + file: wrapper.file + ) + file = "rescues/#{wrapper.rescue_template}" + + if request.xhr? + body = template.render(template: file, layout: false, formats: [:text]) + format = "text/plain" + else + body = template.render(template: file, layout: 'rescues/layout') + format = "text/html" + end + render(wrapper.status_code, body, format) + else + raise exception + end + end + + def render(status, body, format) + [status, {'Content-Type' => "#{format}; charset=#{Response.default_charset}", 'Content-Length' => body.bytesize.to_s}, [body]] + end + + def log_error(env, wrapper) + logger = logger(env) + return unless logger + + exception = wrapper.exception + + trace = wrapper.application_trace + trace = wrapper.framework_trace if trace.empty? + + ActiveSupport::Deprecation.silence do + message = "\n#{exception.class} (#{exception.message}):\n" + message << exception.annoted_source_code.to_s if exception.respond_to?(:annoted_source_code) + message << " " << trace.join("\n ") + logger.fatal("#{message}\n\n") + end + end + + def logger(env) + env['action_dispatch.logger'] || stderr_logger + end + + def stderr_logger + @stderr_logger ||= ActiveSupport::Logger.new($stderr) + end + + def routes_inspector(exception) + if @routes_app.respond_to?(:routes) && (exception.is_a?(ActionController::RoutingError) || exception.is_a?(ActionView::Template::Error)) + ActionDispatch::Routing::RoutesInspector.new(@routes_app.routes.routes) + end + end + + # Augment the exception traces by providing ids for all unique stack frame + def traces_from_wrapper(wrapper) + application_trace = wrapper.application_trace + framework_trace = wrapper.framework_trace + full_trace = wrapper.full_trace + + if application_trace && framework_trace + id_counter = 0 + + application_trace = application_trace.map do |trace| + prev = id_counter + id_counter += 1 + { id: prev, trace: trace } + end + + framework_trace = framework_trace.map do |trace| + prev = id_counter + id_counter += 1 + { id: prev, trace: trace } + end + + full_trace = application_trace + framework_trace + end + + { + "Application Trace" => application_trace, + "Framework Trace" => framework_trace, + "Full Trace" => full_trace + } + end + end +end diff --git a/actionpack/lib/action_dispatch/middleware/exception_wrapper.rb b/actionpack/lib/action_dispatch/middleware/exception_wrapper.rb new file mode 100644 index 0000000000..b98b553c38 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/exception_wrapper.rb @@ -0,0 +1,119 @@ +require 'action_controller/metal/exceptions' +require 'active_support/core_ext/module/attribute_accessors' + +module ActionDispatch + class ExceptionWrapper + cattr_accessor :rescue_responses + @@rescue_responses = Hash.new(:internal_server_error) + @@rescue_responses.merge!( + 'ActionController::RoutingError' => :not_found, + 'AbstractController::ActionNotFound' => :not_found, + 'ActionController::MethodNotAllowed' => :method_not_allowed, + 'ActionController::UnknownHttpMethod' => :method_not_allowed, + 'ActionController::NotImplemented' => :not_implemented, + 'ActionController::UnknownFormat' => :not_acceptable, + 'ActionController::InvalidAuthenticityToken' => :unprocessable_entity, + 'ActionDispatch::ParamsParser::ParseError' => :bad_request, + 'ActionController::BadRequest' => :bad_request, + 'ActionController::ParameterMissing' => :bad_request + ) + + cattr_accessor :rescue_templates + @@rescue_templates = Hash.new('diagnostics') + @@rescue_templates.merge!( + 'ActionView::MissingTemplate' => 'missing_template', + 'ActionController::RoutingError' => 'routing_error', + 'AbstractController::ActionNotFound' => 'unknown_action', + 'ActionView::Template::Error' => 'template_error' + ) + + attr_reader :env, :exception, :line_number, :file + + def initialize(env, exception) + @env = env + @exception = original_exception(exception) + + expand_backtrace if exception.is_a?(SyntaxError) || exception.try(:original_exception).try(:is_a?, SyntaxError) + end + + def rescue_template + @@rescue_templates[@exception.class.name] + end + + def status_code + self.class.status_code_for_exception(@exception.class.name) + end + + def application_trace + clean_backtrace(:silent) + end + + def framework_trace + clean_backtrace(:noise) + end + + def full_trace + clean_backtrace(:all) + end + + def self.status_code_for_exception(class_name) + Rack::Utils.status_code(@@rescue_responses[class_name]) + end + + def source_extract + exception.backtrace.map do |trace| + file, line = trace.split(":") + line_number = line.to_i + { + code: source_fragment(file, line_number), + file: file, + line_number: line_number + } + end if exception.backtrace + end + + private + + def original_exception(exception) + if registered_original_exception?(exception) + exception.original_exception + else + exception + end + end + + def registered_original_exception?(exception) + exception.respond_to?(:original_exception) && @@rescue_responses.has_key?(exception.original_exception.class.name) + end + + def clean_backtrace(*args) + if backtrace_cleaner + backtrace_cleaner.clean(@exception.backtrace, *args) + else + @exception.backtrace + end + end + + def backtrace_cleaner + @backtrace_cleaner ||= @env['action_dispatch.backtrace_cleaner'] + end + + def source_fragment(path, line) + return unless Rails.respond_to?(:root) && Rails.root + full_path = Rails.root.join(path) + if File.exist?(full_path) + File.open(full_path, "r") do |file| + start = [line - 3, 0].max + lines = file.each_line.drop(start).take(6) + Hash[*(start+1..(lines.count+start)).zip(lines).flatten] + end + end + end + + def expand_backtrace + @exception.backtrace.unshift( + @exception.to_s.split("\n") + ).flatten! + end + end +end diff --git a/actionpack/lib/action_dispatch/middleware/flash.rb b/actionpack/lib/action_dispatch/middleware/flash.rb new file mode 100644 index 0000000000..e90f8b9ce6 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/flash.rb @@ -0,0 +1,273 @@ +require 'active_support/core_ext/hash/keys' + +module ActionDispatch + 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] ||= Flash::FlashHash.from_session_value(session["flash"]) + end + end + + # The flash provides a way to pass temporary primitive-types (String, Array, Hash) 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. + # + # class PostsController < ActionController::Base + # def create + # # save post + # flash[:notice] = "Post successfully created" + # redirect_to @post + # end + # + # def show + # # doesn't need to assign the flash notice to the template, that's done automatically + # end + # end + # + # show.html.erb + # <% if flash[:notice] %> + # <div class="notice"><%= flash[:notice] %></div> + # <% end %> + # + # Since the +notice+ and +alert+ keys are a common idiom, convenience accessors are available: + # + # flash.alert = "You must be logged in" + # flash.notice = "Post successfully created" + # + # This example places a string in the flash. And of course, you can put as many as you like at a time too. If you want to pass + # non-primitive types, you will have to handle that in your application. Example: To show messages with links, you will have to + # use sanitize helper. + # + # Just remember: They'll be gone by the time the next action has been performed. + # + # See docs on the FlashHash class for more details about the flash. + class Flash + KEY = 'action_dispatch.request.flash_hash'.freeze + + class FlashNow #:nodoc: + attr_accessor :flash + + def initialize(flash) + @flash = flash + end + + def []=(k, v) + k = k.to_s + @flash[k] = v + @flash.discard(k) + v + end + + def [](k) + @flash[k.to_s] + end + + # Convenience accessor for <tt>flash.now[:alert]=</tt>. + def alert=(message) + self[:alert] = message + end + + # Convenience accessor for <tt>flash.now[:notice]=</tt>. + def notice=(message) + self[:notice] = message + end + end + + class FlashHash + include Enumerable + + 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(stringify_array(discard)) + @flashes = flashes.stringify_keys + @now = nil + end + + def initialize_copy(other) + if other.now_is_loaded? + @now = other.now.dup + @now.flash = self + end + super + end + + def []=(k, v) + k = k.to_s + @discard.delete k + @flashes[k] = v + end + + def [](k) + @flashes[k.to_s] + end + + def update(h) #:nodoc: + @discard.subtract stringify_array(h.keys) + @flashes.update h.stringify_keys + self + end + + def keys + @flashes.keys + end + + def key?(name) + @flashes.key? name + end + + def delete(key) + key = key.to_s + @discard.delete key + @flashes.delete key + self + end + + def to_hash + @flashes.dup + end + + def empty? + @flashes.empty? + end + + def clear + @discard.clear + @flashes.clear + end + + def each(&block) + @flashes.each(&block) + end + + alias :merge! :update + + def replace(h) #:nodoc: + @discard.clear + @flashes.replace h.stringify_keys + self + end + + # Sets a flash that will not be available to the next action, only to the current. + # + # flash.now[:message] = "Hello current action" + # + # This method enables you to use the flash as a central messaging system in your app. + # When you need to pass an object to the next action, you use the standard flash assign (<tt>[]=</tt>). + # When you need to pass an object to the current action, you use <tt>now</tt>, and your object will + # vanish when the current action is done. + # + # Entries set via <tt>now</tt> are accessed the same way as standard entries: <tt>flash['my-key']</tt>. + # + # Also, brings two convenience accessors: + # + # flash.now.alert = "Beware now!" + # # Equivalent to flash.now[:alert] = "Beware now!" + # + # flash.now.notice = "Good luck now!" + # # Equivalent to flash.now[:notice] = "Good luck now!" + def now + @now ||= FlashNow.new(self) + end + + # Keeps either the entire current flash or a specific flash entry available for the next action: + # + # flash.keep # keeps the entire flash + # flash.keep(:notice) # keeps only the "notice" entry, the rest of the flash is discarded + def keep(k = nil) + k = k.to_s if k + @discard.subtract Array(k || keys) + k ? self[k] : self + end + + # Marks the entire flash or a single flash entry to be discarded by the end of the current action: + # + # flash.discard # discard the entire flash at the end of the current action + # flash.discard(:warning) # discard only the "warning" entry at the end of the current action + def discard(k = nil) + k = k.to_s if k + @discard.merge Array(k || keys) + k ? self[k] : self + end + + # Mark for removal entries that were kept, and delete unkept ones. + # + # This method is called automatically by filters, so you generally don't need to care about it. + def sweep #:nodoc: + @discard.each { |k| @flashes.delete k } + @discard.replace @flashes.keys + end + + # Convenience accessor for <tt>flash[:alert]</tt>. + def alert + self[:alert] + end + + # Convenience accessor for <tt>flash[:alert]=</tt>. + def alert=(message) + self[:alert] = message + end + + # Convenience accessor for <tt>flash[:notice]</tt>. + def notice + self[:notice] + end + + # Convenience accessor for <tt>flash[:notice]=</tt>. + def notice=(message) + self[:notice] = message + end + + protected + def now_is_loaded? + @now + end + + def stringify_array(array) + array.map do |item| + item.kind_of?(Symbol) ? item.to_s : item + end + end + end + + def initialize(app) + @app = app + end + + def call(env) + @app.call(env) + ensure + session = Request::Session.find(env) || {} + flash_hash = env[KEY] + + if flash_hash && (flash_hash.present? || session.key?('flash')) + session["flash"] = flash_hash.to_session_value + env[KEY] = flash_hash.dup + end + + 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 + end +end diff --git a/actionpack/lib/action_dispatch/middleware/params_parser.rb b/actionpack/lib/action_dispatch/middleware/params_parser.rb new file mode 100644 index 0000000000..b426183488 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/params_parser.rb @@ -0,0 +1,60 @@ +require 'active_support/core_ext/hash/conversions' +require 'action_dispatch/http/request' +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::JSON => :json } + + def initialize(app, parsers = {}) + @app, @parsers = app, DEFAULT_PARSERS.merge(parsers) + end + + def call(env) + if params = parse_formatted_parameters(env) + env["action_dispatch.request.request_parameters"] = params + end + + @app.call(env) + end + + private + def parse_formatted_parameters(env) + request = Request.new(env) + + return false if request.content_length.zero? + + strategy = @parsers[request.content_mime_type] + + return false unless strategy + + case strategy + when Proc + strategy.call(request.raw_post) + when :json + data = ActiveSupport::JSON.decode(request.raw_post) + data = {:_json => data} unless data.is_a?(Hash) + Request::Utils.deep_munge(data).with_indifferent_access + else + false + end + rescue Exception => e # JSON or Ruby code block errors + logger(env).debug "Error occurred while parsing request parameters.\nContents:\n\n#{request.raw_post}" + + raise ParseError.new(e.message, e) + end + + def logger(env) + env['action_dispatch.logger'] || ActiveSupport::Logger.new($stderr) + end + end +end diff --git a/actionpack/lib/action_dispatch/middleware/public_exceptions.rb b/actionpack/lib/action_dispatch/middleware/public_exceptions.rb new file mode 100644 index 0000000000..6c8944e067 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/public_exceptions.rb @@ -0,0 +1,45 @@ +module ActionDispatch + class PublicExceptions + attr_accessor :public_path + + def initialize(public_path) + @public_path = public_path + end + + def call(env) + status = env["PATH_INFO"][1..-1] + request = ActionDispatch::Request.new(env) + content_type = request.formats.first + body = { :status => status, :error => Rack::Utils::HTTP_STATUS_CODES.fetch(status.to_i, Rack::Utils::HTTP_STATUS_CODES[500]) } + + render(status, content_type, body) + end + + private + + def render(status, content_type, body) + format = "to_#{content_type.to_sym}" if content_type + if format && body.respond_to?(format) + render_format(status, content_type, body.public_send(format)) + else + render_html(status) + end + end + + 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) + path = "#{public_path}/#{status}.#{I18n.locale}.html" + path = "#{public_path}/#{status}.html" unless (found = File.exist?(path)) + + if found || File.exist?(path) + render_format(status, 'text/html', File.read(path)) + else + [404, { "X-Cascade" => "pass" }, []] + end + end + end +end diff --git a/actionpack/lib/action_dispatch/middleware/reloader.rb b/actionpack/lib/action_dispatch/middleware/reloader.rb new file mode 100644 index 0000000000..15b5a48535 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/reloader.rb @@ -0,0 +1,98 @@ +require 'active_support/deprecation/reporting' + +module ActionDispatch + # ActionDispatch::Reloader provides prepare and cleanup callbacks, + # intended to assist with code reloading during development. + # + # Prepare callbacks are run before each request, and cleanup callbacks + # after each request. In this respect they are analogs of ActionDispatch::Callback's + # before and after callbacks. However, cleanup callbacks are not called until the + # request is fully complete -- that is, after #close has been called on + # the response body. This is important for streaming responses such as the + # following: + # + # self.response_body = lambda { |response, output| + # # code here which refers to application models + # } + # + # Cleanup callbacks will not be called until after the response_body lambda + # is evaluated, ensuring that it can refer to application models and other + # 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+ + # is false. Callbacks may be registered even when it is not included in the + # 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 + include ActiveSupport::Deprecation::Reporting + + define_callbacks :prepare + define_callbacks :cleanup + + # Add a prepare callback. Prepare callbacks are run before each request, prior + # to ActionDispatch::Callback's before callbacks. + def self.to_prepare(*args, &block) + unless block_given? + warn "to_prepare without a block is deprecated. Please use a block" + end + set_callback(:prepare, *args, &block) + end + + # Add a cleanup callback. Cleanup callbacks are run after each request is + # complete (after #close is called on the response body). + def self.to_cleanup(*args, &block) + unless block_given? + warn "to_cleanup without a block is deprecated. Please use a block" + end + set_callback(:cleanup, *args, &block) + end + + # Execute all prepare callbacks. + def self.prepare! + new(nil).prepare! + end + + # Execute all cleanup callbacks. + def self.cleanup! + new(nil).cleanup! + end + + def initialize(app, condition=nil) + @app = app + @condition = condition || lambda { true } + @validated = true + end + + def call(env) + @validated = @condition.call + prepare! + + response = @app.call(env) + response[2] = ::Rack::BodyProxy.new(response[2]) { cleanup! } + + response + rescue Exception + cleanup! + raise + end + + def prepare! #:nodoc: + run_callbacks :prepare if validated? + end + + def cleanup! #:nodoc: + run_callbacks :cleanup if validated? + ensure + @validated = true + end + + private + + def validated? #:nodoc: + @validated + end + end +end diff --git a/actionpack/lib/action_dispatch/middleware/remote_ip.rb b/actionpack/lib/action_dispatch/middleware/remote_ip.rb new file mode 100644 index 0000000000..6a79b4e859 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/remote_ip.rb @@ -0,0 +1,187 @@ +module ActionDispatch + # This middleware calculates the IP address of the remote client that is + # making the request. It does this by checking various headers that could + # contain the address, and then picking the last-set address that is not + # on the list of trusted IPs. This follows the precedent set by e.g. + # {the Tomcat server}[https://issues.apache.org/bugzilla/show_bug.cgi?id=50453], + # with {reasoning explained at length}[http://blog.gingerlime.com/2012/rails-ip-spoofing-vulnerabilities-and-protection] + # by @gingerlime. A more detailed explanation of the algorithm is given + # at GetIp#calculate_ip. + # + # Some Rack servers concatenate repeated headers, like {HTTP RFC 2616}[http://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2] + # requires. Some Rack servers simply drop preceding headers, and only report + # the value that was {given in the last header}[http://andre.arko.net/2011/12/26/repeated-headers-and-ruby-web-servers]. + # If you are behind multiple proxy servers (like NGINX to HAProxy to Unicorn) + # then you should test your Rack server to make sure your data is good. + # + # IF YOU DON'T USE A PROXY, THIS MAKES YOU VULNERABLE TO IP SPOOFING. + # This middleware assumes that there is at least one proxy sitting around + # and setting headers with the client's remote IP address. If you don't use + # a proxy, because you are hosted on e.g. Heroku without SSL, any client can + # claim to have any IP address by setting the X-Forwarded-For header. If you + # care about that, then you need to explicitly drop or ignore those headers + # sometime before this middleware runs. + class RemoteIp + class IpSpoofAttackError < StandardError; end + + # The default trusted IPs list simply includes IP addresses that are + # guaranteed by the IP specification to be private addresses. Those will + # not be the ultimate client IP in production, and so are discarded. See + # http://en.wikipedia.org/wiki/Private_network for details. + TRUSTED_PROXIES = %r{ + ^127\.0\.0\.1$ | # localhost IPv4 + ^::1$ | # localhost IPv6 + ^[fF][cCdD] | # private IPv6 range fc00::/7 + ^10\. | # private IPv4 range 10.x.x.x + ^172\.(1[6-9]|2[0-9]|3[0-1])\.| # private IPv4 range 172.16.0.0 .. 172.31.255.255 + ^192\.168\. # private IPv4 range 192.168.x.x + }x + + attr_reader :check_ip, :proxies + + # Create a new +RemoteIp+ middleware instance. + # + # The +check_ip_spoofing+ option is on by default. When on, an exception + # is raised if it looks like the client is trying to lie about its own IP + # address. It makes sense to turn off this check on sites aimed at non-IP + # clients (like WAP devices), or behind proxies that set headers in an + # incorrect or confusing way (like AWS ELB). + # + # The +custom_proxies+ argument can take a regex, which will be used + # instead of +TRUSTED_PROXIES+, or a string, which will be used in addition + # to +TRUSTED_PROXIES+. Any proxy setup will put the value you want in the + # middle (or at the beginning) of the X-Forwarded-For list, with your proxy + # servers after it. If your proxies aren't removed, pass them in via the + # +custom_proxies+ parameter. That way, the middleware will ignore those + # IP addresses, and return the one that you want. + def initialize(app, check_ip_spoofing = true, custom_proxies = nil) + @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 + end + + # Since the IP address may not be needed, we store the object here + # without calculating the IP to keep from slowing down the majority of + # requests. For those requests that do need to know the IP, the + # GetIp#calculate_ip method will calculate the memoized client IP address. + def call(env) + env["action_dispatch.remote_ip"] = GetIp.new(env, self) + @app.call(env) + end + + # The GetIp class exists as a way to defer processing of the request data + # into an actual IP address. If the ActionDispatch::Request#remote_ip method + # is called, this class will calculate the value and then memoize it. + class GetIp + + # This constant contains a regular expression that validates every known + # form of IP v4 and v6 address, with or without abbreviations, adapted + # from {this gist}[https://gist.github.com/gazay/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 beginning + (([0-9A-Fa-f]{1,4}:){1,7}:) # ip v6 without ending + )$) + }x + + def initialize(env, middleware) + @env = env + @check_ip = middleware.check_ip + @proxies = middleware.proxies + end + + # Sort through the various IP address headers, looking for the IP most + # likely to be the address of the actual remote client making this + # request. + # + # REMOTE_ADDR will be correct if the request is made directly against the + # Ruby process, on e.g. Heroku. When the request is proxied by another + # server like HAProxy or NGINX, the IP address that made the original + # request will be put in an X-Forwarded-For header. If there are multiple + # proxies, that header may contain a list of IPs. Other proxy services + # set the Client-Ip header instead, so we check that too. + # + # As discussed in {this post about Rails IP Spoofing}[http://blog.gingerlime.com/2012/rails-ip-spoofing-vulnerabilities-and-protection/], + # while the first IP in the list is likely to be the "originating" IP, + # it could also have been set by the client maliciously. + # + # In order to find the first address that is (probably) accurate, we + # take the list of IPs, remove known and trusted proxies, and then take + # the last address left, which was presumably set by one of those proxies. + def calculate_ip + # Set by the Rack web server, this is a single value. + remote_addr = ips_from('REMOTE_ADDR').last + + # Could be a CSV list and/or repeated headers that were concatenated. + client_ips = ips_from('HTTP_CLIENT_IP').reverse + forwarded_ips = ips_from('HTTP_X_FORWARDED_FOR').reverse + + # +Client-Ip+ and +X-Forwarded-For+ should not, generally, both be set. + # If they are both set, it means that this request passed through two + # proxies with incompatible IP header conventions, and there is no way + # for us to determine which header is the right one after the fact. + # Since we have no idea, we give up and explode. + should_check_ip = @check_ip && client_ips.last && forwarded_ips.last + if should_check_ip && !forwarded_ips.include?(client_ips.last) + # 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 + + # We assume these things about the IP headers: + # + # - X-Forwarded-For will be a list of IPs, one per proxy, or blank + # - Client-Ip is propagated from the outermost proxy, or is blank + # - REMOTE_ADDR will be the IP that made the request to Rack + ips = [forwarded_ips, client_ips, remote_addr].flatten.compact + + # If every single IP option is in the trusted list, just return REMOTE_ADDR + filter_proxies(ips).first || remote_addr + end + + # Memoizes the value returned by #calculate_ip and returns it for + # ActionDispatch::Request to use. + def to_s + @ip ||= calculate_ip + end + + protected + + def ips_from(header) + # Split the comma-separated list into an array of strings + ips = @env[header] ? @env[header].strip.split(/[,\s]+/) : [] + # Only return IPs that are valid according to the regex + ips.select{ |ip| ip =~ VALID_IP } + end + + def filter_proxies(ips) + ips.reject { |ip| ip =~ @proxies } + end + + end + + end +end diff --git a/actionpack/lib/action_dispatch/middleware/request_id.rb b/actionpack/lib/action_dispatch/middleware/request_id.rb new file mode 100644 index 0000000000..5d1740d0d4 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/request_id.rb @@ -0,0 +1,35 @@ +require 'securerandom' +require 'active_support/core_ext/string/access' + +module ActionDispatch + # Makes a unique request id available to the action_dispatch.request_id env variable (which is then accessible through + # ActionDispatch::Request#uuid) and sends the same id to the client via the X-Request-Id header. + # + # The unique request id is either based off the X-Request-Id header in the request, which would typically be generated + # by a firewall, load balancer, or the web server, or, if this header is not available, a random uuid. If the + # header is accepted from the outside world, we sanitize it to a max of 255 chars and alphanumeric and dashes only. + # + # The unique request id can be used to trace a request end-to-end and would typically end up being part of log files + # from multiple pieces of the stack. + class RequestId + def initialize(app) + @app = app + end + + def call(env) + env["action_dispatch.request_id"] = external_request_id(env) || internal_request_id + @app.call(env).tap { |_status, headers, _body| headers["X-Request-Id"] = env["action_dispatch.request_id"] } + end + + private + def external_request_id(env) + if request_id = env["HTTP_X_REQUEST_ID"].presence + request_id.gsub(/[^\w\-]/, "").first(255) + end + end + + def internal_request_id + SecureRandom.uuid + end + end +end diff --git a/actionpack/lib/action_dispatch/middleware/session/abstract_store.rb b/actionpack/lib/action_dispatch/middleware/session/abstract_store.rb new file mode 100644 index 0000000000..84df55fd5a --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/session/abstract_store.rb @@ -0,0 +1,90 @@ +require 'rack/utils' +require 'rack/request' +require 'rack/session/abstract/id' +require 'action_dispatch/middleware/cookies' +require 'action_dispatch/request/session' + +module ActionDispatch + module Session + class SessionRestoreError < StandardError #:nodoc: + attr_reader :original_exception + + def initialize(const_error) + @original_exception = const_error + + 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 + + module Compatibility + def initialize(app, options = {}) + options[:key] ||= '_session_id' + super + end + + def generate_sid + sid = SecureRandom.hex(16) + sid.encode!(Encoding::UTF_8) + sid + end + + protected + + def initialize_sid + @default_options.delete(:sidbits) + @default_options.delete(:secure_random) + end + end + + module StaleSessionCheck + def load_session(env) + stale_session_check! { super } + end + + def extract_session_id(env) + stale_session_check! { super } + end + + def stale_session_check! + yield + rescue ArgumentError => argument_error + if argument_error.message =~ %r{undefined class/module ([\w:]*\w)} + begin + # Note that the regexp does not allow $1 to end with a ':' + $1.constantize + rescue LoadError, NameError => e + raise ActionDispatch::Session::SessionRestoreError, e, e.backtrace + end + retry + else + raise + end + 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/cache_store.rb b/actionpack/lib/action_dispatch/middleware/session/cache_store.rb new file mode 100644 index 0000000000..625050dc4b --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/session/cache_store.rb @@ -0,0 +1,49 @@ +require 'action_dispatch/middleware/session/abstract_store' + +module ActionDispatch + module Session + # Session store that uses an ActiveSupport::Cache::Store to store the sessions. This store is most useful + # if you don't store critical data in your sessions and you don't need them to live for extended periods + # of time. + class CacheStore < AbstractStore + # Create a new store. The cache to use can be passed in the <tt>:cache</tt> option. If it is + # not specified, <tt>Rails.cache</tt> will be used. + def initialize(app, options = {}) + @cache = options[:cache] || Rails.cache + options[:expire_after] ||= @cache.options[:expires_in] + super + end + + # Get a session from the cache. + def get_session(env, sid) + unless sid and session = @cache.read(cache_key(sid)) + sid, session = generate_sid, {} + end + [sid, session] + end + + # Set a session in the cache. + def set_session(env, sid, session, options) + key = cache_key(sid) + if session + @cache.write(key, session, :expires_in => options[:expire_after]) + else + @cache.delete(key) + end + sid + end + + # Remove a session from the cache. + def destroy_session(env, sid, options) + @cache.delete(cache_key(sid)) + generate_sid + end + + private + # Turn the session id into a cache key. + def cache_key(sid) + "_session_id:#{sid}" + 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 new file mode 100644 index 0000000000..ed25c67ae5 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/session/cookie_store.rb @@ -0,0 +1,123 @@ +require 'active_support/core_ext/hash/keys' +require 'action_dispatch/middleware/session/abstract_store' +require 'rack/session/cookie' + +module ActionDispatch + module Session + # This cookie-based session store is the Rails default. It is + # dramatically faster than the alternatives. + # + # Sessions typically contain at most a user_id and flash message; both fit + # within the 4K cookie size limit. A CookieOverflow exception is raised if + # you attempt to store more than 4K of data. + # + # The cookie jar used for storage is automatically configured to be the + # best possible option given your application's configuration. + # + # If you only have secret_token set, your cookies will be signed, but + # not encrypted. This means a user cannot alter their +user_id+ without + # knowing your app's secret key, but can easily read their +user_id+. This + # was the default for Rails 3 apps. + # + # If you have secret_key_base set, your cookies will be encrypted. This + # goes a step further than signed cookies in that encrypted cookies cannot + # be altered or read by users. This is the default starting in Rails 4. + # + # If you have both secret_token and secret_key base set, your cookies will + # be encrypted, and signed cookies generated by Rails 3 will be + # transparently read and encrypted to provide a smooth upgrade path. + # + # Configure your session store in config/initializers/session_store.rb: + # + # Rails.application.config.session_store :cookie_store, key: '_your_app_session' + # + # Configure your secret key in config/secrets.yml: + # + # development: + # secret_key_base: 'secret key' + # + # To generate a secret key for an existing application, run `rake secret`. + # + # If you are upgrading an existing Rails 3 app, you should leave your + # existing secret_token in place and simply add the new secret_key_base. + # Note that you should wait to set secret_key_base until you have 100% of + # your userbase on Rails 4 and are reasonably sure you will not need to + # rollback to Rails 3. This is because cookies signed based on the new + # secret_key_base in Rails 4 are not backwards compatible with Rails 3. + # You are free to leave your existing secret_token in place, not set the + # new secret_key_base, and ignore the deprecation warnings until you are + # reasonably sure that your upgrade is otherwise complete. Additionally, + # you should take care to make sure you are not relying on the ability to + # decode signed cookies generated by your app in external applications or + # JavaScript before upgrading. + # + # Note that changing the secret key will invalidate all existing sessions! + class CookieStore < Rack::Session::Abstract::ID + include Compatibility + include StaleSessionCheck + include SessionObject + + def initialize(app, options={}) + super(app, options.merge!(:cookie_only => true)) + end + + def destroy_session(env, session_id, options) + new_sid = generate_sid unless options[:drop] + # Reset hash and Assign the new session id + env["action_dispatch.request.unsigned_session_cookie"] = new_sid ? { "session_id" => new_sid } : {} + new_sid + end + + def load_session(env) + stale_session_check! do + data = unpacked_cookie_data(env) + data = persistent_session_id!(data) + [data["session_id"], data] + end + end + + private + + def extract_session_id(env) + stale_session_check! do + unpacked_cookie_data(env)["session_id"] + end + end + + def unpacked_cookie_data(env) + env["action_dispatch.request.unsigned_session_cookie"] ||= begin + stale_session_check! do + if data = get_cookie(env) + data.stringify_keys! + end + data || {} + end + end + end + + def persistent_session_id!(data, sid=nil) + data ||= {} + data["session_id"] ||= sid || generate_sid + data + end + + def set_session(env, sid, session_data, options) + 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_or_encrypted + end + 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 new file mode 100644 index 0000000000..b4d6629c35 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/session/mem_cache_store.rb @@ -0,0 +1,22 @@ +require 'action_dispatch/middleware/session/abstract_store' +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::Dalli + include Compatibility + include StaleSessionCheck + include SessionObject + + def initialize(app, options = {}) + options[:expire_after] ||= options[:expires] + super + end + end + end +end diff --git a/actionpack/lib/action_dispatch/middleware/show_exceptions.rb b/actionpack/lib/action_dispatch/middleware/show_exceptions.rb new file mode 100644 index 0000000000..f0779279c1 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/show_exceptions.rb @@ -0,0 +1,58 @@ +require 'action_dispatch/http/request' +require 'action_dispatch/middleware/exception_wrapper' + +module ActionDispatch + # This middleware rescues any exception returned by the application + # 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. 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/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."]] + + def initialize(app, exceptions_app) + @app = app + @exceptions_app = exceptions_app + end + + def call(env) + @app.call(env) + rescue Exception => exception + if env['action_dispatch.show_exceptions'] == false + raise exception + else + render_exception(env, exception) + end + end + + private + + def render_exception(env, exception) + wrapper = ExceptionWrapper.new(env, exception) + status = wrapper.status_code + env["action_dispatch.exception"] = wrapper.exception + env["action_dispatch.original_path"] = env["PATH_INFO"] + env["PATH_INFO"] = "/#{status}" + response = @exceptions_app.call(env) + response[1]['X-Cascade'] == 'pass' ? pass_response(status) : response + rescue Exception => failsafe_error + $stderr.puts "Error during failsafe response: #{failsafe_error}\n #{failsafe_error.backtrace * "\n "}" + FAILSAFE_RESPONSE + end + + def pass_response(status) + [status, {"Content-Type" => "text/html; charset=#{Response.default_charset}", "Content-Length" => "0"}, []] + end + end +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..0c7caef25d --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/ssl.rb @@ -0,0 +1,72 @@ +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) + host = @host || request.host + port = @port || request.port + + location = "https://#{host}" + location << ":#{port}" if port != 80 + location << request.fullpath + + headers = { 'Content-Type' => 'text/html', 'Location' => location } + + [301, headers, []] + end + + # http://tools.ietf.org/html/draft-hodges-strict-transport-sec-02 + def hsts_headers + if @hsts + value = "max-age=#{@hsts[:expires].to_i}" + 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\s*(;|$)/i + "#{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 new file mode 100644 index 0000000000..bbf734f103 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/stack.rb @@ -0,0 +1,129 @@ +require "active_support/inflector/methods" +require "active_support/dependencies" + +module ActionDispatch + class MiddlewareStack + class Middleware + attr_reader :args, :block, :name, :classcache + + def initialize(klass_or_name, *args, &block) + @klass = nil + + if klass_or_name.respond_to?(:name) + @klass = klass_or_name + @name = @klass.name + else + @name = klass_or_name.to_s + end + + @classcache = ActiveSupport::Dependencies::Reference + @args, @block = args, block + end + + def klass + @klass || classcache[@name] + end + + def ==(middleware) + case middleware + when Middleware + klass == middleware.klass + when Class + klass == middleware + else + normalize(@name) == normalize(middleware) + end + end + + def inspect + klass.to_s + end + + def build(app) + klass.new(app, *args, &block) + end + + private + + def normalize(object) + object.to_s.strip.sub(/^::/, '') + end + end + + include Enumerable + + attr_accessor :middlewares + + def initialize(*args) + @middlewares = [] + yield(self) if block_given? + end + + def each + @middlewares.each { |x| yield x } + end + + def size + middlewares.size + end + + def last + middlewares.last + end + + def [](i) + 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 + + def insert(index, *args, &block) + index = assert_index(index, :before) + middleware = self.class::Middleware.new(*args, &block) + middlewares.insert(index, middleware) + end + + alias_method :insert_before, :insert + + def insert_after(index, *args, &block) + index = assert_index(index, :after) + insert(index + 1, *args, &block) + end + + def swap(target, *args, &block) + index = assert_index(target, :before) + insert(index, *args, &block) + middlewares.delete_at(index + 1) + end + + def delete(target) + middlewares.delete target + end + + def use(*args, &block) + middleware = self.class::Middleware.new(*args, &block) + middlewares.push(middleware) + end + + def build(app = nil, &block) + app ||= block + raise "MiddlewareStack#build requires an app" unless app + middlewares.freeze.reverse.inject(app) { |a, e| e.build(a) } + end + + protected + + def assert_index(index, where) + i = index.is_a?(Integer) ? index : middlewares.index(index) + raise "No such middleware to insert #{where}: #{index.inspect}" unless i + i + end + end +end diff --git a/actionpack/lib/action_dispatch/middleware/static.rb b/actionpack/lib/action_dispatch/middleware/static.rb new file mode 100644 index 0000000000..2764584fe9 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/static.rb @@ -0,0 +1,67 @@ +require 'rack/utils' +require 'active_support/core_ext/uri' + +module ActionDispatch + class FileHandler + def initialize(root, cache_control) + @root = root.chomp('/') + @compiled_root = /^#{Regexp.escape(root)}/ + headers = cache_control && { 'Cache-Control' => cache_control } + @file_server = ::Rack::File.new(@root, headers) + end + + def match?(path) + path = unescape_path(path) + return false unless path.valid_encoding? + + full_path = path.empty? ? @root : File.join(@root, escape_glob_chars(path)) + paths = "#{full_path}#{ext}" + + matches = Dir[paths] + match = matches.detect { |m| File.file?(m) } + if match + match.sub!(@compiled_root, '') + ::Rack::Utils.escape(match) + end + end + + def call(env) + @file_server.call(env) + end + + def ext + @ext ||= begin + ext = ::ActionController::Base.default_static_extension + "{,#{ext},/index#{ext}}" + end + end + + def unescape_path(path) + URI.parser.unescape(path) + end + + def escape_glob_chars(path) + path.gsub(/[*?{}\[\]]/, "\\\\\\&") + end + end + + class Static + def initialize(app, path, cache_control=nil) + @app = app + @file_handler = FileHandler.new(path, cache_control) + end + + def call(env) + case env['REQUEST_METHOD'] + when 'GET', 'HEAD' + path = env['PATH_INFO'].chomp('/') + if match = @file_handler.match?(path) + env["PATH_INFO"] = match + return @file_handler.call(env) + end + end + + @app.call(env) + end + end +end diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/_request_and_response.html.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/_request_and_response.html.erb new file mode 100644 index 0000000000..db219c8fa9 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/_request_and_response.html.erb @@ -0,0 +1,34 @@ +<% unless @exception.blamed_files.blank? %> + <% if (hide = @exception.blamed_files.length > 8) %> + <a href="#" onclick="return toggleTrace()">Toggle blamed files</a> + <% end %> + <pre id="blame_trace" <%='style="display:none"' if hide %>><code><%= @exception.describe_blame %></code></pre> +<% end %> + +<% + clean_params = @request.filtered_parameters.clone + clean_params.delete("action") + clean_params.delete("controller") + + request_dump = clean_params.empty? ? 'None' : clean_params.inspect.gsub(',', ",\n") + + def debug_hash(object) + object.to_hash.sort_by { |k, _| k.to_s }.map { |k, v| "#{k}: #{v.inspect rescue $!.message}" }.join("\n") + end unless self.class.method_defined?(:debug_hash) +%> + +<h2 style="margin-top: 30px">Request</h2> +<p><b>Parameters</b>:</p> <pre><%= request_dump %></pre> + +<div class="details"> + <div class="summary"><a href="#" onclick="return toggleSessionDump()">Toggle session dump</a></div> + <div id="session_dump" style="display:none"><pre><%= debug_hash @request.session %></pre></div> +</div> + +<div class="details"> + <div class="summary"><a href="#" onclick="return toggleEnvDump()">Toggle env dump</a></div> + <div id="env_dump" style="display:none"><pre><%= debug_hash @request.env.slice(*@request.class::ENV_METHODS) %></pre></div> +</div> + +<h2 style="margin-top: 30px">Response</h2> +<p><b>Headers</b>:</p> <pre><%= defined?(@response) ? @response.headers.inspect.gsub(',', ",\n") : 'None' %></pre> diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/_request_and_response.text.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/_request_and_response.text.erb new file mode 100644 index 0000000000..396768ecee --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/_request_and_response.text.erb @@ -0,0 +1,23 @@ +<% + clean_params = @request.filtered_parameters.clone + clean_params.delete("action") + clean_params.delete("controller") + + request_dump = clean_params.empty? ? 'None' : clean_params.inspect.gsub(',', ",\n") + + def debug_hash(object) + object.to_hash.sort_by { |k, _| k.to_s }.map { |k, v| "#{k}: #{v.inspect rescue $!.message}" }.join("\n") + end unless self.class.method_defined?(:debug_hash) +%> + +Request parameters +<%= request_dump %> + +Session dump +<%= debug_hash @request.session %> + +Env dump +<%= debug_hash @request.env.slice(*@request.class::ENV_METHODS) %> + +Response headers +<%= defined?(@response) ? @response.headers.inspect.gsub(',', ",\n") : 'None' %> diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/_source.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/_source.erb new file mode 100644 index 0000000000..51660a619b --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/_source.erb @@ -0,0 +1,29 @@ +<% if @source_extract %> + <% @source_extract.each_with_index do |extract_source, index| %> + <% if extract_source[:code] %> + <div class="source <%="hidden" if index != 0%>" id="frame-source-<%=index%>"> + <div class="info"> + Extracted source (around line <strong>#<%= extract_source[:line_number] %></strong>): + </div> + <div class="data"> + <table cellpadding="0" cellspacing="0" class="lines"> + <tr> + <td> + <pre class="line_numbers"> + <% extract_source[:code].keys.each do |line_number| %> +<span><%= line_number -%></span> + <% end %> + </pre> + </td> +<td width="100%"> +<pre> +<% extract_source[:code].each do |line, source| -%><div class="line<%= " active" if line == extract_source[:line_number] -%>"><%= source -%></div><% end -%> +</pre> +</td> + </tr> + </table> + </div> + </div> + <% end %> + <% end %> +<% end %> diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/_trace.html.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/_trace.html.erb new file mode 100644 index 0000000000..f62caf51d7 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/_trace.html.erb @@ -0,0 +1,52 @@ +<% names = @traces.keys %> + +<p><code>Rails.root: <%= defined?(Rails) && Rails.respond_to?(:root) ? Rails.root : "unset" %></code></p> + +<div id="traces"> + <% names.each do |name| %> + <% + show = "show('#{name.gsub(/\s/, '-')}');" + hide = (names - [name]).collect {|hide_name| "hide('#{hide_name.gsub(/\s/, '-')}');"} + %> + <a href="#" onclick="<%= hide.join %><%= show %>; return false;"><%= name %></a> <%= '|' unless names.last == name %> + <% end %> + + <% @traces.each do |name, trace| %> + <div id="<%= name.gsub(/\s/, '-') %>" style="display: <%= (name == "Application Trace") ? 'block' : 'none' %>;"> + <pre><code><% trace.each do |frame| %><a class="trace-frames" data-frame-id="<%= frame[:id] %>" href="#"><%= frame[:trace] %></a><br><% end %></code></pre> + </div> + <% end %> + + <script type="text/javascript"> + var traceFrames = document.getElementsByClassName('trace-frames'); + var selectedFrame, currentSource = document.getElementById('frame-source-0'); + + // Add click listeners for all stack frames + for (var i = 0; i < traceFrames.length; i++) { + traceFrames[i].addEventListener('click', function(e) { + e.preventDefault(); + var target = e.target; + var frame_id = target.dataset.frameId; + + if (selectedFrame) { + selectedFrame.className = selectedFrame.className.replace("selected", ""); + } + + target.className += " selected"; + selectedFrame = target; + + // Change the extracted source code + changeSourceExtract(frame_id); + }); + + function changeSourceExtract(frame_id) { + var el = document.getElementById('frame-source-' + frame_id); + if (currentSource && el) { + currentSource.className += " hidden"; + el.className = el.className.replace(" hidden", ""); + currentSource = el; + } + } + } + </script> +</div> diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/_trace.text.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/_trace.text.erb new file mode 100644 index 0000000000..36b01bf952 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/_trace.text.erb @@ -0,0 +1,9 @@ +Rails.root: <%= defined?(Rails) && Rails.respond_to?(:root) ? Rails.root : "unset" %> + +<% @traces.each do |name, trace| %> +<% if trace.any? %> +<%= name %> +<%= trace.map(&:trace).join("\n") %> + +<% end %> +<% end %> diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/diagnostics.html.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/diagnostics.html.erb new file mode 100644 index 0000000000..f154021ae6 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/diagnostics.html.erb @@ -0,0 +1,16 @@ +<header> + <h1> + <%= @exception.class.to_s %> + <% if @request.parameters['controller'] %> + in <%= @request.parameters['controller'].camelize %>Controller<% if @request.parameters['action'] %>#<%= @request.parameters['action'] %><% end %> + <% end %> + </h1> +</header> + +<div id="container"> + <h2><%= h @exception.message %></h2> + + <%= render template: "rescues/_source" %> + <%= render template: "rescues/_trace" %> + <%= render template: "rescues/_request_and_response" %> +</div> diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/diagnostics.text.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/diagnostics.text.erb new file mode 100644 index 0000000000..603de54b8b --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/diagnostics.text.erb @@ -0,0 +1,9 @@ +<%= @exception.class.to_s %><% + if @request.parameters['controller'] +%> in <%= @request.parameters['controller'].camelize %>Controller<% if @request.parameters['action'] %>#<%= @request.parameters['action'] %><% end %> +<% end %> + +<%= @exception.message %> +<%= render template: "rescues/_source" %> +<%= render template: "rescues/_trace" %> +<%= render template: "rescues/_request_and_response" %> diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/layout.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/layout.erb new file mode 100644 index 0000000000..e0509f56f4 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/layout.erb @@ -0,0 +1,160 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8" /> + <title>Action Controller: Exception caught</title> + <style> + body { + background-color: #FAFAFA; + color: #333; + margin: 0px; + } + + body, p, ol, ul, td { + font-family: helvetica, verdana, arial, sans-serif; + font-size: 13px; + line-height: 18px; + } + + pre { + font-size: 11px; + white-space: pre-wrap; + } + + pre.box { + border: 1px solid #EEE; + padding: 10px; + margin: 0px; + width: 958px; + } + + header { + color: #F0F0F0; + background: #C52F24; + padding: 0.5em 1.5em; + } + + h1 { + margin: 0.2em 0; + line-height: 1.1em; + font-size: 2em; + } + + h2 { + color: #C52F24; + line-height: 25px; + } + + .details { + border: 1px solid #D0D0D0; + border-radius: 4px; + margin: 1em 0px; + display: block; + width: 978px; + } + + .summary { + padding: 8px 15px; + border-bottom: 1px solid #D0D0D0; + display: block; + } + + .details pre { + margin: 5px; + border: none; + } + + #container { + box-sizing: border-box; + width: 100%; + padding: 0 1.5em; + } + + .source * { + margin: 0px; + padding: 0px; + } + + .source { + border: 1px solid #D9D9D9; + background: #ECECEC; + width: 978px; + } + + .source pre { + padding: 10px 0px; + border: none; + } + + .source .data { + font-size: 80%; + overflow: auto; + background-color: #FFF; + } + + .info { + padding: 0.5em; + } + + .source .data .line_numbers { + background-color: #ECECEC; + color: #AAA; + padding: 1em .5em; + border-right: 1px solid #DDD; + text-align: right; + } + + .line { + padding-left: 10px; + } + + .line:hover { + background-color: #F6F6F6; + } + + .line.active { + background-color: #FFCCCC; + } + + .hidden { + display: none; + } + + a { color: #980905; } + a:visited { color: #666; } + a.trace-frames { color: #666; } + a:hover { color: #C52F24; } + a.trace-frames.selected { color: #C52F24 } + + <%= yield :style %> + </style> + + <script> + var toggle = function(id) { + var s = document.getElementById(id).style; + s.display = s.display == 'none' ? 'block' : 'none'; + return false; + } + var show = function(id) { + document.getElementById(id).style.display = 'block'; + } + var hide = function(id) { + document.getElementById(id).style.display = 'none'; + } + var toggleTrace = function() { + return toggle('blame_trace'); + } + var toggleSessionDump = function() { + return toggle('session_dump'); + } + var toggleEnvDump = function() { + return toggle('env_dump'); + } + </script> +</head> +<body> + +<%= yield %> + +</body> +</html> diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/missing_template.html.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/missing_template.html.erb new file mode 100644 index 0000000000..5c016e544e --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/missing_template.html.erb @@ -0,0 +1,7 @@ +<header> + <h1>Template is missing</h1> +</header> + +<div id="container"> + <h2><%= h @exception.message %></h2> +</div> diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/missing_template.text.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/missing_template.text.erb new file mode 100644 index 0000000000..ae62d9eb02 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/missing_template.text.erb @@ -0,0 +1,3 @@ +Template is missing + +<%= @exception.message %> diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/routing_error.html.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/routing_error.html.erb new file mode 100644 index 0000000000..7e9cedb95e --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/routing_error.html.erb @@ -0,0 +1,30 @@ +<header> + <h1>Routing Error</h1> +</header> +<div id="container"> + <h2><%= h @exception.message %></h2> + <% unless @exception.failures.empty? %> + <p> + <h2>Failure reasons:</h2> + <ol> + <% @exception.failures.each do |route, reason| %> + <li><code><%= route.inspect.delete('\\') %></code> failed because <%= reason.downcase %></li> + <% end %> + </ol> + </p> + <% end %> + + <%= render template: "rescues/_trace" %> + + <% if @routes_inspector %> + <h2> + Routes + </h2> + + <p> + Routes match in priority from top to bottom + </p> + + <%= @routes_inspector.format(ActionDispatch::Routing::HtmlTableFormatter.new(self)) %> + <% end %> +</div> diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/routing_error.text.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/routing_error.text.erb new file mode 100644 index 0000000000..f6e4dac1f3 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/routing_error.text.erb @@ -0,0 +1,11 @@ +Routing Error + +<%= @exception.message %> +<% unless @exception.failures.empty? %> +Failure reasons: +<% @exception.failures.each do |route, reason| %> + - <%= route.inspect.delete('\\') %></code> failed because <%= reason.downcase %> +<% end %> +<% end %> + +<%= render template: "rescues/_trace", format: :text %> diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/template_error.html.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/template_error.html.erb new file mode 100644 index 0000000000..c1e8b6cae3 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/template_error.html.erb @@ -0,0 +1,20 @@ +<header> + <h1> + <%= @exception.original_exception.class.to_s %> in + <%= @request.parameters["controller"].camelize if @request.parameters["controller"] %>#<%= @request.parameters["action"] %> + </h1> +</header> + +<div id="container"> + <p> + Showing <i><%= @exception.file_name %></i> where line <b>#<%= @exception.line_number %></b> raised: + </p> + <pre><code><%= h @exception.message %></code></pre> + + <%= render template: "rescues/_source" %> + + <p><%= @exception.sub_template_message %></p> + + <%= render template: "rescues/_trace" %> + <%= render template: "rescues/_request_and_response" %> +</div> diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/template_error.text.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/template_error.text.erb new file mode 100644 index 0000000000..77bcd26726 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/template_error.text.erb @@ -0,0 +1,7 @@ +<%= @exception.original_exception.class.to_s %> in <%= @request.parameters["controller"].camelize if @request.parameters["controller"] %>#<%= @request.parameters["action"] %> + +Showing <%= @exception.file_name %> where line #<%= @exception.line_number %> raised: +<%= @exception.message %> +<%= @exception.sub_template_message %> +<%= render template: "rescues/_trace", format: :text %> +<%= render template: "rescues/_request_and_response", format: :text %> diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/unknown_action.html.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/unknown_action.html.erb new file mode 100644 index 0000000000..259fb2bb3b --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/unknown_action.html.erb @@ -0,0 +1,6 @@ +<header> + <h1>Unknown action</h1> +</header> +<div id="container"> + <h2><%= h @exception.message %></h2> +</div> diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/unknown_action.text.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/unknown_action.text.erb new file mode 100644 index 0000000000..83973addcb --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/unknown_action.text.erb @@ -0,0 +1,3 @@ +Unknown action + +<%= @exception.message %> 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..24e44f31ac --- /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] %>' data-regexp='<%= route[:regexp] %>'> + <%= route[:path] %> + </td> + <td data-route-reqs='<%= route[:reqs] %>'> + <%= route[:reqs] %> + </td> +</tr> diff --git a/actionpack/lib/action_dispatch/middleware/templates/routes/_table.html.erb b/actionpack/lib/action_dispatch/middleware/templates/routes/_table.html.erb new file mode 100644 index 0000000000..6ffa242da4 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/templates/routes/_table.html.erb @@ -0,0 +1,200 @@ +<% content_for :style do %> + #route_table { + margin: 0 auto 0; + border-collapse: collapse; + } + + #route_table thead tr { + border-bottom: 2px solid #ddd; + } + + #route_table thead tr.bottom { + border-bottom: none; + } + + #route_table thead tr.bottom th { + padding: 10px 0; + line-height: 15px; + } + + #route_table tbody tr { + border-bottom: 1px solid #ddd; + } + + #route_table tbody tr:nth-child(odd) { + background: #f2f2f2; + } + + #route_table tbody.exact_matches, + #route_table tbody.fuzzy_matches { + background-color: LightGoldenRodYellow; + border-bottom: solid 2px SlateGrey; + } + + #route_table tbody.exact_matches tr, + #route_table tbody.fuzzy_matches tr { + background: none; + border-bottom: none; + } + + #route_table td { + padding: 4px 30px; + } + + #path_search { + width: 80%; + font-size: inherit; + } +<% end %> + +<table id='route_table' class='route_table'> + <thead> + <tr> + <th>Helper</th> + <th>HTTP Verb</th> + <th>Path</th> + <th>Controller#Action</th> + </tr> + <tr class='bottom'> + <th><%# Helper %> + <%= 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 %> + <%= search_field(:path, nil, id: 'search', placeholder: "Path Match") %> + </th> + <th><%# Controller#action %> + </th> + </tr> + </thead> + <tbody class='exact_matches' id='exact_matches'> + </tbody> + <tbody class='fuzzy_matches' id='fuzzy_matches'> + </tbody> + <tbody> + <%= yield %> + </tbody> +</table> + +<script type='text/javascript'> + // Iterates each element through a function + function each(elems, func) { + if (!elems instanceof Array) { elems = [elems]; } + for (var i = 0, len = elems.length; i < len; i++) { + func(elems[i]); + } + } + + // Sets innerHTML for an element + function setContent(elem, text) { + elem.innerHTML = text; + } + + // Enables path search functionality + function setupMatchPaths() { + // Check if the user input (sanitized as a path) matches the regexp data attribute + function checkExactMatch(section, elem, value) { + var string = sanitizePath(value), + regexp = elem.getAttribute("data-regexp"); + + showMatch(string, regexp, section, elem); + } + + // Check if the route path data attribute contains the user input + function checkFuzzyMatch(section, elem, value) { + var string = elem.getAttribute("data-route-path"), + regexp = value; + + showMatch(string, regexp, section, elem); + } + + // Display the parent <tr> element in the appropriate section when there's a match + function showMatch(string, regexp, section, elem) { + if(string.match(RegExp(regexp))) { + section.appendChild(elem.parentNode.cloneNode(true)); + } + } + + // Check if there are any matched results in a section + function checkNoMatch(section, defaultText, noMatchText) { + if (section.innerHTML === defaultText) { + setContent(section, defaultText + noMatchText); + } + } + + // Ensure path always starts with a slash "/" and remove params or fragments + function sanitizePath(path) { + var path = path.charAt(0) == '/' ? path : "/" + path; + return path.replace(/\#.*|\?.*/, ''); + } + + var regexpElems = document.querySelectorAll('#route_table [data-regexp]'), + searchElem = document.querySelector('#search'), + exactMatches = document.querySelector('#exact_matches'), + fuzzyMatches = document.querySelector('#fuzzy_matches'); + + // Remove matches when no search value is present + searchElem.onblur = function(e) { + if (searchElem.value === "") { + setContent(exactMatches, ""); + setContent(fuzzyMatches, ""); + } + } + + // On key press perform a search for matching paths + searchElem.onkeyup = function(e){ + var userInput = searchElem.value, + defaultExactMatch = '<tr><th colspan="4">Paths Matching (' + escape(sanitizePath(userInput)) +'):</th></tr>', + defaultFuzzyMatch = '<tr><th colspan="4">Paths Containing (' + escape(userInput) +'):</th></tr>', + noExactMatch = '<tr><th colspan="4">No Exact Matches Found</th></tr>', + noFuzzyMatch = '<tr><th colspan="4">No Fuzzy Matches Found</th></tr>'; + + // Clear out results section + setContent(exactMatches, defaultExactMatch); + setContent(fuzzyMatches, defaultFuzzyMatch); + + // Display exact matches and fuzzy matches + each(regexpElems, function(elem) { + checkExactMatch(exactMatches, elem, userInput); + checkFuzzyMatch(fuzzyMatches, elem, userInput); + }) + + // Display 'No Matches' message when no matches are found + checkNoMatch(exactMatches, defaultExactMatch, noExactMatch); + checkNoMatch(fuzzyMatches, defaultFuzzyMatch, noFuzzyMatch); + } + } + + // Enables functionality to toggle between `_path` and `_url` helper suffixes + function setupRouteToggleHelperLinks() { + + // Sets content for each element + function setValOn(elems, val) { + each(elems, function(elem) { + setContent(elem, val); + }); + } + + // Sets onClick event for each element + function onClick(elems, func) { + each(elems, function(elem) { + elem.onclick = func; + }); + } + + var toggleLinks = document.querySelectorAll('#route_table [data-route-helper]'); + onClick(toggleLinks, function(){ + var helperTxt = this.getAttribute("data-route-helper"), + helperElems = document.querySelectorAll('[data-route-name] span.helper'); + + setValOn(helperElems, helperTxt); + }); + } + + setupMatchPaths(); + setupRouteToggleHelperLinks(); +</script> diff --git a/actionpack/lib/action_dispatch/railtie.rb b/actionpack/lib/action_dispatch/railtie.rb new file mode 100644 index 0000000000..ddeea24bb3 --- /dev/null +++ b/actionpack/lib/action_dispatch/railtie.rb @@ -0,0 +1,45 @@ +require "action_dispatch" + +module ActionDispatch + 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 + config.action_dispatch.show_exceptions = true + config.action_dispatch.tld_length = 1 + config.action_dispatch.ignore_accept_header = false + 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.perform_deep_munge = true + + 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::Request::Utils.perform_deep_munge = app.config.action_dispatch.perform_deep_munge + 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) + + config.action_dispatch.always_write_cookie = Rails.env.development? if config.action_dispatch.always_write_cookie.nil? + ActionDispatch::Cookies::CookieJar.always_write_cookie = config.action_dispatch.always_write_cookie + + ActionDispatch.test_app = app + end + end +end diff --git a/actionpack/lib/action_dispatch/request/session.rb b/actionpack/lib/action_dispatch/request/session.rb new file mode 100644 index 0000000000..973627f106 --- /dev/null +++ b/actionpack/lib/action_dispatch/request/session.rb @@ -0,0 +1,193 @@ +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: + + # Singleton object used to determine if an optional param wasn't specified + Unspecified = Object.new + + 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 id + options[:id] + 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 fetch(key, default=Unspecified, &block) + load_for_read! + if default == Unspecified + @delegate.fetch(key.to_s, &block) + else + @delegate.fetch(key.to_s, default, &block) + end + 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/request/utils.rb b/actionpack/lib/action_dispatch/request/utils.rb new file mode 100644 index 0000000000..9d4f1aa3c5 --- /dev/null +++ b/actionpack/lib/action_dispatch/request/utils.rb @@ -0,0 +1,35 @@ +module ActionDispatch + class Request < Rack::Request + class Utils # :nodoc: + + mattr_accessor :perform_deep_munge + self.perform_deep_munge = true + + class << self + # Remove nils from the params hash + def deep_munge(hash, keys = []) + return hash unless perform_deep_munge + + hash.each do |k, v| + keys << k + case v + when Array + v.grep(Hash) { |x| deep_munge(x, keys) } + v.compact! + if v.empty? + hash[k] = nil + ActiveSupport::Notifications.instrument("deep_munge.action_controller", keys: keys) + end + when Hash + deep_munge(v, keys) + end + keys.pop + end + + hash + end + end + end + end +end + diff --git a/actionpack/lib/action_dispatch/routing.rb b/actionpack/lib/action_dispatch/routing.rb new file mode 100644 index 0000000000..ce03164ca9 --- /dev/null +++ b/actionpack/lib/action_dispatch/routing.rb @@ -0,0 +1,261 @@ +# encoding: UTF-8 +require 'active_support/core_ext/object/to_param' +require 'active_support/core_ext/regexp' +require 'active_support/dependencies/autoload' + +module ActionDispatch + # The routing module provides URL rewriting in native Ruby. It's a way to + # redirect incoming requests to controllers and actions. This replaces + # mod_rewrite rules. Best of all, Rails' \Routing works with any web server. + # Routes are defined in <tt>config/routes.rb</tt>. + # + # Think of creating routes as drawing a map for your requests. The map tells + # them where to go based on some predefined pattern: + # + # Rails.application.routes.draw do + # Pattern 1 tells some request to go to one place + # Pattern 2 tell them to go to another + # ... + # end + # + # The following symbols are special: + # + # :controller maps to your controller name + # :action maps to an action with your controllers + # + # Other names simply map to a parameter as in the case of <tt>:id</tt>. + # + # == Resources + # + # Resource routing allows you to quickly declare all of the common routes + # for a given resourceful controller. Instead of declaring separate routes + # for your +index+, +show+, +new+, +edit+, +create+, +update+ and +destroy+ + # actions, a resourceful route declares them in a single line of code: + # + # resources :photos + # + # Sometimes, you have a resource that clients always look up without + # referencing an ID. A common example, /profile always shows the profile of + # the currently logged in user. In this case, you can use a singular resource + # to map /profile (rather than /profile/:id) to the show action. + # + # resource :profile + # + # It's common to have resources that are logically children of other + # resources: + # + # resources :magazines do + # resources :ads + # end + # + # You may wish to organize groups of controllers under a namespace. Most + # commonly, you might group a number of administrative controllers under + # an +admin+ namespace. You would place these controllers under the + # <tt>app/controllers/admin</tt> directory, and you can group them together + # in your router: + # + # namespace "admin" do + # resources :posts, :comments + # end + # + # Alternately, you can add prefixes to your path without using a separate + # directory by using +scope+. +scope+ takes additional options which + # apply to all enclosed routes. + # + # scope path: "/cpanel", as: 'admin' do + # resources :posts, :comments + # end + # + # For more, see <tt>Routing::Mapper::Resources#resources</tt>, + # <tt>Routing::Mapper::Scoping#namespace</tt>, and + # <tt>Routing::Mapper::Scoping#scope</tt>. + # + # == Non-resourceful routes + # + # For routes that don't fit the <tt>resources</tt> mold, you can use the HTTP helper + # methods <tt>get</tt>, <tt>post</tt>, <tt>patch</tt>, <tt>put</tt> and <tt>delete</tt>. + # + # get 'post/:id' => 'posts#show' + # post 'post/:id' => 'posts#create_comment' + # + # If your route needs to respond to more than one HTTP method (or all methods) then using the + # <tt>:via</tt> option on <tt>match</tt> is preferable. + # + # match 'post/:id' => 'posts#show', via: [:get, :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. + # + # == Named routes + # + # Routes can be named by passing an <tt>:as</tt> option, + # allowing for easy reference within your source as +name_of_route_url+ + # for the full URL and +name_of_route_path+ for the URI path. + # + # Example: + # + # # In routes.rb + # get '/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) + # + # Use <tt>root</tt> as a shorthand to name a route for the root path "/". + # + # # In routes.rb + # root to: 'blogs#index' + # + # # would recognize http://www.example.com/ as + # params = { controller: 'blogs', action: 'index' } + # + # # and provide these named routes + # root_url # => 'http://www.example.com/' + # root_path # => '/' + # + # Note: when using +controller+, the route is simply named after the + # method you call on the block parameter rather than map. + # + # # In routes.rb + # controller :blog do + # get 'blog/show' => :list + # get 'blog/delete' => :delete + # get 'blog/edit/:id' => :edit + # end + # + # # provides named routes for show, delete, and edit + # link_to @article.title, show_path(id: @article.id) + # + # == Pretty URLs + # + # Routes can generate pretty URLs. For example: + # + # get '/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'} + # + # == Regular Expressions and parameters + # You can specify a regular expression to define a format for a parameter. + # + # controller 'geocode' do + # get 'geocode/:postalcode' => :show, constraints: { + # postalcode: /\d{5}(-\d{4})?/ + # } + # + # Constraints can include the 'ignorecase' and 'extended syntax' regular + # expression modifiers: + # + # controller 'geocode' do + # get 'geocode/:postalcode' => :show, constraints: { + # postalcode: /hx\d\d\s\d[a-z]{2}/i + # } + # end + # + # controller 'geocode' do + # get 'geocode/:postalcode' => :show, constraints: { + # postalcode: /# Postcode format + # \d{5} #Prefix + # (-\d{4})? #Suffix + # /x + # } + # end + # + # Using the multiline modifier will raise an +ArgumentError+. + # Encoding regular expression modifiers are silently ignored. The + # match will always use the default encoding or ASCII. + # + # == External redirects + # + # You can redirect any path to another path using the redirect helper in your router: + # + # get "/stories" => redirect("/posts") + # + # == Unicode character routes + # + # You can specify unicode character routes in your router: + # + # get "こんにちは" => "welcome#index" + # + # == Routing to Rack Applications + # + # Instead of a String, like <tt>posts#index</tt>, which corresponds to the + # index action in the PostsController, you can specify any Rack application + # as the endpoint for a matcher: + # + # get "/application.js" => Sprockets + # + # == Reloading routes + # + # You can reload routes if you feel you must: + # + # Rails.application.reload_routes! + # + # This will clear all named routes and reload routes.rb if the file has been modified from + # last load. To absolutely force reloading, use <tt>reload!</tt>. + # + # == Testing Routes + # + # The two main methods for testing your routes: + # + # === +assert_routing+ + # + # def test_movie_route_properly_splits + # opts = {controller: "plugin", action: "checkout", id: "2"} + # assert_routing "plugin/checkout/2", opts + # end + # + # +assert_routing+ lets you test whether or not the route properly resolves into options. + # + # === +assert_recognizes+ + # + # def test_route_has_options + # opts = {controller: "plugin", action: "show", id: "12"} + # assert_recognizes opts, "/plugins/show/12" + # end + # + # Note the subtle difference between the two: +assert_routing+ tests that + # a URL fits options while +assert_recognizes+ tests that a URL + # breaks into parameters properly. + # + # In tests you can simply pass the URL or named route to +get+ or +post+. + # + # def send_to_jail + # get '/jail' + # assert_response :success + # assert_template "jail/front" + # end + # + # def goes_to_login + # get login_url + # #... + # end + # + # == View a list of all your routes + # + # rake routes + # + # Target specific controllers by prefixing the command with <tt>CONTROLLER=x</tt>. + # + module Routing + extend ActiveSupport::Autoload + + autoload :Mapper + autoload :RouteSet + autoload :RoutesProxy + autoload :UrlFor + autoload :PolymorphicRoutes + + SEPARATORS = %w( / . ? ) #:nodoc: + HTTP_METHODS = [:get, :head, :post, :patch, :put, :delete, :options] #:nodoc: + end +end diff --git a/actionpack/lib/action_dispatch/routing/endpoint.rb b/actionpack/lib/action_dispatch/routing/endpoint.rb new file mode 100644 index 0000000000..88aa13c3e8 --- /dev/null +++ b/actionpack/lib/action_dispatch/routing/endpoint.rb @@ -0,0 +1,10 @@ +module ActionDispatch + module Routing + class Endpoint # :nodoc: + def dispatcher?; false; end + def redirect?; false; end + def matches?(req); true; end + def app; self; end + end + end +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..ea3b2f419d --- /dev/null +++ b/actionpack/lib/action_dispatch/routing/inspector.rb @@ -0,0 +1,234 @@ +require 'delegate' +require 'active_support/core_ext/string/strip' + +module ActionDispatch + module Routing + class RouteWrapper < SimpleDelegator + def endpoint + app.dispatcher? ? "#{controller}##{action}" : rack_app.inspect + end + + def constraints + requirements.except(:controller, :action) + end + + def rack_app + app.app + end + + def verb + super.source.gsub(/[$^]/, '') + end + + def path + super.spec.to_s + end + + def name + super.to_s + end + + def regexp + __getobj__.path.to_regexp + end + + def json_regexp + str = regexp.inspect. + sub('\\A' , '^'). + sub('\\Z' , '$'). + sub('\\z' , '$'). + sub(/^\// , ''). + sub(/\/[a-z]*$/ , ''). + gsub(/\(\?#.+\)/ , ''). + gsub(/\(\?-\w+:/ , '('). + gsub(/\s/ , '') + Regexp.new(str).source + 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.to_s =~ %r{\Arails/(info|mailers|welcome)} || path =~ %r{\A#{Rails.application.config.assets.prefix}\z} + end + + def engine? + rack_app.respond_to?(:routes) + end + end + + ## + # This class is just used for displaying route information when someone + # executes `rake routes` or looks at the RoutingError page. + # People should not use this class. + class RoutesInspector # :nodoc: + def initialize(routes) + @engines = {} + @routes = routes + end + + def format(formatter, filter = nil) + routes_to_display = filter_routes(filter) + + routes = collect_routes(routes_to_display) + + if routes.none? + formatter.no_routes + return formatter.result + end + + formatter.header routes + formatter.section routes + + @engines.each do |name, engine_routes| + formatter.section_title "Routes for #{name}" + formatter.section engine_routes + end + + formatter.result + end + + private + + def filter_routes(filter) + if filter + @routes.select { |route| route.defaults[:controller] == filter } + else + @routes + end + end + + def collect_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, + regexp: route.json_regexp } + 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 + end + + class ConsoleFormatter + def initialize + @buffer = [] + end + + def result + @buffer.join("\n") + end + + def section_title(title) + @buffer << "\n#{title}:" + end + + def section(routes) + @buffer << draw_section(routes) + end + + def header(routes) + @buffer << draw_header(routes) + end + + def no_routes + @buffer << <<-MESSAGE.strip_heredoc + You don't have any routes defined! + + Please add some routes in config/routes.rb. + + For more information about routes, see the Rails guide: http://guides.rubyonrails.org/routing.html. + MESSAGE + end + + private + def draw_section(routes) + header_lengths = ['Prefix', 'Verb', 'URI Pattern'].map(&:length) + name_width, verb_width, path_width = widths(routes).zip(header_lengths).map(&:max) + + routes.map do |r| + "#{r[:name].rjust(name_width)} #{r[:verb].ljust(verb_width)} #{r[:path].ljust(path_width)} #{r[:reqs]}" + end + end + + def draw_header(routes) + name_width, verb_width, path_width = widths(routes) + + "#{"Prefix".rjust(name_width)} #{"Verb".ljust(verb_width)} #{"URI Pattern".ljust(path_width)} Controller#Action" + end + + def widths(routes) + [routes.map { |r| r[:name].length }.max || 0, + routes.map { |r| r[:verb].length }.max || 0, + routes.map { |r| r[:path].length }.max || 0] + end + end + + class HtmlTableFormatter + def initialize(view) + @view = view + @buffer = [] + end + + def section_title(title) + @buffer << %(<tr><th colspan="4">#{title}</th></tr>) + end + + def section(routes) + @buffer << @view.render(partial: "routes/route", collection: routes) + end + + # the header is part of the HTML page, so we don't construct it here. + def header(routes) + end + + def no_routes + @buffer << <<-MESSAGE.strip_heredoc + <p>You don't have any routes defined!</p> + <ul> + <li>Please add some routes in <tt>config/routes.rb</tt>.</li> + <li> + For more information about routes, please see the Rails guide + <a href="http://guides.rubyonrails.org/routing.html">Rails Routing from the Outside In</a>. + </li> + </ul> + MESSAGE + end + + def result + @view.raw @view.render(layout: "routes/table") { + @view.raw @buffer.join("\n") + } + end + end + end +end diff --git a/actionpack/lib/action_dispatch/routing/mapper.rb b/actionpack/lib/action_dispatch/routing/mapper.rb new file mode 100644 index 0000000000..cd94f35e8f --- /dev/null +++ b/actionpack/lib/action_dispatch/routing/mapper.rb @@ -0,0 +1,1939 @@ +require 'active_support/core_ext/hash/except' +require 'active_support/core_ext/hash/reverse_merge' +require 'active_support/core_ext/hash/slice' +require 'active_support/core_ext/enumerable' +require 'active_support/core_ext/array/extract_options' +require 'active_support/core_ext/module/remove_method' +require 'active_support/inflector' +require 'action_dispatch/routing/redirection' +require 'action_dispatch/routing/endpoint' +require 'active_support/deprecation' + +module ActionDispatch + module Routing + class Mapper + URL_OPTIONS = [:protocol, :subdomain, :domain, :host, :port] + + class Constraints < Endpoint #:nodoc: + attr_reader :app, :constraints + + def initialize(app, constraints, dispatcher_p) + # Unwrap Constraints objects. I don't actually think it's possible + # to pass a Constraints object to this constructor, but there were + # multiple places that kept testing children of this object. I + # *think* they were just being defensive, but I have no idea. + if app.is_a?(self.class) + constraints += app.constraints + app = app.app + end + + @dispatcher = dispatcher_p + + @app, @constraints, = app, constraints + end + + def dispatcher?; @dispatcher; end + + def matches?(req) + @constraints.all? do |constraint| + (constraint.respond_to?(:matches?) && constraint.matches?(req)) || + (constraint.respond_to?(:call) && constraint.call(*constraint_args(constraint, req))) + end + end + + def serve(req) + return [ 404, {'X-Cascade' => 'pass'}, [] ] unless matches?(req) + + if dispatcher? + @app.serve req + else + @app.call req.env + end + end + + private + def constraint_args(constraint, request) + constraint.arity == 1 ? [request] : [request.path_parameters, request] + end + end + + class Mapping #:nodoc: + ANCHOR_CHARACTERS_REGEX = %r{\A(\\A|\^)|(\\Z|\\z|\$)\Z} + + attr_reader :requirements, :conditions, :defaults + attr_reader :to, :default_controller, :default_action, :as, :anchor + + def self.build(scope, set, path, options) + options = scope[:options].merge(options) if scope[:options] + + options.delete :only + options.delete :except + options.delete :shallow_path + options.delete :shallow_prefix + options.delete :shallow + + defaults = (scope[:defaults] || {}).merge options.delete(:defaults) || {} + + new scope, set, path, defaults, options + end + + def initialize(scope, set, path, defaults, options) + @requirements, @conditions = {}, {} + @defaults = defaults + @set = set + + @to = options.delete :to + @default_controller = options.delete(:controller) || scope[:controller] + @default_action = options.delete(:action) || scope[:action] + @as = options.delete :as + @anchor = options.delete :anchor + + formatted = options.delete :format + via = Array(options.delete(:via) { [] }) + options_constraints = options.delete :constraints + + path = normalize_path! path, formatted + ast = path_ast path + path_params = path_params ast + + options = normalize_options!(options, formatted, path_params, ast, scope[:module]) + + + split_constraints(path_params, scope[:constraints]) if scope[:constraints] + constraints = constraints(options, path_params) + + split_constraints path_params, constraints + + @blocks = blocks(options_constraints, scope[:blocks]) + + if options_constraints.is_a?(Hash) + split_constraints path_params, options_constraints + options_constraints.each do |key, default| + if URL_OPTIONS.include?(key) && (String === default || Fixnum === default) + @defaults[key] ||= default + end + end + end + + normalize_format!(formatted) + + @conditions[:path_info] = path + @conditions[:parsed_path_info] = ast + + add_request_method(via, @conditions) + normalize_defaults!(options) + end + + def to_route + [ app(@blocks), conditions, requirements, defaults, as, anchor ] + end + + private + + def normalize_path!(path, format) + path = Mapper.normalize_path(path) + + if format == true + "#{path}.:format" + elsif optional_format?(path, format) + "#{path}(.:format)" + else + path + end + end + + def optional_format?(path, format) + format != false && !path.include?(':format') && !path.end_with?('/') + end + + def normalize_options!(options, formatted, path_params, path_ast, modyoule) + # Add a constraint for wildcard route to make it non-greedy and match the + # optional format part of the route by default + if formatted != false + path_ast.grep(Journey::Nodes::Star) do |node| + options[node.name.to_sym] ||= /.+?/ + end + end + + if path_params.include?(:controller) + raise ArgumentError, ":controller segment is not allowed within a namespace block" if modyoule + + # 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' } + options[:controller] ||= /.+?/ + end + + if to.respond_to? :call + options + else + to_endpoint = split_to to + controller = to_endpoint[0] || default_controller + action = to_endpoint[1] || default_action + + controller = add_controller_module(controller, modyoule) + + options.merge! check_controller_and_action(path_params, controller, action) + end + end + + def split_constraints(path_params, constraints) + constraints.each_pair do |key, requirement| + if path_params.include?(key) || key == :controller + verify_regexp_requirement(requirement) if requirement.is_a?(Regexp) + @requirements[key] = requirement + else + @conditions[key] = requirement + end + end + end + + def normalize_format!(formatted) + if formatted == true + @requirements[:format] ||= /.+/ + elsif Regexp === formatted + @requirements[:format] = formatted + @defaults[:format] = nil + elsif String === formatted + @requirements[:format] = Regexp.compile(formatted) + @defaults[:format] = formatted + end + end + + def verify_regexp_requirement(requirement) + if requirement.source =~ ANCHOR_CHARACTERS_REGEX + raise ArgumentError, "Regexp anchor characters are not allowed in routing requirements: #{requirement.inspect}" + end + + if requirement.multiline? + raise ArgumentError, "Regexp multiline option is not allowed in routing requirements: #{requirement.inspect}" + end + end + + def normalize_defaults!(options) + options.each_pair do |key, default| + unless Regexp === default + @defaults[key] = default + end + end + end + + def verify_callable_constraint(callable_constraint) + unless callable_constraint.respond_to?(:call) || callable_constraint.respond_to?(:matches?) + raise ArgumentError, "Invalid constraint: #{callable_constraint.inspect} must respond to :call or :matches?" + end + end + + def add_request_method(via, conditions) + return if via == [:all] + + if via.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 both GET and POST, add `via: [:get, :post]` option.\n" \ + "If you want to expose your action to GET, use `get` in the router:\n" \ + " Instead of: match \"controller#action\"\n" \ + " Do: get \"controller#action\"" + raise ArgumentError, msg + end + + conditions[:request_method] = via.map { |m| m.to_s.dasherize.upcase } + end + + def app(blocks) + return to if Redirect === to + + if to.respond_to?(:call) + Constraints.new(to, blocks, false) + else + if blocks.any? + Constraints.new(dispatcher(defaults), blocks, true) + else + dispatcher(defaults) + end + end + end + + def check_controller_and_action(path_params, controller, action) + hash = check_part(:controller, controller, path_params, {}) do |part| + translate_controller(part) { + message = "'#{part}' is not a supported controller name. This can lead to potential routing problems." + message << " See http://guides.rubyonrails.org/routing.html#specifying-a-controller-to-use" + + raise ArgumentError, message + } + end + + check_part(:action, action, path_params, hash) { |part| + part.is_a?(Regexp) ? part : part.to_s + } + end + + def check_part(name, part, path_params, hash) + if part + hash[name] = yield(part) + else + unless path_params.include?(name) + message = "Missing :#{name} key on routes definition, please check your routes." + raise ArgumentError, message + end + end + hash + end + + def split_to(to) + case to + when Symbol + ActiveSupport::Deprecation.warn "defining a route where `to` is a symbol is deprecated. Please change \"to: :#{to}\" to \"action: :#{to}\"" + [nil, to.to_s] + when /#/ then to.split('#') + when String + ActiveSupport::Deprecation.warn "defining a route where `to` is a controller without an action is deprecated. Please change \"to: :#{to}\" to \"controller: :#{to}\"" + [to, nil] + else + [] + end + end + + def add_controller_module(controller, modyoule) + if modyoule && !controller.is_a?(Regexp) + if controller =~ %r{\A/} + controller[1..-1] + else + [modyoule, controller].compact.join("/") + end + else + controller + end + end + + def translate_controller(controller) + return controller if Regexp === controller + return controller.to_s if controller =~ /\A[a-z_0-9][a-z_0-9\/]*\z/ + + yield + end + + def blocks(options_constraints, scope_blocks) + if options_constraints && !options_constraints.is_a?(Hash) + verify_callable_constraint(options_constraints) + [options_constraints] + else + scope_blocks || [] + end + end + + def constraints(options, path_params) + constraints = {} + required_defaults = [] + options.each_pair do |key, option| + if Regexp === option + constraints[key] = option + else + required_defaults << key unless path_params.include?(key) + end + end + @conditions[:required_defaults] = required_defaults + constraints + end + + def path_params(ast) + ast.grep(Journey::Nodes::Symbol).map { |n| n.name.to_sym } + end + + def path_ast(path) + parser = Journey::Parser.new + parser.parse path + end + + def dispatcher(defaults) + @set.dispatcher defaults + end + end + + # Invokes Journey::Router::Utils.normalize_path and ensure that + # (:locale) becomes (/:locale) instead of /(:locale). Except + # 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 + end + + def self.normalize_name(name) + 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' + # + # For options, see +match+, as +root+ uses it internally. + # + # You can also pass a string which will expand + # + # root 'pages#main' + # + # You should put the root route at the top of <tt>config/routes.rb</tt>, + # because this means it will be matched first. As this is the most popular route + # of most Rails applications, this is beneficial. + def root(options = {}) + match '/', { :as => :root, :via => :get }.merge!(options) + end + + # Matches a url pattern to one or more routes. + # + # You should not use the `match` method in your router + # without specifying an HTTP method. + # + # If you want to expose your action to both GET and POST, use: + # + # # sets :controller, :action and :id in params + # match ':controller/:action/:id', via: [:get, :post] + # + # Note that +:controller+, +:action+ and +:id+ are interpreted as url + # query parameters and thus available through +params+ in an action. + # + # If you want to expose your action to GET, use `get` in the router: + # + # Instead of: + # + # match ":controller/:action/:id" + # + # Do: + # + # get ":controller/:action/:id" + # + # Two of these symbols are special, +:controller+ maps to the controller + # and +:action+ to the controller's action. A pattern can also map + # wildcard segments (globs) to params: + # + # get 'songs/*category/:title', to: 'songs#show' + # + # # 'songs/rock/classic/stairway-to-heaven' sets + # # params[:category] = 'rock/classic' + # # params[:title] = 'stairway-to-heaven' + # + # To match a wildcard parameter, it must have a name assigned to it. + # Without a variable name to attach the glob parameter to, the route + # can't be parsed. + # + # When a pattern points to an internal route, the route's +:action+ and + # +:controller+ should be set in options or hash shorthand. Examples: + # + # match 'photos/:id' => 'photos#show', via: :get + # match 'photos/:id', to: 'photos#show', via: :get + # match 'photos/:id', controller: 'photos', action: 'show', via: :get + # + # A pattern can also point to a +Rack+ endpoint i.e. anything that + # responds to +call+: + # + # match 'photos/:id', to: lambda {|hash| [200, {}, ["Coming soon"]] }, via: :get + # match 'photos/:id', to: PhotoRackApp, via: :get + # # Yes, controller actions are just rack endpoints + # match 'photos/:id', to: PhotosController.action(:show), via: :get + # + # Because requesting various HTTP verbs with a single action has security + # implications, you must either specify the actions in + # the via options or use one of the HtttpHelpers[rdoc-ref:HttpHelpers] + # instead +match+ + # + # === Options + # + # Any options not seen here are passed on as params with the url. + # + # [:controller] + # The route's controller. + # + # [:action] + # The route's action. + # + # [:param] + # Overrides the default resource identifier `:id` (name of the + # dynamic segment used to generate the routes). + # You can access that segment from your controller using + # <tt>params[<:param>]</tt>. + # + # [:path] + # The path prefix for the routes. + # + # [:module] + # The namespace for :controller. + # + # match 'path', to: 'c#a', module: 'sekret', controller: 'posts', via: :get + # # => Sekret::PostsController + # + # See <tt>Scoping#namespace</tt> for its scope equivalent. + # + # [:as] + # The name used to generate routing helpers. + # + # [:via] + # Allowed HTTP verb(s) for route. + # + # 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', via: :get + # match 'path', to: lambda { |env| [200, {}, ["Success!"]] }, via: :get + # match 'path', to: RackApp, via: :get + # + # [:on] + # Shorthand for wrapping routes in a specific RESTful context. Valid + # values are +:member+, +:collection+, and +:new+. Only use within + # <tt>resource(s)</tt> block. For example: + # + # resource :bar do + # match 'foo', to: 'c#a', on: :member, via: [:get, :post] + # end + # + # Is equivalent to: + # + # resource :bar do + # member do + # match 'foo', to: 'c#a', via: [:get, :post] + # end + # end + # + # [:constraints] + # Constrains parameters with a hash of regular expressions + # or an object that responds to <tt>matches?</tt>. In addition, constraints + # other than path can also be specified with any object + # that responds to <tt>===</tt> (eg. String, Array, Range, etc.). + # + # match 'path/:id', constraints: { id: /[A-Z]\d{5}/ }, via: :get + # + # match 'json_only', constraints: { format: 'json' }, via: :get + # + # class Whitelist + # def matches?(request) request.remote_ip == '1.2.3.4' end + # end + # match 'path', to: 'c#a', constraints: Whitelist.new, via: :get + # + # See <tt>Scoping#constraints</tt> for more examples with its scope + # equivalent. + # + # [:defaults] + # Sets defaults for parameters + # + # # Sets params[:format] to 'jpg' by default + # match 'path', to: 'c#a', defaults: { format: 'jpg' }, via: :get + # + # See <tt>Scoping#defaults</tt> for its scope equivalent. + # + # [:anchor] + # Boolean to anchor a <tt>match</tt> pattern. Default is true. When set to + # false, the pattern matches any request prefixed with the given path. + # + # # Matches any request starting with 'path' + # match 'path', to: 'c#a', anchor: false, via: :get + # + # [: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" + # + # Alternatively: + # + # mount(SomeRackApp => "some_route") + # + # For options, see +match+, as +mount+ uses it internally. + # + # All mounted applications come with routing helpers to access them. + # These are named after the class specified, so for the above example + # 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") + # + # This will generate the +exciting_path+ and +exciting_url+ helpers + # which can be used to navigate to this mounted app. + def mount(app, options = nil) + 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, _| k.respond_to?(:call) } + options.delete(app) if app + end + + raise "A rack application must be specified" unless path + + rails_app = rails_app? app + + if rails_app + options[:as] ||= app.railtie_name + else + # non rails apps can't have an :as + options[:as] = nil + end + + target_as = name_for_action(options[:as], path) + options[:via] ||= :all + + match(path, options.merge(:to => app, :anchor => false, :format => false)) + + define_generate_prefix(app, target_as) if rails_app + self + end + + def default_url_options=(options) + @set.default_url_options = options + end + alias_method :default_url_options, :default_url_options= + + def with_default_scope(scope, &block) + scope(scope) do + instance_exec(&block) + end + end + + # Query if the following named route was already defined. + def has_named_route?(name) + @set.named_routes.routes[name.to_sym] + end + + private + def rails_app?(app) + app.is_a?(Class) && app < Rails::Railtie + end + + def define_generate_prefix(app, name) + _route = @set.named_routes.get name + _routes = @set + app.routes.define_mounted_helper(name) + app.routes.extend Module.new { + def optimize_routes_generation?; false; end + define_method :find_script_name do |options| + if options.key? :script_name + super(options) + else + 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) } + _routes.url_helpers.send("#{name}_path", prefix_options) + end + end + } + end + end + + module HttpHelpers + # Define a route that only recognizes HTTP GET. + # For supported arguments, see match[rdoc-ref:Base#match] + # + # 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 match[rdoc-ref:Base#match] + # + # 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 match[rdoc-ref:Base#match] + # + # 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 match[rdoc-ref:Base#match] + # + # 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 match[rdoc-ref:Base#match] + # + # delete 'broccoli', to: 'food#broccoli' + def delete(*args, &block) + map_method(:delete, args, &block) + end + + private + def map_method(method, args, &block) + options = args.extract_options! + options[:via] = method + match(*args, options, &block) + self + end + end + + # You may wish to organize groups of controllers under a namespace. + # Most commonly, you might group a number of administrative controllers + # under an +admin+ namespace. You would place these controllers under + # the <tt>app/controllers/admin</tt> directory, and you can group them + # together in your router: + # + # namespace "admin" do + # resources :posts, :comments + # end + # + # This will create a number of routes for each of the posts and comments + # controller. For <tt>Admin::PostsController</tt>, Rails will create: + # + # GET /admin/posts + # GET /admin/posts/new + # POST /admin/posts + # GET /admin/posts/1 + # GET /admin/posts/1/edit + # PATCH/PUT /admin/posts/1 + # DELETE /admin/posts/1 + # + # If you want to route /posts (without the prefix /admin) to + # <tt>Admin::PostsController</tt>, you could use + # + # scope module: "admin" do + # resources :posts + # end + # + # or, for a single case + # + # resources :posts, module: "admin" + # + # If you want to route /admin/posts to +PostsController+ + # (without the <tt>Admin::</tt> module prefix), you could use + # + # scope "/admin" do + # resources :posts + # end + # + # or, for a single case + # + # 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 + # +PostsController+: + # + # GET /admin/posts + # GET /admin/posts/new + # POST /admin/posts + # GET /admin/posts/1 + # GET /admin/posts/1/edit + # PATCH/PUT /admin/posts/1 + # DELETE /admin/posts/1 + module Scoping + # Scopes a set of routes to the given default options. + # + # Take the following route definition as an example: + # + # scope path: ":account_id", as: "account" do + # resources :projects + # end + # + # This generates helpers such as +account_projects_path+, just like +resources+ does. + # The difference here being that the routes generated are like /:account_id/projects, + # rather than /accounts/:account_id/projects. + # + # === Options + # + # Takes same options as <tt>Base#match</tt> and <tt>Resources#resources</tt>. + # + # # route /posts (without the prefix /admin) to <tt>Admin::PostsController</tt> + # scope module: "admin" do + # resources :posts + # end + # + # # prefix the posts resource's requests with '/admin' + # scope path: "/admin" do + # resources :posts + # end + # + # # prefix the routing helper name: +sekret_posts_path+ instead of +posts_path+ + # scope as: "sekret" do + # resources :posts + # end + def scope(*args) + options = args.extract_options!.dup + scope = {} + + options[:path] = args.flatten.join('/') if args.any? + options[:constraints] ||= {} + + unless nested_scope? + options[:shallow_path] ||= options[:path] if options.key?(:path) + options[:shallow_prefix] ||= options[:as] if options.key?(:as) + end + + if options[:constraints].is_a?(Hash) + defaults = options[:constraints].select do + |k, v| URL_OPTIONS.include?(k) && (v.is_a?(String) || v.is_a?(Fixnum)) + end + + (options[:defaults] ||= {}).reverse_merge!(defaults) + else + block, options[:constraints] = options[:constraints], {} + end + + @scope.options.each do |option| + if option == :blocks + value = block + elsif option == :options + value = options + else + value = options.delete(option) + end + + if value + scope[option] = send("merge_#{option}_scope", @scope[option], value) + end + end + + @scope = @scope.new scope + yield + self + ensure + @scope = @scope.parent + end + + # Scopes routes to a specific controller + # + # controller "food" do + # match "bacon", action: "bacon" + # end + def controller(controller, options={}) + options[:controller] = controller + scope(options) { yield } + end + + # Scopes routes to a specific namespace. For example: + # + # namespace :admin do + # resources :posts + # end + # + # This generates the following routes: + # + # admin_posts GET /admin/posts(.:format) admin/posts#index + # admin_posts POST /admin/posts(.:format) admin/posts#create + # new_admin_post GET /admin/posts/new(.:format) admin/posts#new + # edit_admin_post GET /admin/posts/:id/edit(.:format) admin/posts#edit + # admin_post GET /admin/posts/:id(.:format) admin/posts#show + # admin_post PATCH/PUT /admin/posts/:id(.:format) admin/posts#update + # admin_post DELETE /admin/posts/:id(.:format) admin/posts#destroy + # + # === Options + # + # The +:path+, +:as+, +:module+, +:shallow_path+ and +:shallow_prefix+ + # options all default to the name of the namespace. + # + # For options, see <tt>Base#match</tt>. For +:shallow_path+ option, see + # <tt>Resources#resources</tt>. + # + # # accessible through /sekret/posts rather than /admin/posts + # 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 + # resources :posts + # end + # + # # generates +sekret_posts_path+ rather than +admin_posts_path+ + # namespace :admin, as: "sekret" do + # resources :posts + # end + def namespace(path, options = {}) + path = path.to_s + + defaults = { + module: path, + path: options.fetch(:path, path), + as: options.fetch(:as, path), + shallow_path: options.fetch(:path, path), + shallow_prefix: options.fetch(:as, path) + } + + scope(defaults.merge!(options)) { yield } + end + + # === Parameter Restriction + # 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 + # resources :posts + # end + # + # Now routes such as +/posts/1+ will no longer be valid, but +/posts/1.1+ will be. + # The +id+ parameter must match the constraint passed in for this example. + # + # You may use this to also restrict other parameters: + # + # resources :posts do + # constraints(post_id: /\d+\.\d+/) do + # resources :comments + # end + # end + # + # === Restricting based on IP + # + # Routes can also be constrained to an IP or a certain range of IP addresses: + # + # constraints(ip: /192\.168\.\d+\.\d+/) do + # resources :posts + # end + # + # Any user connecting from the 192.168.* range will be able to see this resource, + # where as any user connecting outside of this range will be told there is no such route. + # + # === Dynamic request matching + # + # Requests to routes can be constrained based on specific criteria: + # + # constraints(lambda { |req| req.env["HTTP_USER_AGENT"] =~ /iPhone/ }) do + # resources :iphones + # end + # + # You are able to move this logic out into a class if it is too complex for routes. + # This class must have a +matches?+ method defined on it which either returns +true+ + # if the user should be given access to that route, or +false+ if the user should not. + # + # class Iphone + # def self.matches?(request) + # request.env["HTTP_USER_AGENT"] =~ /iPhone/ + # end + # end + # + # An expected place for this code would be +lib/constraints+. + # + # This class is then used like this: + # + # constraints(Iphone) do + # resources :iphones + # end + def constraints(constraints = {}) + scope(:constraints => constraints) { yield } + end + + # Allows you to set default parameters for a route, such as this: + # 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 = {}) + scope(:defaults => defaults) { yield } + end + + private + def merge_path_scope(parent, child) #:nodoc: + Mapper.normalize_path("#{parent}/#{child}") + end + + def merge_shallow_path_scope(parent, child) #:nodoc: + Mapper.normalize_path("#{parent}/#{child}") + end + + def merge_as_scope(parent, child) #:nodoc: + parent ? "#{parent}_#{child}" : child + end + + def merge_shallow_prefix_scope(parent, child) #:nodoc: + parent ? "#{parent}_#{child}" : child + end + + def merge_module_scope(parent, child) #:nodoc: + parent ? "#{parent}/#{child}" : child + end + + def merge_controller_scope(parent, child) #:nodoc: + child + end + + def merge_action_scope(parent, child) #:nodoc: + child + end + + def merge_path_names_scope(parent, child) #:nodoc: + merge_options_scope(parent, child) + end + + def merge_constraints_scope(parent, child) #:nodoc: + merge_options_scope(parent, child) + end + + def merge_defaults_scope(parent, child) #:nodoc: + merge_options_scope(parent, child) + end + + def merge_blocks_scope(parent, child) #:nodoc: + merged = parent ? parent.dup : [] + merged << child if child + merged + end + + def merge_options_scope(parent, child) #:nodoc: + (parent || {}).except(*override_keys(child)).merge!(child) + end + + def merge_shallow_scope(parent, child) #:nodoc: + child ? true : false + end + + def override_keys(child) #:nodoc: + child.key?(:only) || child.key?(:except) ? [:only, :except] : [] + end + end + + # Resource routing allows you to quickly declare all of the common routes + # for a given resourceful controller. Instead of declaring separate routes + # for your +index+, +show+, +new+, +edit+, +create+, +update+ and +destroy+ + # actions, a resourceful route declares them in a single line of code: + # + # resources :photos + # + # Sometimes, you have a resource that clients always look up without + # referencing an ID. A common example, /profile always shows the profile of + # the currently logged in user. In this case, you can use a singular resource + # to map /profile (rather than /profile/:id) to the show action. + # + # resource :profile + # + # It's common to have resources that are logically children of other + # resources: + # + # resources :magazines do + # resources :ads + # end + # + # You may wish to organize groups of controllers under a namespace. Most + # commonly, you might group a number of administrative controllers under + # an +admin+ namespace. You would place these controllers under the + # <tt>app/controllers/admin</tt> directory, and you can group them together + # in your router: + # + # namespace "admin" do + # resources :posts, :comments + # end + # + # By default the +:id+ parameter doesn't accept dots. If you need to + # use dots as part of the +:id+ parameter add a constraint which + # overrides this restriction, e.g: + # + # resources :articles, id: /[^\/]+/ + # + # This allows any character other than a slash as part of your +:id+. + # + module Resources + # 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, :param, :concerns] + CANONICAL_ACTIONS = %w(index create new show update destroy) + RESOURCE_METHOD_SCOPES = [:collection, :member, :new] + RESOURCE_SCOPES = [:resource, :resources] + + class Resource #:nodoc: + 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 + @shallow = false + end + + def default_actions + [:index, :create, :new, :show, :update, :destroy, :edit] + end + + def actions + if only = @options[:only] + Array(only).map(&:to_sym) + elsif except = @options[:except] + default_actions - Array(except).map(&:to_sym) + else + default_actions + end + end + + def name + @as || @name + end + + def plural + @plural ||= name.to_s + end + + def singular + @singular ||= name.to_s.singularize + end + + alias :member_name :singular + + # Checks for uncountable plurals, and appends "_index" if the plural + # and singular form are the same. + def collection_name + singular == plural ? "#{plural}_index" : plural + end + + def resource_scope + { :controller => controller } + end + + alias :collection_scope :path + + def member_scope + "#{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}/:#{nested_param}" + end + + def shallow=(value) + @shallow = value + end + + def shallow? + @shallow + end + end + + class SingletonResource < Resource #:nodoc: + def initialize(entities, options) + super + @as = nil + @controller = (options[:controller] || plural).to_s + @as = options[:as] + end + + def default_actions + [:show, :create, :update, :destroy, :new, :edit] + end + + def plural + @plural ||= name.to_s.pluralize + end + + def singular + @singular ||= name.to_s + end + + alias :member_name :singular + alias :collection_name :singular + + alias :member_scope :path + alias :nested_scope :path + end + + def resources_path_names(options) + @scope[:path_names].merge!(options) + end + + # Sometimes, you have a resource that clients always look up without + # referencing an ID. A common example, /profile always shows the + # profile of the currently logged in user. In this case, you can use + # a singular resource to map /profile (rather than /profile/:id) to + # the show action: + # + # resource :profile + # + # creates six different routes in your application, all mapping to + # the +Profiles+ controller (note that the controller is named after + # the plural): + # + # GET /profile/new + # POST /profile + # GET /profile + # GET /profile/edit + # PATCH/PUT /profile + # DELETE /profile + # + # === Options + # Takes same options as +resources+. + def resource(*resources, &block) + options = resources.extract_options!.dup + + if apply_common_behavior_for(:resource, resources, options, &block) + return self + end + + 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) + + new do + get :new + end if parent_resource.actions.include?(:new) + + set_member_mappings_for_resource + end + + self + end + + # In Rails, a resourceful route provides a mapping between HTTP verbs + # and URLs and controller actions. By convention, each action also maps + # to particular CRUD operations in a database. A single entry in the + # routing file, such as + # + # resources :photos + # + # creates seven different routes in your application, all mapping to + # the +Photos+ controller: + # + # GET /photos + # GET /photos/new + # POST /photos + # GET /photos/:id + # GET /photos/:id/edit + # PATCH/PUT /photos/:id + # DELETE /photos/:id + # + # Resources can also be nested infinitely by using this block syntax: + # + # resources :photos do + # resources :comments + # end + # + # This generates the following comments routes: + # + # GET /photos/:photo_id/comments + # GET /photos/:photo_id/comments/new + # POST /photos/:photo_id/comments + # GET /photos/:photo_id/comments/:id + # GET /photos/:photo_id/comments/:id/edit + # PATCH/PUT /photos/:photo_id/comments/:id + # DELETE /photos/:photo_id/comments/:id + # + # === Options + # Takes same options as <tt>Base#match</tt> as well as: + # + # [:path_names] + # 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" } + # + # 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' + # + # 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] + # + # [:except] + # Generate all routes except for the given actions. + # + # 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 :comments + # end + # + # Is the same as: + # + # resources :posts do + # resources :comments, except: [:show, :edit, :update, :destroy] + # end + # 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> + # to be shortened to just <tt>/comments/1234</tt>. + # + # [:shallow_path] + # Prefixes nested shallow routes with the specified path. + # + # scope shallow_path: "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_comment GET /sekret/comments/:id/edit(.:format) + # comment GET /sekret/comments/:id(.:format) + # 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" + # + # # resource actions are at /admin/posts. + # resources :posts, path: "admin/posts" + def resources(*resources, &block) + options = resources.extract_options!.dup + + if apply_common_behavior_for(:resources, resources, options, &block) + return self + end + + 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) + end + + new do + get :new + end if parent_resource.actions.include?(:new) + + set_member_mappings_for_resource + end + + self + end + + # To add a route to the collection: + # + # resources :photos do + # collection do + # get 'search' + # end + # end + # + # This will enable Rails to recognize paths such as <tt>/photos/search</tt> + # with GET, and route to the search action of +PhotosController+. It will also + # create the <tt>search_photos_url</tt> and <tt>search_photos_path</tt> + # route helpers. + def collection + unless resource_scope? + raise ArgumentError, "can't use collection outside resource(s) scope" + end + + with_scope_level(:collection) do + scope(parent_resource.collection_scope) do + yield + end + end + end + + # To add a member route, add a member block into the resource block: + # + # resources :photos do + # member do + # get 'preview' + # end + # end + # + # This will recognize <tt>/photos/1/preview</tt> with GET, and route to the + # preview action of +PhotosController+. It will also create the + # <tt>preview_photo_url</tt> and <tt>preview_photo_path</tt> helpers. + def member + unless resource_scope? + raise ArgumentError, "can't use member outside resource(s) scope" + end + + with_scope_level(:member) do + if shallow? + shallow_scope(parent_resource.member_scope) { yield } + else + scope(parent_resource.member_scope) { yield } + end + end + end + + def new + unless resource_scope? + raise ArgumentError, "can't use new outside resource(s) scope" + end + + with_scope_level(:new) do + scope(parent_resource.new_scope(action_path(:new))) do + yield + end + end + end + + def nested + unless resource_scope? + raise ArgumentError, "can't use nested outside resource(s) scope" + end + + with_scope_level(:nested) do + if shallow? && shallow_nesting_depth >= 1 + shallow_scope(parent_resource.nested_scope, nested_options) { yield } + else + scope(parent_resource.nested_scope, nested_options) { yield } + end + end + end + + # See ActionDispatch::Routing::Mapper::Scoping#namespace + def namespace(path, options = {}) + if resource_scope? + nested { super } + else + super + end + end + + def shallow + scope(:shallow => true) do + yield + end + end + + def shallow? + parent_resource.instance_of?(Resource) && @scope[:shallow] + end + + # match 'path' => 'controller#action' + # match 'path', to: 'controller#action' + # match 'path', 'otherpath', on: :member, via: :get + def match(path, *rest) + if rest.empty? && Hash === path + options = path + path, to = options.find { |name, _value| name.is_a?(String) } + + case to + when Symbol + options[:action] = to + when String + if to =~ /#/ + options[:to] = to + else + options[:controller] = to + end + else + options[:to] = to + end + + options.delete(path) + paths = [path] + else + options = rest.pop || {} + paths = [path] + rest + end + + options[:anchor] = true unless options.key?(:anchor) + + if options[:on] && !VALID_ON_OPTIONS.include?(options[:on]) + raise ArgumentError, "Unknown scope #{on.inspect} given to :on" + end + + if @scope[:controller] && @scope[:action] + options[:to] ||= "#{@scope[:controller]}##{@scope[:action]}" + end + + paths.each do |_path| + route_options = options.dup + route_options[:path] ||= _path if _path.is_a?(String) + + path_without_format = _path.to_s.sub(/\(\.:format\)$/, '') + if using_match_shorthand?(path_without_format, route_options) + route_options[:to] ||= path_without_format.gsub(%r{^/}, "").sub(%r{/([^/]*)$}, '#\1') + route_options[:to].tr!("-", "_") + end + + decomposed_match(_path, route_options) + end + self + end + + def using_match_shorthand?(path, options) + path && (options[:to] || options[:action]).nil? && path =~ %r{/[\w/]+$} + end + + def decomposed_match(path, options) # :nodoc: + if on = options.delete(:on) + send(on) { decomposed_match(path, options) } + else + case @scope[:scope_level] + when :resources + nested { decomposed_match(path, options) } + when :resource + member { decomposed_match(path, options) } + else + add_route(path, options) + end + end + end + + def add_route(action, options) # :nodoc: + path = path_for_action(action, options.delete(:path)) + raise ArgumentError, "path is required" if path.blank? + + action = action.to_s.dup + + if action =~ /^[\w\-\/]+$/ + options[:action] ||= action.tr('-', '_') unless action.include?("/") + else + action = nil + end + + if !options.fetch(:as, true) # if it's set to nil or false + options.delete(:as) + else + options[:as] = name_for_action(options[:as], action) + end + + mapping = Mapping.build(@scope, @set, URI.parser.escape(path), options) + app, conditions, requirements, defaults, as, anchor = mapping.to_route + @set.add_route(app, conditions, requirements, defaults, as, anchor) + end + + def root(path, options={}) + if path.is_a?(String) + options[:to] = path + elsif path.is_a?(Hash) and options.empty? + options = path + else + raise ArgumentError, "must be called with a path and/or options" + end + + if @scope[:scope_level] == :resources + with_scope_level(:root) do + scope(parent_resource.path) do + super(options) + end + end + else + super(options) + end + end + + protected + + def parent_resource #:nodoc: + @scope[:scope_level_resource] + end + + def apply_common_behavior_for(method, resources, options, &block) #:nodoc: + if resources.length > 1 + resources.each { |r| send(method, r, options, &block) } + return true + end + + if options.delete(:shallow) + shallow do + send(method, resources.pop, options, &block) + end + return true + end + + if resource_scope? + nested { send(method, resources.pop, options, &block) } + return true + end + + options.keys.each do |k| + (options[:constraints] ||= {})[k] = options.delete(k) if options[k].is_a?(Regexp) + end + + scope_options = options.slice!(*RESOURCE_OPTIONS) + unless scope_options.empty? + scope(scope_options) do + send(method, resources.pop, options, &block) + end + return true + end + + unless action_options?(options) + options.merge!(scope_action_options) if scope_action_options? + end + + false + end + + def action_options?(options) #:nodoc: + options[:only] || options[:except] + end + + def scope_action_options? #:nodoc: + @scope[:options] && (@scope[:options][:only] || @scope[:options][:except]) + end + + def scope_action_options #:nodoc: + @scope[:options].slice(:only, :except) + end + + def resource_scope? #:nodoc: + RESOURCE_SCOPES.include? @scope[:scope_level] + end + + def resource_method_scope? #:nodoc: + RESOURCE_METHOD_SCOPES.include? @scope[:scope_level] + end + + def nested_scope? #:nodoc: + @scope[:scope_level] == :nested + end + + def with_exclusive_scope + begin + @scope = @scope.new(:as => nil, :path => nil) + + with_scope_level(:exclusive) do + yield + end + ensure + @scope = @scope.parent + end + end + + def with_scope_level(kind) + @scope = @scope.new(:scope_level => kind) + yield + ensure + @scope = @scope.parent + end + + def resource_scope(kind, resource) #:nodoc: + resource.shallow = @scope[:shallow] + @scope = @scope.new(:scope_level_resource => resource) + @nesting.push(resource) + + with_scope_level(kind) do + scope(parent_resource.resource_scope) { yield } + end + ensure + @nesting.pop + @scope = @scope.parent + end + + def nested_options #:nodoc: + options = { :as => parent_resource.member_name } + options[:constraints] = { + parent_resource.nested_param => param_constraint + } if param_constraint? + + options + end + + def nesting_depth #:nodoc: + @nesting.size + end + + def shallow_nesting_depth #:nodoc: + @nesting.select(&:shallow?).size + end + + def param_constraint? #:nodoc: + @scope[:constraints] && @scope[:constraints][parent_resource.param].is_a?(Regexp) + end + + def param_constraint #:nodoc: + @scope[:constraints][parent_resource.param] + end + + def canonical_action?(action, flag) #:nodoc: + flag && resource_method_scope? && CANONICAL_ACTIONS.include?(action.to_s) + end + + def shallow_scope(path, options = {}) #:nodoc: + scope = { :as => @scope[:shallow_prefix], + :path => @scope[:shallow_path] } + @scope = @scope.new scope + + scope(path, options) { yield } + ensure + @scope = @scope.parent + end + + def path_for_action(action, path) #:nodoc: + if canonical_action?(action, path.blank?) + @scope[:path].to_s + else + "#{@scope[:path]}/#{action_path(action, path)}" + end + end + + def action_path(name, path = nil) #:nodoc: + name = name.to_sym if name.is_a?(String) + path || @scope[:path_names][name] || name.to_s + end + + def prefix_name_for_action(as, action) #:nodoc: + if as + prefix = as + elsif !canonical_action?(action, @scope[:scope_level]) + prefix = action + end + prefix.to_s.tr('-', '_') if prefix + end + + def name_for_action(as, action) #:nodoc: + prefix = prefix_name_for_action(as, action) + prefix = Mapper.normalize_name(prefix) if prefix + name_prefix = @scope[:as] + + if parent_resource + return nil unless as || action + + collection_name = parent_resource.collection_name + member_name = parent_resource.member_name + end + + name = case @scope[:scope_level] + when :nested + [name_prefix, prefix] + when :collection + [prefix, name_prefix, collection_name] + when :new + [prefix, :new, name_prefix, member_name] + when :member + [prefix, name_prefix, member_name] + when :root + [name_prefix, collection_name, prefix] + else + [name_prefix, member_name, prefix] + end + + if candidate = name.select(&:present?).join("_").presence + # If a name was not explicitly given, we check if it is valid + # and return nil in case it isn't. Otherwise, we pass the invalid name + # forward so the underlying router engine treats it and raises an exception. + if as.nil? + candidate unless candidate !~ /\A[_a-z]/i || @set.named_routes.key?(candidate) + else + candidate + 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 + + class Scope # :nodoc: + OPTIONS = [:path, :shallow_path, :as, :shallow_prefix, :module, + :controller, :action, :path_names, :constraints, + :shallow, :blocks, :defaults, :options] + + attr_reader :parent + + def initialize(hash, parent = {}) + @hash = hash + @parent = parent + end + + def options + OPTIONS + end + + def new(hash) + self.class.new hash, self + end + + def [](key) + @hash.fetch(key) { @parent[key] } + end + + def []=(k,v) + @hash[k] = v + end + end + + def initialize(set) #:nodoc: + @set = set + @scope = Scope.new({ :path_names => @set.resources_path_names }) + @concerns = {} + @nesting = [] + end + + include Base + include HttpHelpers + include Redirection + include Scoping + include Concerns + include Resources + end + end +end diff --git a/actionpack/lib/action_dispatch/routing/polymorphic_routes.rb b/actionpack/lib/action_dispatch/routing/polymorphic_routes.rb new file mode 100644 index 0000000000..bd3696cda1 --- /dev/null +++ b/actionpack/lib/action_dispatch/routing/polymorphic_routes.rb @@ -0,0 +1,325 @@ +require 'action_controller/model_naming' + +module ActionDispatch + module Routing + # Polymorphic URL helpers are methods for smart resolution to a named route call when + # given an Active Record model instance. They are to be used in combination with + # ActionController::Resources. + # + # These methods are useful when you want to generate correct URL or path to a RESTful + # resource without having to know the exact type of the record in question. + # + # Nested resources and/or namespaces are also supported, as illustrated in the example: + # + # polymorphic_url([:admin, @article, @comment]) + # + # results in: + # + # admin_article_comment_url(@article, @comment) + # + # == Usage within the framework + # + # Polymorphic URL helpers are used in a number of places throughout the \Rails framework: + # + # * <tt>url_for</tt>, so you can use it with a record as the argument, e.g. + # <tt>url_for(@article)</tt>; + # * ActionView::Helpers::FormHelper uses <tt>polymorphic_path</tt>, so you can write + # <tt>form_for(@article)</tt> without having to specify <tt>:url</tt> parameter for the form + # action; + # * <tt>redirect_to</tt> (which, in fact, uses <tt>url_for</tt>) so you can write + # <tt>redirect_to(post)</tt> in your controllers; + # * ActionView::Helpers::AtomFeedHelper, so you don't have to explicitly specify URLs + # for feed entries. + # + # == 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> + # in options. Those are: + # + # * <tt>edit_polymorphic_url</tt>, <tt>edit_polymorphic_path</tt> + # * <tt>new_polymorphic_url</tt>, <tt>new_polymorphic_path</tt> + # + # Example usage: + # + # edit_polymorphic_path(@post) # => "/posts/1/edit" + # polymorphic_path(@post, format: :pdf) # => "/posts/1.pdf" + # + # == Usage with mounted engines + # + # 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]) # 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: + # + # # calls post_url(post) + # polymorphic_url(post) # => "http://example.com/posts/1" + # polymorphic_url([blog, post]) # => "http://example.com/blogs/1/posts/1" + # polymorphic_url([:admin, blog, post]) # => "http://example.com/admin/blogs/1/posts/1" + # polymorphic_url([user, :blog, post]) # => "http://example.com/users/1/blog/posts/1" + # polymorphic_url(Comment) # => "http://example.com/comments" + # + # ==== Options + # + # * <tt>:action</tt> - Specifies the action prefix for the named route: + # <tt>:new</tt> or <tt>:edit</tt>. Default is no prefix. + # * <tt>:routing_type</tt> - Allowed values are <tt>:path</tt> or <tt>:url</tt>. + # Default is <tt>:url</tt>. + # + # Also includes all the options from <tt>url_for</tt>. These include such + # things as <tt>:anchor</tt> or <tt>:trailing_slash</tt>. Example usage + # is given below: + # + # polymorphic_url([blog, post], anchor: 'my_anchor') + # # => "http://example.com/blogs/1/posts/1#my_anchor" + # polymorphic_url([blog, post], anchor: 'my_anchor', script_name: "/my_app") + # # => "http://example.com/my_app/blogs/1/posts/1#my_anchor" + # + # For all of these options, see the documentation for <tt>url_for</tt>. + # + # ==== Functionality + # + # # an Article record + # polymorphic_url(record) # same as article_url(record) + # + # # a Comment record + # polymorphic_url(record) # same as comment_url(record) + # + # # it recognizes new records and maps to the collection + # record = Comment.new + # polymorphic_url(record) # same as comments_url() + # + # # the class of a record will also map to the collection + # polymorphic_url(Comment) # same as comments_url() + # + def polymorphic_url(record_or_hash_or_array, options = {}) + if Hash === record_or_hash_or_array + options = record_or_hash_or_array.merge(options) + record = options.delete :id + return polymorphic_url record, options + end + + opts = options.dup + action = opts.delete :action + type = opts.delete(:routing_type) || :url + + HelperMethodBuilder.polymorphic_method self, + record_or_hash_or_array, + action, + type, + opts + + end + + # Returns the path component of a URL for the given record. It uses + # <tt>polymorphic_url</tt> with <tt>routing_type: :path</tt>. + def polymorphic_path(record_or_hash_or_array, options = {}) + if Hash === record_or_hash_or_array + options = record_or_hash_or_array.merge(options) + record = options.delete :id + return polymorphic_path record, options + end + + opts = options.dup + action = opts.delete :action + type = :path + + HelperMethodBuilder.polymorphic_method self, + record_or_hash_or_array, + action, + type, + opts + end + + + %w(edit new).each do |action| + module_eval <<-EOT, __FILE__, __LINE__ + 1 + def #{action}_polymorphic_url(record_or_hash, options = {}) # def edit_polymorphic_url(record_or_hash, options = {}) + polymorphic_url( # polymorphic_url( + record_or_hash, # record_or_hash, + options.merge(:action => "#{action}")) # options.merge(:action => "edit")) + end # end + # + def #{action}_polymorphic_path(record_or_hash, options = {}) # def edit_polymorphic_path(record_or_hash, options = {}) + polymorphic_url( # polymorphic_url( + record_or_hash, # record_or_hash, + options.merge(:action => "#{action}", :routing_type => :path)) # options.merge(:action => "edit", :routing_type => :path)) + end # end + EOT + end + + private + + class HelperMethodBuilder # :nodoc: + CACHE = { 'path' => {}, 'url' => {} } + + def self.get(action, type) + type = type.to_s + CACHE[type].fetch(action) { build action, type } + end + + def self.url; CACHE['url'.freeze][nil]; end + def self.path; CACHE['path'.freeze][nil]; end + + def self.build(action, type) + prefix = action ? "#{action}_" : "" + suffix = type + if action.to_s == 'new' + HelperMethodBuilder.singular prefix, suffix + else + HelperMethodBuilder.plural prefix, suffix + end + end + + def self.singular(prefix, suffix) + new(->(name) { name.singular_route_key }, prefix, suffix) + end + + def self.plural(prefix, suffix) + new(->(name) { name.route_key }, prefix, suffix) + end + + def self.polymorphic_method(recipient, record_or_hash_or_array, action, type, options) + builder = get action, type + + case record_or_hash_or_array + when Array + if record_or_hash_or_array.empty? || record_or_hash_or_array.include?(nil) + raise ArgumentError, "Nil location provided. Can't build URI." + end + if record_or_hash_or_array.first.is_a?(ActionDispatch::Routing::RoutesProxy) + recipient = record_or_hash_or_array.shift + end + + method, args = builder.handle_list record_or_hash_or_array + when String, Symbol + method, args = builder.handle_string record_or_hash_or_array + when Class + method, args = builder.handle_class record_or_hash_or_array + + when nil + raise ArgumentError, "Nil location provided. Can't build URI." + else + method, args = builder.handle_model record_or_hash_or_array + end + + + if options.empty? + recipient.send(method, *args) + else + recipient.send(method, *args, options) + end + end + + attr_reader :suffix, :prefix + + def initialize(key_strategy, prefix, suffix) + @key_strategy = key_strategy + @prefix = prefix + @suffix = suffix + end + + def handle_string(record) + [get_method_for_string(record), []] + end + + def handle_string_call(target, str) + target.send get_method_for_string str + end + + def handle_class(klass) + [get_method_for_class(klass), []] + end + + def handle_class_call(target, klass) + target.send get_method_for_class klass + end + + def handle_model(record) + args = [] + + model = record.to_model + name = if record.persisted? + args << model + model.class.model_name.singular_route_key + else + @key_strategy.call model.class.model_name + end + + named_route = prefix + "#{name}_#{suffix}" + + [named_route, args] + end + + def handle_model_call(target, model) + method, args = handle_model model + target.send(method, *args) + end + + def handle_list(list) + record_list = list.dup + record = record_list.pop + + args = [] + + route = record_list.map { |parent| + case parent + when Symbol, String + parent.to_s + when Class + args << parent + parent.model_name.singular_route_key + else + args << parent.to_model + parent.to_model.class.model_name.singular_route_key + end + } + + route << + case record + when Symbol, String + record.to_s + when Class + @key_strategy.call record.model_name + else + if record.persisted? + args << record.to_model + record.to_model.class.model_name.singular_route_key + else + @key_strategy.call record.to_model.class.model_name + end + end + + route << suffix + + named_route = prefix + route.join("_") + [named_route, args] + end + + private + + def get_method_for_class(klass) + name = @key_strategy.call klass.model_name + prefix + "#{name}_#{suffix}" + end + + def get_method_for_string(str) + prefix + "#{str}_#{suffix}" + end + + [nil, 'new', 'edit'].each do |action| + CACHE['url'][action] = build action, 'url' + CACHE['path'][action] = build action, 'path' + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/routing/redirection.rb b/actionpack/lib/action_dispatch/routing/redirection.rb new file mode 100644 index 0000000000..3c1c4fadf6 --- /dev/null +++ b/actionpack/lib/action_dispatch/routing/redirection.rb @@ -0,0 +1,191 @@ +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' +require 'action_dispatch/routing/endpoint' + +module ActionDispatch + module Routing + class Redirect < Endpoint # :nodoc: + attr_reader :status, :block + + def initialize(status, block) + @status = status + @block = block + end + + def redirect?; true; end + + def call(env) + serve Request.new env + end + + def serve(req) + req.check_path_parameters! + uri = URI.parse(path(req.path_parameters, req)) + + unless uri.host + if relative_path?(uri.path) + uri.path = "#{req.script_name}/#{uri.path}" + elsif uri.path.empty? + uri.path = req.script_name.empty? ? "/" : req.script_name + end + end + + uri.scheme ||= req.scheme + uri.host ||= req.host + uri.port ||= req.port unless req.standard_port? + + body = %(<html><body>You are being <a href="#{ERB::Util.unwrapped_html_escape(uri.to_s)}">redirected</a>.</body></html>) + + headers = { + 'Location' => uri.to_s, + 'Content-Type' => 'text/html', + 'Content-Length' => body.length.to_s + } + + [ status, headers, [body] ] + end + + def path(params, request) + block.call params, request + end + + def inspect + "redirect(#{status})" + end + + private + def relative_path?(path) + path && !path.empty? && path[0] != '/' + end + + def escape(params) + Hash[params.map{ |k,v| [k, Rack::Utils.escape(v)] }] + end + + def escape_fragment(params) + Hash[params.map{ |k,v| [k, Journey::Router::Utils.escape_fragment(v)] }] + end + + def escape_path(params) + Hash[params.map{ |k,v| [k, Journey::Router::Utils.escape_path(v)] }] + end + end + + class PathRedirect < Redirect + URL_PARTS = /\A([^?]+)?(\?[^#]+)?(#.+)?\z/ + + def path(params, request) + if block.match(URL_PARTS) + path = interpolation_required?($1, params) ? $1 % escape_path(params) : $1 + query = interpolation_required?($2, params) ? $2 % escape(params) : $2 + fragment = interpolation_required?($3, params) ? $3 % escape_fragment(params) : $3 + + "#{path}#{query}#{fragment}" + else + interpolation_required?(block, params) ? block % escape(params) : block + end + end + + def inspect + "redirect(#{status}, #{block})" + end + + private + def interpolation_required?(string, params) + !params.empty? && string && string.match(/%\{\w*\}/) + end + end + + class OptionRedirect < Redirect # :nodoc: + alias :options :block + + def path(params, request) + url_options = { + :protocol => request.protocol, + :host => request.host, + :port => request.optional_port, + :path => request.path, + :params => request.query_parameters + }.merge! options + + if !params.empty? && url_options[:path].match(/%\{\w*\}/) + url_options[:path] = (url_options[:path] % escape_path(params)) + end + + unless options[:host] || options[:domain] + if relative_path?(url_options[:path]) + url_options[:path] = "/#{url_options[:path]}" + url_options[:script_name] = request.script_name + elsif url_options[:path].empty? + url_options[:path] = request.script_name.empty? ? "/" : "" + url_options[:script_name] = request.script_name + end + end + + ActionDispatch::Http::URL.url_for url_options + end + + def inspect + "redirect(#{status}, #{options.map{ |k,v| "#{k}: #{v}" }.join(', ')})" + end + end + + module Redirection + + # Redirect any path to another path: + # + # get "/stories" => redirect("/posts") + # + # You can also use interpolation in the supplied redirect argument: + # + # get 'docs/:article', to: redirect('/wiki/%{article}') + # + # Note that if you return a path without a leading slash then the url is prefixed with the + # current SCRIPT_NAME environment variable. This is typically '/' but may be different in + # a mounted engine or where the application is deployed to a subdirectory of a website. + # + # Alternatively you can use one of the other syntaxes: + # + # The block version of redirect allows for the easy encapsulation of any logic associated with + # the redirect in question. Either the params and request are supplied as arguments, or just + # params, depending of how many arguments your block accepts. A string is required as a + # return value. + # + # 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}" + # } + # + # 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. + # + # 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. + # + # get 'accounts/:name' => redirect(SubdomainRedirector.new('api')) + # + def redirect(*args, &block) + options = args.extract_options! + status = options.delete(:status) || 301 + path = args.shift + + return OptionRedirect.new(status, options) if options.any? + return PathRedirect.new(status, path) if String === path + + block = path if path.respond_to? :call + raise ArgumentError, "redirection argument not supported" unless block + Redirect.new status, block + end + end + end +end diff --git a/actionpack/lib/action_dispatch/routing/route_set.rb b/actionpack/lib/action_dispatch/routing/route_set.rb new file mode 100644 index 0000000000..5b3651aaee --- /dev/null +++ b/actionpack/lib/action_dispatch/routing/route_set.rb @@ -0,0 +1,787 @@ +require 'action_dispatch/journey' +require 'forwardable' +require 'thread_safe' +require 'active_support/concern' +require 'active_support/core_ext/object/to_query' +require 'active_support/core_ext/hash/slice' +require 'active_support/core_ext/module/remove_method' +require 'active_support/core_ext/array/extract_options' +require 'action_controller/metal/exceptions' +require 'action_dispatch/http/request' +require 'action_dispatch/routing/endpoint' + +module ActionDispatch + module Routing + class RouteSet #:nodoc: + # Since the router holds references to many parts of the system + # like engines, controllers and the application itself, inspecting + # the route set can actually be really slow, therefore we default + # alias inspect to to_s. + alias inspect to_s + + class Dispatcher < Routing::Endpoint #:nodoc: + def initialize(defaults) + @defaults = defaults + @controller_class_names = ThreadSafe::Cache.new + end + + def dispatcher?; true; end + + def serve(req) + req.check_path_parameters! + params = req.path_parameters + + prepare_params!(params) + + # Just raise undefined constant errors if a controller was specified as default. + unless controller = controller(params, @defaults.key?(:controller)) + return [404, {'X-Cascade' => 'pass'}, []] + end + + dispatch(controller, params[:action], req.env) + end + + def prepare_params!(params) + normalize_controller!(params) + merge_default_action!(params) + end + + # If this is a default_controller (i.e. a controller specified by the user) + # we should raise an error in case it's not found, because it usually means + # a user error. However, if the controller was retrieved through a dynamic + # segment, as in :controller(/:action), we should simply return nil and + # delegate the control back to Rack cascade. Besides, if this is not a default + # controller, it means we should respect the @scope[:module] parameter. + def controller(params, default_controller=true) + if params && params.key?(:controller) + controller_param = params[:controller] + controller_reference(controller_param) + end + rescue NameError => e + raise ActionController::RoutingError, e.message, e.backtrace if default_controller + end + + private + + def controller_reference(controller_param) + const_name = @controller_class_names[controller_param] ||= "#{controller_param.camelize}Controller" + ActiveSupport::Dependencies.constantize(const_name) + end + + def dispatch(controller, action, env) + controller.action(action).call(env) + end + + def normalize_controller!(params) + params[:controller] = params[:controller].underscore if params.key?(:controller) + end + + def merge_default_action!(params) + params[:action] ||= 'index' + end + end + + # A NamedRouteCollection instance is a collection of named routes, and also + # maintains an anonymous module that can be used to install helpers for the + # named routes. + class NamedRouteCollection #:nodoc: + include Enumerable + attr_reader :routes, :url_helpers_module + + def initialize + @routes = {} + @path_helpers = Set.new + @url_helpers = Set.new + @url_helpers_module = Module.new + @path_helpers_module = Module.new + end + + def route_defined?(name) + key = name.to_sym + @path_helpers.include?(key) || @url_helpers.include?(key) + end + + def helper_names + @path_helpers.map(&:to_s) + @url_helpers.map(&:to_s) + end + + def clear! + @path_helpers.each do |helper| + @path_helpers_module.send :undef_method, helper + end + + @url_helpers.each do |helper| + @url_helpers_module.send :undef_method, helper + end + + @routes.clear + @path_helpers.clear + @url_helpers.clear + end + + def add(name, route) + key = name.to_sym + path_name = :"#{name}_path" + url_name = :"#{name}_url" + + if routes.key? key + @path_helpers_module.send :undef_method, path_name + @url_helpers_module.send :undef_method, url_name + end + routes[key] = route + define_url_helper @path_helpers_module, route, path_name, route.defaults, name, PATH + define_url_helper @url_helpers_module, route, url_name, route.defaults, name, FULL + + @path_helpers << path_name + @url_helpers << url_name + end + + def get(name) + routes[name.to_sym] + end + + def key?(name) + routes.key? name.to_sym + end + + alias []= add + alias [] get + alias clear clear! + + def each + routes.each { |name, route| yield name, route } + self + end + + def names + routes.keys + end + + def length + routes.length + end + + def path_helpers_module(warn = false) + if warn + mod = @path_helpers_module + helpers = @path_helpers + Module.new do + include mod + + helpers.each do |meth| + define_method(meth) do |*args, &block| + ActiveSupport::Deprecation.warn("The method `#{meth}` cannot be used here as a full URL is required. Use `#{meth.to_s.sub(/_path$/, '_url')}` instead") + super(*args, &block) + end + end + end + else + @path_helpers_module + end + end + + class UrlHelper # :nodoc: + def self.create(route, options, route_name, url_strategy) + if optimize_helper?(route) + OptimizedUrlHelper.new(route, options, route_name, url_strategy) + else + new route, options, route_name, url_strategy + end + end + + def self.optimize_helper?(route) + !route.glob? && route.path.requirements.empty? + end + + attr_reader :url_strategy, :route_name + + class OptimizedUrlHelper < UrlHelper # :nodoc: + attr_reader :arg_size + + def initialize(route, options, route_name, url_strategy) + super + @required_parts = @route.required_parts + @arg_size = @required_parts.size + end + + def call(t, args, inner_options) + if args.size == arg_size && !inner_options && optimize_routes_generation?(t) + options = t.url_options.merge @options + options[:path] = optimized_helper(args) + url_strategy.call options + else + super + end + end + + private + + def optimized_helper(args) + params = parameterize_args(args) + missing_keys = missing_keys(params) + + unless missing_keys.empty? + raise_generation_error(params, missing_keys) + end + + @route.format params + end + + def optimize_routes_generation?(t) + t.send(:optimize_routes_generation?) + end + + def parameterize_args(args) + params = {} + @required_parts.zip(args.map(&:to_param)) { |k,v| params[k] = v } + params + end + + def missing_keys(args) + args.select{ |part, arg| arg.nil? || arg.empty? }.keys + end + + def raise_generation_error(args, missing_keys) + constraints = Hash[@route.requirements.merge(args).sort] + message = "No route matches #{constraints.inspect}" + message << " missing required keys: #{missing_keys.sort.inspect}" + + raise ActionController::UrlGenerationError, message + end + end + + def initialize(route, options, route_name, url_strategy) + @options = options + @segment_keys = route.segment_keys.uniq + @route = route + @url_strategy = url_strategy + @route_name = route_name + end + + def call(t, args, inner_options) + controller_options = t.url_options + options = controller_options.merge @options + hash = handle_positional_args(controller_options, + inner_options || {}, + args, + options, + @segment_keys) + + t._routes.url_for(hash, route_name, url_strategy) + end + + def handle_positional_args(controller_options, inner_options, args, result, path_params) + + if args.size > 0 + if args.size < path_params.size - 1 # take format into account + path_params -= controller_options.keys + path_params -= result.keys + end + path_params.each { |param| + result[param] = inner_options[param] || args.shift + } + end + + result.merge!(inner_options) + end + end + + private + # Create a url helper allowing ordered parameters to be associated + # with corresponding dynamic segments, so you can do: + # + # foo_url(bar, baz, bang) + # + # Instead of: + # + # foo_url(bar: bar, baz: baz, bang: bang) + # + # Also allow options hash, so you can do: + # + # foo_url(bar, baz, bang, sort_by: 'baz') + # + def define_url_helper(mod, route, name, opts, route_key, url_strategy) + helper = UrlHelper.create(route, opts, route_key, url_strategy) + mod.module_eval do + define_method(name) do |*args| + options = nil + options = args.pop if args.last.is_a? Hash + helper.call self, args, options + end + end + end + end + + # :stopdoc: + # strategy for building urls to send to the client + PATH = ->(options) { ActionDispatch::Http::URL.path_for(options) } + FULL = ->(options) { ActionDispatch::Http::URL.full_url_for(options) } + UNKNOWN = ->(options) { ActionDispatch::Http::URL.url_for(options) } + # :startdoc: + + 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 + + alias :routes :set + + def self.default_resources_path_names + { :new => 'new', :edit => 'edit' } + end + + def initialize(request_class = ActionDispatch::Request) + self.named_routes = NamedRouteCollection.new + self.resources_path_names = self.class.default_resources_path_names + self.default_url_options = {} + self.request_class = request_class + + @append = [] + @prepend = [] + @disable_clear_and_finalize = false + @finalized = false + + @set = Journey::Routes.new + @router = Journey::Router.new @set + @formatter = Journey::Formatter.new @set + end + + def draw(&block) + clear! unless @disable_clear_and_finalize + eval_block(block) + finalize! unless @disable_clear_and_finalize + nil + end + + def append(&block) + @append << block + end + + def prepend(&block) + @prepend << block + end + + def eval_block(block) + if block.arity == 1 + raise "You are using the old router DSL which has been removed in Rails 3.1. " << + "Please check how to update your routes file at: http://www.engineyard.com/blog/2010/the-lowdown-on-routes-in-rails-3/" + end + mapper = Mapper.new(self) + if default_scope + mapper.with_default_scope(default_scope, &block) + else + mapper.instance_exec(&block) + end + end + private :eval_block + + def finalize! + return if @finalized + @append.each { |blk| eval_block(blk) } + @finalized = true + end + + def clear! + @finalized = false + named_routes.clear + set.clear + formatter.clear + @prepend.each { |blk| eval_block(blk) } + end + + def dispatcher(defaults) + Routing::RouteSet::Dispatcher.new(defaults) + end + + module MountedHelpers #:nodoc: + extend ActiveSupport::Concern + include UrlFor + end + + # Contains all the mounted helpers across different + # engines and the `main_app` helper for the application. + # You can include this in your classes if you want to + # access routes for other engines. + def mounted_helpers + MountedHelpers + end + + def define_mounted_helper(name) + return if MountedHelpers.method_defined?(name) + + routes = self + MountedHelpers.class_eval do + define_method "_#{name}" do + RoutesProxy.new(routes, _routes_context) + end + end + + MountedHelpers.class_eval(<<-RUBY, __FILE__, __LINE__ + 1) + def #{name} + @_#{name} ||= _#{name} + end + RUBY + end + + def url_helpers(include_path_helpers = true) + routes = self + + Module.new do + extend ActiveSupport::Concern + include UrlFor + + # Define url_for in the singleton level so one can do: + # Rails.application.routes.url_helpers.url_for(args) + @_routes = routes + class << self + delegate :url_for, :optimize_routes_generation?, to: '@_routes' + attr_reader :_routes + def url_options; {}; end + end + + url_helpers = routes.named_routes.url_helpers_module + + # Make named_routes available in the module singleton + # as well, so one can do: + # Rails.application.routes.url_helpers.posts_path + extend url_helpers + + # Any class that includes this module will get all + # named routes... + include url_helpers + + if include_path_helpers + path_helpers = routes.named_routes.path_helpers_module + else + path_helpers = routes.named_routes.path_helpers_module(true) + end + + include path_helpers + extend path_helpers + + # plus a singleton class method called _routes ... + included do + singleton_class.send(:redefine_method, :_routes) { routes } + end + + # And an instance method _routes. Note that + # UrlFor (included in this module) add extra + # conveniences for working with @_routes. + define_method(:_routes) { @_routes || routes } + end + end + + def empty? + routes.empty? + end + + def add_route(app, conditions = {}, requirements = {}, defaults = {}, name = nil, anchor = true) + raise ArgumentError, "Invalid route name: '#{name}'" unless name.blank? || name.to_s.match(/^[_a-z]\w*$/i) + + if name && named_routes[name] + raise ArgumentError, "Invalid route name, already in use: '#{name}' \n" \ + "You may have defined two routes with the same name using the `:as` option, or " \ + "you may be overriding a route already defined by a resource with the same naming. " \ + "For the latter, you can restrict the routes created with `resources` as explained here: \n" \ + "http://guides.rubyonrails.org/routing.html#restricting-the-routes-created" + end + + path = conditions.delete :path_info + ast = conditions.delete :parsed_path_info + path = build_path(path, ast, requirements, anchor) + 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 + route + end + + def build_path(path, ast, requirements, anchor) + strexp = Journey::Router::Strexp.new( + ast, + path, + requirements, + SEPARATORS, + anchor) + + pattern = Journey::Path::Pattern.new(strexp) + + builder = Journey::GTG::Builder.new pattern.spec + + # Get all the symbol nodes followed by literals that are not the + # dummy node. + symbols = pattern.spec.grep(Journey::Nodes::Symbol).find_all { |n| + builder.followpos(n).first.literal? + } + + # Get all the symbol nodes preceded by literals. + symbols.concat pattern.spec.find_all(&:literal?).map { |n| + builder.followpos(n).first + }.find_all(&:symbol?) + + symbols.each { |x| + x.regexp = /(?:#{Regexp.union(x.regexp, '-')})+/ + } + + pattern + end + private :build_path + + def build_conditions(current_conditions, path_values) + conditions = current_conditions.dup + + # 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.keep_if do |k, _| + k == :action || k == :controller || k == :required_defaults || + @request_class.public_method_defined?(k) || path_values.include?(k) + end + end + private :build_conditions + + class Generator #:nodoc: + PARAMETERIZE = lambda do |name, value| + if name == :controller + value + elsif value.is_a?(Array) + value.map { |v| v.to_param }.join('/') + elsif param = value.to_param + param + end + end + + attr_reader :options, :recall, :set, :named_route + + def initialize(named_route, options, recall, set) + @named_route = named_route + @options = options.dup + @recall = recall.dup + @set = set + + normalize_recall! + normalize_options! + normalize_controller_action_id! + use_relative_controller! + normalize_controller! + normalize_action! + end + + def controller + @options[:controller] + end + + def current_controller + @recall[:controller] + end + + def use_recall_for(key) + if @recall[key] && (!@options.key?(key) || @options[key] == @recall[key]) + if !named_route_exists? || segment_keys.include?(key) + @options[key] = @recall.delete(key) + end + end + end + + # Set 'index' as default action for recall + def normalize_recall! + @recall[:action] ||= 'index' + end + + def normalize_options! + # If an explicit :controller was given, always make :action explicit + # too, so that action expiry works as expected for things like + # + # generate({controller: 'content'}, {controller: 'content', action: 'show'}) + # + # (the above is from the unit tests). In the above case, because the + # controller was explicitly given, but no action, the action is implied to + # be "index", not the recalled action of "show". + + if options[:controller] + options[:action] ||= 'index' + options[:controller] = options[:controller].to_s + end + + if options.key?(:action) + options[:action] = (options[:action] || 'index').to_s + end + end + + # This pulls :controller, :action, and :id out of the recall. + # The recall key is only used if there is no key in the options + # or if the key in the options is identical. If any of + # :controller, :action or :id is not found, don't pull any + # more keys from the recall. + def normalize_controller_action_id! + use_recall_for(:controller) or return + use_recall_for(:action) or return + use_recall_for(:id) + end + + # 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 + @options[:controller] = parts.join("/") + end + end + + # Remove leading slashes from controllers + def normalize_controller! + @options[:controller] = controller.sub(%r{^/}, '') if controller + end + + # Move 'index' action from options to recall + def normalize_action! + if @options[:action] == 'index' + @recall[:action] = @options.delete(:action) + end + end + + # Generates a path from routes, returns [path, params]. + # If no route is generated the formatter will raise ActionController::UrlGenerationError + def generate + @set.formatter.generate(named_route, options, recall, PARAMETERIZE) + end + + def different_controller? + return false unless current_controller + controller.to_param != current_controller.to_param + end + + private + def named_route_exists? + named_route && set.named_routes[named_route] + end + + def segment_keys + set.named_routes[named_route].segment_keys + end + end + + # Generate the path indicated by the arguments, and return an array of + # the keys that were not used to generate it. + def extra_keys(options, recall={}) + generate_extras(options, recall).last + end + + def generate_extras(options, recall={}) + route_key = options.delete :use_route + path, params = generate(route_key, options, recall) + return path, params.keys + end + + def generate(route_key, options, recall = {}) + Generator.new(route_key, options, recall, self).generate + end + private :generate + + RESERVED_OPTIONS = [:host, :protocol, :port, :subdomain, :domain, :tld_length, + :trailing_slash, :anchor, :params, :only_path, :script_name, + :original_script_name] + + def optimize_routes_generation? + default_url_options.empty? + end + + def find_script_name(options) + options.delete(:script_name) { '' } + end + + def path_for(options, route_name = nil) # :nodoc: + url_for(options, route_name, PATH) + end + + # The +options+ argument must be a hash whose keys are *symbols*. + def url_for(options, route_name = nil, url_strategy = UNKNOWN) + options = default_url_options.merge options + + user = password = nil + + if options[:user] && options[:password] + user = options.delete :user + password = options.delete :password + end + + recall = options.delete(:_recall) { {} } + + original_script_name = options.delete(:original_script_name) + script_name = find_script_name options + + if original_script_name + script_name = original_script_name + script_name + end + + path_options = options.dup + RESERVED_OPTIONS.each { |ro| path_options.delete ro } + + path, params = generate(route_name, path_options, recall) + + if options.key? :params + params.merge! options[:params] + end + + options[:path] = path + options[:script_name] = script_name + options[:params] = params + options[:user] = user + options[:password] = password + + url_strategy.call options + end + + def call(env) + req = request_class.new(env) + req.path_info = Journey::Router::Utils.normalize_path(req.path_info) + @router.serve(req) + end + + def recognize_path(path, environment = {}) + method = (environment[:method] || "GET").to_s.upcase + path = Journey::Router::Utils.normalize_path(path) unless path =~ %r{://} + extras = environment[:extras] || {} + + begin + env = Rack::MockRequest.env_for(path, {:method => method}) + rescue URI::InvalidURIError => e + raise ActionController::RoutingError, e.message + end + + req = request_class.new(env) + @router.recognize(req) do |route, params| + params.merge!(extras) + params.each do |key, value| + if value.is_a?(String) + value = value.dup.force_encoding(Encoding::BINARY) + params[key] = URI.parser.unescape(value) + end + end + old_params = req.path_parameters + req.path_parameters = old_params.merge params + app = route.app + if app.matches?(req) && app.dispatcher? + dispatcher = app.app + + 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 + + raise ActionController::RoutingError, "No route matches #{path.inspect}" + end + end + end +end diff --git a/actionpack/lib/action_dispatch/routing/routes_proxy.rb b/actionpack/lib/action_dispatch/routing/routes_proxy.rb new file mode 100644 index 0000000000..e2393d3799 --- /dev/null +++ b/actionpack/lib/action_dispatch/routing/routes_proxy.rb @@ -0,0 +1,41 @@ +require 'active_support/core_ext/array/extract_options' + +module ActionDispatch + module Routing + class RoutesProxy #:nodoc: + include ActionDispatch::Routing::UrlFor + + attr_accessor :scope, :routes + alias :_routes :routes + + def initialize(routes, scope) + @routes, @scope = routes, scope + end + + def url_options + scope.send(:_with_routes, routes) do + scope.url_options + end + end + + def respond_to?(method, include_private = false) + super || routes.url_helpers.respond_to?(method) + end + + def method_missing(method, *args) + if routes.url_helpers.respond_to?(method) + self.class.class_eval <<-RUBY, __FILE__, __LINE__ + 1 + def #{method}(*args) + options = args.extract_options! + args << url_options.merge((options || {}).symbolize_keys) + routes.url_helpers.#{method}(*args) + end + RUBY + send(method, *args) + else + super + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/routing/url_for.rb b/actionpack/lib/action_dispatch/routing/url_for.rb new file mode 100644 index 0000000000..eb554ec383 --- /dev/null +++ b/actionpack/lib/action_dispatch/routing/url_for.rb @@ -0,0 +1,189 @@ +module ActionDispatch + module Routing + # In <tt>config/routes.rb</tt> you define URL-to-controller mappings, but the reverse + # is also possible: an URL can be generated from one of your routing definitions. + # URL generation functionality is centralized in this module. + # + # See ActionDispatch::Routing for general information about routing and routes.rb. + # + # <b>Tip:</b> If you need to generate URLs from your models or some other place, + # then ActionController::UrlFor is what you're looking for. Read on for + # an introduction. In general, this module should not be included on its own, + # as it is usually included by url_helpers (as in Rails.application.routes.url_helpers). + # + # == URL generation from parameters + # + # As you may know, some functions, such as ActionController::Base#url_for + # and ActionView::Helpers::UrlHelper#link_to, can generate URLs given a set + # 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!') %> + # # => <a href="/users/new?message=Welcome%21">Click here</a> + # + # link_to, and all other functions that require URL generation functionality, + # actually use ActionController::UrlFor under the hood. And in particular, + # they use the ActionController::UrlFor#url_for method. One can generate + # 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) + # # => "/users/new?message=Welcome%21" + # + # 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') + # # => "http://www.example.com/users/new?message=Welcome%21" + # + # By default, all controllers and views have access to a special version of url_for, + # that already knows what the current hostname is. So if you use url_for in your + # controllers or your views, then you don't need to explicitly pass the <tt>:host</tt> + # argument. + # + # For convenience reasons, mailers provide a shortcut for ActionController::UrlFor#url_for. + # So within mailers, you only have to type 'url_for' instead of 'ActionController::UrlFor#url_for' + # in full. However, mailers don't have hostname information, and that's why you'll still + # have to specify the <tt>:host</tt> argument when generating URLs in mailers. + # + # + # == URL generation for named routes + # + # UrlFor also allows one to access methods that have been auto-generated from + # named routes. For example, suppose that you have a 'users' resource in your + # <tt>config/routes.rb</tt>: + # + # resources :users + # + # 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 Rails.application.routes.url_helpers in your class: + # + # class User < ActiveRecord::Base + # include Rails.application.routes.url_helpers + # + # def base_uri + # user_path(self) + # end + # end + # + # User.find(1).base_uri # => "/users/1" + # + module UrlFor + extend ActiveSupport::Concern + include PolymorphicRoutes + + included do + unless method_defined?(:default_url_options) + # Including in a class uses an inheritable hash. Modules get a plain hash. + if respond_to?(:class_attribute) + class_attribute :default_url_options + else + mattr_writer :default_url_options + end + + self.default_url_options = {} + end + + include(*_url_for_modules) if respond_to?(:_url_for_modules) + end + + def initialize(*) + @_routes = nil + super + end + + # Hook overridden in controller to add request information + # with `default_url_options`. Application logic should not + # go into url_options. + def url_options + default_url_options + end + + # Generate a url based on the options provided, default_url_options and the + # routes defined in routes.rb. The following options are supported: + # + # * <tt>:only_path</tt> - If true, the relative url is returned. Defaults to +false+. + # * <tt>:protocol</tt> - The protocol to connect to. Defaults to 'http'. + # * <tt>:host</tt> - Specifies the host the link should be targeted at. + # If <tt>:only_path</tt> is false, this option must be + # provided either explicitly, or via +default_url_options+. + # * <tt>:subdomain</tt> - Specifies the subdomain of the link, using the +tld_length+ + # to split the subdomain from the host. + # If false, removes all subdomains from the host part of the link. + # * <tt>:domain</tt> - Specifies the domain of the link, using the +tld_length+ + # to split the domain from the host. + # * <tt>:tld_length</tt> - Number of labels the TLD id composed of, only used if + # <tt>:subdomain</tt> or <tt>:domain</tt> are supplied. Defaults to + # <tt>ActionDispatch::Http::URL.tld_length</tt>, which in turn defaults to 1. + # * <tt>:port</tt> - Optionally specify the port to connect to. + # * <tt>:anchor</tt> - An anchor name to be appended to the path. + # * <tt>:trailing_slash</tt> - If true, adds a trailing slash, as in "/archive/2009/" + # * <tt>:script_name</tt> - Specifies application path relative to domain root. If provided, prepends application path. + # + # Any other key (<tt>:controller</tt>, <tt>:action</tt>, etc.) given to + # +url_for+ is forwarded to the Routes module. + # + # 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 + # # => '/tasks/testing#ok' + # 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' + # # => 'http://somehost.org/tasks/testing?number=33' + # url_for controller: 'tasks', action: 'testing', host: 'somehost.org', script_name: "/myapp" + # # => 'http://somehost.org/myapp/tasks/testing' + # url_for controller: 'tasks', action: 'testing', host: 'somehost.org', script_name: "/myapp", only_path: true + # # => '/myapp/tasks/testing' + def url_for(options = nil) + case options + when nil + _routes.url_for(url_options.symbolize_keys) + when Hash + route_name = options.delete :use_route + _routes.url_for(options.symbolize_keys.reverse_merge!(url_options), + route_name) + when String + options + when Symbol + HelperMethodBuilder.url.handle_string_call self, options + when Array + polymorphic_url(options, options.extract_options!) + when Class + HelperMethodBuilder.url.handle_class_call self, options + else + HelperMethodBuilder.url.handle_model_call self, options + end + end + + protected + + def optimize_routes_generation? + _routes.optimize_routes_generation? && default_url_options.empty? + end + + def _with_routes(routes) + old_routes, @_routes = @_routes, routes + yield + ensure + @_routes = old_routes + end + + def _routes_context + self + end + end + end +end diff --git a/actionpack/lib/action_dispatch/testing/assertions.rb b/actionpack/lib/action_dispatch/testing/assertions.rb new file mode 100644 index 0000000000..226baf9ad0 --- /dev/null +++ b/actionpack/lib/action_dispatch/testing/assertions.rb @@ -0,0 +1,18 @@ +module ActionDispatch + module Assertions + autoload :DomAssertions, 'action_dispatch/testing/assertions/dom' + autoload :ResponseAssertions, 'action_dispatch/testing/assertions/response' + autoload :RoutingAssertions, 'action_dispatch/testing/assertions/routing' + autoload :SelectorAssertions, 'action_dispatch/testing/assertions/selector' + autoload :TagAssertions, 'action_dispatch/testing/assertions/tag' + + extend ActiveSupport::Concern + + include DomAssertions + include ResponseAssertions + include RoutingAssertions + include SelectorAssertions + include TagAssertions + end +end + diff --git a/actionpack/lib/action_dispatch/testing/assertions/dom.rb b/actionpack/lib/action_dispatch/testing/assertions/dom.rb new file mode 100644 index 0000000000..241a39393a --- /dev/null +++ b/actionpack/lib/action_dispatch/testing/assertions/dom.rb @@ -0,0 +1,27 @@ +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) + # + # # 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 = nil) + expected_dom = HTML::Document.new(expected).root + actual_dom = HTML::Document.new(actual).root + assert_equal expected_dom, actual_dom, message + end + + # The negated form of +assert_dom_equivalent+. + # + # # 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 = nil) + expected_dom = HTML::Document.new(expected).root + actual_dom = HTML::Document.new(actual).root + assert_not_equal expected_dom, actual_dom, message + end + end + end +end diff --git a/actionpack/lib/action_dispatch/testing/assertions/response.rb b/actionpack/lib/action_dispatch/testing/assertions/response.rb new file mode 100644 index 0000000000..13a72220b3 --- /dev/null +++ b/actionpack/lib/action_dispatch/testing/assertions/response.rb @@ -0,0 +1,82 @@ + +module ActionDispatch + module Assertions + # A small suite of assertions that test responses from \Rails applications. + module ResponseAssertions + # Asserts that the response is one of the following types: + # + # * <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 + # + # You can also pass an explicit status number like <tt>assert_response(501)</tt> + # or its symbolic equivalent <tt>assert_response(:not_implemented)</tt>. + # See Rack::Utils::SYMBOL_TO_STATUS_CODE for a full list. + # + # # 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}>" + + if Symbol === type + if [:success, :missing, :redirect, :error].include?(type) + assert @response.send("#{type}?"), message + else + code = Rack::Utils::SYMBOL_TO_STATUS_CODE[type] + if code.nil? + raise ArgumentError, "Invalid response type :#{type}" + end + assert_equal code, @response.response_code, message + end + else + assert_equal type, @response.response_code, message + end + 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. + # + # # assert that the redirection was to the "index" action on the WeblogController + # assert_redirected_to controller: "weblog", action: "index" + # + # # assert that the redirection was to the named route login_url + # assert_redirected_to login_url + # + # # 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 + + 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_operator redirect_expected, :===, redirect_is, message + end + + private + # Proxy to to_param if the object will respond to it. + def parameterize(value) + value.respond_to?(:to_param) ? value.to_param : value + end + + def normalize_argument_to_redirection(fragment) + if Regexp === fragment + fragment + else + handle = @controller || ActionController::Redirecting + handle._compute_redirect_to_location(@request, fragment) + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/testing/assertions/routing.rb b/actionpack/lib/action_dispatch/testing/assertions/routing.rb new file mode 100644 index 0000000000..2cf38a9c2d --- /dev/null +++ b/actionpack/lib/action_dispatch/testing/assertions/routing.rb @@ -0,0 +1,218 @@ +require 'uri' +require 'active_support/core_ext/hash/indifferent_access' +require 'active_support/core_ext/string/access' +require 'action_controller/metal/exceptions' + +module ActionDispatch + module Assertions + # Suite of assertions to test routes generated by \Rails and the handling of requests made to them. + module RoutingAssertions + # Asserts that the routing of the given +path+ was handled correctly and that the parsed options (given in the +expected_options+ hash) + # match +path+. Basically, it asserts that \Rails recognizes the route given by +expected_options+. + # + # Pass a hash in the second argument (+path+) to specify the request method. This is useful for routes + # requiring a specific HTTP method. The hash should contain a :path with the incoming request path + # 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}) + # + # 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" }) + # + # The +message+ parameter allows you to pass in an error message that is displayed upon failure. + # + # # Check the default route (i.e., the index action) + # assert_recognizes({controller: 'items', action: 'index'}, 'items') + # + # # Test a specific action + # 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') + # + # # Test a custom route + # 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! + + 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+. + # The +extras+ parameter is used to tell the request the names and values of additional request parameters that would be in + # a query string. The +message+ parameter allows you to specify a custom error message for assertion failures. + # + # The +defaults+ parameter is unused. + # + # # Asserts that the default action is generated for a route with no action + # assert_generates "/items", controller: "items", action: "index" + # + # # Tests that the list action is properly routed + # 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" } + # + # # Asserts that the generated route gives us our custom route + # 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{://} + fail_on(URI::InvalidURIError) do + uri = URI.parse(expected_path) + expected_path = uri.path.to_s.empty? ? "/" : uri.path + end + else + expected_path = "/#{expected_path}" unless expected_path.first == '/' + end + # Load routes.rb if it hasn't been loaded. + + generated_path, extra_keys = @routes.generate_extras(options, defaults) + found_extras = options.reject { |k, _| ! extra_keys.include? k } + + msg = message || sprintf("found extras <%s>, not <%s>", found_extras, extras) + assert_equal(extras, found_extras, msg) + + msg = message || sprintf("The generated path <%s> did not match <%s>", generated_path, + expected_path) + assert_equal(expected_path, generated_path, msg) + end + + # Asserts that path and options match both ways; in other words, it verifies that <tt>path</tt> generates + # <tt>options</tt> and then that <tt>options</tt> generates <tt>path</tt>. This essentially combines +assert_recognizes+ + # and +assert_generates+ into one step. + # + # 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. + # + # # Assert a basic route: a controller with the default 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 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' + # + # # Tests a route, providing a defaults hash + # 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" }) + def assert_routing(path, options, defaults={}, extras={}, message=nil) + assert_recognizes(options, path, extras, message) + + controller, default_controller = options[:controller], defaults[:controller] + if controller && controller.include?(?/) && default_controller && default_controller.include?(?/) + options[:controller] = "/#{controller}" + end + + generate_options = options.dup.delete_if{ |k, _| defaults.key?(k) } + assert_generates(path.is_a?(Hash) ? path[:path] : path, generate_options, defaults, extras, message) + end + + # A helper to make it easier to test different route configurations. + # This method temporarily replaces @routes + # with a new RouteSet instance. + # + # The new instance is yielded to the passed block. Typically the block + # will create some routes using <tt>set.draw { match ... }</tt>: + # + # with_routing do |set| + # set.draw do + # resources :users + # end + # assert_equal "/users", users_path + # end + # + def with_routing + old_routes, @routes = @routes, ActionDispatch::Routing::RouteSet.new + if defined?(@controller) && @controller + old_controller, @controller = @controller, @controller.clone + _routes = @routes + + # Unfortunately, there is currently an abstraction leak between AC::Base + # and AV::Base which requires having the URL helpers in both AC and AV. + # To do this safely at runtime for tests, we need to bump up the helper serial + # to that the old AV subclass isn't cached. + # + # TODO: Make this unnecessary + @controller.singleton_class.send(:include, _routes.url_helpers) + @controller.view_context_class = Class.new(@controller.view_context_class) do + include _routes.url_helpers + end + end + yield @routes + ensure + @routes = old_routes + if defined?(@controller) && @controller + @controller = old_controller + end + end + + # ROUTES TODO: These assertions should really work in an integration context + def method_missing(selector, *args, &block) + if defined?(@controller) && @controller && @routes && @routes.named_routes.route_defined?(selector) + @controller.send(selector, *args, &block) + else + super + end + end + + private + # Recognizes the route for a given path. + def recognized_request_for(path, extras = {}) + if path.is_a?(Hash) + method = path[:method] + path = path[:path] + else + method = :get + end + + # Assume given controller + request = ActionController::TestRequest.new + + if path =~ %r{://} + 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 + end + else + path = "/#{path}" unless path.first == "/" + request.path = path + end + + request.request_method = method if method + + 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) + yield + rescue exception_class => e + raise Minitest::Assertion, e.message + end + end + end +end diff --git a/actionpack/lib/action_dispatch/testing/assertions/selector.rb b/actionpack/lib/action_dispatch/testing/assertions/selector.rb new file mode 100644 index 0000000000..12023e6f77 --- /dev/null +++ b/actionpack/lib/action_dispatch/testing/assertions/selector.rb @@ -0,0 +1,430 @@ +require 'action_view/vendor/html-scanner' +require 'active_support/core_ext/object/inclusion' + +#-- +# Copyright (c) 2006 Assaf Arkin (http://labnotes.org) +# Under MIT and/or CC By license. +#++ + +module ActionDispatch + module Assertions + NO_STRIP = %w{pre script style textarea} + + # Adds the +assert_select+ method for use in Rails functional + # test cases, which can be used to make assertions on the response HTML of a controller + # action. You can also call +assert_select+ within another +assert_select+ to + # make assertions on elements selected by the enclosing assertion. + # + # Use +css_select+ to select elements without making an assertions, either + # from the response HTML or elements selected by the enclosing assertion. + # + # In addition to HTML responses, you can make the following assertions: + # + # * +assert_select_encoded+ - Assertions on HTML encoded inside XML, for example for dealing with feed item descriptions. + # * +assert_select_email+ - Assertions on the HTML body of an e-mail. + # + # Also see HTML::Selector to learn how to use selectors. + module SelectorAssertions + # Select and return all matching elements. + # + # If called with a single argument, uses that argument as a selector + # to match all elements of the current page. Returns an empty array + # if no match is found. + # + # If called with two arguments, uses the first argument as the base + # element and the second argument as the selector. Attempts to match the + # base element and any of its children. Returns an empty array if no + # match is found. + # + # The selector may be a CSS selector expression (String), an expression + # with substitution values (Array) or an HTML::Selector object. + # + # # Selects all div tags + # divs = css_select("div") + # + # # Selects all paragraph tags and does something interesting + # pars = css_select("p") + # pars.each do |par| + # # Do something fun with paragraphs here... + # end + # + # # Selects all list items in unordered lists + # items = css_select("ul>li") + # + # # Selects all form tags and then all inputs inside the form + # forms = css_select("form") + # forms.each do |form| + # inputs = css_select(form, "input") + # ... + # end + def css_select(*args) + # See assert_select to understand what's going on here. + arg = args.shift + + if arg.is_a?(HTML::Node) + root = arg + arg = args.shift + elsif arg == nil + raise ArgumentError, "First argument is either selector or element to select, but nil found. Perhaps you called assert_select with an element that does not exist?" + elsif defined?(@selected) && @selected + matches = [] + + @selected.each do |selected| + subset = css_select(selected, HTML::Selector.new(arg.dup, args.dup)) + subset.each do |match| + matches << match unless matches.any? { |m| m.equal?(match) } + end + end + + return matches + else + root = response_from_page + end + + case arg + when String + selector = HTML::Selector.new(arg, args) + when Array + selector = HTML::Selector.new(*arg) + when HTML::Selector + selector = arg + else raise ArgumentError, "Expecting a selector as the first argument" + end + + selector.select(root) + end + + # An assertion that selects elements and makes one or more equality tests. + # + # If the first argument is an element, selects all matching elements + # starting from (and including) that element and all its children in + # depth-first order. + # + # If no element if specified, calling +assert_select+ selects from the + # response HTML unless +assert_select+ is called from within an +assert_select+ block. + # + # When called with a block +assert_select+ passes an array of selected elements + # to the block. Calling +assert_select+ from the block, with no element specified, + # runs the assertion on the complete set of elements selected by the enclosing assertion. + # Alternatively the array may be iterated through so that +assert_select+ can be called + # separately for each element. + # + # + # ==== Example + # If the response contains two ordered lists, each with four list elements then: + # assert_select "ol" do |elements| + # elements.each do |element| + # assert_select element, "li", 4 + # end + # end + # + # will pass, as will: + # assert_select "ol" do + # assert_select "li", 8 + # end + # + # The selector may be a CSS selector expression (String), an expression + # with substitution values, or an HTML::Selector object. + # + # === Equality Tests + # + # The equality test may be one of the following: + # * <tt>true</tt> - Assertion is true if at least one element selected. + # * <tt>false</tt> - Assertion is true if no element selected. + # * <tt>String/Regexp</tt> - Assertion is true if the text value of at least + # one element matches the string or regular expression. + # * <tt>Integer</tt> - Assertion is true if exactly that number of + # elements are selected. + # * <tt>Range</tt> - Assertion is true if the number of selected + # elements fit the range. + # If no equality test specified, the assertion is true if at least one + # element selected. + # + # To perform more than one equality tests, use a hash with the following keys: + # * <tt>:text</tt> - Narrow the selection to elements that have this text + # value (string or regexp). + # * <tt>:html</tt> - Narrow the selection to elements that have this HTML + # content (string or regexp). + # * <tt>:count</tt> - Assertion is true if the number of selected elements + # is equal to this value. + # * <tt>:minimum</tt> - Assertion is true if the number of selected + # elements is at least this value. + # * <tt>:maximum</tt> - Assertion is true if the number of selected + # elements is at most this value. + # + # 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. + # + # # At least one form element + # assert_select "form" + # + # # Form element includes four input fields + # assert_select "form input", 4 + # + # # Page title is "Welcome" + # assert_select "title", "Welcome" + # + # # Page title is "Welcome" and there is only one title element + # assert_select "title", {count: 1, text: "Welcome"}, + # "Wrong title or more than one title element" + # + # # Page contains no forms + # assert_select "form", false, "This page must contain no forms" + # + # # Test the content and style + # assert_select "body div.header ul.menu" + # + # # Use substitution values + # assert_select "ol>li#?", /item-\d+/ + # + # # All input fields in the form have a name + # assert_select "form input" do + # assert_select "[name=?]", /.+/ # Not empty + # end + def assert_select(*args, &block) + # Start with optional element followed by mandatory selector. + arg = args.shift + @selected ||= nil + + if arg.is_a?(HTML::Node) + # First argument is a node (tag or text, but also HTML root), + # so we know what we're selecting from. + root = arg + arg = args.shift + elsif arg == nil + # This usually happens when passing a node/element that + # happens to be nil. + raise ArgumentError, "First argument is either selector or element to select, but nil found. Perhaps you called assert_select with an element that does not exist?" + elsif @selected + root = HTML::Node.new(nil) + root.children.concat @selected + else + # Otherwise just operate on the response document. + root = response_from_page + end + + # First or second argument is the selector: string and we pass + # all remaining arguments. Array and we pass the argument. Also + # accepts selector itself. + case arg + when String + selector = HTML::Selector.new(arg, args) + when Array + selector = HTML::Selector.new(*arg) + when HTML::Selector + selector = arg + else raise ArgumentError, "Expecting a selector as the first argument" + end + + # Next argument is used for equality tests. + equals = {} + case arg = args.shift + when Hash + equals = arg + when String, Regexp + equals[:text] = arg + when Integer + equals[:count] = arg + when Range + equals[:minimum] = arg.begin + equals[:maximum] = arg.end + when FalseClass + equals[:count] = 0 + when NilClass, TrueClass + equals[:minimum] = 1 + else raise ArgumentError, "I don't understand what you're trying to match" + end + + # By default we're looking for at least one match. + if equals[:count] + equals[:minimum] = equals[:maximum] = equals[:count] + else + equals[:minimum] = 1 unless equals[:minimum] + end + + # Last argument is the message we use if the assertion fails. + message = args.shift + #- message = "No match made with selector #{selector.inspect}" unless message + if args.shift + raise ArgumentError, "Not expecting that last argument, you either have too many arguments, or they're the wrong type" + end + + matches = selector.select(root) + # If text/html, narrow down to those elements that match it. + content_mismatch = nil + if match_with = equals[:text] + matches.delete_if do |match| + text = "" + stack = match.children.reverse + while node = stack.pop + if node.tag? + stack.concat node.children.reverse + else + content = node.content + text << content + 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 + end + end + elsif match_with = equals[:html] + matches.delete_if do |match| + html = match.children.map(&:to_s).join + html.strip! unless NO_STRIP.include?(match.name) + unless match_with.is_a?(Regexp) ? (html =~ match_with) : (html == match_with.to_s) + content_mismatch ||= sprintf("<%s> expected but was\n<%s>", match_with, html) + true + end + end + end + # Expecting foo found bar element only if found zero, not if + # found one but expecting two. + message ||= content_mismatch if matches.empty? + # Test minimum/maximum occurrence. + min, max, count = equals[:minimum], equals[:maximum], equals[:count] + + # FIXME: minitest provides messaging when we use assert_operator, + # so is this custom message really needed? + message = message || %(Expected #{count_description(min, max, count)} matching "#{selector.to_s}", found #{matches.size}) + if count + assert_equal count, matches.size, message + else + assert_operator matches.size, :>=, min, message if min + assert_operator matches.size, :<=, max, message if max + end + + # If a block is given call that block. Set @selected to allow + # nested assert_select, which can be nested several levels deep. + if block_given? && !matches.empty? + begin + in_scope, @selected = @selected, matches + yield matches + ensure + @selected = in_scope + end + end + + # Returns all matches elements. + matches + end + + def count_description(min, max, count) #:nodoc: + pluralize = lambda {|word, quantity| word << (quantity == 1 ? '' : 's')} + + if min && max && (max != min) + "between #{min} and #{max} elements" + elsif min && max && max == min && count + "exactly #{count} #{pluralize['element', min]}" + elsif min && !(min == 1 && max == 1) + "at least #{min} #{pluralize['element', min]}" + elsif max + "at most #{max} #{pluralize['element', max]}" + end + end + + # Extracts the content of an element, treats it as encoded HTML and runs + # nested assertion on it. + # + # You typically call this method within another assertion to operate on + # all currently selected elements. You can also pass an element or array + # of elements. + # + # 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. + # + # # 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 + # assert_select "entry>title" do + # # Run assertions on the encoded title elements + # assert_select_encoded do + # assert_select "b" + # end + # end + # end + # + # + # # Selects all paragraph tags from within the description of an RSS feed + # assert_select "rss[version=2.0]" do + # # Select description element of each feed item. + # assert_select "channel>item>description" do + # # Run assertions on the encoded elements. + # assert_select_encoded do + # assert_select "p" + # end + # end + # end + def assert_select_encoded(element = nil, &block) + case element + when Array + elements = element + when HTML::Node + elements = [element] + when nil + unless elements = @selected + raise ArgumentError, "First argument is optional, but must be called from a nested assert_select" + end + else + raise ArgumentError, "Argument is optional, and may be node or array of nodes" + end + + fix_content = lambda do |node| + # Gets around a bug in the Rails 1.1 HTML parser. + node.content.gsub(/<!\[CDATA\[(.*)(\]\]>)?/m) { Rack::Utils.escapeHTML($1) } + end + + selected = elements.map do |elem| + text = elem.children.select{ |c| not c.tag? }.map{ |c| fix_content[c] }.join + root = HTML::Document.new(CGI.unescapeHTML("<encoded>#{text}</encoded>")).root + css_select(root, "encoded:root", &block)[0] + end + + begin + old_selected, @selected = @selected, selected + assert_select ":root", &block + ensure + @selected = old_selected + end + end + + # Extracts the body of an email and runs nested assertions on it. + # + # You must enable deliveries for this assertion to work, use: + # ActionMailer::Base.perform_deliveries = true + # + # assert_select_email do + # assert_select "h1", "Email alert" + # end + # + # assert_select_email do + # items = assert_select "ol>li" + # items.each do + # # Work with items here... + # end + # end + def assert_select_email(&block) + deliveries = ActionMailer::Base.deliveries + assert !deliveries.empty?, "No e-mail in delivery list" + + deliveries.each do |delivery| + (delivery.parts.empty? ? [delivery] : delivery.parts).each do |part| + if part["Content-Type"].to_s =~ /^text\/html\W/ + root = HTML::Document.new(part.body.to_s).root + assert_select root, ":root", &block + end + end + end + end + + protected + # +assert_select+ and +css_select+ call this to obtain the content in the HTML page. + def response_from_page + html_document.root + end + end + end +end diff --git a/actionpack/lib/action_dispatch/testing/assertions/tag.rb b/actionpack/lib/action_dispatch/testing/assertions/tag.rb new file mode 100644 index 0000000000..e5fe30ba82 --- /dev/null +++ b/actionpack/lib/action_dispatch/testing/assertions/tag.rb @@ -0,0 +1,135 @@ +require 'action_view/vendor/html-scanner' + +module ActionDispatch + module Assertions + # Pair of assertions to testing elements in the HTML output of the response. + module TagAssertions + # Asserts that there is a tag/node/element in the body of the response + # that meets all of the given conditions. The +conditions+ parameter must + # be a hash of any of the following keys (all are optional): + # + # * <tt>:tag</tt>: the node type must match the corresponding value + # * <tt>:attributes</tt>: a hash. The node's attributes must match the + # corresponding values in the hash. + # * <tt>:parent</tt>: a hash. The node's parent must match the + # corresponding hash. + # * <tt>:child</tt>: a hash. At least one of the node's immediate children + # must meet the criteria described by the hash. + # * <tt>:ancestor</tt>: a hash. At least one of the node's ancestors must + # meet the criteria described by the hash. + # * <tt>:descendant</tt>: a hash. At least one of the node's descendants + # must meet the criteria described by the hash. + # * <tt>:sibling</tt>: a hash. At least one of the node's siblings must + # meet the criteria described by the hash. + # * <tt>:after</tt>: a hash. The node must be after any sibling meeting + # the criteria described by the hash, and at least one sibling must match. + # * <tt>:before</tt>: a hash. The node must be before any sibling meeting + # the criteria described by the hash, and at least one sibling must match. + # * <tt>:children</tt>: a hash, for counting children of a node. Accepts + # the keys: + # * <tt>:count</tt>: either a number or a range which must equal (or + # include) the number of children that match. + # * <tt>:less_than</tt>: the number of matching children must be less + # than this number. + # * <tt>:greater_than</tt>: the number of matching children must be + # greater than this number. + # * <tt>:only</tt>: another hash consisting of the keys to use + # to match on the children, and only matching children will be + # counted. + # * <tt>:content</tt>: the textual content of the node must match the + # given value. This will not match HTML tags in the body of a + # tag--only text. + # + # Conditions are matched using the following algorithm: + # + # * if the condition is a string, it must be a substring of the value. + # * if the condition is a regexp, it must match the value. + # * if the condition is a number, the value must match number.to_s. + # * if the condition is +true+, the value must not be +nil+. + # * if the condition is +false+ or +nil+, the value must be +nil+. + # + # # Assert that there is a "span" tag + # assert_tag tag: "span" + # + # # Assert that there is a "span" tag with 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 that there is a "span" inside of a "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 that there is a "span" with at least one "em" child + # 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 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" } } + # + # # 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/ } + # + # <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 + # (like br and hr and such) but will not work correctly with tags + # that allow optional closing tags (p, li, td). <em>You must explicitly + # close all of your tags to use these assertions.</em> + def assert_tag(*opts) + opts = opts.size > 1 ? opts.last.merge({ :tag => opts.first.to_s }) : opts.first + tag = find_tag(opts) + assert tag, "expected tag, but no tag found matching #{opts.inspect} in:\n#{@response.body.inspect}" + end + + # Identical to +assert_tag+, but asserts that a matching tag does _not_ + # exist. (See +assert_tag+ for a full discussion of the syntax.) + # + # # Assert that there is not a "div" containing a "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 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" } } + def assert_no_tag(*opts) + opts = opts.size > 1 ? opts.last.merge({ :tag => opts.first.to_s }) : opts.first + tag = find_tag(opts) + assert !tag, "expected no tag, but found tag matching #{opts.inspect} in:\n#{@response.body.inspect}" + end + + def find_tag(conditions) + html_document.find(conditions) + end + + def find_all_tag(conditions) + html_document.find_all(conditions) + end + + def html_document + xml = @response.content_type =~ /xml$/ + @html_document ||= HTML::Document.new(@response.body, false, xml) + end + end + end +end diff --git a/actionpack/lib/action_dispatch/testing/integration.rb b/actionpack/lib/action_dispatch/testing/integration.rb new file mode 100644 index 0000000000..192ccdb9d5 --- /dev/null +++ b/actionpack/lib/action_dispatch/testing/integration.rb @@ -0,0 +1,499 @@ +require 'stringio' +require 'uri' +require 'active_support/core_ext/kernel/singleton_class' +require 'active_support/core_ext/object/try' +require 'rack/test' +require 'minitest' + +module ActionDispatch + module Integration #:nodoc: + module RequestHelpers + # Performs a GET request with the given parameters. + # + # - +path+: The URI (as a String) on which you want to perform a GET + # request. + # - +parameters+: The HTTP parameters that you want to pass. This may + # be +nil+, + # a Hash, or a String that is appropriately encoded + # (<tt>application/x-www-form-urlencoded</tt> or + # <tt>multipart/form-data</tt>). + # - +headers_or_env+: Additional headers to pass, as a Hash. The headers will be + # merged into the Rack env hash. + # + # 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 + # response object. + # + # You can also perform POST, PATCH, PUT, DELETE, and HEAD requests with + # +#post+, +#patch+, +#put+, +#delete+, and +#head+. + def get(path, parameters = nil, headers_or_env = nil) + process :get, path, parameters, headers_or_env + end + + # Performs a POST request with the given parameters. See +#get+ for more + # details. + def post(path, parameters = nil, headers_or_env = nil) + process :post, path, parameters, headers_or_env + end + + # Performs a PATCH request with the given parameters. See +#get+ for more + # details. + def patch(path, parameters = nil, headers_or_env = nil) + process :patch, path, parameters, headers_or_env + end + + # Performs a PUT request with the given parameters. See +#get+ for more + # details. + def put(path, parameters = nil, headers_or_env = nil) + process :put, path, parameters, headers_or_env + end + + # Performs a DELETE request with the given parameters. See +#get+ for + # more details. + def delete(path, parameters = nil, headers_or_env = nil) + process :delete, path, parameters, headers_or_env + end + + # Performs a HEAD request with the given parameters. See +#get+ for more + # details. + def head(path, parameters = nil, headers_or_env = nil) + process :head, path, parameters, headers_or_env + end + + # Performs an XMLHttpRequest request with the given parameters, mirroring + # a request from the Prototype library. + # + # 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. + def xml_http_request(request_method, path, parameters = nil, headers_or_env = nil) + headers_or_env ||= {} + headers_or_env['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest' + headers_or_env['HTTP_ACCEPT'] ||= [Mime::JS, Mime::HTML, Mime::XML, 'text/xml', Mime::ALL].join(', ') + process(request_method, path, parameters, headers_or_env) + end + alias xhr :xml_http_request + + # Follow a single redirect response. If the last response was not a + # redirect, an exception will be raised. Otherwise, the redirect is + # performed on the location header. + def follow_redirect! + raise "not a redirect! #{status} #{status_message}" unless redirect? + get(response.location) + status + end + + # Performs a request using the specified method, following any subsequent + # redirect. Note that the redirects are followed until the response is + # not a redirect--this means you may run into an infinite loop if your + # redirect loops back to itself. + def request_via_redirect(http_method, path, parameters = nil, headers_or_env = nil) + process(http_method, path, parameters, headers_or_env) + follow_redirect! while redirect? + status + end + + # Performs a GET request, following any subsequent redirect. + # See +request_via_redirect+ for more information. + def get_via_redirect(path, parameters = nil, headers_or_env = nil) + request_via_redirect(:get, path, parameters, headers_or_env) + end + + # Performs a POST request, following any subsequent redirect. + # See +request_via_redirect+ for more information. + def post_via_redirect(path, parameters = nil, headers_or_env = nil) + request_via_redirect(:post, path, parameters, headers_or_env) + end + + # Performs a PATCH request, following any subsequent redirect. + # See +request_via_redirect+ for more information. + def patch_via_redirect(path, parameters = nil, headers_or_env = nil) + request_via_redirect(:patch, path, parameters, headers_or_env) + end + + # Performs a PUT request, following any subsequent redirect. + # See +request_via_redirect+ for more information. + def put_via_redirect(path, parameters = nil, headers_or_env = nil) + request_via_redirect(:put, path, parameters, headers_or_env) + end + + # Performs a DELETE request, following any subsequent redirect. + # See +request_via_redirect+ for more information. + def delete_via_redirect(path, parameters = nil, headers_or_env = nil) + request_via_redirect(:delete, path, parameters, headers_or_env) + end + end + + # An instance of this class represents a set of requests and responses + # performed sequentially by a test process. Because you can instantiate + # multiple sessions and run them side-by-side, you can also mimic (to some + # limited extent) multiple simultaneous users interacting with your system. + # + # Typically, you will instantiate a new session using + # IntegrationTest#open_session, rather than instantiating + # Integration::Session directly. + class Session + DEFAULT_HOST = "www.example.com" + + include Minitest::Assertions + include TestProcess, RequestHelpers, Assertions + + %w( status status_message headers body redirect? ).each do |method| + delegate method, :to => :response, :allow_nil => true + end + + %w( path ).each do |method| + delegate method, :to => :request, :allow_nil => true + end + + # The hostname used in the last request. + def host + @host || DEFAULT_HOST + end + attr_writer :host + + # The remote_addr used in the last request. + attr_accessor :remote_addr + + # The Accept header to send. + attr_accessor :accept + + # A map of the cookies returned by the last response, and which will be + # sent with the next request. + def cookies + _mock_session.cookie_jar + end + + # A reference to the controller instance used by the last request. + attr_reader :controller + + # A reference to the request instance used by the last request. + attr_reader :request + + # A reference to the response instance used by the last request. + attr_reader :response + + # A running counter of the number of requests processed. + attr_accessor :request_count + + include ActionDispatch::Routing::UrlFor + + # Create and initialize a new Session instance. + def initialize(app) + super() + @app = app + + # 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) + singleton_class.class_eval do + include app.routes.url_helpers + include app.routes.mounted_helpers + end + end + + reset! + end + + 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) + 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 + # in an existing session instance, so it can be used from a clean-slate + # condition. + # + # session.reset! + def reset! + @https = false + @controller = @request = @response = nil + @_mock_session = nil + @request_count = 0 + @url_options = nil + + self.host = DEFAULT_HOST + self.remote_addr = "127.0.0.1" + self.accept = "text/xml,application/xml,application/xhtml+xml," + + "text/html;q=0.9,text/plain;q=0.8,image/png," + + "*/*;q=0.5" + + unless defined? @named_routes_configured + # the helpers are made protected by default--we make them public for + # easier access during testing and troubleshooting. + @named_routes_configured = true + end + end + + # Specify whether or not the session should mimic a secure HTTPS request. + # + # session.https! + # session.https!(false) + def https!(flag = true) + @https = flag + end + + # Returns +true+ if the session is mimicking a secure HTTPS request. + # + # if session.https? + # ... + # end + def https? + @https + end + + # Set the host name to use in the next request. + # + # session.host! "www.example.com" + alias :host! :host= + + private + def _mock_session + @_mock_session ||= Rack::MockSession.new(@app, host) + end + + # Performs the actual request. + def process(method, path, parameters = nil, headers_or_env = nil) + if path =~ %r{://} + location = URI.parse(path) + https! URI::HTTPS === location if location.scheme + host! "#{location.host}:#{location.port}" if location.host + path = location.query ? "#{location.path}?#{location.query}" : location.path + end + + hostname, port = host.split(':') + + env = { + :method => method, + :params => parameters, + + "SERVER_NAME" => hostname, + "SERVER_PORT" => port || (https? ? "443" : "80"), + "HTTPS" => https? ? "on" : "off", + "rack.url_scheme" => https? ? "https" : "http", + + "REQUEST_URI" => path, + "HTTP_HOST" => host, + "REMOTE_ADDR" => remote_addr, + "CONTENT_TYPE" => "application/x-www-form-urlencoded", + "HTTP_ACCEPT" => accept + } + # this modifies the passed env directly + Http::Headers.new(env).merge!(headers_or_env || {}) + + session = Rack::Test::Session.new(_mock_session) + + # NOTE: rack-test v0.5 doesn't build a default uri correctly + # Make sure requested path is always a full uri + session.request(build_full_uri(path, env), env) + + @request_count += 1 + @request = ActionDispatch::Request.new(session.last_request.env) + 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'] + + return response.status + end + + def build_full_uri(path, env) + "#{env['rack.url_scheme']}://#{env['SERVER_NAME']}:#{env['SERVER_PORT']}#{path}" + end + end + + module Runner + include ActionDispatch::Assertions + + def app + @app ||= nil + end + + # Reset the current session. This is useful for testing multiple sessions + # in a single test case. + def reset! + @integration_session = Integration::Session.new(app) + end + + %w(get post patch put head delete cookies assigns + xml_http_request xhr get_via_redirect post_via_redirect).each do |method| + define_method(method) do |*args| + reset! unless integration_session + reset_template_assertion + # reset the html_document variable, but only for new get/post calls + @html_document = nil unless method == 'cookies' || method == 'assigns' + integration_session.__send__(method, *args).tap do + copy_session_variables! + end + end + end + + # Open a new session instance. If a block is given, the new session is + # yielded to the block before being returned. + # + # session = open_session do |sess| + # sess.extend(CustomAssertions) + # end + # + # By default, a single session is automatically created for you, but you + # can use this method to open multiple sessions that ought to be tested + # simultaneously. + def open_session + dup.tap do |session| + yield session if block_given? + end + end + + # Copy the instance variables from the current session instance into the + # test instance. + def copy_session_variables! #:nodoc: + return unless integration_session + %w(controller response request).each do |var| + instance_variable_set("@#{var}", @integration_session.__send__(var)) + end + end + + def default_url_options + reset! unless integration_session + integration_session.default_url_options + end + + def default_url_options=(options) + reset! unless integration_session + integration_session.default_url_options = options + end + + def respond_to?(method, include_private = false) + integration_session.respond_to?(method, include_private) || super + end + + # Delegate unhandled messages to the current session instance. + def method_missing(sym, *args, &block) + reset! unless integration_session + if integration_session.respond_to?(sym) + integration_session.__send__(sym, *args, &block).tap do + copy_session_variables! + end + else + super + end + end + + private + def integration_session + @integration_session ||= nil + end + end + end + + # An integration test spans multiple controllers and actions, + # tying them all together to ensure they work together as expected. It tests + # more completely than either unit or functional tests do, exercising the + # entire stack, from the dispatcher to the database. + # + # At its simplest, you simply extend <tt>IntegrationTest</tt> and write your tests + # using the get/post methods: + # + # require "test_helper" + # + # class ExampleTest < ActionDispatch::IntegrationTest + # fixtures :people + # + # def test_login + # # get the login page + # get "/login" + # assert_equal 200, status + # + # # post the login and follow through to the home page + # post "/login", username: people(:jamis).username, + # password: people(:jamis).password + # follow_redirect! + # assert_equal 200, status + # assert_equal "/home", path + # end + # end + # + # However, you can also have multiple session instances open per test, and + # even extend those instances with assertions and methods to create a very + # powerful testing DSL that is specific for your application. You can even + # reference any named routes you happen to have defined. + # + # require "test_helper" + # + # class AdvancedTest < ActionDispatch::IntegrationTest + # fixtures :people, :rooms + # + # def test_login_and_speak + # jamis, david = login(:jamis), login(:david) + # room = rooms(:office) + # + # jamis.enter(room) + # jamis.speak(room, "anybody home?") + # + # david.enter(room) + # david.speak(room, "hello!") + # end + # + # private + # + # module CustomAssertions + # def enter(room) + # # reference a named route, for maximum internal consistency! + # get(room_url(id: room.id)) + # assert(...) + # ... + # end + # + # def speak(room, message) + # xml_http_request "/say/#{room.id}", message: message + # assert(...) + # ... + # end + # end + # + # def login(who) + # open_session do |sess| + # sess.extend(CustomAssertions) + # who = people(who) + # sess.post "/login", username: who.username, + # password: who.password + # assert(...) + # end + # end + # end + class IntegrationTest < ActiveSupport::TestCase + include Integration::Runner + include ActionController::TemplateAssertions + include ActionDispatch::Routing::UrlFor + + @@app = nil + + def self.app + @@app || ActionDispatch.test_app + end + + def self.app=(app) + @@app = app + end + + 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 new file mode 100644 index 0000000000..630e6a9b78 --- /dev/null +++ b/actionpack/lib/action_dispatch/testing/test_process.rb @@ -0,0 +1,44 @@ +require 'action_dispatch/middleware/cookies' +require 'action_dispatch/middleware/flash' +require 'active_support/core_ext/hash/indifferent_access' + +module ActionDispatch + module TestProcess + def assigns(key = nil) + assigns = {}.with_indifferent_access + @controller.view_assigns.each { |k, v| assigns.regular_writer(k, v) } + key.nil? ? assigns : assigns[key] + end + + def session + @request.session + end + + def flash + @request.flash + end + + def cookies + @request.cookie_jar + end + + def redirect_to_url + @response.redirect_url + end + + # 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') + # + # 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) + def fixture_file_upload(path, mime_type = nil, binary = false) + 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 new file mode 100644 index 0000000000..de3dc5f924 --- /dev/null +++ b/actionpack/lib/action_dispatch/testing/test_request.rb @@ -0,0 +1,78 @@ +require 'active_support/core_ext/hash/indifferent_access' +require 'rack/utils' + +module ActionDispatch + class TestRequest < Request + DEFAULT_ENV = Rack::MockRequest.env_for('/', + 'HTTP_HOST' => 'test.host', + 'REMOTE_ADDR' => '0.0.0.0', + 'HTTP_USER_AGENT' => 'Rails Testing' + ) + + def self.new(env = {}) + super + end + + def initialize(env = {}) + env = Rails.application.env_config.merge(env) if defined?(Rails.application) && Rails.application + super(default_env.merge(env)) + end + + def request_method=(method) + @env['REQUEST_METHOD'] = method.to_s.upcase + end + + def host=(host) + @env['HTTP_HOST'] = host + end + + def port=(number) + @env['SERVER_PORT'] = number.to_i + end + + def request_uri=(uri) + @env['REQUEST_URI'] = uri + end + + def path=(path) + @env['PATH_INFO'] = path + end + + def action=(action_name) + path_parameters[:action] = action_name.to_s + end + + def if_modified_since=(last_modified) + @env['HTTP_IF_MODIFIED_SINCE'] = last_modified + end + + def if_none_match=(etag) + @env['HTTP_IF_NONE_MATCH'] = etag + end + + def remote_addr=(addr) + @env['REMOTE_ADDR'] = addr + end + + def user_agent=(user_agent) + @env['HTTP_USER_AGENT'] = user_agent + end + + def accept=(mime_types) + @env.delete('action_dispatch.request.accepts') + @env['HTTP_ACCEPT'] = Array(mime_types).collect { |mime_type| mime_type.to_s }.join(",") + end + + alias :rack_cookies :cookies + + def cookies + @cookies ||= {}.with_indifferent_access + end + + private + + def default_env + DEFAULT_ENV + end + end +end diff --git a/actionpack/lib/action_dispatch/testing/test_response.rb b/actionpack/lib/action_dispatch/testing/test_response.rb new file mode 100644 index 0000000000..82039e72e7 --- /dev/null +++ b/actionpack/lib/action_dispatch/testing/test_response.rb @@ -0,0 +1,29 @@ +module ActionDispatch + # Integration test methods such as ActionDispatch::Integration::Session#get + # and ActionDispatch::Integration::Session#post return objects of class + # TestResponse, which represent the HTTP response results of the requested + # controller actions. + # + # See Response for more information on controller response objects. + class TestResponse < Response + def self.from_response(response) + new.tap do |resp| + resp.status = response.status + resp.headers = response.headers + resp.body = response.body + end + end + + # Was the response successful? + alias_method :success?, :successful? + + # Was the URL not found? + alias_method :missing?, :not_found? + + # Were we redirected? + alias_method :redirect?, :redirection? + + # Was there a server-side error? + alias_method :error?, :server_error? + end +end |