# frozen-string-literal: true 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 @app = nil @inherit_middleware = true @middleware = [] @opts = {} @raw_route_block = nil @route_block = nil @rack_app_route_block = nil module RodaPlugins # 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. module ClassMethods # The rack application that this class uses. def app @app || build_rack_app end # 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 # Call the internal rack application with the given environment. # This allows the class itself to be used as a rack application. # However, for performance, it's better to use #app to get direct # access to the underlying rack app. def call(env) app.call(env) end # Clear the middleware stack def clear_middleware! @middleware.clear @app = nil 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 call_meth = meth 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 temp_method = roda_method_name("temp") class_eval("def #{temp_method}(_) #{meth =~ /\A\w+\z/ ? "#{meth}_arity" : "send(:\"#{meth}_arity\")"} end", __FILE__, __LINE__) alias_method meth, temp_method undef_method temp_method private meth alias_method meth, meth meth = :"#{meth}_arity" elsif required_args > 1 b = block block = lambda{|r| instance_exec(r, &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 = if RUBY_VERSION >= '2.7' eval('lambda{|*a, **kw| instance_exec(*a, **kw, &b)}', nil, __FILE__, __LINE__) # Keyword arguments fallback else # :nocov: lambda{|*a| instance_exec(*a, &b)} # Keyword arguments fallback # :nocov: end 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 alias_method meth, 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 alias_method arity_meth, arity_meth end call_meth 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 # Freeze the internal state of the class, to avoid thread safety issues at runtime. # It's optional to call this method, as nothing should be modifying the # internal state at runtime anyway, but this makes sure an exception will # be raised if you try to modify the internal state after calling this. # # Note that freezing the class prevents you from subclassing it, mostly because # it would cause some plugins to break. def freeze return self if frozen? 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 private alias set_default_headers set_default_headers def set_default_headers @headers['Content-Type'] ||= 'text/html' end end end if @middleware.empty? && use_new_dispatch_api? plugin :direct_call end end build_rack_app @opts.freeze @middleware.freeze super end # Rebuild the _roda_before and _roda_after methods whenever a plugin might # have added a _roda_before_* or _roda_after_* method. def include(*a) res = super def_roda_before def_roda_after res end # 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 if block = @raw_route_block subclass.route(&block) end request_class = Class.new(self::RodaRequest) request_class.roda_class = subclass request_class.match_pattern_cache = RodaCache.new subclass.const_set(:RodaRequest, request_class) response_class = Class.new(self::RodaResponse) response_class.roda_class = subclass subclass.const_set(:RodaResponse, response_class) end # Load a new plugin into the current class. A plugin can be a module # which is used directly, or a symbol representing a registered plugin # which will be required and then used. Returns nil. # # Note that you should not load plugins into a Roda class after the # class has been subclassed, as doing so can break the subclasses. # # Roda.plugin PluginModule # Roda.plugin :csrf def plugin(plugin, *args, &block) raise RodaError, "Cannot add a plugin to a frozen Roda class" if frozen? plugin = RodaPlugins.load_plugin(plugin) if plugin.is_a?(Symbol) raise RodaError, "Invalid plugin type: #{plugin.class.inspect}" unless plugin.is_a?(Module) if !plugin.respond_to?(:load_dependencies) && !plugin.respond_to?(:configure) && (!args.empty? || block) # RODA4: switch from warning to error RodaPlugins.warn("Plugin #{plugin} does not accept arguments or a block, but arguments or a block was passed when loading this. This will raise an error in Roda 4.") end plugin.load_dependencies(self, *args, &block) if plugin.respond_to?(:load_dependencies) include(plugin::InstanceMethods) if defined?(plugin::InstanceMethods) extend(plugin::ClassMethods) if defined?(plugin::ClassMethods) self::RodaRequest.send(:include, plugin::RequestMethods) if defined?(plugin::RequestMethods) self::RodaRequest.extend(plugin::RequestClassMethods) if defined?(plugin::RequestClassMethods) self::RodaResponse.send(:include, plugin::ResponseMethods) if defined?(plugin::ResponseMethods) self::RodaResponse.extend(plugin::ResponseClassMethods) if defined?(plugin::ResponseClassMethods) plugin.configure(self, *args, &block) if plugin.respond_to?(:configure) @app = nil end # :nocov: ruby2_keywords(:plugin) if respond_to?(:ruby2_keywords, true) # :nocov: # Setup routing tree for the current Roda application, and build the # underlying rack application using the stored middleware. Requires # a block, which is yielded the request. By convention, the block # argument should be named +r+. Example: # # Roda.route do |r| # r.root do # "Root" # end # 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 = block = rack_app_route_block(block) public define_roda_method(:_roda_main_route, 1, &block) @app = nil end # Add a middleware to use for the rack application. Must be # called before calling #route to have an effect. Example: # # Roda.use Rack::ShowExceptions def use(*args, &block) @middleware << [args, block].freeze @app = nil end # :nocov: ruby2_keywords(:use) if respond_to?(:ruby2_keywords, true) # :nocov: 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 app = base_rack_app_callable(use_new_dispatch_api?) @middleware.reverse_each do |args, bl| mid, *args = args app = mid.new(app, *args, &bl) app.freeze if opts[:freeze_middleware] end @app = app end # Modify the route block to use for any route block provided as input, # which can include route blocks that are delegated to by the main route block. # Can be modified by plugins. def convert_route_block(block) block end # 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 unless meths.empty? 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 alias_method :_roda_before, :_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 unless meths.empty? 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 alias_method :_roda_after, :_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) block end # Whether the new dispatch API should be used. def use_new_dispatch_api? # RODA4: remove this method ancestors.each do |mod| break 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 < 'GET' def env @_request.env end # The class-level options hash. This should probably not be # modified at the instance level. Example: # # Roda.plugin :render # Roda.route do |r| # opts[:render_opts].inspect # end def opts self.class.opts end attr_reader :_request # :nodoc: alias request _request remove_method :_request attr_reader :_response # :nodoc: alias response _response remove_method :_response # The session hash for the current request. Raises RodaError # if no session exists. Example: # # session # => {} def session @_request.session end end end end extend RodaPlugins::Base::ClassMethods plugin RodaPlugins::Base end