lib/roda.rb in roda-3.17.0 vs lib/roda.rb in roda-3.18.0
- old
+ new
@@ -143,10 +143,110 @@
def clear_middleware!
@middleware.clear
build_rack_app
end
+ # Define an instance method using the block with the provided name and
+ # expected arity. If the name is given as a Symbol, it is used directly.
+ # If the name is given as a String, a unique name will be generated using
+ # that string. The expected arity should be either 0 (no arguments),
+ # 1 (single argument), or :any (any number of arguments).
+ #
+ # If the :check_arity app option is not set to false, Roda will check that
+ # the arity of the block matches the expected arity, and compensate for
+ # cases where it does not. If it is set to :warn, Roda will warn in the
+ # cases where the arity does not match what is expected.
+ #
+ # If the expected arity is :any, Roda must perform a dynamic arity check
+ # when the method is called, which can hurt performance even in the case
+ # where the arity matches. The :check_dynamic_arity app option can be
+ # set to false to turn off the dynamic arity checks. The
+ # :check_dynamic_arity app option can be to :warn to warn if Roda needs
+ # to adjust arity dynamically.
+ #
+ # Roda only checks arity for regular blocks, not lambda blocks, as the
+ # fixes Roda uses for regular blocks would not work for lambda blocks.
+ #
+ # Roda does not support blocks with required keyword arguments if the
+ # expected arity is 0 or 1.
+ def define_roda_method(meth, expected_arity, &block)
+ if meth.is_a?(String)
+ meth = roda_method_name(meth)
+ end
+
+ if (check_arity = opts.fetch(:check_arity, true)) && !block.lambda?
+ required_args, optional_args, rest, keyword = _define_roda_method_arg_numbers(block)
+
+ if keyword == :required && (expected_arity == 0 || expected_arity == 1)
+ raise RodaError, "cannot use block with required keyword arguments when calling define_roda_method with expected arity #{expected_arity}"
+ end
+
+ case expected_arity
+ when 0
+ unless required_args == 0
+ if check_arity == :warn
+ RodaPlugins.warn "Arity mismatch in block passed to define_roda_method. Expected Arity 0, but arguments required for #{block.inspect}"
+ end
+ b = block
+ block = lambda{instance_exec(&b)} # Fallback
+ end
+ when 1
+ if required_args == 0 && optional_args == 0 && !rest
+ if check_arity == :warn
+ RodaPlugins.warn "Arity mismatch in block passed to define_roda_method. Expected Arity 1, but no arguments accepted for #{block.inspect}"
+ end
+ b = block
+ block = lambda{|_| instance_exec(&b)} # Fallback
+ end
+ when :any
+ if check_dynamic_arity = opts.fetch(:check_dynamic_arity, check_arity)
+ if keyword
+ # Complexity of handling keyword arguments using define_method is too high,
+ # Fallback to instance_exec in this case.
+ b = block
+ block = lambda{|*a| instance_exec(*a, &b)} # Keyword arguments fallback
+ else
+ arity_meth = meth
+ meth = :"#{meth}_arity"
+ end
+ end
+ else
+ raise RodaError, "unexpected arity passed to define_roda_method: #{expected_arity.inspect}"
+ end
+ end
+
+ define_method(meth, &block)
+ private meth
+
+ if arity_meth
+ required_args, optional_args, rest, keyword = _define_roda_method_arg_numbers(instance_method(meth))
+ max_args = required_args + optional_args
+ define_method(arity_meth) do |*a|
+ arity = a.length
+ if arity > required_args
+ if arity > max_args && !rest
+ if check_dynamic_arity == :warn
+ RodaPlugins.warn "Dynamic arity mismatch in block passed to define_roda_method. At most #{max_args} arguments accepted, but #{arity} arguments given for #{block.inspect}"
+ end
+ a = a.slice(0, max_args)
+ end
+ elsif arity < required_args
+ if check_dynamic_arity == :warn
+ RodaPlugins.warn "Dynamic arity mismatch in block passed to define_roda_method. #{required_args} args required, but #{arity} arguments given for #{block.inspect}"
+ end
+ a.concat([nil] * (required_args - arity))
+ end
+
+ send(meth, *a)
+ end
+ private arity_meth
+ arity_meth
+ else
+ meth
+ end
+ end
+
# Expand the given path, using the root argument as the base directory.
def expand_path(path, root=opts[:root])
::File.expand_path(path, root)
end
@@ -158,10 +258,28 @@
# Note that freezing the class prevents you from subclassing it, mostly because
# it would cause some plugins to break.
def freeze
@opts.freeze
@middleware.freeze
+
+ unless opts[:subclassed]
+ # If the _roda_run_main_route instance method has not been overridden,
+ # make it an alias to _roda_main_route for performance
+ if instance_method(:_roda_run_main_route).owner == InstanceMethods
+ class_eval("alias _roda_run_main_route _roda_main_route")
+ end
+ self::RodaResponse.class_eval do
+ if instance_method(:set_default_headers).owner == ResponseMethods &&
+ instance_method(:default_headers).owner == ResponseMethods
+
+ def set_default_headers
+ @headers['Content-Type'] ||= 'text/html'
+ end
+ end
+ end
+ end
+
super
end
# Rebuild the _roda_before and _roda_after methods whenever a plugin might
# have added a _roda_before_* or _roda_after_* method.
@@ -174,14 +292,20 @@
# When inheriting Roda, copy the shared data into the subclass,
# and setup the request and response subclasses.
def inherited(subclass)
raise RodaError, "Cannot subclass a frozen Roda class" if frozen?
+
+ # Mark current class as having been subclassed, as some optimizations
+ # depend on the class not being subclassed
+ opts[:subclassed] = true
+
super
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.delete(:subclassed)
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
@@ -231,13 +355,19 @@
# end
#
# This should only be called once per class, and if called multiple
# times will overwrite the previous routing.
def route(&block)
+ unless block
+ RodaPlugins.warn "no block passed to Roda.route"
+ return
+ end
+
@raw_route_block = block
@route_block = block = convert_route_block(block)
- @rack_app_route_block = rack_app_route_block(block)
+ @rack_app_route_block = block = rack_app_route_block(block)
+ public define_roda_method(:_roda_main_route, 1, &block)
build_rack_app
end
# Add a middleware to use for the rack application. Must be
# called before calling #route to have an effect. Example:
@@ -248,14 +378,72 @@
build_rack_app
end
private
+ # Return the number of required argument, optional arguments,
+ # whether the callable accepts any additional arguments,
+ # and whether the callable accepts keyword arguments (true, false
+ # or :required).
+ def _define_roda_method_arg_numbers(callable)
+ optional_args = 0
+ rest = false
+ keyword = false
+ callable.parameters.map(&:first).each do |arg_type, _|
+ case arg_type
+ when :opt
+ optional_args += 1
+ when :rest
+ rest = true
+ when :keyreq
+ keyword = :required
+ when :key, :keyrest
+ keyword ||= true
+ end
+ end
+ arity = callable.arity
+ if arity < 0
+ arity = arity.abs - 1
+ end
+ required_args = arity
+ arity -= 1 if keyword == :required
+
+ if callable.is_a?(Proc) && !callable.lambda?
+ optional_args -= arity
+ end
+
+ [required_args, optional_args, rest, keyword]
+ end
+
+ # The base rack app to use, before middleware is added.
+ def base_rack_app_callable(new_api=true)
+ if new_api
+ lambda{|env| new(env)._roda_handle_main_route}
+ else
+ block = @rack_app_route_block
+ lambda{|env| new(env).call(&block)}
+ end
+ end
+
# Build the rack app to use
def build_rack_app
- if block = @rack_app_route_block
- app = lambda{|env| new(env).call(&block)}
+ if @rack_app_route_block
+ # RODA4: Assume optimize is true
+ optimize = ancestors.each do |mod|
+ break true if mod == InstanceMethods
+ meths = mod.instance_methods(false)
+ if meths.include?(:call) && !(meths.include?(:_roda_handle_main_route) || meths.include?(:_roda_run_main_route))
+ RodaPlugins.warn <<WARNING
+Falling back to using #call for dispatching for #{self}, due to #call override in #{mod}.
+#{mod} should be fixed to adjust to Roda's new dispatch API, and override _roda_handle_main_route or _roda_run_main_route
+WARNING
+ break false
+ end
+ end
+
+ app = base_rack_app_callable(optimize)
+
@middleware.reverse_each do |args, bl|
mid, *args = args
app = mid.new(app, *args, &bl)
app.freeze if opts[:freeze_middleware]
end
@@ -272,47 +460,53 @@
# Build a _roda_before method that calls each _roda_before_* method
# in order, if any _roda_before_* methods are defined. Also, rebuild
# the route block if a _roda_before method is defined.
def def_roda_before
- meths = private_instance_methods.grep(/\A_roda_before_\d\d/).sort.join(';')
+ meths = private_instance_methods.grep(/\A_roda_before_\d\d/).sort
unless meths.empty?
- class_eval("def _roda_before; #{meths} end", __FILE__, __LINE__)
- private :_roda_before
- if @raw_route_block
- route(&@raw_route_block)
+ plugin :_before_hook unless private_method_defined?(:_roda_before)
+ if meths.length == 1
+ class_eval("alias _roda_before #{meths.first}", __FILE__, __LINE__)
+ else
+ class_eval("def _roda_before; #{meths.join(';')} end", __FILE__, __LINE__)
end
+ private :_roda_before
end
end
# Build a _roda_after method that calls each _roda_after_* method
# in order, if any _roda_after_* methods are defined. Also, use
# the internal after hook plugin if the _roda_after method is defined.
def def_roda_after
- meths = private_instance_methods.grep(/\A_roda_after_\d\d/).sort.map{|s| "#{s}(res)"}.join(';')
+ meths = private_instance_methods.grep(/\A_roda_after_\d\d/).sort
unless meths.empty?
- plugin :_after_hook unless private_method_defined?(:_roda_after)
- class_eval("def _roda_after(res); #{meths} end", __FILE__, __LINE__)
+ plugin :error_handler unless private_method_defined?(:_roda_after)
+ if meths.length == 1
+ class_eval("alias _roda_after #{meths.first}", __FILE__, __LINE__)
+ else
+ class_eval("def _roda_after(res); #{meths.map{|s| "#{s}(res)"}.join(';')} end", __FILE__, __LINE__)
+ end
private :_roda_after
end
end
# The route block to use when building the rack app (or other initial
# entry point to the route block).
# By default, modifies the rack app route block to support before hooks
# if any before hooks are defined.
# Can be modified by plugins.
def rack_app_route_block(block)
- if private_method_defined?(:_roda_before)
- lambda do |r|
- _roda_before
- instance_exec(r, &block)
- end
- else
- block
- end
+ block
end
+
+ method_num = 0
+ method_num_mutex = Mutex.new
+ # Return a unique method name symbol for the given suffix.
+ define_method(:roda_method_name) do |suffix|
+ :"_roda_#{suffix}_#{method_num_mutex.synchronize{method_num += 1}}"
+ end
end
# Instance methods for the Roda class.
#
# In addition to the listed methods, the following two methods are available:
@@ -326,24 +520,53 @@
klass = self.class
@_request = klass::RodaRequest.new(self, env)
@_response = klass::RodaResponse.new
end
- # instance_exec the route block in the scope of the
- # receiver, with the related request. Catch :halt so that
- # the route block can throw :halt at any point with the
- # rack response to use.
+ # Handle dispatching to the main route, catching :halt and handling
+ # the result of the block.
+ def _roda_handle_main_route
+ catch(:halt) do
+ r = @_request
+ r.block_result(_roda_run_main_route(r))
+ @_response.finish
+ end
+ end
+
+ # Treat the given block as a routing block, catching :halt if
+ # thrown by the block.
+ def _roda_handle_route
+ catch(:halt) do
+ @_request.block_result(yield)
+ @_response.finish
+ end
+ end
+
+ # Default implementation of the main route, usually overridden
+ # by Roda.route.
+ def _roda_main_route(_)
+ end
+
+ # Run the main route block with the request. Designed for
+ # extension by plugins
+ def _roda_run_main_route(r)
+ _roda_main_route(r)
+ end
+
+ # Deprecated method for the previous main route dispatch API.
def call(&block)
+ # RODA4: Remove
catch(:halt) do
r = @_request
- r.block_result(instance_exec(r, &block))
+ r.block_result(instance_exec(r, &block)) # Fallback
@_response.finish
end
end
- # Private alias for internal use
+ # Deprecated private alias for internal use
alias _call call
+ # RODA4: Remove
private :_call
# The environment hash for the current request. Example:
#
# env['REQUEST_METHOD'] # => 'GET'
@@ -902,10 +1125,10 @@
# 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.clear
+ captures = @captures.clear
if match_all(args)
block_result(yield(*captures))
throw :halt, response.finish
else