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