# 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 # # => '#' 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 r.post "" 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 r.get true 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, and yield resulting value as an # integer. def _match_class_Integer consume(/\A\/(\d+)(?=\/|\z)/){|i| [i.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 raise RodaError, "unsupported block result: #{result.inspect}" 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) @remaining_path = matchdata.post_match captures = matchdata.captures captures = yield(*captures) if defined?(yield) @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 # Handle an unsupported matcher. def unsupported_matcher(matcher) raise RodaError, "unsupported matcher: #{matcher.inspect}" end end end end end