lib/sinatra/respond_with.rb



# frozen_string_literal: true

require 'sinatra/json'
require 'sinatra/base'

module Sinatra
  #
  # = Sinatra::RespondWith
  #
  # These extensions let Sinatra automatically choose what template to render or
  # action to perform depending on the request's Accept header.
  #
  # Example:
  #
  #   # Without Sinatra::RespondWith
  #   get '/' do
  #     data = { :name => 'example' }
  #     request.accept.each do |type|
  #       case type.to_s
  #       when 'text/html'
  #         halt haml(:index, :locals => data)
  #       when 'text/json'
  #         halt data.to_json
  #       when 'application/atom+xml'
  #         halt nokogiri(:'index.atom', :locals => data)
  #       when 'application/xml', 'text/xml'
  #         halt nokogiri(:'index.xml', :locals => data)
  #       when 'text/plain'
  #         halt 'just an example'
  #       end
  #     end
  #     error 406
  #   end
  #
  #   # With Sinatra::RespondWith
  #   get '/' do
  #     respond_with :index, :name => 'example' do |f|
  #       f.txt { 'just an example' }
  #     end
  #   end
  #
  # Both helper methods +respond_to+ and +respond_with+ let you define custom
  # handlers like the one above for +text/plain+. +respond_with+ additionally
  # takes a template name and/or an object to offer the following default
  # behavior:
  #
  # * If a template name is given, search for a template called
  #   +name.format.engine+ (+index.xml.nokogiri+ in the above example).
  # * If a template name is given, search for a templated called +name.engine+
  #   for engines known to result in the requested format (+index.haml+).
  # * If a file extension associated with the mime type is known to Sinatra, and
  #   the object responds to +to_extension+, call that method and use the result
  #   (+data.to_json+).
  #
  # == Security
  #
  # Since methods are triggered based on client input, this can lead to security
  # issues (but not as severe as those might appear in the first place: keep in
  # mind that only known file extensions are used). You should limit
  # the possible formats you serve.
  #
  # This is possible with the +provides+ condition:
  #
  #   get '/', :provides => [:html, :json, :xml, :atom] do
  #     respond_with :index, :name => 'example'
  #   end
  #
  # However, since you have to set +provides+ for every route, this extension
  # adds an app global (class method) `respond_to`, that lets you define content
  # types for all routes:
  #
  #   respond_to :html, :json, :xml, :atom
  #   get('/a') { respond_with :index, :name => 'a' }
  #   get('/b') { respond_with :index, :name => 'b' }
  #
  # == Custom Types
  #
  # Use the +on+ method for defining actions for custom types:
  #
  #   get '/' do
  #     respond_to do |f|
  #       f.xml { nokogiri :index }
  #       f.on('application/custom') { custom_action }
  #       f.on('text/*') { data.to_s }
  #       f.on('*/*') { "matches everything" }
  #     end
  #   end
  #
  # Definition order does not matter.
  module RespondWith
    class Format
      def initialize(app)
        @app = app
        @map = {}
        @generic = {}
        @default = nil
      end

      def on(type, &block)
        @app.settings.mime_types(type).each do |mime|
          case mime
          when '*/*'            then @default     = block
          when %r{^([^/]+)/\*$} then @generic[$1] = block
          else                       @map[mime]   = block
          end
        end
      end

      def finish
        yield self if block_given?
        mime_type = @app.content_type ||
                    @app.request.preferred_type(@map.keys)  ||
                    @app.request.preferred_type             ||
                    'text/html'
        type = mime_type.split(/\s*;\s*/, 2).first
        handlers = [@map[type], @generic[type[%r{^[^/]+}]], @default].compact
        handlers.each do |block|
          if (result = block.call(type))
            @app.content_type mime_type
            @app.halt result
          end
        end
        @app.halt 500, 'Unknown template engine'
      end

      def method_missing(method, *args, &block)
        return super if args.any? || block.nil? || !@app.mime_type(method)

        on(method, &block)
      end
    end

    module Helpers
      include Sinatra::JSON

      def respond_with(template, object = nil, &block)
        unless Symbol === template
          object = template
          template = nil
        end
        format = Format.new(self)
        format.on '*/*' do |type|
          exts = settings.ext_map[type]
          exts << :xml if type.end_with? '+xml'
          if template
            args = template_cache.fetch(type, template) { template_for(template, exts) }
            if args.any?
              locals = { object: object }
              locals.merge! object.to_hash if object.respond_to? :to_hash

              renderer = args.first
              options = args[1..] + [{ locals: locals }]

              halt send(renderer, *options)
            end
          end
          if object
            exts.each do |ext|
              halt json(object) if ext == :json
              next unless object.respond_to? method = "to_#{ext}"

              halt(*object.send(method))
            end
          end
          false
        end
        format.finish(&block)
      end

      def respond_to(&block)
        Format.new(self).finish(&block)
      end

      private

      def template_for(name, exts)
        # in production this is cached, so don't worry too much about runtime
        possible = []
        settings.template_engines[:all].each do |engine|
          exts.each { |ext| possible << [engine, "#{name}.#{ext}"] }
        end

        exts.each do |ext|
          settings.template_engines[ext].each { |e| possible << [e, name] }
        end

        possible.each do |engine, template|
          klass = Tilt.default_mapping.template_map[engine.to_s] ||
                  Tilt.lazy_map[engine.to_s].fetch(0, [])[0]

          find_template(settings.views, template, klass) do |file|
            next unless File.exist? file

            return settings.rendering_method(engine) << template.to_sym
          end
        end
        [] # nil or false would not be cached
      end
    end

    def remap_extensions
      ext_map.clear
      Rack::Mime::MIME_TYPES.each { |e, t| ext_map[t] << e[1..].to_sym }
      ext_map['text/javascript'] << 'js'
      ext_map['text/xml'] << 'xml'
    end

    def mime_type(*)
      result = super
      remap_extensions
      result
    end

    def respond_to(*formats)
      @respond_to ||= nil

      if formats.any?
        @respond_to ||= []
        @respond_to.concat formats
      elsif @respond_to.nil? && superclass.respond_to?(:respond_to)
        superclass.respond_to
      else
        @respond_to
      end
    end

    def rendering_method(engine)
      return [engine] if Sinatra::Templates.method_defined? engine
      return [:mab] if engine.to_sym == :markaby

      %i[render engine]
    end

    private

    def compile!(verb, path, block, **options)
      options[:provides] ||= respond_to if respond_to
      super
    end

    def self.jrubyify(engs)
      not_supported = [:markdown]
      engs.each_key do |key|
        engs[key].collect! { |eng| eng == :yajl ? :json_pure : eng }
        engs[key].delete_if { |eng| not_supported.include?(eng) }
      end
      engs
    end

    def self.engines
      engines = {
        css: %i[sass scss],
        xml: %i[builder nokogiri],
        html: %i[erb erubi haml hamlit slim liquid
                 mab markdown rdoc],
        all: (Sinatra::Templates.instance_methods.map(&:to_sym) +
          [:mab] - %i[find_template markaby]),
        json: [:yajl]
      }
      engines.default = []
      defined?(JRUBY_VERSION) ? jrubyify(engines) : engines
    end

    def self.registered(base)
      base.set :ext_map, Hash.new { |h, k| h[k] = [] }
      base.set :template_engines, engines
      base.remap_extensions
      base.helpers Helpers
    end
  end

  register RespondWith
  Delegator.delegate :respond_to
end