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