lib/roda/plugins/class_matchers.rb



# frozen-string-literal: true

#
class Roda
  module RodaPlugins
    # The class_matchers plugin allows you do define custom regexps and
    # conversion procs to use for specific classes.  For example, if you
    # have multiple routes similar to:
    #
    #   r.on /(\d\d\d\d)-(\d\d)-(\d\d)/ do |y, m, d|
    #     date = Date.new(y.to_i, m.to_i, d.to_i)
    #     # ...
    #   end
    #
    # You can register a Date class matcher for that regexp:
    #
    #   class_matcher(Date, /(\d\d\d\d)-(\d\d)-(\d\d)/) do |y, m, d|
    #     Date.new(y.to_i, m.to_i, d.to_i)
    #   end
    #
    # And then use the Date class as a matcher, and it will yield a Date object:
    #
    #   r.on Date do |date|
    #     # ...
    #   end
    #
    # This is useful to DRY up code if you are using the same type of pattern and
    # type conversion in multiple places in your application. You can have the
    # block return an array to yield multiple captures.
    #
    # If you have a segment match the passed regexp, but decide during block
    # processing that you do not want to treat it as a match, you can have the
    # block return nil or false.  This is useful if you want to make sure you
    # are using valid data:
    #
    #   class_matcher(Date, /(\d\d\d\d)-(\d\d)-(\d\d)/) do |y, m, d|
    #     y = y.to_i
    #     m = m.to_i
    #     d = d.to_i
    #     Date.new(y, m, d) if Date.valid_date?(y, m, d)
    #   end
    #
    # The second argument to class_matcher can be a class already registered
    # as a class matcher. This can DRY up code that wants a conversion
    # performed by an existing class matcher:
    #
    #   class_matcher Employee, Integer do |id|
    #     Employee[id]
    #   end
    #
    # With the above example, the Integer matcher performs the conversion to
    # integer, so +id+ is yielded as an integer.  The block then looks up the
    # employee with that id.  If there is no employee with that id, then
    # the Employee matcher will not match.
    #
    # If using the symbol_matchers plugin, you can provide a recognized symbol
    # matcher as the second argument to class_matcher, and it will work in
    # a similar manner:
    #
    #   symbol_matcher(:employee_id, /E-(\d{6})/) do |employee_id|
    #     employee_id.to_i
    #   end
    #   class_matcher Employee, :employee_id do |id|
    #     Employee[id]
    #   end
    #
    # Blocks passed to the class_matchers plugin are evaluated in route
    # block context.
    #
    # This plugin does not work with the params_capturing plugin, as it does not
    # offer the ability to associate block arguments with named keys.
    module ClassMatchers
      def self.load_dependencies(app)
        app.plugin :_symbol_class_matchers
      end

      def self.configure(app)
        app.opts[:class_matchers] ||= {
          Integer=>[/(\d{1,100})/, /\A\/(\d{1,100})(?=\/|\z)/, :_convert_class_Integer].freeze,
          String=>[/([^\/]+)/, nil, nil].freeze
        }
      end

      module ClassMethods
        # Set the matcher and block to use for the given class.
        # The matcher can be a regexp, registered class matcher, or registered symbol
        # matcher (if using the symbol_matchers plugin).
        #
        # If providing a regexp, the block given will be called with all regexp captures.
        # If providing a registered class or symbol, the block will be called with the
        # captures returned by the block for the registered class or symbol, or the regexp
        # captures if no block was registered with the class or symbol. In either case,
        # if a block is given, it should return an array with the captures to yield to
        # the match block.
        def class_matcher(klass, matcher, &block)
          _symbol_class_matcher(Class, klass, matcher, block) do |meth, (_, regexp, convert_meth)|
            if regexp
              define_method(meth){consume(regexp, convert_meth)}
            else
              define_method(meth){_consume_segment(convert_meth)}
            end
          end
        end

        # Freeze the class_matchers hash when freezing the app.
        def freeze
          opts[:class_matchers].freeze
          super
        end
      end

      module RequestMethods
        # Use faster approach for segment matching.  This is used for
        # matchers based on the String class matcher, and avoids the
        # use of regular expressions for scanning.
        def _consume_segment(convert_meth)
          rp = @remaining_path
          if _match_class_String
            if convert_meth
              if captures = scope.send(convert_meth, @captures.pop)
                if captures.is_a?(Array)
                  @captures.concat(captures)
                else
                  @captures << captures
                end
              else
                @remaining_path = rp
                nil
              end
            else
              true
            end
          end
        end
      end
    end

    register_plugin(:class_matchers, ClassMatchers)
  end
end