diff options
Diffstat (limited to 'actionpack/lib/action_dispatch')
106 files changed, 15313 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..a8febc32b3 --- /dev/null +++ b/actionpack/lib/action_dispatch/http/cache.rb @@ -0,0 +1,220 @@ +# frozen_string_literal: true + +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 = get_header(HTTP_IF_MODIFIED_SINCE) + Time.rfc2822(since) rescue nil + end + end + + def if_none_match + get_header HTTP_IF_NONE_MATCH + end + + def if_none_match_etags + if_none_match ? if_none_match.split(/\s*,\s*/) : [] + end + + def not_modified?(modified_at) + if_modified_since && modified_at && if_modified_since >= modified_at + end + + def etag_matches?(etag) + if etag + validators = if_none_match_etags + validators.include?(etag) || validators.include?("*") + 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 + + def last_modified + if last = get_header(LAST_MODIFIED) + Time.httpdate(last) + end + end + + def last_modified? + has_header? LAST_MODIFIED + end + + def last_modified=(utc_time) + set_header LAST_MODIFIED, utc_time.httpdate + end + + def date + if date_header = get_header(DATE) + Time.httpdate(date_header) + end + end + + def date? + has_header? DATE + end + + def date=(utc_time) + set_header DATE, utc_time.httpdate + end + + # This method sets a weak ETag validator on the response so browsers + # and proxies may cache the response, keyed on the ETag. On subsequent + # requests, the If-None-Match header is set to the cached ETag. If it + # matches the current ETag, we can return a 304 Not Modified response + # with no body, letting the browser or proxy know that their cache is + # current. Big savings in request time and network bandwidth. + # + # Weak ETags are considered to be semantically equivalent but not + # byte-for-byte identical. This is perfect for browser caching of HTML + # pages where we don't care about exact equality, just what the user + # is viewing. + # + # Strong ETags are considered byte-for-byte identical. They allow a + # browser or proxy cache to support Range requests, useful for paging + # through a PDF file or scrubbing through a video. Some CDNs only + # support strong ETags and will ignore weak ETags entirely. + # + # Weak ETags are what we almost always need, so they're the default. + # Check out #strong_etag= to provide a strong ETag validator. + def etag=(weak_validators) + self.weak_etag = weak_validators + end + + def weak_etag=(weak_validators) + set_header "ETag", generate_weak_etag(weak_validators) + end + + def strong_etag=(strong_validators) + set_header "ETag", generate_strong_etag(strong_validators) + end + + def etag?; etag; end + + # True if an ETag is set and it's a weak validator (preceded with W/) + def weak_etag? + etag? && etag.starts_with?('W/"') + end + + # True if an ETag is set and it isn't a weak validator (not preceded with W/) + def strong_etag? + etag? && !weak_etag? + end + + private + + DATE = "Date".freeze + LAST_MODIFIED = "Last-Modified".freeze + SPECIAL_KEYS = Set.new(%w[extras no-cache max-age public private must-revalidate]) + + def generate_weak_etag(validators) + "W/#{generate_strong_etag(validators)}" + end + + def generate_strong_etag(validators) + %("#{ActiveSupport::Digest.hexdigest(ActiveSupport::Cache.expand_cache_key(validators))}") + end + + def cache_control_segments + if cache_control = _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 + 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 handle_conditional_get! + # Normally default cache control setting is handled by ETag + # middleware. But, if an etag is already set, the middleware + # defaults to `no-cache` unless a default `Cache-Control` value is + # previously set. So, set a default one here. + if (etag? || last_modified?) && !self._cache_control + self._cache_control = DEFAULT_CACHE_CONTROL + end + end + + def merge_and_normalize_cache_control!(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? + # Let middleware handle default behavior + elsif control[:no_cache] + self._cache_control = NO_CACHE + if control[:extras] + self._cache_control = _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 + + self._cache_control = options.join(", ") + end + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/http/content_security_policy.rb b/actionpack/lib/action_dispatch/http/content_security_policy.rb new file mode 100644 index 0000000000..a3407c9698 --- /dev/null +++ b/actionpack/lib/action_dispatch/http/content_security_policy.rb @@ -0,0 +1,263 @@ +# frozen_string_literal: true + +require "active_support/core_ext/object/deep_dup" + +module ActionDispatch #:nodoc: + class ContentSecurityPolicy + class Middleware + CONTENT_TYPE = "Content-Type".freeze + POLICY = "Content-Security-Policy".freeze + POLICY_REPORT_ONLY = "Content-Security-Policy-Report-Only".freeze + + def initialize(app) + @app = app + end + + def call(env) + request = ActionDispatch::Request.new env + _, headers, _ = response = @app.call(env) + + return response unless html_response?(headers) + return response if policy_present?(headers) + + if policy = request.content_security_policy + if policy.directives["script-src"] + if nonce = request.content_security_policy_nonce + policy.directives["script-src"] << "'nonce-#{nonce}'" + end + end + + headers[header_name(request)] = policy.build(request.controller_instance) + end + + response + end + + private + + def html_response?(headers) + if content_type = headers[CONTENT_TYPE] + content_type =~ /html/ + end + end + + def header_name(request) + if request.content_security_policy_report_only + POLICY_REPORT_ONLY + else + POLICY + end + end + + def policy_present?(headers) + headers[POLICY] || headers[POLICY_REPORT_ONLY] + end + end + + module Request + POLICY = "action_dispatch.content_security_policy".freeze + POLICY_REPORT_ONLY = "action_dispatch.content_security_policy_report_only".freeze + NONCE_GENERATOR = "action_dispatch.content_security_policy_nonce_generator".freeze + NONCE = "action_dispatch.content_security_policy_nonce".freeze + + def content_security_policy + get_header(POLICY) + end + + def content_security_policy=(policy) + set_header(POLICY, policy) + end + + def content_security_policy_report_only + get_header(POLICY_REPORT_ONLY) + end + + def content_security_policy_report_only=(value) + set_header(POLICY_REPORT_ONLY, value) + end + + def content_security_policy_nonce_generator + get_header(NONCE_GENERATOR) + end + + def content_security_policy_nonce_generator=(generator) + set_header(NONCE_GENERATOR, generator) + end + + def content_security_policy_nonce + if content_security_policy_nonce_generator + if nonce = get_header(NONCE) + nonce + else + set_header(NONCE, generate_content_security_policy_nonce) + end + end + end + + private + + def generate_content_security_policy_nonce + content_security_policy_nonce_generator.call(self) + end + end + + MAPPINGS = { + self: "'self'", + unsafe_eval: "'unsafe-eval'", + unsafe_inline: "'unsafe-inline'", + none: "'none'", + http: "http:", + https: "https:", + data: "data:", + mediastream: "mediastream:", + blob: "blob:", + filesystem: "filesystem:", + report_sample: "'report-sample'", + strict_dynamic: "'strict-dynamic'" + }.freeze + + DIRECTIVES = { + base_uri: "base-uri", + child_src: "child-src", + connect_src: "connect-src", + default_src: "default-src", + font_src: "font-src", + form_action: "form-action", + frame_ancestors: "frame-ancestors", + frame_src: "frame-src", + img_src: "img-src", + manifest_src: "manifest-src", + media_src: "media-src", + object_src: "object-src", + script_src: "script-src", + style_src: "style-src", + worker_src: "worker-src" + }.freeze + + private_constant :MAPPINGS, :DIRECTIVES + + attr_reader :directives + + def initialize + @directives = {} + yield self if block_given? + end + + def initialize_copy(other) + @directives = other.directives.deep_dup + end + + DIRECTIVES.each do |name, directive| + define_method(name) do |*sources| + if sources.first + @directives[directive] = apply_mappings(sources) + else + @directives.delete(directive) + end + end + end + + def block_all_mixed_content(enabled = true) + if enabled + @directives["block-all-mixed-content"] = true + else + @directives.delete("block-all-mixed-content") + end + end + + def plugin_types(*types) + if types.first + @directives["plugin-types"] = types + else + @directives.delete("plugin-types") + end + end + + def report_uri(uri) + @directives["report-uri"] = [uri] + end + + def require_sri_for(*types) + if types.first + @directives["require-sri-for"] = types + else + @directives.delete("require-sri-for") + end + end + + def sandbox(*values) + if values.empty? + @directives["sandbox"] = true + elsif values.first + @directives["sandbox"] = values + else + @directives.delete("sandbox") + end + end + + def upgrade_insecure_requests(enabled = true) + if enabled + @directives["upgrade-insecure-requests"] = true + else + @directives.delete("upgrade-insecure-requests") + end + end + + def build(context = nil) + build_directives(context).compact.join("; ") + end + + private + def apply_mappings(sources) + sources.map do |source| + case source + when Symbol + apply_mapping(source) + when String, Proc + source + else + raise ArgumentError, "Invalid content security policy source: #{source.inspect}" + end + end + end + + def apply_mapping(source) + MAPPINGS.fetch(source) do + raise ArgumentError, "Unknown content security policy source mapping: #{source.inspect}" + end + end + + def build_directives(context) + @directives.map do |directive, sources| + if sources.is_a?(Array) + "#{directive} #{build_directive(sources, context).join(' ')}" + elsif sources + directive + else + nil + end + end + end + + def build_directive(sources, context) + sources.map { |source| resolve_source(source, context) } + end + + def resolve_source(source, context) + case source + when String + source + when Symbol + source.to_s + when Proc + if context.nil? + raise RuntimeError, "Missing context for the dynamic content security policy source: #{source.inspect}" + else + context.instance_exec(&source) + end + else + raise RuntimeError, "Unexpected content security policy source: #{source.inspect}" + 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..ec86b8bc47 --- /dev/null +++ b/actionpack/lib/action_dispatch/http/filter_parameters.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +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. Filtering only certain sub-keys + # from a hash is possible by using the dot notation: 'credit_card.number'. + # If a block is given, each key and value of the params hash and all + # sub-hashes is passed to it, where the value or the 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"] = [ "credit_card.code" ] + # => replaces { credit_card: {code: "xxxx"} } with "[FILTERED]", does not + # change { file: { code: "xxxx"} } + # + # env["action_dispatch.parameter_filter"] = -> (k, v) do + # 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 + super + @filtered_parameters = nil + @filtered_env = nil + @filtered_path = nil + end + + # Returns a hash of parameters with all sensitive data replaced. + def filtered_parameters + @filtered_parameters ||= parameter_filter.filter(parameters) + end + + # Returns a hash of request.env with all sensitive data replaced. + def filtered_env + @filtered_env ||= env_filter.filter(@env) + end + + # Reconstructs a path with all sensitive GET parameters replaced. + def filtered_path + @filtered_path ||= query_string.empty? ? path : "#{path}?#{filtered_query_string}" + end + + private + + def parameter_filter # :doc: + parameter_filter_for fetch_header("action_dispatch.parameter_filter") { + return NULL_PARAM_FILTER + } + end + + def env_filter # :doc: + user_key = fetch_header("action_dispatch.parameter_filter") { + return NULL_ENV_FILTER + } + parameter_filter_for(Array(user_key) + ENV_MATCH) + end + + def parameter_filter_for(filters) # :doc: + ParameterFilter.new(filters) + end + + KV_RE = "[^&;=]+" + PAIR_RE = %r{(#{KV_RE})=(#{KV_RE})} + def filtered_query_string # :doc: + 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..25394fe5dd --- /dev/null +++ b/actionpack/lib/action_dispatch/http/filter_redirect.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module ActionDispatch + module Http + module FilterRedirect + FILTERED = "[FILTERED]".freeze # :nodoc: + + def filtered_location # :nodoc: + if location_filter_match? + FILTERED + else + location + end + end + + private + + def location_filters + if request + request.get_header("action_dispatch.redirect_filter") || [] + else + [] + end + end + + def location_filter_match? + location_filters.any? do |filter| + if String === filter + location.include?(filter) + elsif Regexp === filter + location =~ 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..c3c2a9d8c5 --- /dev/null +++ b/actionpack/lib/action_dispatch/http/headers.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +module ActionDispatch + module Http + # Provides access to the request's HTTP headers from the environment. + # + # env = { "CONTENT_TYPE" => "text/plain", "HTTP_USER_AGENT" => "curl/7.43.0" } + # headers = ActionDispatch::Http::Headers.from_hash(env) + # headers["Content-Type"] # => "text/plain" + # headers["User-Agent"] # => "curl/7.43.0" + # + # Also note that when headers are mapped to CGI-like variables by the Rack + # server, both dashes and underscores are converted to underscores. This + # ambiguity cannot be resolved at this stage anymore. Both underscores and + # dashes have to be interpreted as if they were originally sent as dashes. + # + # # GET / HTTP/1.1 + # # ... + # # User-Agent: curl/7.43.0 + # # X_Custom_Header: token + # + # headers["X_Custom_Header"] # => nil + # headers["X-Custom-Header"] # => "token" + 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 + + def self.from_hash(hash) + new ActionDispatch::Request.new hash + end + + def initialize(request) # :nodoc: + @req = request + end + + # Returns the value for the given key mapped to @env. + def [](key) + @req.get_header env_name(key) + end + + # Sets the given value for the key mapped to @env. + def []=(key, value) + @req.set_header env_name(key), value + end + + # Add a value to a multivalued header like Vary or Accept-Encoding. + def add(key, value) + @req.add_header env_name(key), value + end + + def key?(key) + @req.has_header? env_name(key) + end + alias :include? :key? + + DEFAULT = Object.new # :nodoc: + + # 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, default = DEFAULT) + @req.fetch_header(env_name(key)) do + return default unless default == DEFAULT + return yield if block_given? + raise KeyError, key + end + end + + def each(&block) + @req.each_header(&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 = @req.dup.headers + 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| + @req.set_header env_name(key), value + end + end + + def env; @req.env.dup; end + + private + + # Converts an 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..d7435fa8df --- /dev/null +++ b/actionpack/lib/action_dispatch/http/mime_negotiation.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +require "active_support/core_ext/module/attribute_accessors" + +module ActionDispatch + module Http + module MimeNegotiation + extend ActiveSupport::Concern + + included do + mattr_accessor :ignore_accept_header, default: false + end + + # The MIME type of the HTTP request, such as Mime[:xml]. + def content_mime_type + fetch_header("action_dispatch.request.content_type") do |k| + v = if get_header("CONTENT_TYPE") =~ /^([^,\;]*)/ + Mime::Type.lookup($1.strip.downcase) + else + nil + end + set_header k, v + end + end + + def content_type + content_mime_type && content_mime_type.to_s + end + + def has_content_type? # :nodoc: + get_header "CONTENT_TYPE" + end + + # Returns the accepted MIME type for the request. + def accepts + fetch_header("action_dispatch.request.accepts") do |k| + header = get_header("HTTP_ACCEPT").to_s.strip + + v = if header.empty? + [content_mime_type] + else + Mime::Type.parse(header) + end + set_header k, v + 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 + fetch_header("action_dispatch.request.formats") do |k| + params_readable = begin + parameters[:format] + rescue ActionController::BadRequest + false + end + + v = if params_readable + Array(Mime[parameters[:format]]) + elsif use_accept_header && valid_accept_header + accepts + elsif extension_format = format_from_path_extension + [extension_format] + elsif xhr? + [Mime[:js]] + else + [Mime[:html]] + end + set_header k, v + end + end + + # Sets the \variant for template. + def variant=(variant) + variant = Array(variant) + + if variant.all? { |v| v.is_a?(Symbol) } + @variant = ActiveSupport::ArrayInquirer.new(variant) + else + raise ArgumentError, "request.variant must be set to a Symbol or an Array of Symbols. " \ + "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 + + def variant + @variant ||= ActiveSupport::ArrayInquirer.new + 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 + set_header "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 + set_header "action_dispatch.request.formats", extensions.collect { |extension| + Mime::Type.lookup_by_extension(extension) + } + end + + # Returns the first MIME type that matches the provided array of MIME types. + 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 + + private + + BROWSER_LIKE_ACCEPTS = /,\s*\*\/\*|\*\/\*\s*,/ + + def valid_accept_header # :doc: + (xhr? && (accept.present? || content_mime_type)) || + (accept.present? && accept !~ BROWSER_LIKE_ACCEPTS) + end + + def use_accept_header # :doc: + !self.class.ignore_accept_header + end + + def format_from_path_extension # :doc: + path = get_header("action_dispatch.original_path") || get_header("PATH_INFO") + if match = path && path.match(/\.(\w+)\z/) + Mime[match.captures.first] + end + 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..295539281f --- /dev/null +++ b/actionpack/lib/action_dispatch/http/mime_type.rb @@ -0,0 +1,340 @@ +# frozen_string_literal: true + +# -*- frozen-string-literal: true -*- + +require "singleton" +require "active_support/core_ext/string/starts_ends_with" + +module Mime + class Mimes + include Enumerable + + def initialize + @mimes = [] + @symbols = nil + end + + def each + @mimes.each { |x| yield x } + end + + def <<(type) + @mimes << type + @symbols = nil + end + + def delete_if + @mimes.delete_if { |x| yield x }.tap { @symbols = nil } + end + + def symbols + @symbols ||= map(&:to_sym) + end + end + + SET = Mimes.new + EXTENSION_LOOKUP = {} + LOOKUP = {} + + 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 body: @post.to_ics, mime_type: Mime::Type.lookup("text/calendar") } + # format.xml { render xml: @post } + # end + # end + # end + class Type + 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 == "*/*".freeze # 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 + end + + class AcceptList #:nodoc: + def self.sort!(list) + list.sort! + + text_xml_idx = find_item_by_name list, "text/xml" + app_xml_idx = find_item_by_name list, Mime[:xml].to_s + + # Take care of the broken text/xml entry by renaming or deleting it. + if text_xml_idx && app_xml_idx + app_xml = list[app_xml_idx] + text_xml = list[text_xml_idx] + + app_xml.q = [text_xml.q, app_xml.q].max # Set the q value to the max of the two. + if app_xml_idx > text_xml_idx # Make sure app_xml is ahead of text_xml in the list. + list[app_xml_idx], list[text_xml_idx] = text_xml, app_xml + app_xml_idx, text_xml_idx = text_xml_idx, app_xml_idx + end + list.delete_at(text_xml_idx) # Delete text_xml from the list. + elsif text_xml_idx + list[text_xml_idx].name = Mime[:xml].to_s + end + + # Look for more specific XML-based types and sort them ahead of app/xml. + if app_xml_idx + app_xml = list[app_xml_idx] + idx = app_xml_idx + + while idx < list.length + type = list[idx] + break if type.q < app_xml.q + + if type.name.ends_with? "+xml" + list[app_xml_idx], list[idx] = list[idx], app_xml + app_xml_idx = idx + end + idx += 1 + end + end + + list.map! { |i| Mime::Type.lookup(i.name) }.uniq! + list + end + + def self.find_item_by_name(array, name) + array.index { |item| item.name == name } + 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] || Type.new(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) + new_mime = Type.new(string, symbol, mime_type_synonyms) + + SET << new_mime + + ([string] + mime_type_synonyms).each { |str| LOOKUP[str] = new_mime } unless skip_lookup + ([symbol] + extension_synonyms).each { |ext| EXTENSION_LOOKUP[ext.to_s] = new_mime } + + @register_callbacks.each do |callback| + callback.call(new_mime) + end + new_mime + 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 = [], 0 + accept_header.split(",").each do |header| + params, q = header.split(PARAMETER_SEPARATOR_REGEXP) + + next unless params + params.strip! + next if params.empty? + + params = parse_trailing_star(params) || [params] + + params.each do |m| + list << AcceptItem.new(index, m.to_s, q) + index += 1 + end + end + AcceptList.sort! list + 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(type) + Mime::SET.select { |m| m =~ type } + end + + # This method is opposite of register method. + # + # To unregister a MIME type: + # + # Mime::Type.unregister(:mobile) + def unregister(symbol) + symbol = symbol.downcase + if mime = Mime[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 + end + + attr_reader :hash + + def initialize(string, symbol = nil, synonyms = []) + @symbol, @synonyms = symbol, synonyms + @string = string + @hash = [@string, @synonyms, @symbol].hash + end + + def to_s + @string + end + + def to_str + to_s + end + + def to_sym + @symbol + end + + def ref + symbol || 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 unless mime_type + (@synonyms + [ self ]).any? do |synonym| + synonym.to_s == mime_type.to_s || synonym.to_sym == mime_type.to_sym + end + end + + def eql?(other) + super || (self.class == other.class && + @string == other.string && + @synonyms == other.synonyms && + @symbol == other.symbol) + end + + def =~(mime_type) + return false unless mime_type + regexp = Regexp.new(Regexp.quote(mime_type.to_s)) + @synonyms.any? { |synonym| synonym.to_s =~ regexp } || @string =~ regexp + end + + def html? + symbol == :html || @string =~ /html/ + end + + def all?; false; end + + protected + + attr_reader :string, :synonyms + + 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) + (method.to_s.ends_with? "?") || super + end + end + + class AllType < Type + include Singleton + + def initialize + super "*/*", :all + end + + def all?; true; end + def html?; true; end + end + + # ALL isn't a real MIME type, so we don't register it for lookup with the + # other concrete types. It's a wildcard match that we use for `respond_to` + # negotiation internals. + ALL = AllType.instance + + class NullType + include Singleton + + def nil? + true + end + + def ref; end + + private + def respond_to_missing?(method, _) + method.to_s.ends_with? "?" + end + + 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..342e6de312 --- /dev/null +++ b/actionpack/lib/action_dispatch/http/mime_types.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +# Build list of Mime types for HTTP responses +# https://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 "text/vtt", :vtt, %w(vtt) + +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 "image/svg+xml", :svg + +Mime::Type.register "video/mpeg", :mpeg, [], %w(mpg mpeg mpe) + +Mime::Type.register "audio/mpeg", :mp3, [], %w(mp1 mp2 mp3) +Mime::Type.register "audio/ogg", :ogg, [], %w(oga ogg spx opus) +Mime::Type.register "audio/aac", :m4a, %w( audio/mp4 ), %w(m4a mpg4 aac) + +Mime::Type.register "video/webm", :webm, [], %w(webm) +Mime::Type.register "video/mp4", :mp4, [], %w(mp4 m4v) + +Mime::Type.register "font/otf", :otf, [], %w(otf) +Mime::Type.register "font/ttf", :ttf, [], %w(ttf) +Mime::Type.register "font/woff", :woff, [], %w(woff) +Mime::Type.register "font/woff2", :woff2, [], %w(woff2) + +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 ), %w(yml yaml) + +Mime::Type.register "multipart/form-data", :multipart_form +Mime::Type.register "application/x-www-form-urlencoded", :url_encoded_form + +# https://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) +Mime::Type.register "application/gzip", :gzip, %w(application/x-gzip), %w(gz) 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..1d58964862 --- /dev/null +++ b/actionpack/lib/action_dispatch/http/parameter_filter.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require "active_support/core_ext/object/duplicable" + +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 << Regexp.escape(item.to_s) + end + end + + deep_regexps, regexps = regexps.partition { |r| r.to_s.include?("\\.".freeze) } + deep_strings, strings = strings.partition { |s| s.include?("\\.".freeze) } + + regexps << Regexp.new(strings.join("|".freeze), true) unless strings.empty? + deep_regexps << Regexp.new(deep_strings.join("|".freeze), true) unless deep_strings.empty? + + new regexps, deep_regexps, blocks + end + + attr_reader :regexps, :deep_regexps, :blocks + + def initialize(regexps, deep_regexps, blocks) + @regexps = regexps + @deep_regexps = deep_regexps.any? ? deep_regexps : nil + @blocks = blocks + end + + def call(original_params, parents = []) + filtered_params = original_params.class.new + + original_params.each do |key, value| + parents.push(key) if deep_regexps + if regexps.any? { |r| key =~ r } + value = FILTERED + elsif deep_regexps && (joined = parents.join(".")) && deep_regexps.any? { |r| joined =~ r } + value = FILTERED + elsif value.is_a?(Hash) + value = call(value, parents) + elsif value.is_a?(Array) + value = value.map { |v| v.is_a?(Hash) ? call(v, parents) : v } + elsif blocks.any? + key = key.dup if key.duplicable? + value = value.dup if value.duplicable? + blocks.each { |b| b.call(key, value) } + end + parents.pop if deep_regexps + + 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..8d7431fd6b --- /dev/null +++ b/actionpack/lib/action_dispatch/http/parameters.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +module ActionDispatch + module Http + module Parameters + extend ActiveSupport::Concern + + PARAMETERS_KEY = "action_dispatch.request.path_parameters" + + DEFAULT_PARSERS = { + Mime[:json].symbol => -> (raw_post) { + data = ActiveSupport::JSON.decode(raw_post) + data.is_a?(Hash) ? data : { _json: data } + } + } + + # Raised when raw data from the request cannot be parsed by the parser + # defined for request's content MIME type. + class ParseError < StandardError + def initialize + super($!.message) + end + end + + included do + class << self + # Returns the parameter parsers. + attr_reader :parameter_parsers + end + + self.parameter_parsers = DEFAULT_PARSERS + end + + module ClassMethods + # Configure the parameter parser for a given MIME type. + # + # It accepts a hash where the key is the symbol of the MIME type + # and the value is a proc. + # + # original_parsers = ActionDispatch::Request.parameter_parsers + # xml_parser = -> (raw_post) { Hash.from_xml(raw_post) || {} } + # new_parsers = original_parsers.merge(xml: xml_parser) + # ActionDispatch::Request.parameter_parsers = new_parsers + def parameter_parsers=(parsers) + @parameter_parsers = parsers.transform_keys { |key| key.respond_to?(:symbol) ? key.symbol : key } + end + end + + # Returns both GET and POST \parameters in a single hash. + def parameters + params = get_header("action_dispatch.request.parameters") + return params if params + + params = begin + request_parameters.merge(query_parameters) + rescue EOFError + query_parameters.dup + end + params.merge!(path_parameters) + params = set_binary_encoding(params, params[:controller], params[:action]) + set_header("action_dispatch.request.parameters", params) + params + end + alias :params :parameters + + def path_parameters=(parameters) #:nodoc: + delete_header("action_dispatch.request.parameters") + + parameters = set_binary_encoding(parameters, parameters[:controller], parameters[:action]) + # If any of the path parameters has an invalid encoding then + # raise since it's likely to trigger errors further on. + Request::Utils.check_param_encoding(parameters) + + set_header PARAMETERS_KEY, parameters + rescue Rack::Utils::ParameterTypeError, Rack::Utils::InvalidParameterError => e + raise ActionController::BadRequest.new("Invalid path parameters: #{e.message}") + 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 + get_header(PARAMETERS_KEY) || set_header(PARAMETERS_KEY, {}) + end + + private + + def set_binary_encoding(params, controller, action) + return params unless controller && controller.valid_encoding? + + if binary_params_for?(controller, action) + ActionDispatch::Request::Utils.each_param_value(params) do |param| + param.force_encoding ::Encoding::ASCII_8BIT + end + end + params + end + + def binary_params_for?(controller, action) + controller_class_for(controller).binary_params_for?(action) + rescue NameError + false + end + + def parse_formatted_parameters(parsers) + return yield if content_length.zero? || content_mime_type.nil? + + strategy = parsers.fetch(content_mime_type.symbol) { return yield } + + begin + strategy.call(raw_post) + rescue # JSON or Ruby code block errors. + my_logger = logger || ActiveSupport::Logger.new($stderr) + my_logger.debug "Error occurred while parsing request parameters.\nContents:\n\n#{raw_post}" + + raise ParseError + end + end + + def params_parsers + ActionDispatch::Request.parameter_parsers + 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..3e2d01aea3 --- /dev/null +++ b/actionpack/lib/action_dispatch/http/rack_cache.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +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..3838b84a7a --- /dev/null +++ b/actionpack/lib/action_dispatch/http/request.rb @@ -0,0 +1,430 @@ +# frozen_string_literal: true + +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 + include Rack::Request::Helpers + include ActionDispatch::Http::Cache::Request + include ActionDispatch::Http::MimeNegotiation + include ActionDispatch::Http::Parameters + include ActionDispatch::Http::FilterParameters + include ActionDispatch::Http::URL + include ActionDispatch::ContentSecurityPolicy::Request + include Rack::Request::Env + + 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 + ORIGINAL_SCRIPT_NAME + + HTTP_ACCEPT HTTP_ACCEPT_CHARSET HTTP_ACCEPT_ENCODING + HTTP_ACCEPT_LANGUAGE HTTP_CACHE_CONTROL HTTP_FROM + HTTP_NEGOTIATE HTTP_PRAGMA HTTP_CLIENT_IP + HTTP_X_FORWARDED_FOR HTTP_ORIGIN HTTP_VERSION + HTTP_X_CSRF_TOKEN HTTP_X_REQUEST_ID HTTP_X_FORWARDED_HOST + SERVER_ADDR + ].freeze + + ENV_METHODS.each do |env| + class_eval <<-METHOD, __FILE__, __LINE__ + 1 + def #{env.sub(/^HTTP_/n, '').downcase} # def accept_charset + get_header "#{env}".freeze # get_header "HTTP_ACCEPT_CHARSET".freeze + end # end + METHOD + end + + def self.empty + new({}) + end + + def initialize(env) + super + @method = nil + @request_method = nil + @remote_ip = nil + @original_fullpath = nil + @fullpath = nil + @ip = nil + end + + def commit_cookie_jar! # :nodoc: + end + + PASS_NOT_FOUND = Class.new { # :nodoc: + def self.action(_); self; end + def self.call(_); [404, { "X-Cascade" => "pass" }, []]; end + def self.binary_params_for?(action); false; end + } + + def controller_class + params = path_parameters + params[:action] ||= "index" + controller_class_for(params[:controller]) + end + + def controller_class_for(name) + if name + controller_param = name.underscore + const_name = "#{controller_param.camelize}Controller" + ActiveSupport::Dependencies.constantize(const_name) + else + PASS_NOT_FOUND + end + end + + # Returns true if the request has a header matching the given key parameter. + # + # request.key? :ip_spoofing_check # => true + def key?(key) + has_header? key + end + + # List of HTTP request methods from the following RFCs: + # Hypertext Transfer Protocol -- HTTP/1.1 (https://www.ietf.org/rfc/rfc2616.txt) + # HTTP Extensions for Distributed Authoring -- WEBDAV (https://www.ietf.org/rfc/rfc2518.txt) + # Versioning Extensions to WebDAV (https://www.ietf.org/rfc/rfc3253.txt) + # Ordered Collections Protocol (WebDAV) (https://www.ietf.org/rfc/rfc3648.txt) + # Web Distributed Authoring and Versioning (WebDAV) Access Control Protocol (https://www.ietf.org/rfc/rfc3744.txt) + # Web Distributed Authoring and Versioning (WebDAV) SEARCH (https://www.ietf.org/rfc/rfc5323.txt) + # Calendar Extensions to WebDAV (https://www.ietf.org/rfc/rfc4791.txt) + # PATCH Method for HTTP (https://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(super) + end + + def routes # :nodoc: + get_header("action_dispatch.routes".freeze) + end + + def routes=(routes) # :nodoc: + set_header("action_dispatch.routes".freeze, routes) + end + + def engine_script_name(_routes) # :nodoc: + get_header(_routes.env_key) + end + + def engine_script_name=(name) # :nodoc: + set_header(routes.env_key, name.dup) + end + + def request_method=(request_method) #:nodoc: + if check_method(request_method) + @request_method = set_header("REQUEST_METHOD", request_method) + end + end + + def controller_instance # :nodoc: + get_header("action_controller.instance".freeze) + end + + def controller_instance=(controller) # :nodoc: + set_header("action_controller.instance".freeze, controller) + end + + def http_auth_salt + get_header "action_dispatch.http_auth_salt" + end + + def show_exceptions? # :nodoc: + # We're treating `nil` as "unset", and we want the default setting to be + # `true`. This logic should be extracted to `env_config` and calculated + # once. + !(get_header("action_dispatch.show_exceptions".freeze) == false) + 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(get_header("rack.methodoverride.original_method") || get_header("REQUEST_METHOD")) + end + + # Returns a symbol form of the #method. + def method_symbol + HTTP_METHOD_LOOKUP[method] + end + + # Provides access to the request's HTTP headers, for example: + # + # request.headers["Content-Type"] # => "text/plain" + def headers + @headers ||= Http::Headers.new(self) + end + + # Early Hints is an HTTP/2 status code that indicates hints to help a client start + # making preparations for processing the final response. + # + # If the env contains +rack.early_hints+ then the server accepts HTTP2 push for Link headers. + # + # The +send_early_hints+ method accepts a hash of links as follows: + # + # send_early_hints("Link" => "</style.css>; rel=preload; as=style\n</script.js>; rel=preload") + # + # If you are using +javascript_include_tag+ or +stylesheet_link_tag+ the + # Early Hints headers are included by default if supported. + def send_early_hints(links) + return unless env["rack.early_hints"] + + env["rack.early_hints"].call(links) + 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 ||= (get_header("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? + get_header("HTTP_X_REQUESTED_WITH") =~ /XMLHttpRequest/i + end + alias :xhr? :xml_http_request? + + # Returns the IP address of client as a +String+. + def ip + @ip ||= super + end + + # Returns the IP address of client as a +String+, + # usually set by the RemoteIp middleware. + def remote_ip + @remote_ip ||= (get_header("action_dispatch.remote_ip") || ip).to_s + end + + def remote_ip=(remote_ip) + set_header "action_dispatch.remote_ip".freeze, remote_ip + end + + ACTION_DISPATCH_REQUEST_ID = "action_dispatch.request_id".freeze # :nodoc: + + # Returns the unique request id, which is based on 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 request_id + get_header ACTION_DISPATCH_REQUEST_ID + end + + def request_id=(id) # :nodoc: + set_header ACTION_DISPATCH_REQUEST_ID, id + end + + alias_method :uuid, :request_id + + # Returns the lowercase name of the HTTP server software. + def server_software + (get_header("SERVER_SOFTWARE") && /^([a-zA-Z]+)/ =~ get_header("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 has_header? "RAW_POST_DATA" + raw_post_body = body + set_header("RAW_POST_DATA", raw_post_body.read(content_length)) + raw_post_body.rewind if raw_post_body.respond_to?(:rewind) + end + get_header "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 = get_header("RAW_POST_DATA") + raw_post = raw_post.dup.force_encoding(Encoding::BINARY) + StringIO.new(raw_post) + else + body_stream + end + end + + # Determine whether the request body contains form-data by checking + # the request Content-Type for one of the media-types: + # "application/x-www-form-urlencoded" or "multipart/form-data". The + # list of form-data media types can be modified through the + # +FORM_DATA_MEDIA_TYPES+ array. + # + # A request body is not assumed to contain form-data when no + # Content-Type header is provided and the request_method is POST. + def form_data? + FORM_DATA_MEDIA_TYPES.include?(media_type) + end + + def body_stream #:nodoc: + get_header("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 + end + + def session=(session) #:nodoc: + Session.set self, session + end + + def session_options=(options) + Session::Options.set self, options + end + + # Override Rack's GET method to support indifferent access. + def GET + fetch_header("action_dispatch.request.query_parameters") do |k| + rack_query_params = super || {} + # Check for non UTF-8 parameter values, which would cause errors later + Request::Utils.check_param_encoding(rack_query_params) + set_header k, Request::Utils.normalize_encode_params(rack_query_params) + end + rescue Rack::Utils::ParameterTypeError, Rack::Utils::InvalidParameterError => e + raise ActionController::BadRequest.new("Invalid query parameters: #{e.message}") + end + alias :query_parameters :GET + + # Override Rack's POST method to support indifferent access. + def POST + fetch_header("action_dispatch.request.request_parameters") do + pr = parse_formatted_parameters(params_parsers) do |params| + super || {} + end + self.request_parameters = Request::Utils.normalize_encode_params(pr) + end + rescue Http::Parameters::ParseError # one of the parse strategies blew up + self.request_parameters = Request::Utils.normalize_encode_params(super || {}) + raise + rescue Rack::Utils::ParameterTypeError, Rack::Utils::InvalidParameterError => e + raise ActionController::BadRequest.new("Invalid request parameters: #{e.message}") + 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 + get_header("HTTP_AUTHORIZATION") || + get_header("X-HTTP_AUTHORIZATION") || + get_header("X_HTTP_AUTHORIZATION") || + get_header("REDIRECT_X_HTTP_AUTHORIZATION") + end + + # True if the request came from localhost, 127.0.0.1, or ::1. + def local? + LOCALHOST =~ remote_addr && LOCALHOST =~ remote_ip + end + + def request_parameters=(params) + raise if params.nil? + set_header("action_dispatch.request.request_parameters".freeze, params) + end + + def logger + get_header("action_dispatch.logger".freeze) + end + + def commit_flash + end + + def ssl? + super || scheme == "wss".freeze + end + + private + def check_method(name) + HTTP_METHOD_LOOKUP[name] || raise(ActionController::UnknownHttpMethod, "#{name}, accepted HTTP methods are #{HTTP_METHODS[0...-1].join(', ')}, and #{HTTP_METHODS[-1]}") + 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..7e50cb6d23 --- /dev/null +++ b/actionpack/lib/action_dispatch/http/response.rb @@ -0,0 +1,520 @@ +# frozen_string_literal: true + +require "active_support/core_ext/module/attribute_accessors" +require "action_dispatch/http/filter_redirect" +require "action_dispatch/http/cache" +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 + class Header < DelegateClass(Hash) # :nodoc: + def initialize(response, header) + @response = response + super(header) + end + + def []=(k, v) + if @response.sending? || @response.sent? + raise ActionDispatch::IllegalStateError, "header already sent" + end + + super + end + + def merge(other) + self.class.new @response, __getobj__.merge(other) + end + + def to_hash + __getobj__.dup + end + end + + # The request that the response is responding to. + attr_accessor :request + + # The HTTP status code. + attr_reader :status + + # Get headers for this response. + attr_reader :header + + alias_method :headers, :header + + delegate :[], :[]=, to: :@header + + def each(&block) + sending! + x = @stream.each(&block) + sent! + x + end + + CONTENT_TYPE = "Content-Type".freeze + SET_COOKIE = "Set-Cookie".freeze + LOCATION = "Location".freeze + NO_CONTENT_CODES = [100, 101, 102, 204, 205, 304] + + cattr_accessor :default_charset, default: "utf-8" + cattr_accessor :default_headers + + include Rack::Response::Helpers + # Aliasing these off because AD::Http::Cache::Response defines them. + alias :_cache_control :cache_control + alias :_cache_control= :cache_control= + + include ActionDispatch::Http::FilterRedirect + include ActionDispatch::Http::Cache::Response + include MonitorMixin + + class Buffer # :nodoc: + def initialize(response, buf) + @response = response + @buf = buf + @closed = false + @str_body = nil + end + + def body + @str_body ||= begin + buf = "".dup + each { |chunk| buf << chunk } + buf + end + end + + def write(string) + raise IOError, "closed stream" if closed? + + @str_body = nil + @response.commit! + @buf.push string + end + + def each(&block) + if @str_body + return enum_for(:each) unless block_given? + + yield @str_body + else + each_chunk(&block) + end + end + + def abort + end + + def close + @response.commit! + @closed = true + end + + def closed? + @closed + end + + private + + def each_chunk(&block) + @buf.each(&block) + end + end + + def self.create(status = 200, header = {}, body = [], default_headers: self.default_headers) + header = merge_default_headers(header, default_headers) + new status, header, body + end + + def self.merge_default_headers(original, default) + default.respond_to?(:merge) ? default.merge(original) : original + end + + # The underlying body, as a streamable object. + attr_reader :stream + + def initialize(status = 200, header = {}, body = []) + super() + + @header = Header.new(self, header) + + self.body, self.status = body, status + + @cv = new_cond + @committed = false + @sending = false + @sent = false + + prepare_cache_control! + + yield self if block_given? + end + + def has_header?(key); headers.key? key; end + def get_header(key); headers[key]; end + def set_header(key, v); headers[key] = v; end + def delete_header(key); headers.delete key; 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) + return unless content_type + new_header_info = parse_content_type(content_type.to_s) + prev_header_info = parsed_content_type_header + charset = new_header_info.charset || prev_header_info.charset + charset ||= self.class.default_charset unless prev_header_info.mime_type + set_content_type new_header_info.mime_type, charset + end + + # 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. + + def content_type + parsed_content_type_header.mime_type + end + + def sending_file=(v) + if true == v + self.charset = false + end + end + + # Sets the HTTP character set. In case of +nil+ parameter + # it sets the charset to +default_charset+. + # + # response.charset = 'utf-16' # => 'utf-16' + # response.charset = nil # => 'utf-8' + def charset=(charset) + content_type = parsed_content_type_header.mime_type + if false == charset + set_content_type content_type, nil + else + set_content_type content_type, charset || self.class.default_charset + end + end + + # 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. + def charset + header_info = parsed_content_type_header + header_info.charset || self.class.default_charset + 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 + @stream.body + end + + def write(string) + @stream.write string + end + + # Allows you to manually set or override the response body. + def body=(body) + if body.respond_to?(:to_path) + @stream = body + else + synchronize do + @stream = build_buffer self, munge_body_object(body) + end + end + end + + # Avoid having to pass an open file handle as the response body. + # Rack::Sendfile will usually intercept the response and uses + # the path directly, so there is no reason to open the file. + class FileBody #:nodoc: + attr_reader :to_path + + def initialize(path) + @to_path = path + end + + def body + File.binread(to_path) + end + + # Stream the file's contents if Rack::Sendfile isn't present. + def each + File.open(to_path, "rb") do |file| + while chunk = file.read(16384) + yield chunk + end + end + end + end + + # Send the file stored at +path+ as the response body. + def send_file(path) + commit! + @stream = FileBody.new(path) + end + + def reset_body! + @stream = build_buffer(self, []) + end + + def body_parts + parts = [] + @stream.each { |x| parts << x } + parts + end + + # The location header we'll be responding with. + alias_method :redirect_url, :location + + 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. Allows explicit splatting: + # + # status, headers, body = *response + def to_a + commit! + rack_response @status, @header.to_hash + end + alias prepare! 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 = get_header(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 + + private + + ContentTypeHeader = Struct.new :mime_type, :charset + NullContentTypeHeader = ContentTypeHeader.new nil, nil + + def parse_content_type(content_type) + if content_type + type, charset = content_type.split(/;\s*charset=/) + type = nil if type && type.empty? + ContentTypeHeader.new(type, charset) + else + NullContentTypeHeader + end + end + + # Small internal convenience method to get the parsed version of the current + # content type header. + def parsed_content_type_header + parse_content_type(get_header(CONTENT_TYPE)) + end + + def set_content_type(content_type, charset) + type = (content_type || "").dup + type << "; charset=#{charset.to_s.downcase}" if charset + set_header CONTENT_TYPE, type + end + + def before_committed + return if committed? + assign_default_content_type_and_charset! + merge_and_normalize_cache_control!(@cache_control) + handle_conditional_get! + handle_no_content! + end + + def before_sending + # Normally we've already committed by now, but it's possible + # (e.g., if the controller action tries to read back its own + # response) to get here before that. In that case, we must force + # an "early" commit: we're about to freeze the headers, so this is + # our last chance. + commit! unless committed? + + headers.freeze + request.commit_cookie_jar! unless committed? + 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! + return if content_type + + ct = parsed_content_type_header + set_content_type(ct.mime_type || Mime[:html].to_s, + ct.charset || self.class.default_charset) + 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 + + def to_ary + nil + end + end + + def handle_no_content! + if NO_CONTENT_CODES.include?(@status) + @header.delete CONTENT_TYPE + @header.delete "Content-Length" + end + end + + def rack_response(status, header) + if NO_CONTENT_CODES.include?(status) + [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..0b162dc7f1 --- /dev/null +++ b/actionpack/lib/action_dispatch/http/upload.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +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 + + if hash[:filename] + @original_filename = hash[:filename].dup + + begin + @original_filename.encode!(Encoding::UTF_8) + rescue EncodingError + @original_filename.force_encoding(Encoding::UTF_8) + end + else + @original_filename = nil + end + + @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..35ba44005a --- /dev/null +++ b/actionpack/lib/action_dispatch/http/url.rb @@ -0,0 +1,350 @@ +# frozen_string_literal: true + +require "active_support/core_ext/module/attribute_accessors" + +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, default: 1 + + class << self + # Returns the domain part of a host given the domain level. + # + # # Top-level domain example + # extract_domain('www.example.com', 1) # => "example.com" + # # Second-level domain example + # extract_domain('dev.www.example.co.uk', 2) # => "example.co.uk" + def extract_domain(host, tld_length) + extract_domain_from(host, tld_length) if named_host?(host) + end + + # Returns the subdomains of a host as an Array given the domain level. + # + # # Top-level domain example + # extract_subdomains('www.example.com', 1) # => ["www"] + # # Second-level domain example + # extract_subdomains('dev.www.example.co.uk', 2) # => ["dev", "www"] + def extract_subdomains(host, tld_length) + if named_host?(host) + extract_subdomains_from(host, tld_length) + else + [] + end + end + + # Returns the subdomains of a host as a String given the domain level. + # + # # Top-level domain example + # extract_subdomain('www.example.com', 1) # => "www" + # # Second-level domain example + # extract_subdomain('dev.www.example.co.uk', 2) # => "dev.www" + 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("/".freeze) + 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? } + query = params.to_query + path << "?#{query}" unless query.empty? + end + + def add_anchor(path, anchor) + if anchor + path << "##{Journey::Router::Utils.escape_fragment(anchor.to_param)}" + end + 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) + if path.include?("?") + path.sub!(/\?/, '/\&') + 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 = "".dup + 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 + super + @protocol = nil + @port = nil + end + + # Returns the complete URL used for this request. + # + # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com' + # req.url # => "http://example.com" + def url + protocol + host_with_port + fullpath + end + + # Returns 'https://' if this is an SSL request and 'http://' otherwise. + # + # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com' + # req.protocol # => "http://" + # + # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com', 'HTTPS' => 'on' + # req.protocol # => "https://" + def protocol + @protocol ||= ssl? ? "https://" : "http://" + end + + # Returns the \host and port for this request, such as "example.com:8080". + # + # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com' + # req.raw_host_with_port # => "example.com" + # + # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com:80' + # req.raw_host_with_port # => "example.com:80" + # + # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com:8080' + # req.raw_host_with_port # => "example.com:8080" + def raw_host_with_port + if forwarded = x_forwarded_host.presence + forwarded.split(/,\s?/).last + else + get_header("HTTP_HOST") || "#{server_name || server_addr}:#{get_header('SERVER_PORT')}" + end + end + + # Returns the host for this request, such as "example.com". + # + # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com:8080' + # req.host # => "example.com" + def host + raw_host_with_port.sub(/:\d+$/, "".freeze) + end + + # Returns a \host:\port string for this request, such as "example.com" or + # "example.com:8080". Port is only included if it is not a default port + # (80 or 443) + # + # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com' + # req.host_with_port # => "example.com" + # + # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com:80' + # req.host_with_port # => "example.com" + # + # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com:8080' + # req.host_with_port # => "example.com:8080" + def host_with_port + "#{host}#{port_string}" + end + + # Returns the port number of this request as an integer. + # + # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com' + # req.port # => 80 + # + # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com:8080' + # req.port # => 8080 + 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. + # + # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com:8080' + # req.standard_port # => 80 + def standard_port + case protocol + when "https://" then 443 + else 80 + end + end + + # Returns whether this request is using the standard port + # + # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com:80' + # req.standard_port? # => true + # + # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com:8080' + # req.standard_port? # => false + 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. + # + # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com:80' + # req.optional_port # => nil + # + # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com:8080' + # req.optional_port # => 8080 + 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. + # + # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com:80' + # req.port_string # => "" + # + # req = ActionDispatch::Request.new 'HTTP_HOST' => 'example.com:8080' + # req.port_string # => ":8080" + def port_string + standard_port? ? "" : ":#{port}" + end + + # Returns the requested port, such as 8080, based on SERVER_PORT + # + # req = ActionDispatch::Request.new 'SERVER_PORT' => '80' + # req.server_port # => 80 + # + # req = ActionDispatch::Request.new 'SERVER_PORT' => '8080' + # req.server_port # => 8080 + def server_port + get_header("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..2852efa6ae --- /dev/null +++ b/actionpack/lib/action_dispatch/journey.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +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/formatter.rb b/actionpack/lib/action_dispatch/journey/formatter.rb new file mode 100644 index 0000000000..0f04839d9b --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/formatter.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +require "action_controller/metal/exceptions" + +module ActionDispatch + # :stopdoc: + 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 + 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 = nil + + 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 if missing_keys && !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 + + route.parts.reverse_each do |key| + break if defaults[key].nil? && parameterized_parts[key].present? + next if parameterized_parts[key].to_s != defaults[key].to_s + break if required_parts.include?(key) + + parameterized_parts.delete(key) + end + + return [route.format(parameterized_parts), params] + end + + unmatched_keys = (missing_keys || []) & constraints.keys + missing_keys = (missing_keys || []) - unmatched_keys + + message = "No route matches #{Hash[constraints.sort_by { |k, v| k.to_s }].inspect}".dup + message << ", missing required keys: #{missing_keys.sort.inspect}" if missing_keys && !missing_keys.empty? + message << ", possible unmatched constraints: #{unmatched_keys.sort.inspect}" if unmatched_keys && !unmatched_keys.empty? + + 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_each.drop_while { |part| + !options.key?(part) || (options[part] || recall[part]).nil? + } | route.required_parts + + parameterized_parts.delete_if do |bad_key, _| + !keys_to_keep.include?(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) + + supplied_keys = options.each_with_object({}) do |(k, v), h| + h[k.to_s] = true if v + end + + hash = routes.group_by { |_, r| r.score(supplied_keys) } + + 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 + + module RegexCaseComparator + DEFAULT_INPUT = /[-_.a-zA-Z0-9]+\/[-_.a-zA-Z0-9]+/ + DEFAULT_REGEX = /\A#{DEFAULT_INPUT}\Z/ + + def self.===(regex) + DEFAULT_INPUT == regex + end + end + + # Returns an array populated with missing keys if any are present. + def missing_keys(route, parts) + missing_keys = nil + tests = route.path.requirements + route.required_parts.each { |key| + case tests[key] + when nil + unless parts[key] + missing_keys ||= [] + missing_keys << key + end + when RegexCaseComparator + unless RegexCaseComparator::DEFAULT_REGEX === parts[key] + missing_keys ||= [] + missing_keys << key + end + else + unless /\A#{tests[key]}\Z/ === parts[key] + missing_keys ||= [] + missing_keys << key + end + 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.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 + # :startdoc: +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..44c31053cb --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/gtg/builder.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +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..2ee4f5c30c --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/gtg/simulator.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +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 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..ea647e051a --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/gtg/transition_table.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +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 __dir__, "..", "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.sample(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 }.sample + 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] + + 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..d22302e101 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/nfa/builder.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +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..56e9e3c83d --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/nfa/dot.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +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..8efe48d91c --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/nfa/simulator.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +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..fe55861507 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/nfa/transition_table.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +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 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(&: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..32f632800c --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/nodes/node.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +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::INSTANCE.accept(self, block) + end + + def to_s + Visitors::String::INSTANCE.accept(self, "") + end + + def to_dot + Visitors::Dot::INSTANCE.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 + def terminal?; false; end + def star?; false; end + def cat?; false; end + def group?; false; end + end + + class Terminal < Node # :nodoc: + alias :symbol :left + def terminal?; true; end + 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 + attr_reader :name + + DEFAULT_EXP = /[^\.\/\?]+/ + def initialize(left) + super + @regexp = DEFAULT_EXP + @name = -left.tr("*:", "") + 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 + def group?; true; end + end + + class Star < Unary # :nodoc: + def star?; true; end + 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 cat?; true; end + 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..e002755bcf --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/parser.rb @@ -0,0 +1,199 @@ +# +# DO NOT MODIFY!!!! +# This file is automatically generated by Racc 1.4.14 +# from Racc grammar file "". +# + +require 'racc/parser.rb' + +# :stopdoc: + +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, 19, 16, 8, 19, 13, 15, + 14, 7, 17, 16, 8, 13, 15, 14, 7, 21, + 16, 8, 13, 15, 14, 7, 24, 16, 8 ] + +racc_action_check = [ + 2, 2, 2, 2, 22, 2, 2, 2, 19, 19, + 19, 19, 1, 19, 19, 7, 7, 7, 7, 17, + 7, 7, 0, 0, 0, 0, 20, 0, 0 ] + +racc_action_pointer = [ + 20, 12, -2, nil, nil, nil, nil, 13, nil, nil, + nil, nil, nil, nil, nil, nil, nil, 19, nil, 6, + 20, nil, -5, 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(val.first) +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..f9b1a7a958 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/parser.y @@ -0,0 +1,50 @@ +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(val.first) } + ; + symbol + : SYMBOL { Symbol.new(val.first) } + ; + literal + : LITERAL { Literal.new(val.first) } + ; + dot + : DOT { Dot.new(val.first) } + ; + +end + +---- header +# :stopdoc: + +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..18ec6c9b9b --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/parser_extras.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "action_dispatch/journey/scanner" +require "action_dispatch/journey/nodes/node" + +module ActionDispatch + # :stopdoc: + module Journey + class Parser < Racc::Parser + include Journey::Nodes + + def self.parse(string) + new.parse string + end + + 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 + # :startdoc: +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..537f479ee5 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/path/pattern.rb @@ -0,0 +1,198 @@ +# frozen_string_literal: true + +module ActionDispatch + module Journey # :nodoc: + module Path # :nodoc: + class Pattern # :nodoc: + attr_reader :spec, :requirements, :anchored + + def self.from_string(string) + build(string, {}, "/.?", true) + end + + def self.build(path, requirements, separators, anchored) + parser = Journey::Parser.new + ast = parser.parse path + new ast, requirements, separators, anchored + end + + def initialize(ast, requirements, separators, anchored) + @spec = ast + @requirements = requirements + @separators = separators + @anchored = anchored + + @names = nil + @optional_names = nil + @required_names = nil + @re = nil + @offsets = nil + end + + def build_formatter + Visitors::FormatBuilder.new.accept(spec) + end + + def eager_load! + required_names + offsets + to_regexp + nil + end + + def ast + @spec.find_all(&:symbol?).each do |node| + re = @requirements[node.to_sym] + node.regexp = re if re + end + + @spec.find_all(&:star?).each do |node| + node = node.left + node.regexp = @requirements[node.to_sym] || /(.+)/ + end + + @spec + end + + def names + @names ||= spec.find_all(&:symbol?).map(&:name) + end + + def required_names + @required_names ||= names - optional_names + end + + def optional_names + @optional_names ||= spec.find_all(&:group?).flat_map { |group| + group.find_all(&:symbol?) + }.map(&:name).uniq + 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] + "(#{Regexp.union(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 + + def visit_OR(node) + children = node.children.map { |n| visit n } + "(?:#{children.join(?|)})" + 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 + Array.new(length - 1) { |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 + + @offsets = [0] + + spec.find_all(&:symbol?).each do |node| + node = node.to_sym + + if @requirements.key?(node) + re = /#{Regexp.union(@requirements[node])}|/ + @offsets.push((re.match("").length - 1) + @offsets.last) + else + @offsets << @offsets.last + end + end + + @offsets + 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..8165709a3d --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/route.rb @@ -0,0 +1,203 @@ +# frozen_string_literal: true + +module ActionDispatch + # :stopdoc: + module Journey + class Route + attr_reader :app, :path, :defaults, :name, :precedence + + attr_reader :constraints, :internal + alias :conditions :constraints + + module VerbMatchers + VERBS = %w{ DELETE GET HEAD OPTIONS LINK PATCH POST PUT TRACE UNLINK } + VERBS.each do |v| + class_eval <<-eoc, __FILE__, __LINE__ + 1 + class #{v} + def self.verb; name.split("::").last; end + def self.call(req); req.#{v.downcase}?; end + end + eoc + end + + class Unknown + attr_reader :verb + + def initialize(verb) + @verb = verb + end + + def call(request); @verb === request.request_method; end + end + + class All + def self.call(_); true; end + def self.verb; ""; end + end + + VERB_TO_CLASS = VERBS.each_with_object(all: All) do |verb, hash| + klass = const_get verb + hash[verb] = klass + hash[verb.downcase] = klass + hash[verb.downcase.to_sym] = klass + end + end + + def self.verb_matcher(verb) + VerbMatchers::VERB_TO_CLASS.fetch(verb) do + VerbMatchers::Unknown.new verb.to_s.dasherize.upcase + end + end + + def self.build(name, app, path, constraints, required_defaults, defaults) + request_method_match = verb_matcher(constraints.delete(:request_method)) + new name, app, path, constraints, required_defaults, defaults, request_method_match, 0 + end + + ## + # +path+ is a path constraint. + # +constraints+ is a hash of constraints to be applied to this route. + def initialize(name, app, path, constraints, required_defaults, defaults, request_method_match, precedence, internal = false) + @name = name + @app = app + @path = path + + @request_method_match = request_method_match + @constraints = constraints + @defaults = defaults + @required_defaults = nil + @_required_defaults = required_defaults + @required_parts = nil + @parts = nil + @decorated_ast = nil + @precedence = precedence + @path_formatter = @path.build_formatter + @internal = internal + end + + def eager_load! + path.eager_load! + ast + parts + required_defaults + nil + end + + def ast + @decorated_ast ||= begin + decorated_ast = path.ast + decorated_ast.find_all(&:terminal?).each { |n| n.memo = self } + decorated_ast + end + end + + # Needed for `rails routes`. Picks up succinctly defined requirements + # for a route, for example route + # + # get 'photo/:id', :controller => 'photos', :action => 'show', + # :id => /[A-Z]\d{5}/ + # + # will have {:controller=>"photos", :action=>"show", :id=>/[A-Z]\d{5}/} + # as requirements. + def requirements + @defaults.merge(path.requirements).delete_if { |_, v| + /.+?/ == v + } + end + + def segments + path.names + end + + def required_keys + required_parts + required_defaults.keys + end + + def score(supplied_keys) + required_keys = path.required_names + + required_keys.each do |k| + return -1 unless supplied_keys.include?(k) + end + + score = 0 + path.names.each do |k| + score += 1 if supplied_keys.include?(k) + end + + score + (required_defaults.length * 2) + end + + def parts + @parts ||= segments.map(&:to_sym) + end + alias :segment_keys :parts + + def format(path_options) + @path_formatter.evaluate path_options + end + + def required_parts + @required_parts ||= path.required_names.map(&:to_sym) + end + + def required_default?(key) + @_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) + match_verb(request) && + constraints.all? { |method, value| + 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 + + def ip + constraints[:ip] || // + end + + def requires_matching_verb? + !@request_method_match.all? { |x| x == VerbMatchers::All } + end + + def verb + verbs.join("|") + end + + private + def verbs + @request_method_match.map(&:verb) + end + + def match_verb(request) + @request_method_match.any? { |m| m.call request } + end + end + end + # :startdoc: +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..30af3ff930 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/router.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +require "action_dispatch/journey/router/utils" +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 + + attr_accessor :routes + + def initialize(routes) + @routes = routes + end + + def eager_load! + # Eagerly trigger the simulator's initialization so + # it doesn't happen during a request cycle. + simulator + nil + 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 + + parameters = route.defaults.merge parameters.transform_values { |val| + val.dup.force_encoding(::Encoding::UTF_8) + } + + 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 + + [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 + + parameters = route.defaults.merge parameters + yield(route, parameters) + end + end + + def visualizer + tt = GTG::Builder.new(ast).transition_table + groups = partitioned_routes.first.map(&:ast).group_by(&:to_s) + asts = groups.values.map(&:first) + tt.visualizer(asts) + end + + private + + def partitioned_routes + routes.partition { |r| + r.path.anchored && r.ast.grep(Nodes::Symbol).all? { |n| n.default_regexp? } + } + end + + def ast + routes.ast + end + + def simulator + routes.simulator + end + + def custom_routes + routes.custom_routes + 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) + } + + routes = + if req.head? + match_head_routes(routes, req) + else + match_routes(routes, req) + end + + routes.sort_by!(&:precedence) + + routes.map! { |r| + match_data = r.path.match(req.path_info) + path_parameters = {} + 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 match_head_routes(routes, req) + verb_specific_routes = routes.select(&:requires_matching_verb?) + head_routes = match_routes(verb_specific_routes, req) + + if head_routes.empty? + begin + req.request_method = "GET" + match_routes(routes, req) + ensure + req.request_method = "HEAD" + end + else + head_routes + end + end + + def match_routes(routes, req) + routes.select { |r| r.matches?(req) } + 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..df3f79a407 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/router/utils.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +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 ||= "" + encoding = path.encoding + path = "/#{path}".dup + path.squeeze!("/".freeze) + path.sub!(%r{/+\Z}, "".freeze) + path.gsub!(/(%[a-f0-9]{2})/) { $1.upcase } + path = "/".dup if path == "".freeze + path.force_encoding(encoding) + path + end + + # URI path and fragment escaping + # https://tools.ietf.org/html/rfc3986 + class UriEncoder # :nodoc: + ENCODE = "%%%02X".freeze + US_ASCII = Encoding::US_ASCII + UTF_8 = Encoding::UTF_8 + EMPTY = "".dup.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) { |match| [match[1, 2].hex].pack("C") }.force_encoding(encoding) + end + + private + 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 + + # Replaces any escaped sequences with their unescaped representations. + # + # uri = "/topics?title=Ruby%20on%20Rails" + # unescape_uri(uri) #=> "/topics?title=Ruby on Rails" + 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..639c063495 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/routes.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +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, :custom_routes, :anchored_routes + + def initialize + @routes = [] + @ast = nil + @anchored_routes = [] + @custom_routes = [] + @simulator = nil + end + + def empty? + routes.empty? + 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 + anchored_routes.clear + custom_routes.clear + end + + def partition_route(route) + if route.path.anchored && route.ast.grep(Nodes::Symbol).all?(&:default_regexp?) + anchored_routes << route + else + custom_routes << route + end + end + + def ast + @ast ||= begin + asts = anchored_routes.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 + + def add_route(name, mapping) + route = mapping.make_route name, routes.length + routes << route + partition_route(route) + clear_cache! + route + end + + private + + def clear_cache! + @ast = 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..2a075862e9 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/scanner.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +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 + + # takes advantage of String @- deduping capabilities in Ruby 2.5 upwards + # see: https://bugs.ruby-lang.org/issues/13077 + def dedup_scan(regex) + r = @ss.scan(regex) + r ? -r : nil + end + + def scan + case + # / + when @ss.skip(/\//) + [:SLASH, "/"] + when @ss.skip(/\(/) + [:LPAREN, "("] + when @ss.skip(/\)/) + [:RPAREN, ")"] + when @ss.skip(/\|/) + [:OR, "|"] + when @ss.skip(/\./) + [:DOT, "."] + when text = dedup_scan(/:\w+/) + [:SYMBOL, text] + when text = dedup_scan(/\*\w+/) + [:STAR, text] + when text = @ss.scan(/(?:[\w%\-~!$&'*+,;=@]|\\[:()])+/) + text.tr! "\\", "" + [:LITERAL, -text] + # any char + when text = dedup_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..3395471a85 --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/visitors.rb @@ -0,0 +1,268 @@ +# frozen_string_literal: true + +module ActionDispatch + # :stopdoc: + module Journey + class Format + ESCAPE_PATH = ->(value) { Router::Utils.escape_path(value) } + ESCAPE_SEGMENT = ->(value) { Router::Utils.escape_segment(value) } + + Parameter = Struct.new(:name, :escaper) do + 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 FunctionalVisitor # :nodoc: + DISPATCH_CACHE = {} + + def accept(node, seed) + visit(node, seed) + end + + def visit(node, seed) + send(DISPATCH_CACHE[node.type], node, seed) + end + + def binary(node, seed) + visit(node.right, visit(node.left, seed)) + end + def visit_CAT(n, seed); binary(n, seed); end + + def nary(node, seed) + node.children.inject(seed) { |s, c| visit(c, s) } + end + def visit_OR(n, seed); nary(n, seed); end + + def unary(node, seed) + visit(node.left, seed) + end + def visit_GROUP(n, seed); unary(n, seed); end + def visit_STAR(n, seed); unary(n, seed); end + + def terminal(node, seed); seed; end + def visit_LITERAL(n, seed); terminal(n, seed); end + def visit_SYMBOL(n, seed); terminal(n, seed); end + def visit_SLASH(n, seed); terminal(n, seed); end + def visit_DOT(n, seed); terminal(n, seed); end + + 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 < FunctionalVisitor # :nodoc: + def visit(node, block) + block.call(node) + super + end + + INSTANCE = new + end + + class String < FunctionalVisitor # :nodoc: + private + + def binary(node, seed) + visit(node.right, visit(node.left, seed)) + end + + def nary(node, seed) + last_child = node.children.last + node.children.inject(seed) { |s, c| + string = visit(c, s) + string << "|" unless last_child == c + string + } + end + + def terminal(node, seed) + seed + node.left + end + + def visit_GROUP(node, seed) + visit(node.left, seed.dup << "(") << ")" + end + + INSTANCE = new + end + + class Dot < FunctionalVisitor # :nodoc: + def initialize + @nodes = [] + @edges = [] + end + + def accept(node, seed = [[], []]) + super + nodes, edges = seed + <<-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, seed) + seed.last.concat node.children.map { |c| + "#{node.object_id} -> #{c.object_id};" + } + super + end + + def nary(node, seed) + seed.last.concat node.children.map { |c| + "#{node.object_id} -> #{c.object_id};" + } + super + end + + def unary(node, seed) + seed.last << "#{node.object_id} -> #{node.left.object_id};" + super + end + + def visit_GROUP(node, seed) + seed.first << "#{node.object_id} [label=\"()\"];" + super + end + + def visit_CAT(node, seed) + seed.first << "#{node.object_id} [label=\"○\"];" + super + end + + def visit_STAR(node, seed) + seed.first << "#{node.object_id} [label=\"*\"];" + super + end + + def visit_OR(node, seed) + seed.first << "#{node.object_id} [label=\"|\"];" + super + end + + def terminal(node, seed) + value = node.left + + seed.first << "#{node.object_id} [label=\"#{value}\"];" + seed + end + INSTANCE = new + end + end + end + # :startdoc: +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..403e16a7bb --- /dev/null +++ b/actionpack/lib/action_dispatch/journey/visualizer/fsm.css @@ -0,0 +1,30 @@ +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; +} + +.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..5b2ad36dd5 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/callbacks.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module ActionDispatch + # Provides callbacks to be executed before and after dispatching the request. + class Callbacks + include ActiveSupport::Callbacks + + define_callbacks :call + + class << self + 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..c45d947904 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/cookies.rb @@ -0,0 +1,681 @@ +# frozen_string_literal: true + +require "active_support/core_ext/hash/keys" +require "active_support/key_generator" +require "active_support/message_verifier" +require "active_support/json" +require "rack/utils" + +module ActionDispatch + class Request + def cookie_jar + fetch_header("action_dispatch.cookies".freeze) do + self.cookie_jar = Cookies::CookieJar.build(self, cookies) + end + end + + # :stopdoc: + prepend Module.new { + def commit_cookie_jar! + cookie_jar.commit! + end + } + + def have_cookie_jar? + has_header? "action_dispatch.cookies".freeze + end + + def cookie_jar=(jar) + set_header "action_dispatch.cookies".freeze, jar + end + + def key_generator + get_header Cookies::GENERATOR_KEY + end + + def signed_cookie_salt + get_header Cookies::SIGNED_COOKIE_SALT + end + + def encrypted_cookie_salt + get_header Cookies::ENCRYPTED_COOKIE_SALT + end + + def encrypted_signed_cookie_salt + get_header Cookies::ENCRYPTED_SIGNED_COOKIE_SALT + end + + def authenticated_encrypted_cookie_salt + get_header Cookies::AUTHENTICATED_ENCRYPTED_COOKIE_SALT + end + + def use_authenticated_cookie_encryption + get_header Cookies::USE_AUTHENTICATED_COOKIE_ENCRYPTION + end + + def encrypted_cookie_cipher + get_header Cookies::ENCRYPTED_COOKIE_CIPHER + end + + def signed_cookie_digest + get_header Cookies::SIGNED_COOKIE_DIGEST + end + + def secret_token + get_header Cookies::SECRET_TOKEN + end + + def secret_key_base + get_header Cookies::SECRET_KEY_BASE + end + + def cookies_serializer + get_header Cookies::COOKIES_SERIALIZER + end + + def cookies_digest + get_header Cookies::COOKIES_DIGEST + end + + def cookies_rotations + get_header Cookies::COOKIES_ROTATIONS + end + + # :startdoc: + 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 } + # + # # Sets a cookie that expires at a specific time. + # cookies[:login] = { value: "XJ-122", expires: Time.utc(2020, 10, 15, 5) } + # + # # Sets a signed cookie, which prevents users from tampering with its value. + # # It can be read using the signed method `cookies.signed[:name]` + # cookies.signed[:user_id] = current_user.id + # + # # Sets an encrypted cookie value before sending it to the client which + # # prevent users from reading and tampering with its value. + # # It can be read using the encrypted method `cookies.encrypted[:name]` + # cookies.encrypted[:discount] = 45 + # + # # Sets a "permanent" cookie (which expires in 20 years from now). + # cookies.permanent[:login] = "XJ-122" + # + # # You can also chain these methods: + # cookies.signed.permanent[: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" + # cookies.encrypted[:discount] # => 45 + # + # 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, + # 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> or <tt>Array</tt> again when deleting cookies. + # + # domain: nil # Does not set cookie domain. (default) + # domain: :all # Allow the cookie for the top most level + # # domain and subdomains. + # domain: %w(.example.com .example.org) # Allow the cookie + # # for concrete domain names. + # + # * <tt>:tld_length</tt> - When using <tt>:domain => :all</tt>, this option can be used to explicitly + # set the TLD length when using a short (<= 3 character) domain that is being interpreted as part of a TLD. + # For example, to share cookies between user1.lvh.me and user2.lvh.me, set <tt>:tld_length</tt> to 2. + # * <tt>:expires</tt> - The time at which this cookie expires, as a \Time or ActiveSupport::Duration 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 + AUTHENTICATED_ENCRYPTED_COOKIE_SALT = "action_dispatch.authenticated_encrypted_cookie_salt".freeze + USE_AUTHENTICATED_COOKIE_ENCRYPTION = "action_dispatch.use_authenticated_cookie_encryption".freeze + ENCRYPTED_COOKIE_CIPHER = "action_dispatch.encrypted_cookie_cipher".freeze + SIGNED_COOKIE_DIGEST = "action_dispatch.signed_cookie_digest".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_DIGEST = "action_dispatch.cookies_digest".freeze + COOKIES_ROTATIONS = "action_dispatch.cookies_rotations".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) + 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 +secret_key_base+ and +secrets.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 +secret_key_base+. + # + # Example: + # + # cookies.signed[:discount] = 45 + # # => Set-Cookie: discount=BAhpMg==--2c1c6906c90a3bc4fd54a51ffb41dffa4bf6b5f7; path=/ + # + # cookies.signed[:discount] # => 45 + def signed + @signed ||= SignedKeyRotatingCookieJar.new(self) + 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 +secret_key_base+ and +secrets.secret_token+ (deprecated) are both set, + # legacy cookies signed with the old key generator will be transparently upgraded. + # + # If +config.action_dispatch.encrypted_cookie_salt+ and +config.action_dispatch.encrypted_signed_cookie_salt+ + # are both set, legacy cookies encrypted with HMAC AES-256-CBC will be transparently upgraded. + # + # This jar requires that you set a suitable secret for the verification on your app's +secret_key_base+. + # + # Example: + # + # cookies.encrypted[:discount] = 45 + # # => Set-Cookie: discount=DIQ7fw==--K3n//8vvnSbGq9dA--7Xh91HfLpwzbj1czhBiwOg==; path=/ + # + # cookies.encrypted[:discount] # => 45 + def encrypted + @encrypted ||= EncryptedKeyRotatingCookieJar.new(self) + 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 request.secret_key_base.present? + encrypted + else + signed + end + end + + private + + def upgrade_legacy_signed_cookies? + request.secret_token.present? && request.secret_key_base.present? + end + + def upgrade_legacy_hmac_aes_cbc_cookies? + request.secret_key_base.present? && + request.encrypted_signed_cookie_salt.present? && + request.encrypted_cookie_salt.present? && + request.use_authenticated_cookie_encryption + end + + def encrypted_cookie_cipher + request.encrypted_cookie_cipher || "aes-256-gcm" + end + + def signed_cookie_digest + request.signed_cookie_digest || "SHA1" + 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.build(req, cookies) + new(req).tap do |hash| + hash.update(cookies) + end + end + + attr_reader :request + + def initialize(request) + @set_cookies = {} + @delete_cookies = {} + @request = request + @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? + + # Returns the cookies as Hash. + alias :to_hash :to_h + + def update(other_hash) + @cookies.update other_hash.stringify_keys + self + end + + def update_cookies_from_jar + request_jar = @request.cookie_jar.instance_variable_get(:@cookies) + set_cookies = request_jar.reject { |k, _| @delete_cookies.key?(k) } + + @cookies.update set_cookies if set_cookies + end + + def to_header + @cookies.map { |k, v| "#{escape(k)}=#{escape(v)}" }.join "; " + end + + def handle_options(options) # :nodoc: + if options[:expires].respond_to?(:from_now) + options[:expires] = options[:expires].from_now + end + + options[:path] ||= "/" + + if options[:domain] == :all || 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 (request.host !~ /^[\d.]+$/) && (request.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| request.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 || 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) + if header = make_set_cookie_header(headers[HTTP_HEADER]) + headers[HTTP_HEADER] = header + end + end + + mattr_accessor :always_write_cookie, default: false + + private + + def escape(string) + ::Rack::Utils.escape(string) + end + + def make_set_cookie_header(header) + header = @set_cookies.inject(header) { |m, (k, v)| + if write_cookie?(v) + ::Rack::Utils.add_cookie_to_header(m, k, v) + else + m + end + } + @delete_cookies.inject(header) { |m, (k, v)| + ::Rack::Utils.add_remove_cookie_to_header(m, k, v) + } + end + + def write_cookie?(cookie) + request.ssl? || !cookie[:secure] || always_write_cookie + end + end + + class AbstractCookieJar # :nodoc: + include ChainedCookieJars + + def initialize(parent_jar) + @parent_jar = parent_jar + end + + def [](name) + if data = @parent_jar[name.to_s] + parse name, data + end + end + + def []=(name, options) + if options.is_a?(Hash) + options.symbolize_keys! + else + options = { value: options } + end + + commit(options) + @parent_jar[name] = options + end + + protected + def request; @parent_jar.request; end + + private + def expiry_options(options) + if options[:expires].respond_to?(:from_now) + { expires_in: options[:expires] } + else + { expires_at: options[:expires] } + end + end + + def parse(name, data); data; end + def commit(options); end + end + + class PermanentCookieJar < AbstractCookieJar # :nodoc: + private + def commit(options) + options[:expires] = 20.years.from_now + end + end + + class JsonSerializer # :nodoc: + def self.load(value) + ActiveSupport::JSON.decode(value) + end + + def self.dump(value) + ActiveSupport::JSON.encode(value) + end + end + + module SerializedCookieJars # :nodoc: + MARSHAL_SIGNATURE = "\x04\x08".freeze + SERIALIZER = ActiveSupport::MessageEncryptor::NullSerializer + + protected + def needs_migration?(value) + request.cookies_serializer == :hybrid && value.start_with?(MARSHAL_SIGNATURE) + end + + def serialize(value) + serializer.dump(value) + end + + def deserialize(name) + rotate = false + value = yield -> { rotate = true } + + if value + case + when needs_migration?(value) + self[name] = Marshal.load(value) + when rotate + self[name] = serializer.load(value) + else + serializer.load(value) + end + end + end + + def serializer + serializer = request.cookies_serializer || :marshal + case serializer + when :marshal + Marshal + when :json, :hybrid + JsonSerializer + else + serializer + end + end + + def digest + request.cookies_digest || "SHA1" + end + end + + class SignedKeyRotatingCookieJar < AbstractCookieJar # :nodoc: + include SerializedCookieJars + + def initialize(parent_jar) + super + + secret = request.key_generator.generate_key(request.signed_cookie_salt) + @verifier = ActiveSupport::MessageVerifier.new(secret, digest: signed_cookie_digest, serializer: SERIALIZER) + + request.cookies_rotations.signed.each do |*secrets, **options| + @verifier.rotate(*secrets, serializer: SERIALIZER, **options) + end + + if upgrade_legacy_signed_cookies? + @verifier.rotate request.secret_token, serializer: SERIALIZER + end + end + + private + def parse(name, signed_message) + deserialize(name) do |rotate| + @verifier.verified(signed_message, on_rotation: rotate) + end + end + + def commit(options) + options[:value] = @verifier.generate(serialize(options[:value]), expiry_options(options)) + + raise CookieOverflow if options[:value].bytesize > MAX_COOKIE_SIZE + end + end + + class EncryptedKeyRotatingCookieJar < AbstractCookieJar # :nodoc: + include SerializedCookieJars + + def initialize(parent_jar) + super + + if request.use_authenticated_cookie_encryption + key_len = ActiveSupport::MessageEncryptor.key_len(encrypted_cookie_cipher) + secret = request.key_generator.generate_key(request.authenticated_encrypted_cookie_salt, key_len) + @encryptor = ActiveSupport::MessageEncryptor.new(secret, cipher: encrypted_cookie_cipher, serializer: SERIALIZER) + else + key_len = ActiveSupport::MessageEncryptor.key_len("aes-256-cbc") + secret = request.key_generator.generate_key(request.encrypted_cookie_salt, key_len) + sign_secret = request.key_generator.generate_key(request.encrypted_signed_cookie_salt) + @encryptor = ActiveSupport::MessageEncryptor.new(secret, sign_secret, cipher: "aes-256-cbc", serializer: SERIALIZER) + end + + request.cookies_rotations.encrypted.each do |*secrets, **options| + @encryptor.rotate(*secrets, serializer: SERIALIZER, **options) + end + + if upgrade_legacy_hmac_aes_cbc_cookies? + legacy_cipher = "aes-256-cbc" + secret = request.key_generator.generate_key(request.encrypted_cookie_salt, ActiveSupport::MessageEncryptor.key_len(legacy_cipher)) + sign_secret = request.key_generator.generate_key(request.encrypted_signed_cookie_salt) + + @encryptor.rotate(secret, sign_secret, cipher: legacy_cipher, digest: digest, serializer: SERIALIZER) + end + + if upgrade_legacy_signed_cookies? + @legacy_verifier = ActiveSupport::MessageVerifier.new(request.secret_token, digest: digest, serializer: SERIALIZER) + end + end + + private + def parse(name, encrypted_message) + deserialize(name) do |rotate| + @encryptor.decrypt_and_verify(encrypted_message, on_rotation: rotate) + end + rescue ActiveSupport::MessageEncryptor::InvalidMessage, ActiveSupport::MessageVerifier::InvalidSignature + parse_legacy_signed_message(name, encrypted_message) + end + + def commit(options) + options[:value] = @encryptor.encrypt_and_sign(serialize(options[:value]), expiry_options(options)) + + raise CookieOverflow if options[:value].bytesize > MAX_COOKIE_SIZE + end + + def parse_legacy_signed_message(name, legacy_signed_message) + if defined?(@legacy_verifier) + deserialize(name) do |rotate| + rotate.call + + @legacy_verifier.verified(legacy_signed_message) + end + end + end + end + + def initialize(app) + @app = app + end + + def call(env) + request = ActionDispatch::Request.new env + + status, headers, body = @app.call(env) + + if request.have_cookie_jar? + cookie_jar = request.cookie_jar + 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..511306eb0e --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb @@ -0,0 +1,205 @@ +# frozen_string_literal: true + +require "action_dispatch/http/request" +require "action_dispatch/middleware/exception_wrapper" +require "action_dispatch/routing/inspector" +require "action_view" +require "action_view/base" + +require "pp" + +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", __dir__) + + class DebugView < ActionView::Base + def debug_params(params) + clean_params = params.clone + clean_params.delete("action") + clean_params.delete("controller") + + if clean_params.empty? + "None" + else + PP.pp(clean_params, "".dup, 200) + end + end + + def debug_headers(headers) + if headers.present? + headers.inspect.gsub(",", ",\n") + else + "None" + end + end + + def debug_hash(object) + object.to_hash.sort_by { |k, _| k.to_s }.map { |k, v| "#{k}: #{v.inspect rescue $!.message}" }.join("\n") + end + + def render(*) + logger = ActionView::Base.logger + + if logger && logger.respond_to?(:silence) + logger.silence { super } + else + super + end + end + end + + def initialize(app, routes_app = nil, response_format = :default) + @app = app + @routes_app = routes_app + @response_format = response_format + end + + def call(env) + request = ActionDispatch::Request.new 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 unless request.show_exceptions? + render_exception(request, exception) + end + + private + + def render_exception(request, exception) + backtrace_cleaner = request.get_header("action_dispatch.backtrace_cleaner") + wrapper = ExceptionWrapper.new(backtrace_cleaner, exception) + log_error(request, wrapper) + + if request.get_header("action_dispatch.show_detailed_exceptions") + content_type = request.formats.first + + if api_request?(content_type) + render_for_api_request(content_type, wrapper) + else + render_for_browser_request(request, wrapper) + end + else + raise exception + end + end + + def render_for_browser_request(request, wrapper) + template = create_template(request, wrapper) + 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) + end + + def render_for_api_request(content_type, wrapper) + body = { + status: wrapper.status_code, + error: Rack::Utils::HTTP_STATUS_CODES.fetch( + wrapper.status_code, + Rack::Utils::HTTP_STATUS_CODES[500] + ), + exception: wrapper.exception.inspect, + traces: wrapper.traces + } + + to_format = "to_#{content_type.to_sym}" + + if content_type && body.respond_to?(to_format) + formatted_body = body.public_send(to_format) + format = content_type + else + formatted_body = body.to_json + format = Mime[:json] + end + + render(wrapper.status_code, formatted_body, format) + end + + def create_template(request, wrapper) + traces = wrapper.traces + + trace_to_show = "Application Trace" + if traces[trace_to_show].empty? && wrapper.rescue_template != "routing_error" + trace_to_show = "Full Trace" + end + + if source_to_show = traces[trace_to_show].first + source_to_show_id = source_to_show[:id] + end + + DebugView.new([RESCUES_TEMPLATE_PATH], + request: request, + exception: wrapper.exception, + traces: traces, + show_source_idx: source_to_show_id, + trace_to_show: trace_to_show, + routes_inspector: routes_inspector(wrapper.exception), + source_extracts: wrapper.source_extracts, + line_number: wrapper.line_number, + file: wrapper.file + ) + 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(request, wrapper) + logger = logger(request) + return unless logger + + exception = wrapper.exception + + trace = wrapper.application_trace + trace = wrapper.framework_trace if trace.empty? + + ActiveSupport::Deprecation.silence do + logger.fatal " " + logger.fatal "#{exception.class} (#{exception.message}):" + log_array logger, exception.annoted_source_code if exception.respond_to?(:annoted_source_code) + logger.fatal " " + log_array logger, trace + end + end + + def log_array(logger, array) + if logger.formatter && logger.formatter.respond_to?(:tags_text) + logger.fatal array.join("\n#{logger.formatter.tags_text}") + else + logger.fatal array.join("\n") + end + end + + def logger(request) + request.logger || ActionView::Base.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 + + def api_request?(content_type) + @response_format == :api && !content_type.html? + end + end +end diff --git a/actionpack/lib/action_dispatch/middleware/debug_locks.rb b/actionpack/lib/action_dispatch/middleware/debug_locks.rb new file mode 100644 index 0000000000..03760438f7 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/debug_locks.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +module ActionDispatch + # This middleware can be used to diagnose deadlocks in the autoload interlock. + # + # To use it, insert it near the top of the middleware stack, using + # <tt>config/application.rb</tt>: + # + # config.middleware.insert_before Rack::Sendfile, ActionDispatch::DebugLocks + # + # After restarting the application and re-triggering the deadlock condition, + # <tt>/rails/locks</tt> will show a summary of all threads currently known to + # the interlock, which lock level they are holding or awaiting, and their + # current backtrace. + # + # Generally a deadlock will be caused by the interlock conflicting with some + # other external lock or blocking I/O call. These cannot be automatically + # identified, but should be visible in the displayed backtraces. + # + # NOTE: The formatting and content of this middleware's output is intended for + # human consumption, and should be expected to change between releases. + # + # This middleware exposes operational details of the server, with no access + # control. It should only be enabled when in use, and removed thereafter. + class DebugLocks + def initialize(app, path = "/rails/locks") + @app = app + @path = path + end + + def call(env) + req = ActionDispatch::Request.new env + + if req.get? + path = req.path_info.chomp("/".freeze) + if path == @path + return render_details(req) + end + end + + @app.call(env) + end + + private + def render_details(req) + threads = ActiveSupport::Dependencies.interlock.raw_state do |raw_threads| + # The Interlock itself comes to a complete halt as long as this block + # is executing. That gives us a more consistent picture of everything, + # but creates a pretty strong Observer Effect. + # + # Most directly, that means we need to do as little as possible in + # this block. More widely, it means this middleware should remain a + # strictly diagnostic tool (to be used when something has gone wrong), + # and not for any sort of general monitoring. + + raw_threads.each.with_index do |(thread, info), idx| + info[:index] = idx + info[:backtrace] = thread.backtrace + end + + raw_threads + end + + str = threads.map do |thread, info| + if info[:exclusive] + lock_state = "Exclusive".dup + elsif info[:sharing] > 0 + lock_state = "Sharing".dup + lock_state << " x#{info[:sharing]}" if info[:sharing] > 1 + else + lock_state = "No lock".dup + end + + if info[:waiting] + lock_state << " (yielded share)" + end + + msg = "Thread #{info[:index]} [0x#{thread.__id__.to_s(16)} #{thread.status || 'dead'}] #{lock_state}\n".dup + + if info[:sleeper] + msg << " Waiting in #{info[:sleeper]}" + msg << " to #{info[:purpose].to_s.inspect}" unless info[:purpose].nil? + msg << "\n" + + if info[:compatible] + compat = info[:compatible].map { |c| c == false ? "share" : c.to_s.inspect } + msg << " may be pre-empted for: #{compat.join(', ')}\n" + end + + blockers = threads.values.select { |binfo| blocked_by?(info, binfo, threads.values) } + msg << " blocked by: #{blockers.map { |i| i[:index] }.join(', ')}\n" if blockers.any? + end + + blockees = threads.values.select { |binfo| blocked_by?(binfo, info, threads.values) } + msg << " blocking: #{blockees.map { |i| i[:index] }.join(', ')}\n" if blockees.any? + + msg << "\n#{info[:backtrace].join("\n")}\n" if info[:backtrace] + end.join("\n\n---\n\n\n") + + [200, { "Content-Type" => "text/plain", "Content-Length" => str.size }, [str]] + end + + def blocked_by?(victim, blocker, all_threads) + return false if victim.equal?(blocker) + + case victim[:sleeper] + when :start_sharing + blocker[:exclusive] || + (!victim[:waiting] && blocker[:compatible] && !blocker[:compatible].include?(false)) + when :start_exclusive + blocker[:sharing] > 0 || + blocker[:exclusive] || + (blocker[:compatible] && !blocker[:compatible].include?(victim[:purpose])) + when :yield_shares + blocker[:exclusive] + when :stop_exclusive + blocker[:exclusive] || + victim[:compatible] && + victim[:compatible].include?(blocker[:purpose]) && + all_threads.all? { |other| !other[:compatible] || blocker.equal?(other) || other[:compatible].include?(blocker[:purpose]) } + end + 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..d1b4508378 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/exception_wrapper.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +require "active_support/core_ext/module/attribute_accessors" +require "rack/utils" + +module ActionDispatch + class ExceptionWrapper + cattr_accessor :rescue_responses, default: Hash.new(:internal_server_error).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, + "ActionController::InvalidCrossOriginRequest" => :unprocessable_entity, + "ActionDispatch::Http::Parameters::ParseError" => :bad_request, + "ActionController::BadRequest" => :bad_request, + "ActionController::ParameterMissing" => :bad_request, + "Rack::QueryParser::ParameterTypeError" => :bad_request, + "Rack::QueryParser::InvalidParameterError" => :bad_request + ) + + cattr_accessor :rescue_templates, default: Hash.new("diagnostics").merge!( + "ActionView::MissingTemplate" => "missing_template", + "ActionController::RoutingError" => "routing_error", + "AbstractController::ActionNotFound" => "unknown_action", + "ActiveRecord::StatementInvalid" => "invalid_statement", + "ActionView::Template::Error" => "template_error" + ) + + attr_reader :backtrace_cleaner, :exception, :line_number, :file + + def initialize(backtrace_cleaner, exception) + @backtrace_cleaner = backtrace_cleaner + @exception = original_exception(exception) + + expand_backtrace if exception.is_a?(SyntaxError) || exception.cause.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 traces + application_trace_with_ids = [] + framework_trace_with_ids = [] + full_trace_with_ids = [] + + full_trace.each_with_index do |trace, idx| + trace_with_id = { id: idx, trace: trace } + + if application_trace.include?(trace) + application_trace_with_ids << trace_with_id + else + framework_trace_with_ids << trace_with_id + end + + full_trace_with_ids << trace_with_id + end + + { + "Application Trace" => application_trace_with_ids, + "Framework Trace" => framework_trace_with_ids, + "Full Trace" => full_trace_with_ids + } + end + + def self.status_code_for_exception(class_name) + Rack::Utils.status_code(@@rescue_responses[class_name]) + end + + def source_extracts + backtrace.map do |trace| + file, line_number = extract_file_and_line_number(trace) + + { + code: source_fragment(file, line_number), + line_number: line_number + } + end + end + + private + + def backtrace + Array(@exception.backtrace) + end + + def original_exception(exception) + if @@rescue_responses.has_key?(exception.cause.class.name) + exception.cause + else + exception + end + end + + def clean_backtrace(*args) + if backtrace_cleaner + backtrace_cleaner.clean(backtrace, *args) + else + backtrace + end + 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 extract_file_and_line_number(trace) + # Split by the first colon followed by some digits, which works for both + # Windows and Unix path styles. + file, line = trace.match(/^(.+?):(\d+).*$/, &:captures) || trace + [file, line.to_i] + end + + def expand_backtrace + @exception.backtrace.unshift( + @exception.to_s.split("\n") + ).flatten! + end + end +end diff --git a/actionpack/lib/action_dispatch/middleware/executor.rb b/actionpack/lib/action_dispatch/middleware/executor.rb new file mode 100644 index 0000000000..129b18d3d9 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/executor.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "rack/body_proxy" + +module ActionDispatch + class Executor + def initialize(app, executor) + @app, @executor = app, executor + end + + def call(env) + state = @executor.run! + begin + response = @app.call(env) + returned = response << ::Rack::BodyProxy.new(response.pop) { state.complete! } + ensure + state.complete! unless returned + end + 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..3e11846778 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/flash.rb @@ -0,0 +1,300 @@ +# frozen_string_literal: true + +require "active_support/core_ext/hash/keys" + +module ActionDispatch + # 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 + + module RequestMethods + # 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 + flash = flash_hash + return flash if flash + self.flash = Flash::FlashHash.from_session_value(session["flash"]) + end + + def flash=(flash) + set_header Flash::KEY, flash + end + + def flash_hash # :nodoc: + get_header Flash::KEY + end + + def commit_flash # :nodoc: + session = self.session || {} + flash_hash = self.flash_hash + + if flash_hash && (flash_hash.present? || session.key?("flash")) + session["flash"] = flash_hash.to_session_value + self.flash = 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 + + def reset_session # :nodoc + super + self.flash = nil + end + end + + 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) #:nodoc: + case value + when FlashHash # Rails 3.1, 3.2 + flashes = value.instance_variable_get(:@flashes) + if discard = value.instance_variable_get(:@used) + flashes.except!(*discard) + end + new(flashes, flashes.keys) + when Hash # Rails 4.0 + flashes = value["flashes"] + if discard = value["discard"] + flashes.except!(*discard) + end + new(flashes, flashes.keys) + else + new + end + end + + # Builds a hash containing the flashes to keep for the next request. + # If there are none to keep, returns +nil+. + def to_session_value #:nodoc: + flashes_to_keep = @flashes.except(*@discard) + return nil if flashes_to_keep.empty? + { "discard" => [], "flashes" => flashes_to_keep } + 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.to_s + 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 + + private + def stringify_array(array) # :doc: + array.map do |item| + item.kind_of?(Symbol) ? item.to_s : item + end + end + end + + def self.new(app) app; end + end + + class Request + prepend Flash::RequestMethods + 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..3feb3a19f3 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/public_exceptions.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module ActionDispatch + # When called, this middleware renders an error page. By default if an HTML + # response is expected it will render static error pages from the <tt>/public</tt> + # directory. For example when this middleware receives a 500 response it will + # render the template found in <tt>/public/500.html</tt>. + # If an internationalized locale is set, this middleware will attempt to render + # the template in <tt>/public/500.<locale>.html</tt>. If an internationalized template + # is not found it will fall back on <tt>/public/500.html</tt>. + # + # When a request with a content type other than HTML is made, this middleware + # will attempt to convert error information into the appropriate response type. + class PublicExceptions + attr_accessor :public_path + + def initialize(public_path) + @public_path = public_path + end + + def call(env) + request = ActionDispatch::Request.new(env) + status = request.path_info[1..-1].to_i + content_type = request.formats.first + body = { status: status, error: Rack::Utils::HTTP_STATUS_CODES.fetch(status, 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..8bb3ba7504 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/reloader.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module ActionDispatch + # ActionDispatch::Reloader wraps the request with callbacks provided by ActiveSupport::Reloader + # callbacks, intended to assist with code reloading during development. + # + # By default, ActionDispatch::Reloader is included in the middleware stack + # only in the development environment; specifically, when +config.cache_classes+ + # is false. + class Reloader < Executor + 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..35158f9062 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/remote_ip.rb @@ -0,0 +1,183 @@ +# frozen_string_literal: true + +require "ipaddr" + +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}[https://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 + # https://en.wikipedia.org/wiki/Private_network for details. + TRUSTED_PROXIES = [ + "127.0.0.1", # localhost IPv4 + "::1", # localhost IPv6 + "fc00::/7", # private IPv6 range fc00::/7 + "10.0.0.0/8", # private IPv4 range 10.x.x.x + "172.16.0.0/12", # private IPv4 range 172.16.0.0 .. 172.31.255.255 + "192.168.0.0/16", # private IPv4 range 192.168.x.x + ].map { |proxy| IPAddr.new(proxy) } + + attr_reader :check_ip, :proxies + + # Create a new +RemoteIp+ middleware instance. + # + # The +ip_spoofing_check+ 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 an Array of string, IPAddr, or + # Regexp objects which will be used instead of +TRUSTED_PROXIES+. If a + # single string, IPAddr, or Regexp object is provided, it 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, ip_spoofing_check = true, custom_proxies = nil) + @app = app + @check_ip = ip_spoofing_check + @proxies = if custom_proxies.blank? + TRUSTED_PROXIES + elsif custom_proxies.respond_to?(:any?) + custom_proxies + else + Array(custom_proxies) + TRUSTED_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) + req = ActionDispatch::Request.new env + req.remote_ip = GetIp.new(req, check_ip, proxies) + @app.call(req.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 + def initialize(req, check_ip, proxies) + @req = req + @check_ip = check_ip + @proxies = 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(@req.remote_addr).last + + # Could be a CSV list and/or repeated headers that were concatenated. + client_ips = ips_from(@req.client_ip).reverse + forwarded_ips = ips_from(@req.x_forwarded_for).reverse + + # +Client-Ip+ and +X-Forwarded-For+ should not, generally, both be set. + # If they are both set, it means that either: + # + # 1) This request passed through two proxies with incompatible IP header + # conventions. + # 2) The client passed one of +Client-Ip+ or +X-Forwarded-For+ + # (whichever the proxy servers weren't using) themselves. + # + # Either way, there is no way for us to determine which header is the + # right one after the fact. Since we have no idea, if we are concerned + # about IP spoofing we need to give up and explode. (If you're not + # concerned about IP spoofing you can turn the +ip_spoofing_check+ + # option off.) + 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=#{@req.client_ip.inspect} " \ + "HTTP_X_FORWARDED_FOR=#{@req.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 + + private + + def ips_from(header) # :doc: + return [] unless header + # Split the comma-separated list into an array of strings. + ips = header.strip.split(/[,\s]+/) + ips.select do |ip| + begin + # Only return IPs that are valid according to the IPAddr#new method. + range = IPAddr.new(ip).to_range + # We want to make sure nobody is sneaking a netmask in. + range.begin == range.end + rescue ArgumentError + nil + end + end + end + + def filter_proxies(ips) # :doc: + ips.reject do |ip| + @proxies.any? { |proxy| proxy === ip } + end + 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..da2871b551 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/request_id.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +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 <tt>ActionDispatch::Request#request_id</tt> or the alias <tt>ActionDispatch::Request#uuid</tt>) and sends + # the same id to the client via the X-Request-Id header. + # + # The unique request id is either based on 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 + X_REQUEST_ID = "X-Request-Id".freeze #:nodoc: + + def initialize(app) + @app = app + end + + def call(env) + req = ActionDispatch::Request.new env + req.request_id = make_request_id(req.x_request_id) + @app.call(env).tap { |_status, headers, _body| headers[X_REQUEST_ID] = req.request_id } + end + + private + def make_request_id(request_id) + if request_id.presence + request_id.gsub(/[^\w\-@]/, "".freeze).first(255) + else + internal_request_id + 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..5b0be96223 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/session/abstract_store.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +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: + def initialize + 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: #{$!.message} [#{$!.class}])\n") + set_backtrace $!.backtrace + 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 + + private + + def initialize_sid # :doc: + @default_options.delete(:sidbits) + @default_options.delete(:secure_random) + end + + def make_request(env) + ActionDispatch::Request.new env + 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 + raise ActionDispatch::Session::SessionRestoreError + end + retry + else + raise + end + end + end + + module SessionObject # :nodoc: + def prepare_session(req) + Request::Session.create(self, req, @default_options) + end + + def loaded_session?(session) + !session.is_a?(Request::Session) || session.loaded? + end + end + + class AbstractStore < Rack::Session::Abstract::Persisted + include Compatibility + include StaleSessionCheck + include SessionObject + + private + + def set_cookie(request, session_id, cookie) + 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..a6d965a644 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/session/cache_store.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require "action_dispatch/middleware/session/abstract_store" + +module ActionDispatch + module Session + # A 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. + # + # ==== Options + # * <tt>cache</tt> - The cache to use. If it is not specified, <tt>Rails.cache</tt> will be used. + # * <tt>expire_after</tt> - The length of time a session will be stored before automatically expiring. + # By default, the <tt>:expires_in</tt> option of the cache is used. + class CacheStore < AbstractStore + 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 find_session(env, sid) + unless sid && (session = @cache.read(cache_key(sid))) + sid, session = generate_sid, {} + end + [sid, session] + end + + # Set a session in the cache. + def write_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 delete_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..4ea96196d3 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/session/cookie_store.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +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. + # + # Your cookies will be encrypted using your apps secret_key_base. 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. + # + # Configure your session store in <tt>config/initializers/session_store.rb</tt>: + # + # Rails.application.config.session_store :cookie_store, key: '_your_app_session' + # + # By default, your secret key base is derived from your application name in + # the test and development environments. In all other environments, it is stored + # encrypted in the <tt>config/credentials.yml.enc</tt> file. + # + # If your application was not updated to Rails 5.2 defaults, the secret_key_base + # will be found in the old <tt>config/secrets.yml</tt> file. + # + # Note that changing your secret_key_base will invalidate all existing session. + # 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 changing it. + # + # Because CookieStore extends Rack::Session::Abstract::Persisted, many of the + # options described there can be used to customize the session cookie that + # is generated. For example: + # + # Rails.application.config.session_store :cookie_store, expire_after: 14.days + # + # would set the session cookie to expire automatically 14 days after creation. + # Other useful options include <tt>:key</tt>, <tt>:secure</tt> and + # <tt>:httponly</tt>. + class CookieStore < AbstractStore + def initialize(app, options = {}) + super(app, options.merge!(cookie_only: true)) + end + + def delete_session(req, session_id, options) + new_sid = generate_sid unless options[:drop] + # Reset hash and Assign the new session id + req.set_header("action_dispatch.request.unsigned_session_cookie", new_sid ? { "session_id" => new_sid } : {}) + new_sid + end + + def load_session(req) + stale_session_check! do + data = unpacked_cookie_data(req) + data = persistent_session_id!(data) + [data["session_id"], data] + end + end + + private + + def extract_session_id(req) + stale_session_check! do + unpacked_cookie_data(req)["session_id"] + end + end + + def unpacked_cookie_data(req) + req.fetch_header("action_dispatch.request.unsigned_session_cookie") do |k| + v = stale_session_check! do + if data = get_cookie(req) + data.stringify_keys! + end + data || {} + end + req.set_header k, v + end + end + + def persistent_session_id!(data, sid = nil) + data ||= {} + data["session_id"] ||= sid || generate_sid + data + end + + def write_session(req, sid, session_data, options) + session_data["session_id"] = sid + session_data + end + + def set_cookie(request, session_id, cookie) + cookie_jar(request)[@key] = cookie + end + + def get_cookie(req) + cookie_jar(req)[@key] + end + + def cookie_jar(request) + 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..914df3a2b1 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/session/mem_cache_store.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +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 + # A session store that uses MemCache to implement storage. + # + # ==== Options + # * <tt>expire_after</tt> - The length of time a session will be stored before automatically expiring. + 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..3c88afd4d3 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/show_exceptions.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +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) + request = ActionDispatch::Request.new env + @app.call(env) + rescue Exception => exception + if request.show_exceptions? + render_exception(request, exception) + else + raise exception + end + end + + private + + def render_exception(request, exception) + backtrace_cleaner = request.get_header "action_dispatch.backtrace_cleaner" + wrapper = ExceptionWrapper.new(backtrace_cleaner, exception) + status = wrapper.status_code + request.set_header "action_dispatch.exception", wrapper.exception + request.set_header "action_dispatch.original_path", request.path_info + request.path_info = "/#{status}" + response = @exceptions_app.call(request.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..ef633aadc6 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/ssl.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +module ActionDispatch + # This middleware is added to the stack when <tt>config.force_ssl = true</tt>, and is passed + # the options set in +config.ssl_options+. It does three jobs to enforce secure HTTP + # requests: + # + # 1. <b>TLS redirect</b>: Permanently redirects +http://+ requests to +https://+ + # with the same URL host, path, etc. Enabled by default. Set +config.ssl_options+ + # to modify the destination URL + # (e.g. <tt>redirect: { host: "secure.widgets.com", port: 8080 }</tt>), or set + # <tt>redirect: false</tt> to disable this feature. + # + # Requests can opt-out of redirection with +exclude+: + # + # config.ssl_options = { redirect: { exclude: -> request { request.path =~ /healthcheck/ } } } + # + # 2. <b>Secure cookies</b>: Sets the +secure+ flag on cookies to tell browsers they + # must not be sent along with +http://+ requests. Enabled by default. Set + # +config.ssl_options+ with <tt>secure_cookies: false</tt> to disable this feature. + # + # 3. <b>HTTP Strict Transport Security (HSTS)</b>: Tells the browser to remember + # this site as TLS-only and automatically redirect non-TLS requests. + # Enabled by default. Configure +config.ssl_options+ with <tt>hsts: false</tt> to disable. + # + # Set +config.ssl_options+ with <tt>hsts: { ... }</tt> to configure HSTS: + # + # * +expires+: How long, in seconds, these settings will stick. The minimum + # required to qualify for browser preload lists is 18 weeks. Defaults to + # 180 days (recommended). + # + # * +subdomains+: Set to +true+ to tell the browser to apply these settings + # to all subdomains. This protects your cookies from interception by a + # vulnerable site on a subdomain. Defaults to +true+. + # + # * +preload+: Advertise that this site may be included in browsers' + # preloaded HSTS lists. HSTS protects your site on every visit <i>except the + # first visit</i> since it hasn't seen your HSTS header yet. To close this + # gap, browser vendors include a baked-in list of HSTS-enabled sites. + # Go to https://hstspreload.org to submit your site for inclusion. + # Defaults to +false+. + # + # To turn off HSTS, omitting the header is not enough. Browsers will remember the + # original HSTS directive until it expires. Instead, use the header to tell browsers to + # expire HSTS immediately. Setting <tt>hsts: false</tt> is a shortcut for + # <tt>hsts: { expires: 0 }</tt>. + class SSL + # :stopdoc: + + # Default to 180 days, the low end for https://www.ssllabs.com/ssltest/ + # and greater than the 18-week requirement for browser preload lists. + HSTS_EXPIRES_IN = 15552000 + + def self.default_hsts_options + { expires: HSTS_EXPIRES_IN, subdomains: true, preload: false } + end + + def initialize(app, redirect: {}, hsts: {}, secure_cookies: true) + @app = app + + @redirect = redirect + + @exclude = @redirect && @redirect[:exclude] || proc { !@redirect } + @secure_cookies = secure_cookies + + @hsts_header = build_hsts_header(normalize_hsts_options(hsts)) + end + + def call(env) + request = Request.new env + + if request.ssl? + @app.call(env).tap do |status, headers, body| + set_hsts_header! headers + flag_cookies_as_secure! headers if @secure_cookies + end + else + return redirect_to_https request unless @exclude.call(request) + @app.call(env) + end + end + + private + def set_hsts_header!(headers) + headers["Strict-Transport-Security".freeze] ||= @hsts_header + end + + def normalize_hsts_options(options) + case options + # Explicitly disabling HSTS clears the existing setting from browsers + # by setting expiry to 0. + when false + self.class.default_hsts_options.merge(expires: 0) + # Default to enabled, with default options. + when nil, true + self.class.default_hsts_options + else + self.class.default_hsts_options.merge(options) + end + end + + # https://tools.ietf.org/html/rfc6797#section-6.1 + def build_hsts_header(hsts) + value = "max-age=#{hsts[:expires].to_i}".dup + value << "; includeSubDomains" if hsts[:subdomains] + value << "; preload" if hsts[:preload] + value + end + + def flag_cookies_as_secure!(headers) + if cookies = headers["Set-Cookie".freeze] + cookies = cookies.split("\n".freeze) + + headers["Set-Cookie".freeze] = cookies.map { |cookie| + if cookie !~ /;\s*secure\s*(;|$)/i + "#{cookie}; secure" + else + cookie + end + }.join("\n".freeze) + end + end + + def redirect_to_https(request) + [ @redirect.fetch(:status, redirection_status(request)), + { "Content-Type" => "text/html", + "Location" => https_location_for(request) }, + @redirect.fetch(:body, []) ] + end + + def redirection_status(request) + if request.get? || request.head? + 301 # Issue a permanent redirect via a GET request. + else + 307 # Issue a fresh request redirect to preserve the HTTP method. + end + end + + def https_location_for(request) + host = @redirect[:host] || request.host + port = @redirect[:port] || request.port + + location = "https://#{host}".dup + location << ":#{port}" if port != 80 && port != 443 + location << request.fullpath + location + 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..b82f8aa3a3 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/stack.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require "active_support/inflector/methods" +require "active_support/dependencies" + +module ActionDispatch + class MiddlewareStack + class Middleware + attr_reader :args, :block, :klass + + def initialize(klass, args, block) + @klass = klass + @args = args + @block = block + end + + def name; klass.name; end + + def ==(middleware) + case middleware + when Middleware + klass == middleware.klass + when Class + klass == middleware + end + end + + def inspect + if klass.is_a?(Class) + klass.to_s + else + klass.class.to_s + end + end + + def build(app) + klass.new(app, *args, &block) + 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(klass, *args, &block) + middlewares.unshift(build_middleware(klass, args, block)) + end + + def initialize_copy(other) + self.middlewares = other.middlewares.dup + end + + def insert(index, klass, *args, &block) + index = assert_index(index, :before) + middlewares.insert(index, build_middleware(klass, args, block)) + 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_if { |m| m.klass == target } + end + + def use(klass, *args, &block) + middlewares.push(build_middleware(klass, args, block)) + end + + def build(app = Proc.new) + middlewares.freeze.reverse.inject(app) { |a, e| e.build(a) } + end + + private + + def assert_index(index, where) + i = index.is_a?(Integer) ? index : middlewares.index { |m| m.klass == index } + raise "No such middleware to insert #{where}: #{index.inspect}" unless i + i + end + + def build_middleware(klass, args, block) + Middleware.new(klass, args, block) + 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..23492e14eb --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/static.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require "rack/utils" +require "active_support/core_ext/uri" + +module ActionDispatch + # This middleware returns a file's contents from disk in the body response. + # When initialized, it can accept optional HTTP headers, which will be set + # when a response containing a file's contents is delivered. + # + # This middleware will render the file specified in <tt>env["PATH_INFO"]</tt> + # where the base path is in the +root+ directory. For example, if the +root+ + # is set to +public/+, then a request with <tt>env["PATH_INFO"]</tt> of + # +assets/application.js+ will return a response with the contents of a file + # located at +public/assets/application.js+ if the file exists. If the file + # does not exist, a 404 "File not Found" response will be returned. + class FileHandler + def initialize(root, index: "index", headers: {}) + @root = root.chomp("/") + @file_server = ::Rack::File.new(@root, headers) + @index = index + end + + # Takes a path to a file. If the file is found, has valid encoding, and has + # correct read permissions, the return value is a URI-escaped string + # representing the filename. Otherwise, false is returned. + # + # Used by the +Static+ class to check the existence of a valid file + # in the server's +public/+ directory (see Static#call). + def match?(path) + path = ::Rack::Utils.unescape_path path + return false unless ::Rack::Utils.valid_path? path + path = ::Rack::Utils.clean_path_info path + + paths = [path, "#{path}#{ext}", "#{path}/#{@index}#{ext}"] + + if match = paths.detect { |p| + path = File.join(@root, p.dup.force_encoding(Encoding::UTF_8)) + begin + File.file?(path) && File.readable?(path) + rescue SystemCallError + false + end + + } + return ::Rack::Utils.escape_path(match) + end + end + + def call(env) + serve(Rack::Request.new(env)) + end + + def serve(request) + path = request.path_info + gzip_path = gzip_file_path(path) + + if gzip_path && gzip_encoding_accepted?(request) + request.path_info = gzip_path + status, headers, body = @file_server.call(request.env) + if status == 304 + return [status, headers, body] + end + headers["Content-Encoding"] = "gzip" + headers["Content-Type"] = content_type(path) + else + status, headers, body = @file_server.call(request.env) + end + + headers["Vary"] = "Accept-Encoding" if gzip_path + + return [status, headers, body] + ensure + request.path_info = path + end + + private + def ext + ::ActionController::Base.default_static_extension + end + + def content_type(path) + ::Rack::Mime.mime_type(::File.extname(path), "text/plain".freeze) + end + + def gzip_encoding_accepted?(request) + request.accept_encoding.any? { |enc, quality| enc =~ /\bgzip\b/i } + end + + def gzip_file_path(path) + can_gzip_mime = content_type(path) =~ /\A(?:text\/|application\/javascript)/ + gzip_path = "#{path}.gz" + if can_gzip_mime && File.exist?(File.join(@root, ::Rack::Utils.unescape_path(gzip_path))) + gzip_path + else + false + end + end + end + + # This middleware will attempt to return the contents of a file's body from + # disk in the response. If a file is not found on disk, the request will be + # delegated to the application stack. This middleware is commonly initialized + # to serve assets from a server's +public/+ directory. + # + # This middleware verifies the path to ensure that only files + # living in the root directory can be rendered. A request cannot + # produce a directory traversal using this middleware. Only 'GET' and 'HEAD' + # requests will result in a file being returned. + class Static + def initialize(app, path, index: "index", headers: {}) + @app = app + @file_handler = FileHandler.new(path, index: index, headers: headers) + end + + def call(env) + req = Rack::Request.new env + + if req.get? || req.head? + path = req.path_info.chomp("/".freeze) + if match = @file_handler.match?(path) + req.path_info = match + return @file_handler.serve(req) + end + end + + @app.call(req.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..49b1e83551 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/_request_and_response.html.erb @@ -0,0 +1,22 @@ +<% 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 %> + +<h2 style="margin-top: 30px">Request</h2> +<p><b>Parameters</b>:</p> <pre><%= debug_params(@request.filtered_parameters) %></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><%= debug_headers(defined?(@response) ? @response.headers : {}) %></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.html.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/_source.html.erb new file mode 100644 index 0000000000..e7b913bbe4 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/_source.html.erb @@ -0,0 +1,27 @@ +<% @source_extracts.each_with_index do |source_extract, index| %> + <% if source_extract[:code] %> + <div class="source <%="hidden" if @show_source_idx != index%>" id="frame-source-<%=index%>"> + <div class="info"> + Extracted source (around line <strong>#<%= source_extract[:line_number] %></strong>): + </div> + <div class="data"> + <table cellpadding="0" cellspacing="0" class="lines"> + <tr> + <td> + <pre class="line_numbers"> + <% source_extract[:code].each_key do |line_number| %> +<span><%= line_number -%></span> + <% end %> + </pre> + </td> +<td width="100%"> +<pre> +<% source_extract[:code].each do |line, source| -%><div class="line<%= " active" if line == source_extract[:line_number] -%>"><%= source -%></div><% end -%> +</pre> +</td> + </tr> + </table> + </div> + </div> + <% end %> +<% end %> diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/_source.text.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/_source.text.erb new file mode 100644 index 0000000000..23a9c7ba3f --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/_source.text.erb @@ -0,0 +1,8 @@ +<% @source_extracts.first(3).each do |source_extract| %> +<% if source_extract[:code] %> +Extracted source (around line #<%= source_extract[:line_number] %>): + +<% source_extract[:code].each do |line, source| -%> +<%= line == source_extract[:line_number] ? "*#{line}" : "##{line}" -%> <%= source -%><% 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..ab57b11c7d --- /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 == @trace_to_show) ? '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..c0b53068f7 --- /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 { |t| t[: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/invalid_statement.html.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/invalid_statement.html.erb new file mode 100644 index 0000000000..e1b129ccc5 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/invalid_statement.html.erb @@ -0,0 +1,21 @@ +<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 %> + <% if @exception.message.match? %r{#{ActiveStorage::Blob.table_name}|#{ActiveStorage::Attachment.table_name}} %> + <br />To resolve this issue run: bin/rails active_storage:install + <% end %> + </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/invalid_statement.text.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/invalid_statement.text.erb new file mode 100644 index 0000000000..033518cf8a --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/invalid_statement.text.erb @@ -0,0 +1,13 @@ +<%= @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 %> +<% if @exception.message.match? %r{#{ActiveStorage::Blob.table_name}|#{ActiveStorage::Attachment.table_name}} %> +To resolve this issue run: bin/rails active_storage:install +<% end %> + +<%= 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..39ea25bdfc --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/layout.erb @@ -0,0 +1,161 @@ +<!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; + white-space: pre; + } + + .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..2a65fd06ad --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/missing_template.html.erb @@ -0,0 +1,11 @@ +<header> + <h1>Template is missing</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/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..55dd5ddc7b --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/routing_error.html.erb @@ -0,0 +1,32 @@ +<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 %> + + <%= render template: "rescues/_request_and_response" %> +</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..5060da9369 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/template_error.html.erb @@ -0,0 +1,20 @@ +<header> + <h1> + <%= @exception.cause.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..78d52acd96 --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/template_error.text.erb @@ -0,0 +1,7 @@ +<%= @exception.cause.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..6e995c85c1 --- /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> + <%= route[:verb] %> + </td> + <td data-route-path='<%= route[:path] %>'> + <%= route[:path] %> + </td> + <td> + <%=simple_format 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..1fa0691303 --- /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; + 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 thead tr.bottom th input#search { + -webkit-appearance: textfield; + } + + #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'> + // support forEarch iterator on NodeList + NodeList.prototype.forEach = Array.prototype.forEach; + + // Enables path search functionality + function setupMatchPaths() { + // Check if there are any matched results in a section + function checkNoMatch(section, noMatchText) { + if (section.children.length <= 1) { + section.innerHTML += noMatchText; + } + } + + // get JSON from URL and invoke callback with result + function getJSON(url, success) { + var xhr = new XMLHttpRequest(); + xhr.open('GET', url); + xhr.onload = function() { + if (this.status == 200) + success(JSON.parse(this.response)); + }; + xhr.send(); + } + + function delayedKeyup(input, callback) { + var timeout; + input.onkeyup = function(){ + if (timeout) clearTimeout(timeout); + timeout = setTimeout(callback, 300); + } + } + + // remove params or fragments + function sanitizePath(path) { + return path.replace(/[#?].*/, ''); + } + + var pathElements = document.querySelectorAll('#route_table [data-route-path]'), + searchElem = document.querySelector('#search'), + exactSection = document.querySelector('#exact_matches'), + fuzzySection = document.querySelector('#fuzzy_matches'); + + // Remove matches when no search value is present + searchElem.onblur = function(e) { + if (searchElem.value === "") { + exactSection.innerHTML = ""; + fuzzySection.innerHTML = ""; + } + } + + // On key press perform a search for matching paths + delayedKeyup(searchElem, function() { + var path = sanitizePath(searchElem.value), + defaultExactMatch = '<tr><th colspan="4">Paths Matching (' + path +'):</th></tr>', + defaultFuzzyMatch = '<tr><th colspan="4">Paths Containing (' + path +'):</th></tr>', + noExactMatch = '<tr><th colspan="4">No Exact Matches Found</th></tr>', + noFuzzyMatch = '<tr><th colspan="4">No Fuzzy Matches Found</th></tr>'; + + if (!path) + return searchElem.onblur(); + + getJSON('/rails/info/routes?path=' + path, function(matches){ + // Clear out results section + exactSection.innerHTML = defaultExactMatch; + fuzzySection.innerHTML = defaultFuzzyMatch; + + // Display exact matches and fuzzy matches + pathElements.forEach(function(elem) { + var elemPath = elem.getAttribute('data-route-path'); + + if (matches['exact'].indexOf(elemPath) != -1) + exactSection.appendChild(elem.parentNode.cloneNode(true)); + + if (matches['fuzzy'].indexOf(elemPath) != -1) + fuzzySection.appendChild(elem.parentNode.cloneNode(true)); + }) + + // Display 'No Matches' message when no matches are found + checkNoMatch(exactSection, noExactMatch); + checkNoMatch(fuzzySection, noFuzzyMatch); + }) + }) + } + + // Enables functionality to toggle between `_path` and `_url` helper suffixes + function setupRouteToggleHelperLinks() { + + // Sets content for each element + function setValOn(elems, val) { + elems.forEach(function(elem) { + elem.innerHTML = val; + }); + } + + // Sets onClick event for each element + function onClick(elems, func) { + elems.forEach(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..eb6fbca6ba --- /dev/null +++ b/actionpack/lib/action_dispatch/railtie.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "action_dispatch" +require "active_support/messages/rotation_configuration" + +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.authenticated_encrypted_cookie_salt = "authenticated encrypted cookie" + config.action_dispatch.use_authenticated_cookie_encryption = false + 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", + "X-Download-Options" => "noopen", + "X-Permitted-Cross-Domain-Policies" => "none", + "Referrer-Policy" => "strict-origin-when-cross-origin" + } + + config.action_dispatch.cookies_rotations = ActiveSupport::Messages::RotationConfiguration.new + + 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..000847e193 --- /dev/null +++ b/actionpack/lib/action_dispatch/request/session.rb @@ -0,0 +1,234 @@ +# frozen_string_literal: true + +require "rack/session/abstract/id" + +module ActionDispatch + class Request + # Session is responsible for lazily loading the session from store. + class Session # :nodoc: + ENV_SESSION_KEY = Rack::RACK_SESSION # :nodoc: + ENV_SESSION_OPTIONS_KEY = Rack::RACK_SESSION_OPTIONS # :nodoc: + + # Singleton object used to determine if an optional param wasn't specified. + Unspecified = Object.new + + # Creates a session hash, merging the properties of the previous session if any. + def self.create(store, req, default_options) + session_was = find req + session = Request::Session.new(store, req) + session.merge! session_was if session_was + + set(req, session) + Options.set(req, Request::Session::Options.new(store, default_options)) + session + end + + def self.find(req) + req.get_header ENV_SESSION_KEY + end + + def self.set(req, session) + req.set_header ENV_SESSION_KEY, session + end + + class Options #:nodoc: + def self.set(req, options) + req.set_header ENV_SESSION_OPTIONS_KEY, options + end + + def self.find(req) + req.get_header ENV_SESSION_OPTIONS_KEY + end + + def initialize(by, default_options) + @by = by + @delegate = default_options.dup + end + + def [](key) + @delegate[key] + end + + def id(req) + @delegate.fetch(:id) { + @by.send(:extract_session_id, req) + } + 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, req) + @by = by + @req = req + @delegate = {} + @loaded = false + @exists = nil # We haven't checked yet. + end + + def id + options.id(@req) + end + + def options + Options.find @req + end + + def destroy + clear + options = self.options || {} + @by.send(:delete_session, @req, options.id(@req), options) + + # Load the new sid to be written with the response. + @loaded = false + load_for_write! + end + + # Returns value of the key stored in the session or + # +nil+ if the given key is not found in the session. + def [](key) + load_for_read! + @delegate[key.to_s] + end + + # Returns true if the session has the given key or false. + def has_key?(key) + load_for_read! + @delegate.key?(key.to_s) + end + alias :key? :has_key? + alias :include? :has_key? + + # Returns keys of the session as Array. + def keys + load_for_read! + @delegate.keys + end + + # Returns values of the session as Array. + def values + load_for_read! + @delegate.values + end + + # Writes given value to given key of the session. + def []=(key, value) + load_for_write! + @delegate[key.to_s] = value + end + + # Clears the session. + def clear + load_for_write! + @delegate.clear + end + + # Returns the session as Hash. + def to_hash + load_for_read! + @delegate.dup.delete_if { |_, v| v.nil? } + end + alias :to_h :to_hash + + # Updates the session with given Hash. + # + # session.to_hash + # # => {"session_id"=>"e29b9ea315edf98aad94cc78c34cc9b2"} + # + # session.update({ "foo" => "bar" }) + # # => {"session_id"=>"e29b9ea315edf98aad94cc78c34cc9b2", "foo" => "bar"} + # + # session.to_hash + # # => {"session_id"=>"e29b9ea315edf98aad94cc78c34cc9b2", "foo" => "bar"} + def update(hash) + load_for_write! + @delegate.update stringify_keys(hash) + end + + # Deletes given key from the session. + def delete(key) + load_for_write! + @delegate.delete key.to_s + end + + # Returns value of the given key from the session, or raises +KeyError+ + # if can't find the given key and no default value is set. + # Returns default value if specified. + # + # session.fetch(:foo) + # # => KeyError: key not found: "foo" + # + # session.fetch(:foo, :bar) + # # => :bar + # + # session.fetch(:foo) do + # :bar + # end + # # => :bar + 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?, @req) + end + + def loaded? + @loaded + end + + def empty? + load_for_read! + @delegate.empty? + end + + def merge!(other) + load_for_write! + @delegate.merge!(other) + end + + def each(&block) + to_hash.each(&block) + 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 @req + 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..0ae464082d --- /dev/null +++ b/actionpack/lib/action_dispatch/request/utils.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module ActionDispatch + class Request + class Utils # :nodoc: + mattr_accessor :perform_deep_munge, default: true + + def self.each_param_value(params, &block) + case params + when Array + params.each { |element| each_param_value(element, &block) } + when Hash + params.each_value { |value| each_param_value(value, &block) } + when String + block.call params + end + end + + def self.normalize_encode_params(params) + if perform_deep_munge + NoNilParamEncoder.normalize_encode_params params + else + ParamEncoder.normalize_encode_params params + end + end + + def self.check_param_encoding(params) + case params + when Array + params.each { |element| check_param_encoding(element) } + when Hash + params.each_value { |value| check_param_encoding(value) } + when String + unless params.valid_encoding? + # Raise Rack::Utils::InvalidParameterError for consistency with Rack. + # ActionDispatch::Request#GET will re-raise as a BadRequest error. + raise Rack::Utils::InvalidParameterError, "Invalid encoding for parameter: #{params.scrub}" + end + end + end + + class ParamEncoder # :nodoc: + # Convert nested Hash to HashWithIndifferentAccess. + def self.normalize_encode_params(params) + case params + when Array + handle_array params + when Hash + if params.has_key?(:tempfile) + ActionDispatch::Http::UploadedFile.new(params) + else + params.each_with_object({}) do |(key, val), new_hash| + new_hash[key] = normalize_encode_params(val) + end.with_indifferent_access + end + else + params + end + end + + def self.handle_array(params) + params.map! { |el| normalize_encode_params(el) } + end + end + + # Remove nils from the params hash. + class NoNilParamEncoder < ParamEncoder # :nodoc: + def self.handle_array(params) + list = super + list.compact! + list + 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..72f7407c6e --- /dev/null +++ b/actionpack/lib/action_dispatch/routing.rb @@ -0,0 +1,260 @@ +# frozen_string_literal: true + +require "active_support/core_ext/string/filters" + +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 + # + # Alternatively, 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' + # + # 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. + # + # 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] + # + # == 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 config/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 config/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 config/routes.rb + # controller :blog do + # get 'blog/show' => :list + # get 'blog/delete' => :delete + # get 'blog/edit' => :edit + # end + # + # # provides named routes for show, delete, and edit + # link_to @article.title, blog_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})?/ + # } + # end + # + # 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: /# Postalcode 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 config/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 + # end + # + # def goes_to_login + # get login_url + # #... + # end + # + # == View a list of all your routes + # + # rails routes + # + # Target specific controllers by prefixing the command with <tt>-c</tt> option. + # + 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..24dced1efd --- /dev/null +++ b/actionpack/lib/action_dispatch/routing/endpoint.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module ActionDispatch + module Routing + class Endpoint # :nodoc: + def dispatcher?; false; end + def redirect?; false; end + def engine?; rack_app.respond_to?(:routes); end + def matches?(req); true; end + def app; self; end + def rack_app; app; 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..22336c59b6 --- /dev/null +++ b/actionpack/lib/action_dispatch/routing/inspector.rb @@ -0,0 +1,224 @@ +# frozen_string_literal: true + +require "delegate" + +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.rack_app + end + + def path + super.spec.to_s + end + + def name + super.to_s + end + + def reqs + @reqs ||= begin + reqs = endpoint + reqs += " #{constraints}" unless constraints.empty? + reqs + end + end + + def controller + parts.include?(:controller) ? ":controller" : requirements[:controller] + end + + def action + parts.include?(:action) ? ":action" : requirements[:action] + end + + def internal? + internal + end + + def engine? + app.engine? + end + end + + ## + # This class is just used for displaying route information when someone + # executes `rails 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(normalize_filter(filter)) + routes = collect_routes(routes_to_display) + if routes.none? + formatter.no_routes(collect_routes(@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 normalize_filter(filter) + if filter.is_a?(Hash) && filter[:controller] + { controller: /#{filter[:controller].downcase.sub(/_?controller\z/, '').sub('::', '/')}/ } + elsif filter + { controller: /#{filter}/, action: /#{filter}/, verb: /#{filter}/, name: /#{filter}/, path: /#{filter}/ } + end + end + + def filter_routes(filter) + if filter + @routes.select do |route| + route_wrapper = RouteWrapper.new(route) + filter.any? { |default, value| route_wrapper.send(default) =~ value } + end + else + @routes + end + end + + def collect_routes(routes) + routes.collect do |route| + RouteWrapper.new(route) + end.reject(&:internal?).collect do |route| + collect_engine_routes(route) + + { name: route.name, + verb: route.verb, + path: route.path, + reqs: route.reqs } + end + end + + def collect_engine_routes(route) + name = route.endpoint + return unless route.engine? + return if @engines[name] + + routes = route.rack_app.routes + if routes.is_a?(ActionDispatch::Routing::RouteSet) + @engines[name] = collect_routes(routes.routes) + end + end + 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(routes) + @buffer << + if routes.none? + <<~MESSAGE + You don't have any routes defined! + + Please add some routes in config/routes.rb. + MESSAGE + else + "No routes were found for this controller" + end + @buffer << "For more information about routes, see the Rails guide: http://guides.rubyonrails.org/routing.html." + 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 + <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..f3970d5445 --- /dev/null +++ b/actionpack/lib/action_dispatch/routing/mapper.rb @@ -0,0 +1,2266 @@ +# frozen_string_literal: true + +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/regexp" +require "action_dispatch/routing/redirection" +require "action_dispatch/routing/endpoint" + +module ActionDispatch + module Routing + class Mapper + URL_OPTIONS = [:protocol, :subdomain, :domain, :host, :port] + + class Constraints < Routing::Endpoint #:nodoc: + attr_reader :app, :constraints + + SERVE = ->(app, req) { app.serve req } + CALL = ->(app, req) { app.call req.env } + + def initialize(app, constraints, strategy) + # 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 + + @strategy = strategy + + @app, @constraints, = app, constraints + end + + def dispatcher?; @strategy == SERVE; 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) + + @strategy.call @app, req + 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} + OPTIONAL_FORMAT_REGEX = %r{(?:\(\.:format\)+|\.:format|/)\Z} + + attr_reader :requirements, :defaults + attr_reader :to, :default_controller, :default_action + attr_reader :required_defaults, :ast + + def self.build(scope, set, ast, controller, default_action, to, via, formatted, options_constraints, anchor, options) + options = scope[:options].merge(options) if scope[:options] + + defaults = (scope[:defaults] || {}).dup + scope_constraints = scope[:constraints] || {} + + new set, ast, defaults, controller, default_action, scope[:module], to, formatted, scope_constraints, scope[:blocks] || [], via, options_constraints, anchor, options + end + + def self.check_via(via) + 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 + via + end + + def self.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 self.optional_format?(path, format) + format != false && path !~ OPTIONAL_FORMAT_REGEX + end + + def initialize(set, ast, defaults, controller, default_action, modyoule, to, formatted, scope_constraints, blocks, via, options_constraints, anchor, options) + @defaults = defaults + @set = set + + @to = to + @default_controller = controller + @default_action = default_action + @ast = ast + @anchor = anchor + @via = via + @internal = options.delete(:internal) + + path_params = ast.find_all(&:symbol?).map(&:to_sym) + + options = add_wildcard_options(options, formatted, ast) + + options = normalize_options!(options, path_params, modyoule) + + split_options = constraints(options, path_params) + + constraints = scope_constraints.merge Hash[split_options[:constraints] || []] + + if options_constraints.is_a?(Hash) + @defaults = Hash[options_constraints.find_all { |key, default| + URL_OPTIONS.include?(key) && (String === default || Integer === default) + }].merge @defaults + @blocks = blocks + constraints.merge! options_constraints + else + @blocks = blocks(options_constraints) + end + + requirements, conditions = split_constraints path_params, constraints + verify_regexp_requirements requirements.map(&:last).grep(Regexp) + + formats = normalize_format(formatted) + + @requirements = formats[:requirements].merge Hash[requirements] + @conditions = Hash[conditions] + @defaults = formats[:defaults].merge(@defaults).merge(normalize_defaults(options)) + + if path_params.include?(:action) && !@requirements.key?(:action) + @defaults[:action] ||= "index" + end + + @required_defaults = (split_options[:required_defaults] || []).map(&:first) + end + + def make_route(name, precedence) + route = Journey::Route.new(name, + application, + path, + conditions, + required_defaults, + defaults, + request_method, + precedence, + @internal) + + route + end + + def application + app(@blocks) + end + + def path + build_path @ast, requirements, @anchor + end + + def conditions + build_conditions @conditions, @set.request_class + end + + def build_conditions(current_conditions, request_class) + conditions = current_conditions.dup + + conditions.keep_if do |k, _| + request_class.public_method_defined?(k) + end + end + private :build_conditions + + def request_method + @via.map { |x| Journey::Route.verb_matcher(x) } + end + private :request_method + + JOINED_SEPARATORS = SEPARATORS.join # :nodoc: + + def build_path(ast, requirements, anchor) + pattern = Journey::Path::Pattern.new(ast, requirements, JOINED_SEPARATORS, anchor) + + # Find all the symbol nodes that are adjacent to literal nodes and alter + # the regexp so that Journey will partition them into custom routes. + ast.find_all { |node| + next unless node.cat? + + if node.left.literal? && node.right.symbol? + symbol = node.right + elsif node.left.literal? && node.right.cat? && node.right.left.symbol? + symbol = node.right.left + elsif node.left.symbol? && node.right.literal? + symbol = node.left + elsif node.left.symbol? && node.right.cat? && node.right.left.literal? + symbol = node.left + else + next + end + + if symbol + symbol.regexp = /(?:#{Regexp.union(symbol.regexp, '-')})+/ + end + } + + pattern + end + private :build_path + + private + def add_wildcard_options(options, formatted, path_ast) + # 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).each_with_object({}) { |node, hash| + hash[node.name.to_sym] ||= /.+?/ + }.merge options + else + options + end + end + + def normalize_options!(options, path_params, modyoule) + 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?(:action) || 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.partition do |key, requirement| + path_params.include?(key) || key == :controller + end + end + + def normalize_format(formatted) + case formatted + when true + { requirements: { format: /.+/ }, + defaults: {} } + when Regexp + { requirements: { format: formatted }, + defaults: { format: nil } } + when String + { requirements: { format: Regexp.compile(formatted) }, + defaults: { format: formatted } } + else + { requirements: {}, defaults: {} } + end + end + + def verify_regexp_requirements(requirements) + requirements.each do |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 + end + + def normalize_defaults(options) + Hash[options.reject { |_, default| Regexp === default }] + end + + def app(blocks) + if to.respond_to?(:action) + Routing::RouteSet::StaticDispatcher.new to + elsif to.respond_to?(:call) + Constraints.new(to, blocks, Constraints::CALL) + elsif blocks.any? + Constraints.new(dispatcher(defaults.key?(:controller)), blocks, Constraints::SERVE) + else + dispatcher(defaults.key?(:controller)) + 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.".dup + 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) + if to =~ /#/ + to.split("#") + 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(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 + [callable_constraint] + end + + def constraints(options, path_params) + options.group_by do |key, option| + if Regexp === option + :constraints + else + if path_params.include?(key) + :path_params + else + :required_defaults + end + end + end + end + + def dispatcher(raise_on_name_error) + Routing::RouteSet::Dispatcher.new raise_on_name_error + 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 + # 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: -> (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 HttpHelpers[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>. + # In your router: + # + # resources :users, param: :name + # + # The +users+ resource here will have the following routes generated for it: + # + # GET /users(.:format) + # POST /users(.:format) + # GET /users/new(.:format) + # GET /users/:name/edit(.:format) + # GET /users/:name(.:format) + # PATCH/PUT /users/:name(.:format) + # DELETE /users/:name(.:format) + # + # You can override <tt>ActiveRecord::Base#to_param</tt> of a related + # model to construct a URL: + # + # class User < ActiveRecord::Base + # def to_param + # name + # end + # end + # + # user = User.find_by(name: 'Phusion') + # user_path(user) # => "/users/Phusion" + # + # [: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: -> (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) + elsif Hash === app + options = app + app, path = options.find { |k, _| k.respond_to?(:call) } + options.delete(app) if app + end + + raise ArgumentError, "A rack application must be specified" unless app.respond_to?(:call) + raise ArgumentError, <<~MSG unless path + Must be called with mount point + + mount SomeRackApp, at: "some_route" + or + mount(SomeRackApp => "some_route") + MSG + + rails_app = rails_app? app + options[:as] ||= app_name(app, rails_app) + + 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.key? name + end + + private + def rails_app?(app) + app.is_a?(Class) && app < Rails::Railtie + end + + def app_name(app, rails_app) + if rails_app + app.railtie_name + elsif app.is_a?(Class) + class_name = app.name + ActiveSupport::Inflector.underscore(class_name).tr("/", "_") + end + end + + def define_generate_prefix(app, name) + _route = @set.named_routes.get name + _routes = @set + + script_namer = ->(options) do + prefix_options = options.slice(*_route.segment_keys) + prefix_options[:relative_url_root] = "".freeze + + if options[:_recall] + prefix_options.reverse_merge!(options[:_recall].slice(*_route.segment_keys)) + end + + # 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 + + app.routes.define_mounted_helper(name, script_namer) + + 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 + script_namer.call(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?(Integer)) + end + + options[:defaults] = defaults.merge(options[:defaults] || {}) + else + block, options[:constraints] = options[:constraints], {} + end + + if options.key?(:only) || options.key?(:except) + scope[:action_options] = { only: options.delete(:only), + except: options.delete(:except) } + end + + if options.key? :anchor + raise ArgumentError, "anchor is ignored unless passed to `match`" + end + + @scope.options.each do |option| + if option == :blocks + value = block + elsif option == :options + value = options + else + value = options.delete(option) { POISON } + end + + unless POISON == value + scope[option] = send("merge_#{option}_scope", @scope[option], value) + end + end + + @scope = @scope.new scope + yield + self + ensure + @scope = @scope.parent + end + + POISON = Object.new # :nodoc: + + # Scopes routes to a specific controller + # + # controller "food" do + # match "bacon", action: :bacon, via: :get + # end + def controller(controller) + @scope = @scope.new(controller: controller) + yield + ensure + @scope = @scope.parent + 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, + as: options.fetch(:as, path), + shallow_path: options.fetch(:path, path), + shallow_prefix: options.fetch(:as, path) + } + + path_scope(options.delete(:path) { path }) do + scope(defaults.merge!(options)) { yield } + end + 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(-> (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 = @scope.new(defaults: merge_defaults_scope(@scope[:defaults], defaults)) + yield + ensure + @scope = @scope.parent + end + + private + def merge_path_scope(parent, child) + Mapper.normalize_path("#{parent}/#{child}") + end + + def merge_shallow_path_scope(parent, child) + Mapper.normalize_path("#{parent}/#{child}") + end + + def merge_as_scope(parent, child) + parent ? "#{parent}_#{child}" : child + end + + def merge_shallow_prefix_scope(parent, child) + parent ? "#{parent}_#{child}" : child + end + + def merge_module_scope(parent, child) + parent ? "#{parent}/#{child}" : child + end + + def merge_controller_scope(parent, child) + child + end + + def merge_action_scope(parent, child) + child + end + + def merge_via_scope(parent, child) + child + end + + def merge_format_scope(parent, child) + child + end + + def merge_path_names_scope(parent, child) + merge_options_scope(parent, child) + end + + def merge_constraints_scope(parent, child) + merge_options_scope(parent, child) + end + + def merge_defaults_scope(parent, child) + merge_options_scope(parent, child) + end + + def merge_blocks_scope(parent, child) + merged = parent ? parent.dup : [] + merged << child if child + merged + end + + def merge_options_scope(parent, child) + (parent || {}).merge(child) + end + + def merge_shallow_scope(parent, child) + child ? true : false + end + + def merge_to_scope(parent, child) + child + 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) + + class Resource #:nodoc: + attr_reader :controller, :path, :param + + def initialize(entities, api_only, shallow, 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 = shallow + @api_only = api_only + @only = options.delete :only + @except = options.delete :except + end + + def default_actions + if @api_only + [:index, :create, :show, :update, :destroy] + else + [:index, :create, :new, :show, :update, :destroy, :edit] + end + end + + def actions + if @only + Array(@only).map(&:to_sym) + elsif @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 + 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? + @shallow + end + + def singleton?; false; end + end + + class SingletonResource < Resource #:nodoc: + def initialize(entities, api_only, shallow, options) + super + @as = nil + @controller = (options[:controller] || plural).to_s + @as = options[:as] + end + + def default_actions + if @api_only + [:show, :create, :update, :destroy] + else + [:show, :create, :update, :destroy, :new, :edit] + end + 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 + + def singleton?; true; end + 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 + # + # This 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 + # GET /profile + # GET /profile/edit + # PATCH/PUT /profile + # DELETE /profile + # POST /profile + # + # === Options + # Takes same options as resources[rdoc-ref:#resources] + def resource(*resources, &block) + options = resources.extract_options!.dup + + if apply_common_behavior_for(:resource, resources, options, &block) + return self + end + + with_scope_level(:resource) do + options = apply_action_options options + resource_scope(SingletonResource.new(resources.pop, api_only?, @scope[:shallow], options)) do + yield if block_given? + + concerns(options[:concerns]) if options[:concerns] + + new do + get :new + end if parent_resource.actions.include?(:new) + + set_member_mappings_for_resource + + collection do + post :create + end if parent_resource.actions.include?(:create) + end + 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 match[rdoc-ref:Base#match] 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 + + with_scope_level(:resources) do + options = apply_action_options options + resource_scope(Resource.new(resources.pop, api_only?, @scope[:shallow], 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 + 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 + path_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 { + path_scope(parent_resource.member_scope) { yield } + } + else + path_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 + path_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 do + path_scope(parent_resource.nested_scope) do + scope(nested_options) { yield } + end + end + else + path_scope(parent_resource.nested_scope) do + scope(nested_options) { yield } + end + 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 = @scope.new(shallow: true) + yield + ensure + @scope = @scope.parent + end + + def shallow? + !parent_resource.singleton? && @scope[:shallow] + end + + # Matches a URL pattern to one or more routes. + # For more information, see match[rdoc-ref:Base#match]. + # + # match 'path' => 'controller#action', via: :patch + # match 'path', to: 'controller#action', via: :post + # match 'path', 'otherpath', on: :member, via: :get + def match(path, *rest, &block) + if rest.empty? && Hash === path + options = path + path, to = options.find { |name, _value| name.is_a?(String) } + + raise ArgumentError, "Route path not specified" if path.nil? + + 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 + + if options.key?(:defaults) + defaults(options.delete(:defaults)) { map_match(paths, options, &block) } + else + map_match(paths, options, &block) + end + end + + # 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(path, options = {}) + if path.is_a?(String) + options[:to] = path + elsif path.is_a?(Hash) && options.empty? + options = path + else + raise ArgumentError, "must be called with a path and/or options" + end + + if @scope.resources? + with_scope_level(:root) do + path_scope(parent_resource.path) do + match_root_route(options) + end + end + else + match_root_route(options) + end + end + + private + + def parent_resource + @scope[:scope_level_resource] + end + + def apply_common_behavior_for(method, resources, options, &block) + 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 + + false + end + + def apply_action_options(options) + return options if action_options? options + options.merge scope_action_options + end + + def action_options?(options) + options[:only] || options[:except] + end + + def scope_action_options + @scope[:action_options] || {} + end + + def resource_scope? + @scope.resource_scope? + end + + def resource_method_scope? + @scope.resource_method_scope? + end + + def nested_scope? + @scope.nested? + end + + def with_scope_level(kind) # :doc: + @scope = @scope.new_level(kind) + yield + ensure + @scope = @scope.parent + end + + def resource_scope(resource) + @scope = @scope.new(scope_level_resource: resource) + + controller(resource.resource_scope) { yield } + ensure + @scope = @scope.parent + end + + def nested_options + options = { as: parent_resource.member_name } + options[:constraints] = { + parent_resource.nested_param => param_constraint + } if param_constraint? + + options + end + + def shallow_nesting_depth + @scope.find_all { |node| + node.frame[:scope_level_resource] + }.count { |node| node.frame[:scope_level_resource].shallow? } + end + + def param_constraint? + @scope[:constraints] && @scope[:constraints][parent_resource.param].is_a?(Regexp) + end + + def param_constraint + @scope[:constraints][parent_resource.param] + end + + def canonical_action?(action) + resource_method_scope? && CANONICAL_ACTIONS.include?(action.to_s) + end + + def shallow_scope + scope = { as: @scope[:shallow_prefix], + path: @scope[:shallow_path] } + @scope = @scope.new scope + + yield + ensure + @scope = @scope.parent + end + + def path_for_action(action, path) + return "#{@scope[:path]}/#{path}" if path + + if canonical_action?(action) + @scope[:path].to_s + else + "#{@scope[:path]}/#{action_path(action)}" + end + end + + def action_path(name) + @scope[:path_names][name.to_sym] || name + end + + def prefix_name_for_action(as, action) + if as + prefix = as + elsif !canonical_action?(action) + prefix = action + end + + if prefix && prefix != "/" && !prefix.empty? + Mapper.normalize_name prefix.to_s.tr("-", "_") + end + end + + def name_for_action(as, action) + prefix = prefix_name_for_action(as, action) + 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 + + action_name = @scope.action_name(name_prefix, prefix, collection_name, member_name) + candidate = action_name.select(&:present?).join("_") + + unless candidate.empty? + # 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 || has_named_route?(candidate) + else + candidate + end + end + end + + def set_member_mappings_for_resource # :doc: + 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 + + def api_only? # :doc: + @set.api_only? + end + + def path_scope(path) + @scope = @scope.new(path: merge_path_scope(@scope[:path], path)) + yield + ensure + @scope = @scope.parent + end + + def map_match(paths, options) + if options[:on] && !VALID_ON_OPTIONS.include?(options[:on]) + raise ArgumentError, "Unknown scope #{on.inspect} given to :on" + end + + if @scope[:to] + options[:to] ||= @scope[:to] + end + + if @scope[:controller] && @scope[:action] + options[:to] ||= "#{@scope[:controller]}##{@scope[:action]}" + end + + controller = options.delete(:controller) || @scope[:controller] + option_path = options.delete :path + to = options.delete :to + via = Mapping.check_via Array(options.delete(:via) { + @scope[:via] + }) + formatted = options.delete(:format) { @scope[:format] } + anchor = options.delete(:anchor) { true } + options_constraints = options.delete(:constraints) || {} + + path_types = paths.group_by(&:class) + path_types.fetch(String, []).each do |_path| + route_options = options.dup + if _path && option_path + raise ArgumentError, "Ambiguous route definition. Both :path and the route path were specified as strings." + end + to = get_to_from_path(_path, to, route_options[:action]) + decomposed_match(_path, controller, route_options, _path, to, via, formatted, anchor, options_constraints) + end + + path_types.fetch(Symbol, []).each do |action| + route_options = options.dup + decomposed_match(action, controller, route_options, option_path, to, via, formatted, anchor, options_constraints) + end + + self + end + + def get_to_from_path(path, to, action) + return to if to || action + + path_without_format = path.sub(/\(\.:format\)$/, "") + if using_match_shorthand?(path_without_format) + path_without_format.gsub(%r{^/}, "").sub(%r{/([^/]*)$}, '#\1').tr("-", "_") + else + nil + end + end + + def using_match_shorthand?(path) + path =~ %r{^/?[-\w]+/[-\w/]+$} + end + + def decomposed_match(path, controller, options, _path, to, via, formatted, anchor, options_constraints) + if on = options.delete(:on) + send(on) { decomposed_match(path, controller, options, _path, to, via, formatted, anchor, options_constraints) } + else + case @scope.scope_level + when :resources + nested { decomposed_match(path, controller, options, _path, to, via, formatted, anchor, options_constraints) } + when :resource + member { decomposed_match(path, controller, options, _path, to, via, formatted, anchor, options_constraints) } + else + add_route(path, controller, options, _path, to, via, formatted, anchor, options_constraints) + end + end + end + + def add_route(action, controller, options, _path, to, via, formatted, anchor, options_constraints) + path = path_for_action(action, _path) + raise ArgumentError, "path is required" if path.blank? + + action = action.to_s + + default_action = options.delete(:action) || @scope[:action] + + if action =~ /^[\w\-\/]+$/ + default_action ||= action.tr("-", "_") unless action.include?("/") + else + action = nil + end + + as = if !options.fetch(:as, true) # if it's set to nil or false + options.delete(:as) + else + name_for_action(options.delete(:as), action) + end + + path = Mapping.normalize_path URI.parser.escape(path), formatted + ast = Journey::Parser.parse path + + mapping = Mapping.build(@scope, @set, ast, controller, default_action, to, via, formatted, options_constraints, anchor, options) + @set.add_route(mapping, as) + end + + def match_root_route(options) + name = has_named_route?(name_for_action(:root, nil)) ? nil : :root + args = ["/", { as: name, via: :get }.merge!(options)] + + match(*args) + 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 + + module CustomUrls + # Define custom URL helpers that will be added to the application's + # routes. This allows you to override and/or replace the default behavior + # of routing helpers, e.g: + # + # direct :homepage do + # "http://www.rubyonrails.org" + # end + # + # direct :commentable do |model| + # [ model, anchor: model.dom_id ] + # end + # + # direct :main do + # { controller: "pages", action: "index", subdomain: "www" } + # end + # + # The return value from the block passed to +direct+ must be a valid set of + # arguments for +url_for+ which will actually build the URL string. This can + # be one of the following: + # + # * A string, which is treated as a generated URL + # * A hash, e.g. <tt>{ controller: "pages", action: "index" }</tt> + # * An array, which is passed to +polymorphic_url+ + # * An Active Model instance + # * An Active Model class + # + # NOTE: Other URL helpers can be called in the block but be careful not to invoke + # your custom URL helper again otherwise it will result in a stack overflow error. + # + # You can also specify default options that will be passed through to + # your URL helper definition, e.g: + # + # direct :browse, page: 1, size: 10 do |options| + # [ :products, options.merge(params.permit(:page, :size).to_h.symbolize_keys) ] + # end + # + # In this instance the +params+ object comes from the context in which the + # block is executed, e.g. generating a URL inside a controller action or a view. + # If the block is executed where there isn't a +params+ object such as this: + # + # Rails.application.routes.url_helpers.browse_path + # + # then it will raise a +NameError+. Because of this you need to be aware of the + # context in which you will use your custom URL helper when defining it. + # + # NOTE: The +direct+ method can't be used inside of a scope block such as + # +namespace+ or +scope+ and will raise an error if it detects that it is. + def direct(name, options = {}, &block) + unless @scope.root? + raise RuntimeError, "The direct method can't be used inside a routes scope block" + end + + @set.add_url_helper(name, options, &block) + end + + # Define custom polymorphic mappings of models to URLs. This alters the + # behavior of +polymorphic_url+ and consequently the behavior of + # +link_to+ and +form_for+ when passed a model instance, e.g: + # + # resource :basket + # + # resolve "Basket" do + # [:basket] + # end + # + # This will now generate "/basket" when a +Basket+ instance is passed to + # +link_to+ or +form_for+ instead of the standard "/baskets/:id". + # + # NOTE: This custom behavior only applies to simple polymorphic URLs where + # a single model instance is passed and not more complicated forms, e.g: + # + # # config/routes.rb + # resource :profile + # namespace :admin do + # resources :users + # end + # + # resolve("User") { [:profile] } + # + # # app/views/application/_menu.html.erb + # link_to "Profile", @current_user + # link_to "Profile", [:admin, @current_user] + # + # The first +link_to+ will generate "/profile" but the second will generate + # the standard polymorphic URL of "/admin/users/1". + # + # You can pass options to a polymorphic mapping - the arity for the block + # needs to be two as the instance is passed as the first argument, e.g: + # + # resolve "Basket", anchor: "items" do |basket, options| + # [:basket, options] + # end + # + # This generates the URL "/basket#items" because when the last item in an + # array passed to +polymorphic_url+ is a hash then it's treated as options + # to the URL helper that gets called. + # + # NOTE: The +resolve+ method can't be used inside of a scope block such as + # +namespace+ or +scope+ and will raise an error if it detects that it is. + def resolve(*args, &block) + unless @scope.root? + raise RuntimeError, "The resolve method can't be used inside a routes scope block" + end + + options = args.extract_options! + args = args.flatten(1) + + args.each do |klass| + @set.add_polymorphic_mapping(klass, options, &block) + end + end + end + + class Scope # :nodoc: + OPTIONS = [:path, :shallow_path, :as, :shallow_prefix, :module, + :controller, :action, :path_names, :constraints, + :shallow, :blocks, :defaults, :via, :format, :options, :to] + + RESOURCE_SCOPES = [:resource, :resources] + RESOURCE_METHOD_SCOPES = [:collection, :member, :new] + + attr_reader :parent, :scope_level + + def initialize(hash, parent = NULL, scope_level = nil) + @hash = hash + @parent = parent + @scope_level = scope_level + end + + def nested? + scope_level == :nested + end + + def null? + @hash.nil? && @parent.nil? + end + + def root? + @parent.null? + end + + def resources? + scope_level == :resources + end + + def resource_method_scope? + RESOURCE_METHOD_SCOPES.include? scope_level + end + + def action_name(name_prefix, prefix, collection_name, member_name) + case 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 + end + + def resource_scope? + RESOURCE_SCOPES.include? scope_level + end + + def options + OPTIONS + end + + def new(hash) + self.class.new hash, self, scope_level + end + + def new_level(level) + self.class.new(frame, self, level) + end + + def [](key) + scope = find { |node| node.frame.key? key } + scope && scope.frame[key] + end + + include Enumerable + + def each + node = self + until node.equal? NULL + yield node + node = node.parent + end + end + + def frame; @hash; end + + NULL = Scope.new(nil, nil) + end + + def initialize(set) #:nodoc: + @set = set + @scope = Scope.new(path_names: @set.resources_path_names) + @concerns = {} + end + + include Base + include HttpHelpers + include Redirection + include Scoping + include Concerns + include Resources + include CustomUrls + 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..6da869c0c2 --- /dev/null +++ b/actionpack/lib/action_dispatch/routing/polymorphic_routes.rb @@ -0,0 +1,352 @@ +# frozen_string_literal: true + +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 the 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 + # 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 {url_for}[rdoc-ref:ActionDispatch::Routing::UrlFor]. + # + # ==== 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 + + if mapping = polymorphic_mapping(record_or_hash_or_array) + return mapping.call(self, [record_or_hash_or_array, options], false) + 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 + + if mapping = polymorphic_mapping(record_or_hash_or_array) + return mapping.call(self, [record_or_hash_or_array, options], true) + 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 = {}) + polymorphic_url_for_action("#{action}", record_or_hash, options) + end + + def #{action}_polymorphic_path(record_or_hash, options = {}) + polymorphic_path_for_action("#{action}", record_or_hash, options) + end + EOT + end + + private + + def polymorphic_url_for_action(action, record_or_hash, options) + polymorphic_url(record_or_hash, options.merge(action: action)) + end + + def polymorphic_path_for_action(action, record_or_hash, options) + polymorphic_path(record_or_hash, options.merge(action: action)) + end + + def polymorphic_mapping(record) + if record.respond_to?(:to_model) + _routes.polymorphic_mappings[record.to_model.model_name.name] + else + _routes.polymorphic_mappings[record.class.name] + end + end + + 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 + record_or_hash_or_array = record_or_hash_or_array.compact + if record_or_hash_or_array.empty? + 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 + named_route = if model.persisted? + args << model + get_method_for_string model.model_name.singular_route_key + else + get_method_for_class model + end + + [named_route, args] + end + + def handle_model_call(target, record) + if mapping = polymorphic_mapping(target, record) + mapping.call(target, [record], suffix == "path") + else + method, args = handle_model(record) + target.send(method, *args) + end + 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.model_name.singular_route_key + end + } + + route << + case record + when Symbol, String + record.to_s + when Class + @key_strategy.call record.model_name + else + model = record.to_model + if model.persisted? + args << model + model.model_name.singular_route_key + else + @key_strategy.call model.model_name + end + end + + route << suffix + + named_route = prefix + route.join("_") + [named_route, args] + end + + private + + def polymorphic_mapping(target, record) + if record.respond_to?(:to_model) + target._routes.polymorphic_mappings[record.to_model.model_name.name] + else + target._routes.polymorphic_mappings[record.class.name] + end + end + + def get_method_for_class(klass) + name = @key_strategy.call klass.model_name + get_method_for_string name + 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..143a4b3d62 --- /dev/null +++ b/actionpack/lib/action_dispatch/routing/redirection.rb @@ -0,0 +1,201 @@ +# frozen_string_literal: true + +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) + 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? + + req.commit_flash + + 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") + # + # This will redirect the user, while ignoring certain parts of the request, including query string, etc. + # <tt>/stories</tt>, <tt>/stories?foo=bar</tt>, etc all redirect to <tt>/posts</tt>. + # + # 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}') + # get '/stories', to: redirect(path: '/posts') + # + # This will redirect the user, while changing only the specified parts of the request, + # for example the +path+ option in the last example. + # <tt>/stories</tt>, <tt>/stories?foo=bar</tt>, redirect to <tt>/posts</tt> and <tt>/posts?foo=bar</tt> respectively. + # + # 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..a29a5a04ef --- /dev/null +++ b/actionpack/lib/action_dispatch/routing/route_set.rb @@ -0,0 +1,889 @@ +# frozen_string_literal: true + +require "action_dispatch/journey" +require "active_support/core_ext/object/to_query" +require "active_support/core_ext/module/redefine_method" +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 + # :stopdoc: + class RouteSet + # 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 + def initialize(raise_on_name_error) + @raise_on_name_error = raise_on_name_error + end + + def dispatcher?; true; end + + def serve(req) + params = req.path_parameters + controller = controller req + res = controller.make_response! req + dispatch(controller, params[:action], req, res) + rescue ActionController::RoutingError + if @raise_on_name_error + raise + else + return [404, { "X-Cascade" => "pass" }, []] + end + end + + private + + def controller(req) + req.controller_class + rescue NameError => e + raise ActionController::RoutingError, e.message, e.backtrace + end + + def dispatch(controller, action, req, res) + controller.dispatch(action, req, res) + end + end + + class StaticDispatcher < Dispatcher + def initialize(controller_class) + super(false) + @controller_class = controller_class + end + + private + + def controller(_); @controller_class; 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 + include Enumerable + attr_reader :routes, :url_helpers_module, :path_helpers_module + private :routes + + 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 :remove_method, helper + end + + @url_helpers.each do |helper| + @url_helpers_module.send :remove_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, UNKNOWN + + @path_helpers << path_name + @url_helpers << url_name + end + + def get(name) + routes[name.to_sym] + end + + def key?(name) + return unless 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 + + # Given a +name+, defines name_path and name_url helpers. + # Used by 'direct', 'resolve', and 'polymorphic' route helpers. + def add_url_helper(name, defaults, &block) + helper = CustomUrlHelper.new(name, defaults, &block) + path_name = :"#{name}_path" + url_name = :"#{name}_url" + + @path_helpers_module.module_eval do + redefine_method(path_name) do |*args| + helper.call(self, args, true) + end + end + + @url_helpers_module.module_eval do + redefine_method(url_name) do |*args| + helper.call(self, args, false) + end + end + + @path_helpers << path_name + @url_helpers << url_name + + self + end + + class UrlHelper + 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 + 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) + + original_script_name = options.delete(:original_script_name) + script_name = t._routes.find_script_name(options) + + if original_script_name + script_name = original_script_name + script_name + end + + options[:script_name] = script_name + + url_strategy.call options + else + super + end + end + + private + + def optimized_helper(args) + params = parameterize_args(args) do + raise_generation_error(args) + end + + @route.format params + end + + def optimize_routes_generation?(t) + t.send(:optimize_routes_generation?) + end + + def parameterize_args(args) + params = {} + @arg_size.times { |i| + key = @required_parts[i] + value = args[i].to_param + yield key if value.nil? || value.empty? + params[key] = value + } + params + end + + def raise_generation_error(args) + missing_keys = [] + params = parameterize_args(args) { |missing_key| + missing_keys << missing_key + } + constraints = Hash[@route.requirements.merge(params).sort_by { |k, v| k.to_s }] + message = "No route matches #{constraints.inspect}".dup + 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 + # take format into account + if path_params.include?(:format) + path_params_size = path_params.size - 1 + else + path_params_size = path_params.size + end + + if args.size < path_params_size + path_params -= controller_options.keys + path_params -= result.keys + else + path_params = path_params.dup + end + inner_options.each_key do |key| + path_params.delete(key) + end + + args.each_with_index do |arg, index| + param = path_params[index] + result[param] = arg if param + end + 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| + last = args.last + options = \ + case last + when Hash + args.pop + when ActionController::Parameters + args.pop.to_h + end + helper.call self, args, options + end + end + end + end + + # strategy for building urls to send to the client + PATH = ->(options) { ActionDispatch::Http::URL.path_for(options) } + UNKNOWN = ->(options) { ActionDispatch::Http::URL.url_for(options) } + + attr_accessor :formatter, :set, :named_routes, :default_scope, :router + attr_accessor :disable_clear_and_finalize, :resources_path_names + attr_accessor :default_url_options + attr_reader :env_key, :polymorphic_mappings + + alias :routes :set + + def self.default_resources_path_names + { new: "new", edit: "edit" } + end + + def self.new_with_config(config) + route_set_config = DEFAULT_CONFIG + + # engines apparently don't have this set + if config.respond_to? :relative_url_root + route_set_config.relative_url_root = config.relative_url_root + end + + if config.respond_to? :api_only + route_set_config.api_only = config.api_only + end + + new route_set_config + end + + Config = Struct.new :relative_url_root, :api_only + + DEFAULT_CONFIG = Config.new(nil, false) + + def initialize(config = DEFAULT_CONFIG) + self.named_routes = NamedRouteCollection.new + self.resources_path_names = self.class.default_resources_path_names + self.default_url_options = {} + + @config = config + @append = [] + @prepend = [] + @disable_clear_and_finalize = false + @finalized = false + @env_key = "ROUTES_#{object_id}_SCRIPT_NAME".freeze + + @set = Journey::Routes.new + @router = Journey::Router.new @set + @formatter = Journey::Formatter.new self + @polymorphic_mappings = {} + end + + def eager_load! + router.eager_load! + routes.each(&:eager_load!) + nil + end + + def relative_url_root + @config.relative_url_root + end + + def api_only? + @config.api_only + end + + def request_class + ActionDispatch::Request + end + + def make_request(env) + request_class.new env + end + private :make_request + + 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) + 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 + @polymorphic_mappings.clear + @prepend.each { |blk| eval_block(blk) } + end + + module MountedHelpers + 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, script_namer = nil) + return if MountedHelpers.method_defined?(name) + + routes = self + helpers = routes.url_helpers + + MountedHelpers.class_eval do + define_method "_#{name}" do + RoutesProxy.new(routes, _routes_context, helpers, script_namer) + end + end + + MountedHelpers.class_eval(<<-RUBY, __FILE__, __LINE__ + 1) + def #{name} + @_#{name} ||= _#{name} + end + RUBY + end + + def url_helpers(supports_path = 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) + proxy_class = Class.new do + include UrlFor + include routes.named_routes.path_helpers_module + include routes.named_routes.url_helpers_module + + attr_reader :_routes + + def initialize(routes) + @_routes = routes + end + + def optimize_routes_generation? + @_routes.optimize_routes_generation? + end + end + + @_proxy = proxy_class.new(routes) + + class << self + def url_for(options) + @_proxy.url_for(options) + end + + def full_url_for(options) + @_proxy.full_url_for(options) + end + + def route_for(name, *args) + @_proxy.route_for(name, *args) + end + + def optimize_routes_generation? + @_proxy.optimize_routes_generation? + end + + def polymorphic_url(record_or_hash_or_array, options = {}) + @_proxy.polymorphic_url(record_or_hash_or_array, options) + end + + def polymorphic_path(record_or_hash_or_array, options = {}) + @_proxy.polymorphic_path(record_or_hash_or_array, options) + end + + def _routes; @_proxy._routes; end + 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 supports_path + path_helpers = routes.named_routes.path_helpers_module + + include path_helpers + extend path_helpers + end + + # plus a singleton class method called _routes ... + included do + redefine_singleton_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 } + + define_method(:_generate_paths_by_default) do + supports_path + end + + private :_generate_paths_by_default + end + end + + def empty? + routes.empty? + end + + def add_route(mapping, name) + 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 + + route = @set.add_route(name, mapping) + named_routes[name] = route if name + + if route.segment_keys.include?(:controller) + ActiveSupport::Deprecation.warn(<<-MSG.squish) + Using a dynamic :controller segment in a route is deprecated and + will be removed in Rails 6.0. + MSG + end + + if route.segment_keys.include?(:action) + ActiveSupport::Deprecation.warn(<<-MSG.squish) + Using a dynamic :action segment in a route is deprecated and + will be removed in Rails 6.0. + MSG + end + + route + end + + def add_polymorphic_mapping(klass, options, &block) + @polymorphic_mappings[klass] = CustomUrlHelper.new(klass, options, &block) + end + + def add_url_helper(name, options, &block) + named_routes.add_url_helper(name, options, &block) + end + + class CustomUrlHelper + attr_reader :name, :defaults, :block + + def initialize(name, defaults, &block) + @name = name + @defaults = defaults + @block = block + end + + def call(t, args, only_path = false) + options = args.extract_options! + url = t.full_url_for(eval_block(t, args, options)) + + if only_path + "/" + url.partition(%r{(?<!/)/(?!/)}).last + else + url + end + end + + private + def eval_block(t, args, options) + t.instance_exec(*args, merge_defaults(options), &block) + end + + def merge_defaults(options) + defaults ? defaults.merge(options) : options + end + end + + class Generator + PARAMETERIZE = lambda do |name, value| + if name == :controller + value + else + value.to_param + end + end + + attr_reader :options, :recall, :set, :named_route + + def initialize(named_route, options, recall, set) + @named_route = named_route + @options = options + @recall = recall + @set = set + + normalize_options! + normalize_controller_action_id! + use_relative_controller! + normalize_controller! + 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[key] + end + end + 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) || return + use_recall_for(:action) || 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! + if controller + if controller.start_with?("/".freeze) + @options[:controller] = controller[1..-1] + else + @options[:controller] = controller + end + 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, :relative_url_root] + + def optimize_routes_generation? + default_url_options.empty? + end + + def find_script_name(options) + options.delete(:script_name) || find_relative_url_root(options) || "" + end + + def find_relative_url_root(options) + options.delete(:relative_url_root) || relative_url_root + end + + def path_for(options, route_name = nil) + 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 = make_request(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 = make_request(env) + recognize_path_with_request(req, path, extras) + end + + def recognize_path_with_request(req, path, extras, raise_on_missing: true) + @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 + req.path_parameters = params + app = route.app + if app.matches?(req) && app.dispatcher? + begin + req.controller_class + rescue NameError + raise ActionController::RoutingError, "A route matches #{path.inspect}, but references missing controller: #{params[:controller].camelize}Controller" + end + + return req.path_parameters + elsif app.matches?(req) && app.engine? + path_parameters = app.rack_app.routes.recognize_path_with_request(req, path, extras, raise_on_missing: false) + return path_parameters if path_parameters + end + end + + if raise_on_missing + raise ActionController::RoutingError, "No route matches #{path.inspect}" + end + end + end + # :startdoc: + 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..587a72729c --- /dev/null +++ b/actionpack/lib/action_dispatch/routing/routes_proxy.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +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, helpers, script_namer = nil) + @routes, @scope = routes, scope + @helpers = helpers + @script_namer = script_namer + end + + def url_options + scope.send(:_with_routes, routes) do + scope.url_options + end + end + + private + def respond_to_missing?(method, _) + super || @helpers.respond_to?(method) + end + + def method_missing(method, *args) + if @helpers.respond_to?(method) + self.class.class_eval <<-RUBY, __FILE__, __LINE__ + 1 + def #{method}(*args) + options = args.extract_options! + options = url_options.merge((options || {}).symbolize_keys) + + if @script_namer + options[:script_name] = merge_script_names( + options[:script_name], + @script_namer.call(options) + ) + end + + args << options + @helpers.#{method}(*args) + end + RUBY + public_send(method, *args) + else + super + end + end + + # Keeps the part of the script name provided by the global + # context via ENV["SCRIPT_NAME"], which `mount` doesn't know + # about since it depends on the specific request, but use our + # script name resolver for the mount point dependent part. + def merge_script_names(previous_script_name, new_script_name) + return new_script_name unless previous_script_name + + resolved_parts = new_script_name.count("/") + previous_parts = previous_script_name.count("/") + context_parts = previous_parts - resolved_parts + 1 + + (previous_script_name.split("/").slice(0, context_parts).join("/")) + new_script_name + 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..fa345dccdf --- /dev/null +++ b/actionpack/lib/action_dispatch/routing/url_for.rb @@ -0,0 +1,218 @@ +# frozen_string_literal: true + +module ActionDispatch + module Routing + # In <tt>config/routes.rb</tt> you define URL-to-controller mappings, but the reverse + # is also possible: a 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 you still have to provide + # the +:host+ argument or set the default host that will be used in all mailers using the + # configuration option +config.action_mailer.default_url_options+. For more information on + # url_for in mailers read the ActionMailer#Base documentation. + # + # + # == 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' + # + # Missing routes keys may be filled in from the current request's parameters + # (e.g. +:controller+, +:action+, +:id+ and any other parameters that are + # placed in the path). Given that the current action has been reached + # through <tt>GET /users/1</tt>: + # + # url_for(only_path: true) # => '/users/1' + # url_for(only_path: true, action: 'edit') # => '/users/1/edit' + # url_for(only_path: true, action: 'edit', id: 2) # => '/users/2/edit' + # + # Notice that no +:id+ parameter was provided to the first +url_for+ call + # and the helper used the one from the route's path. Any path parameter + # implicitly used by +url_for+ can always be overwritten like shown on the + # last +url_for+ calls. + def url_for(options = nil) + full_url_for(options) + end + + def full_url_for(options = nil) # :nodoc: + case options + when nil + _routes.url_for(url_options.symbolize_keys) + when Hash, ActionController::Parameters + route_name = options.delete :use_route + merged_url_options = options.to_h.symbolize_keys.reverse_merge!(url_options) + _routes.url_for(merged_url_options, route_name) + when String + options + when Symbol + HelperMethodBuilder.url.handle_string_call self, options + when Array + components = options.dup + polymorphic_url(components, components.extract_options!) + when Class + HelperMethodBuilder.url.handle_class_call self, options + else + HelperMethodBuilder.url.handle_model_call self, options + end + end + + def route_for(name, *args) # :nodoc: + public_send(:"#{name}_url", *args) + end + + protected + + def optimize_routes_generation? + _routes.optimize_routes_generation? && default_url_options.empty? + end + + private + + def _with_routes(routes) # :doc: + old_routes, @_routes = @_routes, routes + yield + ensure + @_routes = old_routes + end + + def _routes_context # :doc: + self + end + end + end +end diff --git a/actionpack/lib/action_dispatch/system_test_case.rb b/actionpack/lib/action_dispatch/system_test_case.rb new file mode 100644 index 0000000000..f85f816bb9 --- /dev/null +++ b/actionpack/lib/action_dispatch/system_test_case.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +gem "capybara", "~> 2.15" + +require "capybara/dsl" +require "capybara/minitest" +require "action_controller" +require "action_dispatch/system_testing/driver" +require "action_dispatch/system_testing/browser" +require "action_dispatch/system_testing/server" +require "action_dispatch/system_testing/test_helpers/screenshot_helper" +require "action_dispatch/system_testing/test_helpers/setup_and_teardown" +require "action_dispatch/system_testing/test_helpers/undef_methods" + +module ActionDispatch + # = System Testing + # + # System tests let you test applications in the browser. Because system + # tests use a real browser experience, you can test all of your JavaScript + # easily from your test suite. + # + # To create a system test in your application, extend your test class + # from <tt>ApplicationSystemTestCase</tt>. System tests use Capybara as a + # base and allow you to configure the settings through your + # <tt>application_system_test_case.rb</tt> file that is generated with a new + # application or scaffold. + # + # Here is an example system test: + # + # require 'application_system_test_case' + # + # class Users::CreateTest < ApplicationSystemTestCase + # test "adding a new user" do + # visit users_path + # click_on 'New User' + # + # fill_in 'Name', with: 'Arya' + # click_on 'Create User' + # + # assert_text 'Arya' + # end + # end + # + # When generating an application or scaffold, an +application_system_test_case.rb+ + # file will also be generated containing the base class for system testing. + # This is where you can change the driver, add Capybara settings, and other + # configuration for your system tests. + # + # require "test_helper" + # + # class ApplicationSystemTestCase < ActionDispatch::SystemTestCase + # driven_by :selenium, using: :chrome, screen_size: [1400, 1400] + # end + # + # By default, <tt>ActionDispatch::SystemTestCase</tt> is driven by the + # Selenium driver, with the Chrome browser, and a browser size of 1400x1400. + # + # Changing the driver configuration options is easy. Let's say you want to use + # the Firefox browser instead of Chrome. In your +application_system_test_case.rb+ + # file add the following: + # + # require "test_helper" + # + # class ApplicationSystemTestCase < ActionDispatch::SystemTestCase + # driven_by :selenium, using: :firefox + # end + # + # +driven_by+ has a required argument for the driver name. The keyword + # arguments are +:using+ for the browser and +:screen_size+ to change the + # size of the browser screen. These two options are not applicable for + # headless drivers and will be silently ignored if passed. + # + # Headless browsers such as headless Chrome and headless Firefox are also supported. + # You can use these browsers by setting the +:using+ argument to +:headless_chrome+ or +:headless_firefox+. + # + # To use a headless driver, like Poltergeist, update your Gemfile to use + # Poltergeist instead of Selenium and then declare the driver name in the + # +application_system_test_case.rb+ file. In this case, you would leave out + # the +:using+ option because the driver is headless, but you can still use + # +:screen_size+ to change the size of the browser screen, also you can use + # +:options+ to pass options supported by the driver. Please refer to your + # driver documentation to learn about supported options. + # + # require "test_helper" + # require "capybara/poltergeist" + # + # class ApplicationSystemTestCase < ActionDispatch::SystemTestCase + # driven_by :poltergeist, screen_size: [1400, 1400], options: + # { js_errors: true } + # end + # + # Because <tt>ActionDispatch::SystemTestCase</tt> is a shim between Capybara + # and Rails, any driver that is supported by Capybara is supported by system + # tests as long as you include the required gems and files. + class SystemTestCase < IntegrationTest + include Capybara::DSL + include Capybara::Minitest::Assertions + include SystemTesting::TestHelpers::SetupAndTeardown + include SystemTesting::TestHelpers::ScreenshotHelper + include SystemTesting::TestHelpers::UndefMethods + + def initialize(*) # :nodoc: + super + self.class.driver.use + end + + def self.start_application # :nodoc: + Capybara.app = Rack::Builder.new do + map "/" do + run Rails.application + end + end + + SystemTesting::Server.new.run + end + + class_attribute :driver, instance_accessor: false + + # System Test configuration options + # + # The default settings are Selenium, using Chrome, with a screen size + # of 1400x1400. + # + # Examples: + # + # driven_by :poltergeist + # + # driven_by :selenium, screen_size: [800, 800] + # + # driven_by :selenium, using: :chrome + # + # driven_by :selenium, using: :headless_chrome + # + # driven_by :selenium, using: :firefox + # + # driven_by :selenium, using: :headless_firefox + def self.driven_by(driver, using: :chrome, screen_size: [1400, 1400], options: {}) + self.driver = SystemTesting::Driver.new(driver, using: using, screen_size: screen_size, options: options) + end + + driven_by :selenium + + ActiveSupport.run_load_hooks(:action_dispatch_system_test_case, self) + end + + SystemTestCase.start_application +end diff --git a/actionpack/lib/action_dispatch/system_testing/browser.rb b/actionpack/lib/action_dispatch/system_testing/browser.rb new file mode 100644 index 0000000000..10e6888ab3 --- /dev/null +++ b/actionpack/lib/action_dispatch/system_testing/browser.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module ActionDispatch + module SystemTesting + class Browser # :nodoc: + attr_reader :name + + def initialize(name) + @name = name + end + + def type + case name + when :headless_chrome + :chrome + when :headless_firefox + :firefox + else + name + end + end + + def options + case name + when :headless_chrome + headless_chrome_browser_options + when :headless_firefox + headless_firefox_browser_options + end + end + + private + def headless_chrome_browser_options + options = Selenium::WebDriver::Chrome::Options.new + options.args << "--headless" + options.args << "--disable-gpu" + + options + end + + def headless_firefox_browser_options + options = Selenium::WebDriver::Firefox::Options.new + options.args << "-headless" + + options + end + end + end +end diff --git a/actionpack/lib/action_dispatch/system_testing/driver.rb b/actionpack/lib/action_dispatch/system_testing/driver.rb new file mode 100644 index 0000000000..5252ff6746 --- /dev/null +++ b/actionpack/lib/action_dispatch/system_testing/driver.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module ActionDispatch + module SystemTesting + class Driver # :nodoc: + def initialize(name, **options) + @name = name + @browser = Browser.new(options[:using]) + @screen_size = options[:screen_size] + @options = options[:options] + end + + def use + register if registerable? + + setup + end + + private + def registerable? + [:selenium, :poltergeist, :webkit].include?(@name) + end + + def register + Capybara.register_driver @name do |app| + case @name + when :selenium then register_selenium(app) + when :poltergeist then register_poltergeist(app) + when :webkit then register_webkit(app) + end + end + end + + def browser_options + @options.merge(options: @browser.options).compact + end + + def register_selenium(app) + Capybara::Selenium::Driver.new(app, { browser: @browser.type }.merge(browser_options)).tap do |driver| + driver.browser.manage.window.size = Selenium::WebDriver::Dimension.new(*@screen_size) + end + end + + def register_poltergeist(app) + Capybara::Poltergeist::Driver.new(app, @options.merge(window_size: @screen_size)) + end + + def register_webkit(app) + Capybara::Webkit::Driver.new(app, Capybara::Webkit::Configuration.to_hash.merge(@options)).tap do |driver| + driver.resize_window_to(driver.current_window_handle, *@screen_size) + end + end + + def setup + Capybara.current_driver = @name + end + end + end +end diff --git a/actionpack/lib/action_dispatch/system_testing/server.rb b/actionpack/lib/action_dispatch/system_testing/server.rb new file mode 100644 index 0000000000..4fc1f33767 --- /dev/null +++ b/actionpack/lib/action_dispatch/system_testing/server.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module ActionDispatch + module SystemTesting + class Server # :nodoc: + class << self + attr_accessor :silence_puma + end + + self.silence_puma = false + + def run + setup + end + + private + def setup + set_server + set_port + end + + def set_server + Capybara.server = :puma, { Silent: self.class.silence_puma } if Capybara.server == Capybara.servers[:default] + end + + def set_port + Capybara.always_include_port = true + end + end + end +end diff --git a/actionpack/lib/action_dispatch/system_testing/test_helpers/screenshot_helper.rb b/actionpack/lib/action_dispatch/system_testing/test_helpers/screenshot_helper.rb new file mode 100644 index 0000000000..df0c5d3f0e --- /dev/null +++ b/actionpack/lib/action_dispatch/system_testing/test_helpers/screenshot_helper.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +module ActionDispatch + module SystemTesting + module TestHelpers + # Screenshot helper for system testing. + module ScreenshotHelper + # Takes a screenshot of the current page in the browser. + # + # +take_screenshot+ can be used at any point in your system tests to take + # a screenshot of the current state. This can be useful for debugging or + # automating visual testing. + # + # The screenshot will be displayed in your console, if supported. + # + # You can set the +RAILS_SYSTEM_TESTING_SCREENSHOT+ environment variable to + # control the output. Possible values are: + # * [+simple+ (default)] Only displays the screenshot path. + # This is the default value. + # * [+inline+] Display the screenshot in the terminal using the + # iTerm image protocol (https://iterm2.com/documentation-images.html). + # * [+artifact+] Display the screenshot in the terminal, using the terminal + # artifact format (https://buildkite.github.io/terminal/inline-images/). + def take_screenshot + save_image + puts display_image + end + + # Takes a screenshot of the current page in the browser if the test + # failed. + # + # +take_failed_screenshot+ is included in <tt>application_system_test_case.rb</tt> + # that is generated with the application. To take screenshots when a test + # fails add +take_failed_screenshot+ to the teardown block before clearing + # sessions. + def take_failed_screenshot + take_screenshot if failed? && supports_screenshot? + end + + private + def image_name + failed? ? "failures_#{method_name}" : method_name + end + + def image_path + @image_path ||= absolute_image_path.relative_path_from(Pathname.pwd).to_s + end + + def absolute_image_path + Rails.root.join("tmp/screenshots/#{image_name}.png") + end + + def save_image + page.save_screenshot(absolute_image_path) + end + + def output_type + # Environment variables have priority + output_type = ENV["RAILS_SYSTEM_TESTING_SCREENSHOT"] || ENV["CAPYBARA_INLINE_SCREENSHOT"] + + # Default to outputting a path to the screenshot + output_type ||= "simple" + + output_type + end + + def display_image + message = "[Screenshot]: #{image_path}\n".dup + + case output_type + when "artifact" + message << "\e]1338;url=artifact://#{absolute_image_path}\a\n" + when "inline" + name = inline_base64(File.basename(absolute_image_path)) + image = inline_base64(File.read(absolute_image_path)) + message << "\e]1337;File=name=#{name};height=400px;inline=1:#{image}\a\n" + end + + message + end + + def inline_base64(path) + Base64.encode64(path).gsub("\n", "") + end + + def failed? + !passed? && !skipped? + end + + def supports_screenshot? + Capybara.current_driver != :rack_test + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/system_testing/test_helpers/setup_and_teardown.rb b/actionpack/lib/action_dispatch/system_testing/test_helpers/setup_and_teardown.rb new file mode 100644 index 0000000000..ffa85f4e14 --- /dev/null +++ b/actionpack/lib/action_dispatch/system_testing/test_helpers/setup_and_teardown.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module ActionDispatch + module SystemTesting + module TestHelpers + module SetupAndTeardown # :nodoc: + DEFAULT_HOST = "http://127.0.0.1" + + def host!(host) + super + Capybara.app_host = host + end + + def before_setup + host! DEFAULT_HOST + super + end + + def after_teardown + take_failed_screenshot + Capybara.reset_sessions! + super + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/system_testing/test_helpers/undef_methods.rb b/actionpack/lib/action_dispatch/system_testing/test_helpers/undef_methods.rb new file mode 100644 index 0000000000..d64be3b3d9 --- /dev/null +++ b/actionpack/lib/action_dispatch/system_testing/test_helpers/undef_methods.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module ActionDispatch + module SystemTesting + module TestHelpers + module UndefMethods # :nodoc: + extend ActiveSupport::Concern + included do + METHODS = %i(get post put patch delete).freeze + + METHODS.each do |verb| + undef_method verb + end + + def method_missing(method, *args, &block) + if METHODS.include?(method) + raise NoMethodError, "System tests cannot make direct requests via ##{method}; use #visit and #click_on instead. See http://www.rubydoc.info/github/teamcapybara/capybara/master#The_DSL for more information." + else + super + end + end + end + end + end + end +end diff --git a/actionpack/lib/action_dispatch/testing/assertion_response.rb b/actionpack/lib/action_dispatch/testing/assertion_response.rb new file mode 100644 index 0000000000..dc019db6ac --- /dev/null +++ b/actionpack/lib/action_dispatch/testing/assertion_response.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module ActionDispatch + # This is a class that abstracts away an asserted response. It purposely + # does not inherit from Response because it doesn't need it. That means it + # does not have headers or a body. + class AssertionResponse + attr_reader :code, :name + + GENERIC_RESPONSE_CODES = { # :nodoc: + success: "2XX", + missing: "404", + redirect: "3XX", + error: "5XX" + } + + # Accepts a specific response status code as an Integer (404) or String + # ('404') or a response status range as a Symbol pseudo-code (:success, + # indicating any 200-299 status code). + def initialize(code_or_name) + if code_or_name.is_a?(Symbol) + @name = code_or_name + @code = code_from_name(code_or_name) + else + @name = name_from_code(code_or_name) + @code = code_or_name + end + + raise ArgumentError, "Invalid response name: #{name}" if @code.nil? + raise ArgumentError, "Invalid response code: #{code}" if @name.nil? + end + + def code_and_name + "#{code}: #{name}" + end + + private + + def code_from_name(name) + GENERIC_RESPONSE_CODES[name] || Rack::Utils::SYMBOL_TO_STATUS_CODE[name] + end + + def name_from_code(code) + GENERIC_RESPONSE_CODES.invert[code] || Rack::Utils::HTTP_STATUS_CODES[code] + 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..08c2969685 --- /dev/null +++ b/actionpack/lib/action_dispatch/testing/assertions.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "rails-dom-testing" + +module ActionDispatch + module Assertions + autoload :ResponseAssertions, "action_dispatch/testing/assertions/response" + autoload :RoutingAssertions, "action_dispatch/testing/assertions/routing" + + extend ActiveSupport::Concern + + include ResponseAssertions + include RoutingAssertions + include Rails::Dom::Testing::Assertions + + def html_document + @html_document ||= if @response.content_type.to_s.end_with?("xml") + Nokogiri::XML::Document.parse(@response.body) + else + Nokogiri::HTML::Document.parse(@response.body) + 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..98b1965d22 --- /dev/null +++ b/actionpack/lib/action_dispatch/testing/assertions/response.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +module ActionDispatch + module Assertions + # A small suite of assertions that test responses from \Rails applications. + module ResponseAssertions + RESPONSE_PREDICATES = { # :nodoc: + success: :successful?, + missing: :not_found?, + redirect: :redirection?, + error: :server_error?, + } + + # 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. + # + # # Asserts that the response was a redirection + # assert_response :redirect + # + # # Asserts that the response code was status code 401 (unauthorized) + # assert_response 401 + def assert_response(type, message = nil) + message ||= generate_response_message(type) + + if RESPONSE_PREDICATES.keys.include?(type) + assert @response.send(RESPONSE_PREDICATES[type]), message + else + assert_equal AssertionResponse.new(type).code, @response.response_code, message + end + end + + # Asserts 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. + # + # # Asserts that the redirection was to the "index" action on the WeblogController + # assert_redirected_to controller: "weblog", action: "index" + # + # # Asserts that the redirection was to the named route login_url + # assert_redirected_to login_url + # + # # Asserts 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 + + def generate_response_message(expected, actual = @response.response_code) + "Expected response to be a <#{code_with_name(expected)}>,"\ + " but was a <#{code_with_name(actual)}>" + .dup.concat(location_if_redirected).concat(response_body_if_short) + end + + def response_body_if_short + return "" if @response.body.size > 500 + "\nResponse body: #{@response.body}" + end + + def location_if_redirected + return "" unless @response.redirection? && @response.location.present? + location = normalize_argument_to_redirection(@response.location) + " redirect to <#{location}>" + end + + def code_with_name(code_or_name) + if RESPONSE_PREDICATES.values.include?("#{code_or_name}?".to_sym) + code_or_name = RESPONSE_PREDICATES.invert["#{code_or_name}?".to_sym] + end + + AssertionResponse.new(code_or_name).code_and_name + 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..5390581139 --- /dev/null +++ b/actionpack/lib/action_dispatch/testing/assertions/routing.rb @@ -0,0 +1,222 @@ +# frozen_string_literal: true + +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. + # + # # Asserts 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 will end up in the params hash correctly. To test query strings you must use the extras + # argument because appending the query string on the path directly will not work. For example: + # + # # Asserts 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) + if path.is_a?(Hash) && path[:method].to_s == "all" + [:get, :post, :put, :delete].each do |method| + assert_recognizes(expected_options, path.merge(method: method), extras, msg) + end + else + request = recognized_request_for(path, extras, msg) + + 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 + 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, message) 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. + + options = options.clone + generated_path, query_string_keys = @routes.generate_extras(options, defaults) + found_extras = options.reject { |k, _| ! query_string_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. + # + # # Asserts 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 + # + # # Asserts 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 an 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 + + @controller.singleton_class.include(_routes.url_helpers) + + if @controller.respond_to? :view_context_class + @controller.view_context_class = Class.new(@controller.view_context_class) do + include _routes.url_helpers + end + 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 && defined?(@routes) && @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 = {}, msg) + if path.is_a?(Hash) + method = path[:method] + path = path[:path] + else + method = :get + end + + request = ActionController::TestRequest.create @controller.class + + if path =~ %r{://} + fail_on(URI::InvalidURIError, msg) 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, msg) do + @routes.recognize_path(path, method: method, extras: extras) + end + request.path_parameters = params.with_indifferent_access + + request + end + + def fail_on(exception_class, message) + yield + rescue exception_class => e + raise Minitest::Assertion, message || e.message + 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..7171b6942c --- /dev/null +++ b/actionpack/lib/action_dispatch/testing/integration.rb @@ -0,0 +1,652 @@ +# frozen_string_literal: true + +require "stringio" +require "uri" +require "active_support/core_ext/kernel/singleton_class" +require "active_support/core_ext/object/try" +require "rack/test" +require "minitest" + +require "action_dispatch/testing/request_encoder" + +module ActionDispatch + module Integration #:nodoc: + module RequestHelpers + # Performs a GET request with the given parameters. See ActionDispatch::Integration::Session#process + # for more details. + def get(path, **args) + process(:get, path, **args) + end + + # Performs a POST request with the given parameters. See ActionDispatch::Integration::Session#process + # for more details. + def post(path, **args) + process(:post, path, **args) + end + + # Performs a PATCH request with the given parameters. See ActionDispatch::Integration::Session#process + # for more details. + def patch(path, **args) + process(:patch, path, **args) + end + + # Performs a PUT request with the given parameters. See ActionDispatch::Integration::Session#process + # for more details. + def put(path, **args) + process(:put, path, **args) + end + + # Performs a DELETE request with the given parameters. See ActionDispatch::Integration::Session#process + # for more details. + def delete(path, **args) + process(:delete, path, **args) + end + + # Performs a HEAD request with the given parameters. See ActionDispatch::Integration::Session#process + # for more details. + def head(path, *args) + process(:head, path, *args) + end + + # 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 + 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 + + 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 + + # Performs the actual request. + # + # - +method+: The HTTP method (GET, POST, PATCH, PUT, DELETE, HEAD, OPTIONS) + # as a symbol. + # - +path+: The URI (as a String) on which you want to perform the + # request. + # - +params+: 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+: Additional headers to pass, as a Hash. The headers will be + # merged into the Rack env hash. + # - +env+: Additional env to pass, as a Hash. The headers will be + # merged into the Rack env hash. + # + # This method is rarely used directly. Use +#get+, +#post+, or other standard + # HTTP methods in integration tests. +#process+ is only required when using a + # request method that doesn't have a method defined in the integration tests. + # + # This method returns the response status, after performing the request. + # Furthermore, if this method was called from an ActionDispatch::IntegrationTest object, + # then that object's <tt>@response</tt> instance variable will point to a Response object + # which one can use to inspect the details of the response. + # + # Example: + # process :get, '/author', params: { since: 201501011400 } + def process(method, path, params: nil, headers: nil, env: nil, xhr: false, as: nil) + request_encoder = RequestEncoder.encoder(as) + headers ||= {} + + if method == :get && as == :json && params + headers["X-Http-Method-Override"] = "GET" + method = :post + end + + if path =~ %r{://} + path = build_expanded_path(path) do |location| + https! URI::HTTPS === location if location.scheme + + if url_host = location.host + default = Rack::Request::DEFAULT_PORTS[location.scheme] + url_host += ":#{location.port}" if default != location.port + host! url_host + end + end + end + + hostname, port = host.split(":") + + request_env = { + :method => method, + :params => request_encoder.encode_params(params), + + "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" => request_encoder.content_type, + "HTTP_ACCEPT" => request_encoder.accept_header || accept + } + + wrapped_headers = Http::Headers.from_hash({}) + wrapped_headers.merge!(headers) if headers + + if xhr + wrapped_headers["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest" + wrapped_headers["HTTP_ACCEPT"] ||= [Mime[:js], Mime[:html], Mime[:xml], "text/xml", "*/*"].join(", ") + end + + # This modifies the passed request_env directly. + if wrapped_headers.present? + Http::Headers.from_hash(request_env).merge!(wrapped_headers) + end + if env.present? + Http::Headers.from_hash(request_env).merge!(env) + end + + 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, request_env), request_env) + + @request_count += 1 + @request = ActionDispatch::Request.new(session.last_request.env) + response = _mock_session.last_response + @response = ActionDispatch::TestResponse.from_response(response) + @response.request = @request + @html_document = nil + @url_options = nil + + @controller = @request.controller_instance + + response.status + 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 + + def build_full_uri(path, env) + "#{env['rack.url_scheme']}://#{env['SERVER_NAME']}:#{env['SERVER_PORT']}#{path}" + end + + def build_expanded_path(path) + location = URI.parse(path) + yield location if block_given? + path = location.path + location.query ? "#{path}?#{location.query}" : path + end + end + + module Runner + include ActionDispatch::Assertions + + APP_SESSIONS = {} + + attr_reader :app + + def initialize(*args, &blk) + super(*args, &blk) + @integration_session = nil + end + + def before_setup # :nodoc: + @app = nil + super + end + + def integration_session + @integration_session ||= create_session(app) + end + + # Reset the current session. This is useful for testing multiple sessions + # in a single test case. + def reset! + @integration_session = create_session(app) + end + + def create_session(app) + klass = APP_SESSIONS[app] ||= Class.new(Integration::Session) { + # 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) + include app.routes.url_helpers + include app.routes.mounted_helpers + end + } + klass.new(app) + end + + def remove! # :nodoc: + @integration_session = nil + end + + %w(get post patch put head delete cookies assigns follow_redirect!).each do |method| + define_method(method) do |*args| + # reset the html_document variable, except for cookies/assigns calls + unless method == "cookies" || method == "assigns" + @html_document = nil + end + + 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| + session.reset! + 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: + @controller = @integration_session.controller + @response = @integration_session.response + @request = @integration_session.request + end + + def default_url_options + integration_session.default_url_options + end + + def default_url_options=(options) + integration_session.default_url_options = options + end + + private + def respond_to_missing?(method, _) + integration_session.respond_to?(method) || super + end + + # Delegate unhandled messages to the current session instance. + def method_missing(method, *args, &block) + if integration_session.respond_to?(method) + integration_session.public_send(method, *args, &block).tap do + copy_session_variables! + end + else + super + end + 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", params: { 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) + # post "/say/#{room.id}", xhr: true, params: { message: message } + # assert(...) + # ... + # end + # end + # + # def login(who) + # open_session do |sess| + # sess.extend(CustomAssertions) + # who = people(who) + # sess.post "/login", params: { username: who.username, + # password: who.password } + # assert(...) + # end + # end + # end + # + # Another longer example would be: + # + # A simple integration test that exercises multiple controllers: + # + # require 'test_helper' + # + # class UserFlowsTest < ActionDispatch::IntegrationTest + # test "login and browse site" do + # # login via https + # https! + # get "/login" + # assert_response :success + # + # post "/login", params: { username: users(:david).username, password: users(:david).password } + # follow_redirect! + # assert_equal '/welcome', path + # assert_equal 'Welcome david!', flash[:notice] + # + # https!(false) + # get "/articles/all" + # assert_response :success + # assert_select 'h1', 'Articles' + # end + # end + # + # As you can see the integration test involves multiple controllers and + # exercises the entire stack from database to dispatcher. In addition you can + # have multiple session instances open simultaneously in a test and extend + # those instances with assertion methods to create a very powerful testing + # DSL (domain-specific language) just for your application. + # + # Here's an example of multiple sessions and custom DSL in an integration test + # + # require 'test_helper' + # + # class UserFlowsTest < ActionDispatch::IntegrationTest + # test "login and browse site" do + # # User david logs in + # david = login(:david) + # # User guest logs in + # guest = login(:guest) + # + # # Both are now available in different sessions + # assert_equal 'Welcome david!', david.flash[:notice] + # assert_equal 'Welcome guest!', guest.flash[:notice] + # + # # User david can browse site + # david.browses_site + # # User guest can browse site as well + # guest.browses_site + # + # # Continue with other assertions + # end + # + # private + # + # module CustomDsl + # def browses_site + # get "/products/all" + # assert_response :success + # assert_select 'h1', 'Products' + # end + # end + # + # def login(user) + # open_session do |sess| + # sess.extend(CustomDsl) + # u = users(user) + # sess.https! + # sess.post "/login", params: { username: u.username, password: u.password } + # assert_equal '/welcome', sess.path + # sess.https!(false) + # end + # end + # end + # + # See the {request helpers documentation}[rdoc-ref:ActionDispatch::Integration::RequestHelpers] for help on how to + # use +get+, etc. + # + # === Changing the request encoding + # + # You can also test your JSON API easily by setting what the request should + # be encoded as: + # + # require "test_helper" + # + # class ApiTest < ActionDispatch::IntegrationTest + # test "creates articles" do + # assert_difference -> { Article.count } do + # post articles_path, params: { article: { title: "Ahoy!" } }, as: :json + # end + # + # assert_response :success + # assert_equal({ id: Article.last.id, title: "Ahoy!" }, response.parsed_body) + # end + # end + # + # The +as+ option passes an "application/json" Accept header (thereby setting + # the request format to JSON unless overridden), sets the content type to + # "application/json" and encodes the parameters as JSON. + # + # Calling +parsed_body+ on the response parses the response body based on the + # last response MIME type. + # + # Out of the box, only <tt>:json</tt> is supported. But for any custom MIME + # types you've registered, you can add your own encoders with: + # + # ActionDispatch::IntegrationTest.register_encoder :wibble, + # param_encoder: -> params { params.to_wibble }, + # response_parser: -> body { body } + # + # Where +param_encoder+ defines how the params should be encoded and + # +response_parser+ defines how the response body should be parsed through + # +parsed_body+. + # + # Consult the Rails Testing Guide for more. + + class IntegrationTest < ActiveSupport::TestCase + include TestProcess::FixtureFile + + module UrlOptions + extend ActiveSupport::Concern + def url_options + integration_session.url_options + end + end + + module Behavior + extend ActiveSupport::Concern + + include Integration::Runner + include ActionController::TemplateAssertions + + included do + include ActionDispatch::Routing::UrlFor + include UrlOptions # don't let UrlFor override the url_options method + ActiveSupport.run_load_hooks(:action_dispatch_integration_test, self) + @@app = nil + end + + module ClassMethods + def app + if defined?(@@app) && @@app + @@app + else + ActionDispatch.test_app + end + end + + def app=(app) + @@app = app + end + + def register_encoder(*args) + RequestEncoder.register_encoder(*args) + end + end + + def app + super || self.class.app + end + + def document_root_element + html_document.root + end + end + + include Behavior + end +end diff --git a/actionpack/lib/action_dispatch/testing/request_encoder.rb b/actionpack/lib/action_dispatch/testing/request_encoder.rb new file mode 100644 index 0000000000..01246b7a2e --- /dev/null +++ b/actionpack/lib/action_dispatch/testing/request_encoder.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module ActionDispatch + class RequestEncoder # :nodoc: + class IdentityEncoder + def content_type; end + def accept_header; end + def encode_params(params); params; end + def response_parser; -> body { body }; end + end + + @encoders = { identity: IdentityEncoder.new } + + attr_reader :response_parser + + def initialize(mime_name, param_encoder, response_parser) + @mime = Mime[mime_name] + + unless @mime + raise ArgumentError, "Can't register a request encoder for " \ + "unregistered MIME Type: #{mime_name}. See `Mime::Type.register`." + end + + @response_parser = response_parser || -> body { body } + @param_encoder = param_encoder || :"to_#{@mime.symbol}".to_proc + end + + def content_type + @mime.to_s + end + + def accept_header + @mime.to_s + end + + def encode_params(params) + @param_encoder.call(params) + end + + def self.parser(content_type) + mime = Mime::Type.lookup(content_type) + encoder(mime ? mime.ref : nil).response_parser + end + + def self.encoder(name) + @encoders[name] || @encoders[:identity] + end + + def self.register_encoder(mime_name, param_encoder: nil, response_parser: nil) + @encoders[mime_name] = new(mime_name, param_encoder, response_parser) + end + + register_encoder :json, response_parser: -> body { JSON.parse(body) } + 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..8ac50c730d --- /dev/null +++ b/actionpack/lib/action_dispatch/testing/test_process.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "action_dispatch/middleware/cookies" +require "action_dispatch/middleware/flash" + +module ActionDispatch + module TestProcess + module FixtureFile + # Shortcut for <tt>Rack::Test::UploadedFile.new(File.join(ActionDispatch::IntegrationTest.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 && + !File.exist?(path) + path = File.join(self.class.fixture_path, path) + end + Rack::Test::UploadedFile.new(path, mime_type, binary) + end + end + + include FixtureFile + + def assigns(key = nil) + raise NoMethodError, + "assigns has been extracted to a gem. To continue using it, + add `gem 'rails-controller-testing'` to your Gemfile." + end + + def session + @request.session + end + + def flash + @request.flash + end + + def cookies + @cookie_jar ||= Cookies::CookieJar.build(@request, @request.cookies) + end + + def redirect_to_url + @response.redirect_url + 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..6c5b7af50e --- /dev/null +++ b/actionpack/lib/action_dispatch/testing/test_request.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +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", + ) + + # Create a new test request with default +env+ values. + def self.create(env = {}) + env = Rails.application.env_config.merge(env) if defined?(Rails.application) && Rails.application + env["rack.request.cookie_hash"] ||= {}.with_indifferent_access + new(default_env.merge(env)) + end + + def self.default_env + DEFAULT_ENV + end + private_class_method :default_env + + def request_method=(method) + super(method.to_s.upcase) + end + + def host=(host) + set_header("HTTP_HOST", host) + end + + def port=(number) + set_header("SERVER_PORT", number.to_i) + end + + def request_uri=(uri) + set_header("REQUEST_URI", uri) + end + + def path=(path) + set_header("PATH_INFO", path) + end + + def action=(action_name) + path_parameters[:action] = action_name.to_s + end + + def if_modified_since=(last_modified) + set_header("HTTP_IF_MODIFIED_SINCE", last_modified) + end + + def if_none_match=(etag) + set_header("HTTP_IF_NONE_MATCH", etag) + end + + def remote_addr=(addr) + set_header("REMOTE_ADDR", addr) + end + + def user_agent=(user_agent) + set_header("HTTP_USER_AGENT", user_agent) + end + + def accept=(mime_types) + delete_header("action_dispatch.request.accepts") + set_header("HTTP_ACCEPT", Array(mime_types).collect(&:to_s).join(",")) + 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..1e6b21f235 --- /dev/null +++ b/actionpack/lib/action_dispatch/testing/test_response.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require "action_dispatch/testing/request_encoder" + +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 response.status, response.headers, response.body + end + + def initialize(*) # :nodoc: + super + @response_parser = RequestEncoder.parser(content_type) + end + + # Was the response successful? + def success? + ActiveSupport::Deprecation.warn(<<-MSG.squish) + The success? predicate is deprecated and will be removed in Rails 6.0. + Please use successful? as provided by Rack::Response::Helpers. + MSG + successful? + end + + # Was the URL not found? + def missing? + ActiveSupport::Deprecation.warn(<<-MSG.squish) + The missing? predicate is deprecated and will be removed in Rails 6.0. + Please use not_found? as provided by Rack::Response::Helpers. + MSG + not_found? + end + + # Was there a server-side error? + def error? + ActiveSupport::Deprecation.warn(<<-MSG.squish) + The error? predicate is deprecated and will be removed in Rails 6.0. + Please use server_error? as provided by Rack::Response::Helpers. + MSG + server_error? + end + + def parsed_body + @parsed_body ||= @response_parser.call(body) + end + end +end |