lib/roda/request.rb



# frozen-string-literal: true

begin
  require "rack/version"
rescue LoadError
  require "rack"
else
  if Rack.release >= '3'
    require "rack/request"
  else
    require "rack"
  end
end

require_relative "cache"

class Roda
  # Base class used for Roda requests.  The instance methods for this
  # class are added by Roda::RodaPlugins::Base::RequestMethods, the
  # class methods are added by Roda::RodaPlugins::Base::RequestClassMethods.
  class RodaRequest < ::Rack::Request
    @roda_class = ::Roda
    @match_pattern_cache = ::Roda::RodaCache.new
  end

  module RodaPlugins
    module Base
      # Class methods for RodaRequest
      module RequestClassMethods
        # Reference to the Roda class related to this request class.
        attr_accessor :roda_class

        # The cache to use for match patterns for this request class.
        attr_accessor :match_pattern_cache

        # Return the cached pattern for the given object.  If the object is
        # not already cached, yield to get the basic pattern, and convert the
        # basic pattern to a pattern that does not match partial segments.
        def cached_matcher(obj)
          cache = @match_pattern_cache

          unless pattern = cache[obj]
            pattern = cache[obj] = consume_pattern(yield)
          end

          pattern
        end

        # Since RodaRequest is anonymously subclassed when Roda is subclassed,
        # and then assigned to a constant of the Roda subclass, make inspect
        # reflect the likely name for the class.
        def inspect
          "#{roda_class.inspect}::RodaRequest"
        end

        private

        # The pattern to use for consuming, based on the given argument.  The returned
        # pattern requires the path starts with a string and does not match partial
        # segments.
        def consume_pattern(pattern)
          /\A\/(?:#{pattern})(?=\/|\z)/
        end
      end

      # Instance methods for RodaRequest, mostly related to handling routing
      # for the request.
      module RequestMethods
        TERM = Object.new
        def TERM.inspect
          "TERM"
        end
        TERM.freeze

        # The current captures for the request.  This gets modified as routing
        # occurs.
        attr_reader :captures

        # The Roda instance related to this request object.  Useful if routing
        # methods need access to the scope of the Roda route block.
        attr_reader :scope

        # Store the roda instance and environment.
        def initialize(scope, env)
          @scope = scope
          @captures = []
          @remaining_path = _remaining_path(env)
          @env = env
        end

        # Handle match block return values.  By default, if a string is given
        # and the response is empty, use the string as the response body.
        def block_result(result)
          res = response
          if res.empty? && (body = block_result_body(result))
            res.write(body)
          end
        end

        # Match GET requests.  If no arguments are provided, matches all GET
        # requests, otherwise, matches only GET requests where the arguments
        # given fully consume the path.
        def get(*args, &block)
          _verb(args, &block) if is_get?
        end

        # Immediately stop execution of the route block and return the given
        # rack response array of status, headers, and body.  If no argument
        # is given, uses the current response.
        #
        #   r.halt [200, {'Content-Type'=>'text/html'}, ['Hello World!']]
        #   
        #   response.status = 200
        #   response['Content-Type'] = 'text/html'
        #   response.write 'Hello World!'
        #   r.halt
        def halt(res=response.finish)
          throw :halt, res
        end

        # Show information about current request, including request class,
        # request method and full path.
        #
        #   r.inspect
        #   # => '#<Roda::RodaRequest GET /foo/bar>'
        def inspect
          "#<#{self.class.inspect} #{@env["REQUEST_METHOD"]} #{path}>"
        end

        if Rack.release >= '3'
          def http_version
            # Prefer SERVER_PROTOCOL as it is required in Rack 3.
            # Still fall back to HTTP_VERSION if SERVER_PROTOCOL
            # is not set, in case the server in use is not Rack 3
            # compliant.
            @env['SERVER_PROTOCOL'] || @env['HTTP_VERSION']
          end
        else
          # What HTTP version the request was submitted with.
          def http_version
            # Prefer HTTP_VERSION as it is backwards compatible
            # with previous Roda versions. Fallback to
            # SERVER_PROTOCOL for servers that do not set
            # HTTP_VERSION.
            @env['HTTP_VERSION'] || @env['SERVER_PROTOCOL']
          end
        end

        # Does a terminal match on the current path, matching only if the arguments
        # have fully matched the path.  If it matches, the match block is
        # executed, and when the match block returns, the rack response is
        # returned.
        # 
        #   r.remaining_path
        #   # => "/foo/bar"
        #
        #   r.is 'foo' do
        #     # does not match, as path isn't fully matched (/bar remaining)
        #   end
        #
        #   r.is 'foo/bar' do
        #     # matches as path is empty after matching
        #   end
        #
        # If no arguments are given, matches if the path is already fully matched.
        # 
        #   r.on 'foo/bar' do
        #     r.is do
        #       # matches as path is already empty
        #     end
        #   end
        #
        # Note that this matches only if the path after matching the arguments
        # is empty, not if it still contains a trailing slash:
        #
        #   r.remaining_path
        #   # =>  "/foo/bar/"
        #
        #   r.is 'foo/bar' do
        #     # does not match, as path isn't fully matched (/ remaining)
        #   end
        # 
        #   r.is 'foo/bar/' do
        #     # matches as path is empty after matching
        #   end
        # 
        #   r.on 'foo/bar' do
        #     r.is "" do
        #       # matches as path is empty after matching
        #     end
        #   end
        def is(*args, &block)
          if args.empty?
            if empty_path?
              always(&block)
            end
          else
            args << TERM
            if_match(args, &block)
          end
        end

        # Optimized method for whether this request is a +GET+ request.
        # Similar to the default Rack::Request get? method, but can be
        # overridden without changing rack's behavior.
        def is_get?
          @env["REQUEST_METHOD"] == 'GET'
        end

        # Does a match on the path, matching only if the arguments
        # have matched the path.  Because this doesn't fully match the
        # path, this is usually used to setup branches of the routing tree,
        # not for final handling of the request.
        # 
        #   r.remaining_path
        #   # => "/foo/bar"
        #
        #   r.on 'foo' do
        #     # matches, path is /bar after matching
        #   end
        #
        #   r.on 'bar' do
        #     # does not match
        #   end
        #
        # Like other routing methods, If it matches, the match block is
        # executed, and when the match block returns, the rack response is
        # returned.  However, in general you will call another routing method
        # inside the match block that fully matches the path and does the
        # final handling for the request:
        #
        #   r.on 'foo' do
        #     r.is 'bar' do
        #       # handle /foo/bar request
        #     end
        #   end
        def on(*args, &block)
          if args.empty?
            always(&block)
          else
            if_match(args, &block)
          end
        end

        # The already matched part of the path, including the original SCRIPT_NAME.
        def matched_path
          e = @env
          e["SCRIPT_NAME"] + e["PATH_INFO"].chomp(@remaining_path)
        end

        # This an an optimized version of Rack::Request#path.
        #
        #   r.env['SCRIPT_NAME'] = '/foo'
        #   r.env['PATH_INFO'] = '/bar'
        #   r.path
        #   # => '/foo/bar'
        def path
          e = @env
          "#{e["SCRIPT_NAME"]}#{e["PATH_INFO"]}"
        end

        # The current path to match requests against.
        attr_reader :remaining_path

        # An alias of remaining_path. If a plugin changes remaining_path then
        # it should override this method to return the untouched original.
        alias real_remaining_path remaining_path

        # Match POST requests.  If no arguments are provided, matches all POST
        # requests, otherwise, matches only POST requests where the arguments
        # given fully consume the path.
        def post(*args, &block)
          _verb(args, &block) if post?
        end

        # Immediately redirect to the path using the status code.  This ends
        # the processing of the request:
        #
        #   r.redirect '/page1', 301 if r['param'] == 'value1'
        #   r.redirect '/page2' # uses 302 status code
        #   response.status = 404 # not reached
        #   
        # If you do not provide a path, by default it will redirect to the same
        # path if the request is not a +GET+ request.  This is designed to make
        # it easy to use where a +POST+ request to a URL changes state, +GET+
        # returns the current state, and you want to show the current state
        # after changing:
        #
        #   r.is "foo" do
        #     r.get do
        #       # show state
        #     end
        #   
        #     r.post do
        #       # change state
        #       r.redirect
        #     end
        #   end
        def redirect(path=default_redirect_path, status=default_redirect_status)
          response.redirect(path, status)
          throw :halt, response.finish
        end

        # The response related to the current request.  See ResponseMethods for
        # instance methods for the response, but in general the most common usage
        # is to override the response status and headers:
        #
        #   response.status = 200
        #   response['Header-Name'] = 'Header value'
        def response
          @scope.response
        end

        # Return the Roda class related to this request.
        def roda_class
          self.class.roda_class
        end

        # Match method that only matches +GET+ requests where the current
        # path is +/+.  If it matches, the match block is executed, and when
        # the match block returns, the rack response is returned.
        #
        #   [r.request_method, r.remaining_path]
        #   # => ['GET', '/']
        #
        #   r.root do
        #     # matches
        #   end
        #
        # This is usuable inside other match blocks:
        #
        #   [r.request_method, r.remaining_path]
        #   # => ['GET', '/foo/']
        #
        #   r.on 'foo' do
        #     r.root do
        #       # matches
        #     end
        #   end
        #
        # Note that this does not match non-+GET+ requests:
        #
        #   [r.request_method, r.remaining_path]
        #   # => ['POST', '/']
        #
        #   r.root do
        #     # does not match
        #   end
        #
        # Use <tt>r.post ""</tt> for +POST+ requests where the current path
        # is +/+.
        # 
        # Nor does it match empty paths:
        #
        #   [r.request_method, r.remaining_path]
        #   # => ['GET', '/foo']
        #
        #   r.on 'foo' do
        #     r.root do
        #       # does not match
        #     end
        #   end
        #
        # Use <tt>r.get true</tt> to handle +GET+ requests where the current
        # path is empty.
        def root(&block)
          if @remaining_path == "/" && is_get?
            always(&block)
          end
        end

        # Call the given rack app with the environment and return the response
        # from the rack app as the response for this request.  This ends
        # the processing of the request:
        #
        #   r.run(proc{[403, {}, []]}) unless r['letmein'] == '1'
        #   r.run(proc{[404, {}, []]})
        #   response.status = 404 # not reached
        #
        # This updates SCRIPT_NAME/PATH_INFO based on the current remaining_path
        # before dispatching to another rack app, so the app still works as
        # a URL mapper.
        def run(app)
          e = @env
          path = real_remaining_path
          sn = "SCRIPT_NAME"
          pi = "PATH_INFO"
          script_name = e[sn]
          path_info = e[pi]
          begin
            e[sn] += path_info.chomp(path)
            e[pi] = path
            throw :halt, app.call(e)
          ensure
            e[sn] = script_name
            e[pi] = path_info
          end
        end

        # The session for the current request.  Raises a RodaError if
        # a session handler has not been loaded.
        def session
          @env['rack.session'] || raise(RodaError, "You're missing a session handler, try using the sessions plugin.")
        end

        private

        # Match any of the elements in the given array.  Return at the
        # first match without evaluating future matches.  Returns false
        # if no elements in the array match.
        def _match_array(matcher)
          matcher.any? do |m|
            if matched = match(m)
              if m.is_a?(String)
                @captures.push(m)
              end
            end

            matched
          end
        end

        # Match the given class.  Currently, the following classes
        # are supported by default:
        # Integer :: Match an integer segment, yielding result to block as an integer
        # String :: Match any non-empty segment, yielding result to block as a string
        def _match_class(klass)
          meth = :"_match_class_#{klass}"
          if respond_to?(meth, true)
            # Allow calling private methods, as match methods are generally private
            send(meth)
          else
            unsupported_matcher(klass)
          end
        end

        # Match the given hash if all hash matchers match.
        def _match_hash(hash)
          # Allow calling private methods, as match methods are generally private
          hash.all?{|k,v| send("match_#{k}", v)}
        end

        # Match integer segment of up to 100 decimal characters, and yield resulting value as an
        # integer.
        def _match_class_Integer
          consume(/\A\/(\d{1,100})(?=\/|\z)/) do |i|
            if i = _match_class_convert_Integer(i)
              [i]
            end
          end
        end

        # Convert the segment matched by the Integer matcher to an integer.
        def _match_class_convert_Integer(value)
          value.to_i
        end

        # Match only if all of the arguments in the given array match.
        # Match the given regexp exactly if it matches a full segment.
        def _match_regexp(re)
          consume(self.class.cached_matcher(re){re})
        end

        # Match the given string to the request path.  Matches only if the
        # request path ends with the string or if the next character in the
        # request path is a slash (indicating a new segment).
        def _match_string(str)
          rp = @remaining_path
          length = str.length

          match = case rp.rindex(str, length)
          when nil
            # segment does not match, most common case
            return
          when 1
            # segment matches, check first character is /
            rp.getbyte(0) == 47
          else # must be 0
            # segment matches at first character, only a match if
            # empty string given and first character is /
            length == 0 && rp.getbyte(0) == 47
          end

          if match 
            length += 1
            case rp.getbyte(length)
            when 47
              # next character is /, update remaining path to rest of string
              @remaining_path = rp[length, 100000000]
            when nil
              # end of string, so remaining path is empty
              @remaining_path = ""
            # else
              # Any other value means this was partial segment match,
              # so we return nil in that case without updating the
              # remaining_path.  No need for explicit else clause.
            end
          end
        end

        # Match the given symbol if any segment matches.
        def _match_symbol(sym=nil)
          rp = @remaining_path
          if rp.getbyte(0) == 47
            if last = rp.index('/', 1)
              @captures << rp[1, last-1]
              @remaining_path = rp[last, rp.length]
            elsif (len = rp.length) > 1
              @captures << rp[1, len]
              @remaining_path = ""
            end
          end
        end

        # Match any nonempty segment.  This should be called without an argument.
        alias _match_class_String _match_symbol

        # The base remaining path to use.
        def _remaining_path(env)
          env["PATH_INFO"]
        end

        # Backbone of the verb method support, using a terminal match if
        # args is not empty, or a regular match if it is empty.
        def _verb(args, &block)
          if args.empty?
            always(&block)
          else
            args << TERM
            if_match(args, &block)
          end
        end

        # Yield to the match block and return rack response after the block returns.
        def always
          block_result(yield)
          throw :halt, response.finish
        end

        # The body to use for the response if the response does not already have
        # a body.  By default, a String is returned directly, and nil is
        # returned otherwise.
        def block_result_body(result)
          case result
          when String
            result
          when nil, false
            # nothing
          else
            unsupported_block_result(result)
          end
        end

        # Attempts to match the pattern to the current path.  If there is no
        # match, returns false without changes.  Otherwise, modifies
        # SCRIPT_NAME to include the matched path, removes the matched
        # path from PATH_INFO, and updates captures with any regex captures.
        def consume(pattern)
          if matchdata = pattern.match(@remaining_path)
            captures = matchdata.captures
            if defined?(yield)
              return unless captures = yield(*captures)
            end
            @remaining_path = matchdata.post_match
            @captures.concat(captures)
          end
        end

        # The default path to use for redirects when a path is not given.
        # For non-GET requests, redirects to the current path, which will
        # trigger a GET request.  This is to make the common case where
        # a POST request will redirect to a GET request at the same location
        # will work fine.
        #
        # If the current request is a GET request, raise an error, as otherwise
        # it is easy to create an infinite redirect.
        def default_redirect_path
          raise RodaError, "must provide path argument to redirect for get requests" if is_get?
          path
        end

        # The default status to use for redirects if a status is not provided,
        # 302 by default.
        def default_redirect_status
          302
        end

        # Whether the current path is considered empty.
        def empty_path?
          @remaining_path.empty?
        end

        # If all of the arguments match, yields to the match block and
        # returns the rack response when the block returns.  If any of
        # the match arguments doesn't match, does nothing.
        def if_match(args)
          path = @remaining_path
          # For every block, we make sure to reset captures so that
          # nesting matchers won't mess with each other's captures.
          captures = @captures.clear

          if match_all(args)
            block_result(yield(*captures))
            throw :halt, response.finish
          else
            @remaining_path = path
            false
          end
        end

        # Attempt to match the argument to the given request, handling
        # common ruby types.
        def match(matcher)
          case matcher
          when String
            _match_string(matcher)
          when Class
            _match_class(matcher)
          when TERM
            empty_path?
          when Regexp
            _match_regexp(matcher)
          when true
            matcher
          when Array
            _match_array(matcher)
          when Hash
            _match_hash(matcher)
          when Symbol
            _match_symbol(matcher)
          when false, nil
            matcher
          when Proc
            matcher.call
          else
            unsupported_matcher(matcher)
          end
        end

        # Match only if all of the arguments in the given array match.
        def match_all(args)
          args.all?{|arg| match(arg)}
        end

        # Match by request method.  This can be an array if you want
        # to match on multiple methods.
        def match_method(type)
          if type.is_a?(Array)
            type.any?{|t| match_method(t)}
          else
            type.to_s.upcase == @env["REQUEST_METHOD"]
          end
        end

        # How to handle block results that are not nil, false, or a String.
        # By default raises an exception.
        def unsupported_block_result(result)
          raise RodaError, "unsupported block result: #{result.inspect}"
        end

        # Handle an unsupported matcher.
        def unsupported_matcher(matcher)
          raise RodaError, "unsupported matcher: #{matcher.inspect}"
        end
      end
    end
  end
end