lib/roda.rb



# 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

          # RODA4: Switch to false # :warn in last Roda 3 version
          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
                if check_arity == :warn
                  RodaPlugins.warn "Arity mismatch in block passed to define_roda_method. Expected Arity 1, but multiple arguments required for #{block.inspect}"
                end
                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[RodaResponseHeaders::CONTENT_TYPE] ||= 'text/html'
                end
              end
            end

            if @middleware.empty? && use_new_dispatch_api?
              plugin :direct_call
            end

            if ([:on, :is, :_verb, :_match_class_String, :_match_class_Integer, :_match_string, :_match_regexp, :empty_path?, :if_match, :match, :_match_class]).all?{|m| self::RodaRequest.instance_method(m).owner == RequestMethods}
              plugin :_optimized_matching
            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 <<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
              return false
            end
          end

          true
        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:
      #
      # request :: The instance of the request class related to this request.
      #            This is the same object yielded by Roda.route.
      # response :: The instance of the response class related to this request.
      module InstanceMethods
        # Create a request and response of the appropriate class
        def initialize(env)
          klass = self.class
          @_request = klass::RodaRequest.new(self, env)
          @_response = klass::RodaResponse.new
        end

        # 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)) # Fallback
            @_response.finish
          end
        end

        # 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'
        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