lib/roda.rb in roda-1.1.0 vs lib/roda.rb in roda-1.2.0

- old
+ new

@@ -52,10 +52,11 @@ class RodaResponse < ::Rack::Response; @roda_class = ::Roda end @app = nil + @inherit_middleware = true @middleware = [] @opts = {} @route_block = nil # Module in which all Roda plugins should be stored. Also contains logic for @@ -87,17 +88,20 @@ # 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 - SESSION_KEY = 'rack.session'.freeze - # Class methods for the Roda class. module ClassMethods # The rack application that this class uses. attr_reader :app + # Whether middleware from the current class should be inherited by subclasses. + # True by default, should be set to false when using a design where the parent + # class accepts requests and uses run to dispatch the request to a subclass. + attr_accessor :inherit_middleware + # The settings/options hash for the current class. attr_reader :opts # The route block that this class uses. attr_reader :route_block @@ -108,10 +112,16 @@ # access to the underlying rack app. def call(env) app.call(env) end + # Clear the middleware stack + def clear_middleware! + @middleware.clear + build_rack_app + end + # Create a match_#{key} method in the request class using the given # block, so that using a hash key in a request match method will # call the block. The block should return nil or false to not # match, and anything else to match. # @@ -132,12 +142,18 @@ # When inheriting Roda, copy the shared data into the subclass, # and setup the request and response subclasses. def inherited(subclass) super - subclass.instance_variable_set(:@middleware, @middleware.dup) + subclass.instance_variable_set(:@inherit_middleware, @inherit_middleware) + subclass.instance_variable_set(:@middleware, @inherit_middleware ? @middleware.dup : []) subclass.instance_variable_set(:@opts, opts.dup) + subclass.opts.to_a.each do |k,v| + if (v.is_a?(Array) || v.is_a?(Hash)) && !v.frozen? + subclass.opts[k] = v.dup + end + end subclass.instance_variable_set(:@route_block, @route_block) subclass.send(:build_rack_app) request_class = Class.new(self::RodaRequest) request_class.roda_class = subclass @@ -153,40 +169,40 @@ # which is used directly, or a symbol represented a registered plugin # which will be required and then used. # # Roda.plugin PluginModule # Roda.plugin :csrf - def plugin(mixin, *args, &block) - if mixin.is_a?(Symbol) - mixin = RodaPlugins.load_plugin(mixin) + def plugin(plugin, *args, &block) + if plugin.is_a?(Symbol) + plugin = RodaPlugins.load_plugin(plugin) end - if mixin.respond_to?(:load_dependencies) - mixin.load_dependencies(self, *args, &block) + if plugin.respond_to?(:load_dependencies) + plugin.load_dependencies(self, *args, &block) end - if defined?(mixin::InstanceMethods) - include mixin::InstanceMethods + if defined?(plugin::InstanceMethods) + include(plugin::InstanceMethods) end - if defined?(mixin::ClassMethods) - extend mixin::ClassMethods + if defined?(plugin::ClassMethods) + extend(plugin::ClassMethods) end - if defined?(mixin::RequestMethods) - self::RodaRequest.send(:include, mixin::RequestMethods) + if defined?(plugin::RequestMethods) + self::RodaRequest.send(:include, plugin::RequestMethods) end - if defined?(mixin::RequestClassMethods) - self::RodaRequest.extend mixin::RequestClassMethods + if defined?(plugin::RequestClassMethods) + self::RodaRequest.extend(plugin::RequestClassMethods) end - if defined?(mixin::ResponseMethods) - self::RodaResponse.send(:include, mixin::ResponseMethods) + if defined?(plugin::ResponseMethods) + self::RodaResponse.send(:include, plugin::ResponseMethods) end - if defined?(mixin::ResponseClassMethods) - self::RodaResponse.extend mixin::ResponseClassMethods + if defined?(plugin::ResponseClassMethods) + self::RodaResponse.extend(plugin::ResponseClassMethods) end - if mixin.respond_to?(:configure) - mixin.configure(self, *args, &block) + if plugin.respond_to?(:configure) + plugin.configure(self, *args, &block) end end # Include the given module in the request class. If a block # is provided instead of a module, create a module using the @@ -313,11 +329,11 @@ # The environment hash for the current request. Example: # # env['REQUEST_METHOD'] # => 'GET' def env - request.env + @_request.env end # The class-level options hash. This should probably not be # modified at the instance level. Example: # @@ -338,24 +354,27 @@ # The instance of the response class related to this request. def response @_response end - # The session for the current request. Raises a RodaError if - # a session handler has not been loaded. + # The session hash for the current request. Raises RodaError + # if no session existsExample: + # + # session # => {} def session - env[SESSION_KEY] || raise(RodaError, "You're missing a session handler. You can get started by adding use Rack::Session::Cookie") + @_request.session end private # Internals of #call, extracted so that plugins can override # behavior after the request and response have been setup. def _route(&block) catch(:halt) do - request.block_result(instance_exec(@_request, &block)) - response.finish + r = @_request + r.block_result(instance_exec(r, &block)) + @_response.finish end end end # Class methods for RodaRequest @@ -377,21 +396,10 @@ end pattern end - # Define a verb method in the given that will yield to the match block - # if the request method matches and there are either no arguments or - # there is a successful terminal match on the arguments. - def def_verb_method(mod, verb) - mod.class_eval(<<-END, __FILE__, __LINE__+1) - def #{verb}(*args, &block) - _verb(args, &block) if #{verb == :get ? :is_get : verb}? - end - END - 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" @@ -401,11 +409,11 @@ # 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)/ + /\A\/(?:#{pattern})(?=\/|\z)/ end end # Instance methods for RodaRequest, mostly related to handling routing # for the request. @@ -416,10 +424,11 @@ EMPTY_STRING = "".freeze SLASH = "/".freeze SEGMENT = "([^\\/]+)".freeze TERM_INSPECT = "TERM".freeze GET_REQUEST_METHOD = 'GET'.freeze + SESSION_KEY = 'rack.session'.freeze TERM = Object.new def TERM.inspect TERM_INSPECT end @@ -438,21 +447,26 @@ @scope = scope @captures = [] super(env) end - # As request routing modifies SCRIPT_NAME and PATH_INFO, this exists - # as a helper method to get the full path of the request. - # - # r.env['SCRIPT_NAME'] = '/foo' - # r.env['PATH_INFO'] = '/bar' - # r.full_path_info - # # => '/foo/bar' - def full_path_info - "#{@env[SCRIPT_NAME]}#{@env[PATH_INFO]}" + # 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!']] @@ -463,33 +477,17 @@ # r.halt def halt(res=response.finish) throw :halt, res 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_REQUEST_METHOD - 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 - # 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]} #{full_path_info}>" + "#<#{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 @@ -533,19 +531,26 @@ # # matches as path is empty after matching # end # end def is(*args, &block) if args.empty? - if @env[PATH_INFO] == EMPTY_STRING + 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_REQUEST_METHOD + 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. # @@ -577,20 +582,40 @@ else if_match(args, &block) end 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: + # The already matched part of the path, including the original SCRIPT_NAME. + def matched_path + @env[SCRIPT_NAME] + end + + # This an an optimized version of Rack::Request#path. # - # response.status = 200 - # response['Header-Name'] = 'Header value' - def response - scope.response + # 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 + alias full_path_info path + # The current path to match requests against. This is the same as PATH_INFO + # in the environment, which gets updated as the request is being routed. + def remaining_path + @env[PATH_INFO] + 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 @@ -610,15 +635,30 @@ # r.post do # # change state # r.redirect # end # end - def redirect(path=default_redirect_path, status=302) + 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 + # Routing matches 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.path_info] @@ -663,11 +703,11 @@ # end # # Use <tt>r.get true</tt> to handle +GET+ requests where the current # path is empty. def root(&block) - if @env[PATH_INFO] == SLASH && is_get? + if remaining_path == SLASH && is_get? always(&block) end end # Call the given rack app with the environment and return the response @@ -679,37 +719,43 @@ # response.status = 404 # not reached def run(app) throw :halt, app.call(@env) end + # The session for the current request. Raises a RodaError if + # a session handler has not been loaded. + def session + @env[SESSION_KEY] || raise(RodaError, "You're missing a session handler. You can get started by adding use Rack::Session::Cookie") + 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) + @captures.push(m) end end matched end end - # 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 hash if all hash matchers match. def _match_hash(hash) hash.all?{|k,v| send("match_#{k}", v)} end + # 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. Regexp escapes the # string so that regexp metacharacters are not matched, and recognizes # colon tokens for placeholders. def _match_string(str) consume(self.class.cached_matcher(str){Regexp.escape(str).gsub(/:(\w+)/){|m| _match_symbol_regexp($1)}}) @@ -755,20 +801,14 @@ # 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) - env = @env - return unless matchdata = env[PATH_INFO].match(pattern) - - vars = matchdata.captures - - # Don't mutate SCRIPT_NAME, breaks try - env[SCRIPT_NAME] += vars.shift - env[PATH_INFO] = matchdata.post_match - - captures.concat(vars) + if matchdata = remaining_path.match(pattern) + update_remaining_path(matchdata.post_match) + @captures.concat(matchdata.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 @@ -777,33 +817,51 @@ # # 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? - full_path_info + 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_STRING + 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) - env = @env - script = env[SCRIPT_NAME] - path = env[PATH_INFO] + keep_remaining_path do + # For every block, we make sure to reset captures so that + # nesting matchers won't mess with each other's captures. + @captures.clear - # For every block, we make sure to reset captures so that - # nesting matchers won't mess with each other's captures. - captures.clear - - return unless match_all(args) - block_result(yield(*captures)) - throw :halt, response.finish - ensure - env[SCRIPT_NAME] = script - env[PATH_INFO] = path + return unless match_all(args) + block_result(yield(*captures)) + throw :halt, response.finish + end end + # Yield to the block, restoring SCRIPT_NAME and PATH_INFO to + # their initial values before returning from the block. + def keep_remaining_path + env = @env + script = env[sn = SCRIPT_NAME] + path = env[pi = PATH_INFO] + yield + ensure + env[sn] = script + env[pi] = path + end + # Attempt to match the argument to the given request, handling # common ruby types. def match(matcher) case matcher when String @@ -811,11 +869,11 @@ when Regexp _match_regexp(matcher) when Symbol _match_symbol(matcher) when TERM - @env[PATH_INFO] == EMPTY_STRING + empty_path? when Hash _match_hash(matcher) when Array _match_array(matcher) when Proc @@ -848,21 +906,30 @@ # Match the given parameter if present, even if the parameter is empty. # Adds any match to the captures. def match_param(key) if v = self[key] - captures << v + @captures << v end end # Match the given parameter if present and not empty. # Adds any match to the captures. def match_param!(key) if (v = self[key]) && !v.empty? - captures << v + @captures << v end end + + # Update PATH_INFO and SCRIPT_NAME based on the matchend and remaining variables. + def update_remaining_path(remaining) + e = @env + + # Don't mutate SCRIPT_NAME, breaks try + e[SCRIPT_NAME] += e[pi = PATH_INFO].chomp(remaining) + e[pi] = remaining + end end # Class methods for RodaResponse module ResponseClassMethods # Reference to the Roda class related to this response class. @@ -877,25 +944,24 @@ end # Instance methods for RodaResponse module ResponseMethods CONTENT_LENGTH = "Content-Length".freeze - CONTENT_TYPE = "Content-Type".freeze - DEFAULT_CONTENT_TYPE = "text/html".freeze + DEFAULT_HEADERS = {"Content-Type" => "text/html".freeze}.freeze LOCATION = "Location".freeze + # 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 - # The hash of response headers for the current response. - attr_reader :headers - # Set the default headers when creating a response. def initialize @status = nil - @headers = default_headers + @headers = {} @body = [] @length = 0 end # Return the response header with the given key. Example: @@ -910,18 +976,13 @@ # response['Content-Type'] = 'application/json' def []=(key, value) @headers[key] = value end - # Show response class, status code, response headers, and response body - def inspect - "#<#{self.class.inspect} #{@status.inspect} #{@headers.inspect} #{@body.inspect}>" - end - # The default headers to use for responses. def default_headers - {CONTENT_TYPE => DEFAULT_CONTENT_TYPE} + DEFAULT_HEADERS end # Modify the headers to include a Set-Cookie value that # deletes the cookie. A value hash can be provided to # override the default one used to delete the cookie. @@ -957,33 +1018,45 @@ # # {'Content-Type'=>'text/html', 'Content-Length'=>'0'}, # # []] def finish b = @body s = (@status ||= b.empty? ? 404 : 200) + set_default_headers h = @headers - h[CONTENT_LENGTH] = @length.to_s + h[CONTENT_LENGTH] ||= @length.to_s [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 || 200, @headers, body] 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 end + # Return the Roda class related to this response. + def roda_class + self.class.roda_class + end + # Set the cookie with the given key in the headers. # # response.set_cookie('foo', 'bar') # response.set_cookie('foo', :value=>'bar', :domain=>'example.org') def set_cookie(key, value) @@ -997,14 +1070,23 @@ 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 extend RodaPlugins::Base::ClassMethods plugin RodaPlugins::Base - RodaRequest.def_verb_method(RodaPlugins::Base::RequestMethods, :get) - RodaRequest.def_verb_method(RodaPlugins::Base::RequestMethods, :post) end