lib/roda.rb in roda-3.26.0 vs lib/roda.rb in roda-3.27.0

- old
+ new

@@ -1,118 +1,30 @@ # frozen-string-literal: true -require "rack" require "thread" +require_relative "roda/request" +require_relative "roda/response" +require_relative "roda/plugins" +require_relative "roda/cache" require_relative "roda/version" # The main class for Roda. Roda is built completely out of plugins, with the # default plugin being Roda::RodaPlugins::Base, so this class is mostly empty # except for some constants. class Roda # Error class raised by Roda class RodaError < StandardError; end - # A thread safe cache class, offering only #[] and #[]= methods, - # each protected by a mutex. - class RodaCache - # Create a new thread safe cache. - def initialize - @mutex = Mutex.new - @hash = {} - end - - # Make getting value from underlying hash thread safe. - def [](key) - @mutex.synchronize{@hash[key]} - end - - # Make setting value in underlying hash thread safe. - def []=(key, value) - @mutex.synchronize{@hash[key] = value} - end - - private - - # Create a copy of the cache with a separate mutex. - def initialize_copy(other) - @mutex = Mutex.new - other.instance_variable_get(:@mutex).synchronize do - @hash = other.instance_variable_get(:@hash).dup - end - end - end - - # 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 - - # Base class used for Roda responses. The instance methods for this - # class are added by Roda::RodaPlugins::Base::ResponseMethods, the class - # methods are added by Roda::RodaPlugins::Base::ResponseClassMethods. - class RodaResponse - @roda_class = ::Roda - end - @app = nil @inherit_middleware = true @middleware = [] @opts = {} @raw_route_block = nil @route_block = nil @rack_app_route_block = nil - # Module in which all Roda plugins should be stored. Also contains logic for - # registering and loading plugins. module RodaPlugins - OPTS = {}.freeze - EMPTY_ARRAY = [].freeze - - # Stores registered plugins - @plugins = RodaCache.new - - class << self - # Make warn a public method, as it is used for deprecation warnings. - # Roda::RodaPlugins.warn can be overridden for custom handling of - # deprecation warnings. - public :warn - end - - # If the registered plugin already exists, use it. Otherwise, - # require it and return it. This raises a LoadError if such a - # plugin doesn't exist, or a RodaError if it exists but it does - # not register itself correctly. - def self.load_plugin(name) - h = @plugins - unless plugin = h[name] - require "roda/plugins/#{name}" - raise RodaError, "Plugin #{name} did not register itself correctly in Roda::RodaPlugins" unless plugin = h[name] - end - plugin - end - - # Register the given plugin with Roda, so that it can be loaded using #plugin - # with a symbol. Should be used by plugin files. Example: - # - # Roda::RodaPlugins.register_plugin(:plugin_name, PluginModule) - def self.register_plugin(name, mod) - @plugins[name] = mod - end - - # Deprecate the constant with the given name in the given module, - # if the ruby version supports it. - def self.deprecate_constant(mod, name) - # :nocov: - if RUBY_VERSION >= '2.3' - mod.deprecate_constant(name) - end - # :nocov: - end - # The base plugin for Roda, implementing all default functionality. # Methods are put into a plugin so future plugins can easily override # them and call super to get the default behavior. module Base # Class methods for the Roda class. @@ -624,774 +536,9 @@ # if no session exists. Example: # # session # => {} def session @_request.session - end - end - - # 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 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 - - # 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. - def real_remaining_path - remaining_path - end - - # 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, 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) - if last > 1 - @captures << rp[1, last-1] - @remaining_path = rp[last, rp.length] - end - elsif rp.length > 1 - @captures << rp[1,rp.length] - @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 = remaining_path.match(pattern) - @remaining_path = matchdata.post_match - captures = matchdata.captures - captures = yield(*captures) if block_given? - @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 - - # Class methods for RodaResponse - module ResponseClassMethods - # Reference to the Roda class related to this response class. - attr_accessor :roda_class - - # Since RodaResponse 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}::RodaResponse" - end - end - - # Instance methods for RodaResponse - module ResponseMethods - DEFAULT_HEADERS = {"Content-Type" => "text/html".freeze}.freeze - - # The body for the current response. - attr_reader :body - - # The hash of response headers for the current response. - attr_reader :headers - - # The status code to use for the response. If none is given, will use 200 - # code for non-empty responses and a 404 code for empty responses. - attr_accessor :status - - # Set the default headers when creating a response. - def initialize - @headers = {} - @body = [] - @length = 0 - end - - # Return the response header with the given key. Example: - # - # response['Content-Type'] # => 'text/html' - def [](key) - @headers[key] - end - - # Set the response header with the given key to the given value. - # - # response['Content-Type'] = 'application/json' - def []=(key, value) - @headers[key] = value - end - - # The default headers to use for responses. - def default_headers - DEFAULT_HEADERS - end - - # Whether the response body has been written to yet. Note - # that writing an empty string to the response body marks - # the response as not empty. Example: - # - # response.empty? # => true - # response.write('a') - # response.empty? # => false - def empty? - @body.empty? - end - - # Return the rack response array of status, headers, and body - # for the current response. If the status has not been set, - # uses the return value of default_status if the body has - # been written to, otherwise uses a 404 status. - # Adds the Content-Length header to the size of the response body. - # - # Example: - # - # response.finish - # # => [200, - # # {'Content-Type'=>'text/html', 'Content-Length'=>'0'}, - # # []] - def finish - b = @body - set_default_headers - h = @headers - - if b.empty? - s = @status || 404 - if (s == 304 || s == 204 || (s >= 100 && s <= 199)) - h.delete("Content-Type") - elsif s == 205 - h.delete("Content-Type") - h["Content-Length"] = '0' - else - h["Content-Length"] ||= '0' - end - else - s = @status || default_status - h["Content-Length"] ||= @length.to_s - end - - [s, h, b] - end - - # Return the rack response array using a given body. Assumes a - # 200 response status unless status has been explicitly set, - # and doesn't add the Content-Length header or use the existing - # body. - def finish_with_body(body) - set_default_headers - [@status || default_status, @headers, body] - end - - # Return the default response status to be used when the body - # has been written to. This is split out to make overriding - # easier in plugins. - def default_status - 200 - end - - # Show response class, status code, response headers, and response body - def inspect - "#<#{self.class.inspect} #{@status.inspect} #{@headers.inspect} #{@body.inspect}>" - end - - # Set the Location header to the given path, and the status - # to the given status. Example: - # - # response.redirect('foo', 301) - # response.redirect('bar') - def redirect(path, status = 302) - @headers["Location"] = path - @status = status - nil - end - - # Return the Roda class related to this response. - def roda_class - self.class.roda_class - end - - # Write to the response body. Returns nil. - # - # response.write('foo') - def write(str) - s = str.to_s - @length += s.bytesize - @body << s - nil - end - - private - - # For each default header, if a header has not already been set for the - # response, set the header in the response. - def set_default_headers - h = @headers - default_headers.each do |k,v| - h[k] ||= v - end end end end end