lib/roda.rb



require "rack"
require "thread"
require "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

  if defined?(RUBY_ENGINE) && RUBY_ENGINE != 'ruby'
  # :nocov:
    # A thread safe cache class, offering only #[] and #[]= methods,
    # each protected by a mutex.  Used on non-MRI where Hash is not
    # thread safe.
    class RodaCache
      # Create a new thread safe cache.
      def initialize
        @mutex = Mutex.new
        @hash = {}
      end

      # Make getting value from underlying hash thread safe.
      def [](key)
        @mutex.synchronize{@hash[key]}
      end

      # Make setting value in underlying hash thread safe.
      def []=(key, value)
        @mutex.synchronize{@hash[key] = value}
      end
    end
  # :nocov:
  else
    # Hashes are already thread-safe in MRI, due to the GVL, so they
    # can safely be used as a cache.
    RodaCache = Hash
  end

  # Base class used for Roda requests.  The instance methods for this
  # class are added by Roda::RodaPlugins::Base::RequestMethods, the
  # class methods are added by Roda::RodaPlugins::Base::RequestClassMethods.
  class RodaRequest < ::Rack::Request
    @roda_class = ::Roda
    @match_pattern_cache = ::Roda::RodaCache.new
  end

  # Base class used for Roda responses.  The instance methods for this
  # class are added by Roda::RodaPlugins::Base::ResponseMethods, the class
  # methods are added by Roda::RodaPlugins::Base::ResponseClassMethods.
  class RodaResponse
    @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
  # registering and loading plugins.
  module RodaPlugins
    # Stores registered plugins
    @plugins = RodaCache.new

    # If the registered plugin already exists, use it.  Otherwise,
    # require it and return it.  This raises a LoadError if such a
    # plugin doesn't exist, or a RodaError if it exists but it does
    # not register itself correctly.
    def self.load_plugin(name)
      h = @plugins
      unless plugin = h[name]
        require "roda/plugins/#{name}"
        raise RodaError, "Plugin #{name} did not register itself correctly in Roda::RodaPlugins" unless plugin = h[name]
      end
      plugin
    end

    # Register the given plugin with Roda, so that it can be loaded using #plugin
    # with a symbol.  Should be used by plugin files. Example:
    #
    #   Roda::RodaPlugins.register_plugin(:plugin_name, PluginModule)
    def self.register_plugin(name, mod)
      @plugins[name] = mod
    end

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

        # 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
          build_rack_app
        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
          @opts.freeze
          @middleware.freeze
          super
        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?
          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.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
          request_class.match_pattern_cache = thread_safe_cache
          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 represented a registered plugin
        # which will be required and then used.
        #
        #   Roda.plugin PluginModule
        #   Roda.plugin :csrf
        def plugin(plugin, *args, &block)
          raise RodaError, "Cannot subclass a frozen Roda class" if frozen?
          plugin = RodaPlugins.load_plugin(plugin) if plugin.is_a?(Symbol)
          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)
        end

        # 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)
          @route_block = block
          build_rack_app
        end

        # A new thread safe cache instance.  This is a method so it can be
        # easily overridden for alternative implementations.
        def thread_safe_cache
          RodaCache.new
        end

        # Add a middleware to use for the rack application.  Must be
        # called before calling #route to have an effect. Example:
        #
        #   Roda.use Rack::Session::Cookie, :secret=>ENV['secret']
        def use(*args, &block)
          @middleware << [args, block].freeze
          build_rack_app
        end

        private

        # Build the rack app to use
        def build_rack_app
          if block = @route_block
            builder = Rack::Builder.new
            @middleware.each{|a, b| builder.use(*a, &b)}
            builder.run lambda{|env| new(env).call(&block)}
            @app = builder.to_app
          end
        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 appopriate class
        def initialize(env)
          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.
        def call(&block)
          catch(:halt) do
            r = @_request
            r.block_result(instance_exec(r, &block))
            @_response.finish
          end
        end

        # 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

      # Class methods for RodaRequest
      module RequestClassMethods
        # Reference to the Roda class related to this request class.
        attr_accessor :roda_class

        # The cache to use for match patterns for this request class.
        attr_accessor :match_pattern_cache

        # Return the cached pattern for the given object.  If the object is
        # not already cached, yield to get the basic pattern, and convert the
        # basic pattern to a pattern that does not partial segments.
        def cached_matcher(obj)
          cache = @match_pattern_cache

          unless pattern = cache[obj]
            pattern = cache[obj] = consume_pattern(yield)
          end

          pattern
        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"
        end

        private

        # 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)/
        end
      end

      # Instance methods for RodaRequest, mostly related to handling routing
      # for the request.
      module RequestMethods
        PATH_INFO = "PATH_INFO".freeze
        SCRIPT_NAME = "SCRIPT_NAME".freeze
        REQUEST_METHOD = "REQUEST_METHOD".freeze
        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
        TERM.freeze

        # The current captures for the request.  This gets modified as routing
        # occurs.
        attr_reader :captures

        # The Roda instance related to this request object.  Useful if routing
        # methods need access to the scope of the Roda route block.
        attr_reader :scope

        # Store the roda instance and environment.
        def initialize(scope, env)
          @scope = scope
          @captures = []
          @remaining_path = env[PATH_INFO]
          super(env)
        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

        # 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!']]
        #   
        #   response.status = 200
        #   response['Content-Type'] = 'text/html'
        #   response.write 'Hello World!'
        #   r.halt
        def halt(res=response.finish)
          throw :halt, res
        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]} #{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
        # returned.
        # 
        #   r.remaining_path
        #   # => "/foo/bar"
        #
        #   r.is 'foo' do
        #     # does not match, as path isn't fully matched (/bar remaining)
        #   end
        #
        #   r.is 'foo/bar' do
        #     # matches as path is empty after matching
        #   end
        #
        # If no arguments are given, matches if the path is already fully matched.
        # 
        #   r.on 'foo/bar' do
        #     r.is do
        #       # matches as path is already empty
        #     end
        #   end
        #
        # Note that this matches only if the path after matching the arguments
        # is empty, not if it still contains a trailing slash:
        #
        #   r.remaining_path
        #   # =>  "/foo/bar/"
        #
        #   r.is 'foo/bar' do
        #     # does not match, as path isn't fully matched (/ remaining)
        #   end
        # 
        #   r.is 'foo/bar/' do
        #     # matches as path is empty after matching
        #   end
        # 
        #   r.on 'foo/bar' do
        #     r.is "" do
        #       # matches as path is empty after matching
        #     end
        #   end
        def is(*args, &block)
          if args.empty?
            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.
        # 
        #   r.remaining_path
        #   # => "/foo/bar"
        #
        #   r.on 'foo' do
        #     # matches, path is /bar after matching
        #   end
        #
        #   r.on 'bar' do
        #     # does not match
        #   end
        #
        # Like other routing methods, If it matches, the match block is
        # executed, and when the match block returns, the rack response is
        # returned.  However, in general you will call another routing method
        # inside the match block that fully matches the path and does the
        # final handling for the request:
        #
        #   r.on 'foo' do
        #     r.is 'bar' do
        #       # handle /foo/bar request
        #     end
        #   end
        def on(*args, &block)
          if args.empty?
            always(&block)
          else
            if_match(args, &block)
          end
        end

        # The already matched part of the path, including the original SCRIPT_NAME.
        def matched_path
          e = @env
          e[SCRIPT_NAME] + e[PATH_INFO].chomp(@remaining_path)
        end

        # This an an optimized version of Rack::Request#path.
        #
        #   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

        # The current path to match requests against.
        attr_reader :remaining_path

        # 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
        #   response.status = 404 # not reached
        #   
        # If you do not provide a path, by default it will redirect to the same
        # path if the request is not a +GET+ request.  This is designed to make
        # it easy to use where a +POST+ request to a URL changes state, +GET+
        # returns the current state, and you want to show the current state
        # after changing:
        #
        #   r.is "foo" do
        #     r.get do
        #       # show state
        #     end
        #   
        #     r.post do
        #       # change state
        #       r.redirect
        #     end
        #   end
        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.remaining_path]
        #   # => ['GET', '/']
        #
        #   r.root do
        #     # matches
        #   end
        #
        # This is usuable inside other match blocks:
        #
        #   [r.request_method, r.remaining_path]
        #   # => ['GET', '/foo/']
        #
        #   r.on 'foo' do
        #     r.root do
        #       # matches
        #     end
        #   end
        #
        # Note that this does not match non-+GET+ requests:
        #
        #   [r.request_method, r.remaining_path]
        #   # => ['POST', '/']
        #
        #   r.root do
        #     # does not match
        #   end
        #
        # Use <tt>r.post ""</tt> for +POST+ requests where the current path
        # is +/+.
        # 
        # Nor does it match empty paths:
        #
        #   [r.request_method, r.remaining_path]
        #   # => ['GET', '/foo']
        #
        #   r.on 'foo' do
        #     r.root do
        #       # does not match
        #     end
        #   end
        #
        # Use <tt>r.get true</tt> to handle +GET+ requests where the current
        # path is empty.
        def root(&block)
          if remaining_path == SLASH && is_get?
            always(&block)
          end
        end

        # Call the given rack app with the environment and return the response
        # from the rack app as the response for this request.  This ends
        # the processing of the request:
        #
        #   r.run(proc{[403, {}, []]}) unless r['letmein'] == '1'
        #   r.run(proc{[404, {}, []]})
        #   response.status = 404 # not reached
        #
        # This updates SCRIPT_NAME/PATH_INFO based on the current remaining_path
        # before dispatching to another rack app, so the app still works as
        # a URL mapper.
        def run(app)
          e = @env
          path = @remaining_path
          sn = SCRIPT_NAME
          pi = PATH_INFO
          script_name = e[sn]
          path_info = e[pi]
          begin
            e[sn] += path_info.chomp(path)
            e[pi] = path
            throw :halt, app.call(e)
          ensure
            e[sn] = script_name
            e[pi] = path_info
          end
        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)
              end
            end

            matched
          end
        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)}})
        end

        # Match the given symbol if any segment matches.
        def _match_symbol(sym)
          consume(self.class.cached_matcher(sym){_match_symbol_regexp(sym)})
        end

        # The regular expression to use for matching symbols.  By default, any non-empty
        # segment matches.
        def _match_symbol_regexp(s)
          SEGMENT
        end

        # Backbone of the verb method support, using a terminal match if
        # args is not empty, or a regular match if it is empty.
        def _verb(args, &block)
          if args.empty?
            always(&block)
          else
            args << TERM
            if_match(args, &block)
          end
        end

        # Yield to the match block and return rack response after the block returns.
        def always
          block_result(yield)
          throw :halt, response.finish
        end

        # The body to use for the response if the response does not return
        # a body.  By default, a String is returned directly, and nil is
        # returned otherwise.
        def block_result_body(result)
          if result.is_a?(String)
            result
          end
        end

        # 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)
          if matchdata = remaining_path.match(pattern)
            @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
        # a POST request will redirect to a GET request at the same location
        # will work fine.
        #
        # 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?
          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)
          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

          return unless match_all(args)
          block_result(yield(*captures))
          throw :halt, response.finish
        ensure
          @remaining_path = path
        end
        
        # Attempt to match the argument to the given request, handling
        # common ruby types.
        def match(matcher)
          case matcher
          when String
            _match_string(matcher)
          when Regexp
            _match_regexp(matcher)
          when Symbol
            _match_symbol(matcher)
          when TERM
            empty_path?
          when Hash
            _match_hash(matcher)
          when Array
            _match_array(matcher)
          when Proc
            matcher.call
          else
            matcher
          end
        end

        # Match only if all of the arguments in the given array match.
        def match_all(args)
          args.all?{|arg| match(arg)}
        end

        # Match by request method.  This can be an array if you want
        # to match on multiple methods.
        def match_method(type)
          if type.is_a?(Array)
            type.any?{|t| match_method(t)}
          else
            type.to_s.upcase == @env[REQUEST_METHOD]
          end
        end
      end

      # Class methods for RodaResponse
      module ResponseClassMethods
        # Reference to the Roda class related to this response class.
        attr_accessor :roda_class

        # Since RodaResponse 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}::RodaResponse"
        end
      end

      # Instance methods for RodaResponse
      module ResponseMethods
        CONTENT_LENGTH = "Content-Length".freeze
        DEFAULT_HEADERS = {"Content-Type" => "text/html".freeze}.freeze
        LOCATION = "Location".freeze

        # The body for the current response.
        attr_reader :body

        # 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

        # Set the default headers when creating a response.
        def initialize
          @status  = nil
          @headers = {}
          @body    = []
          @length  = 0
        end

        # Return the response header with the given key. Example:
        #
        #   response['Content-Type'] # => 'text/html'
        def [](key)
          @headers[key]
        end

        # Set the response header with the given key to the given value.
        #
        #   response['Content-Type'] = 'application/json'
        def []=(key, value)
          @headers[key] = value
        end

        # The default headers to use for responses.
        def default_headers
          DEFAULT_HEADERS
        end

        # Whether the response body has been written to yet.  Note
        # that writing an empty string to the response body marks
        # the response as not empty. Example:
        #
        #   response.empty? # => true
        #   response.write('a')
        #   response.empty? # => false
        def empty?
          @body.empty?
        end

        # Return the rack response array of status, headers, and body
        # for the current response.  If the status has not been set,
        # uses a 200 status if the body has been written to, otherwise
        # uses a 404 status.  Adds the Content-Length header to the
        # size of the response body.
        #
        # Example:
        #
        #   response.finish
        #   #  => [200,
        #   #      {'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
          [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

        # Write to the response body.  Returns nil.
        #
        #   response.write('foo')
        def write(str)
          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
end