cantino/huginn

View on GitHub
app/concerns/liquid_interpolatable.rb

Summary

Maintainability
A
0 mins
Test Coverage
# :markup: markdown

module LiquidInterpolatable
  extend ActiveSupport::Concern

  included do
    validate :validate_interpolation
  end

  def valid?(context = nil)
    super
  rescue Liquid::Error
    errors.empty?
  end

  def validate_interpolation
    interpolated
  rescue Liquid::ZeroDivisionError => e
    # Ignore error (likely due to possibly missing variables on "divided_by")
  rescue Liquid::Error => e
    errors.add(:options, "has an error with Liquid templating: #{e.message}")
  rescue StandardError
    # Calling `interpolated` without an incoming may naturally fail
    # with various errors when an agent expects one.
  end

  # Return the current interpolation context.  Use this in your Agent
  # class to manipulate interpolation context for user.
  #
  # For example, to provide local variables:
  #
  #     # Create a new scope to define variables in:
  #     interpolation_context.stack {
  #       interpolation_context['_something_'] = 42
  #       # And user can say "{{_something_}}" in their options.
  #       value = interpolated['some_key']
  #     }
  #
  def interpolation_context
    @interpolation_context ||= Context.new(self)
  end

  # Take the given object as "self" in the current interpolation
  # context while running a given block.
  #
  # The most typical use case for this is to evaluate options for each
  # received event like this:
  #
  #     def receive(incoming_events)
  #       incoming_events.each do |event|
  #         interpolate_with(event) do
  #           # Handle each event based on "interpolated" options.
  #         end
  #       end
  #     end
  def interpolate_with(self_object)
    case self_object
    when nil
      yield
    else
      context = interpolation_context
      begin
        context.environments.unshift(self_object.to_liquid)
        yield
      ensure
        context.environments.shift
      end
    end
  end

  def interpolate_with_each(array)
    array.each do |object|
      interpolate_with(object) do
        self.current_event = object
        yield object
      end
    end
  end

  def interpolate_options(options, self_object = nil)
    interpolate_with(self_object) do
      case options
      when String
        interpolate_string(options)
      when ActiveSupport::HashWithIndifferentAccess, Hash
        options.each_with_object(ActiveSupport::HashWithIndifferentAccess.new) { |(key, value), memo|
          memo[key] = interpolate_options(value)
        }
      when Array
        options.map { |value| interpolate_options(value) }
      else
        options
      end
    end
  end

  def interpolated(self_object = nil)
    interpolate_with(self_object) do
      (@interpolated_cache ||= {})[[options, interpolation_context].hash] ||=
        interpolate_options(options)
    end
  end

  def interpolate_string(string, self_object = nil)
    interpolate_with(self_object) do
      catch :as_object do
        Liquid::Template.parse(string).render!(interpolation_context)
      end
    end
  end

  class Context < Liquid::Context
    def initialize(agent)
      outer_scope = { '_agent_' => agent }

      Agents::KeyValueStoreAgent.merge(agent.controllers).find_each do |kvs|
        outer_scope[kvs.options[:variable]] = kvs.memory
      end

      super({}, outer_scope, { agent: }, true)
    end

    def hash
      [@environments, @scopes, @registers].hash
    end

    def eql?(other)
      other.environments == @environments &&
        other.scopes == @scopes &&
        other.registers == @registers
    end
  end

  require 'uri'
  module Filters
    # Percent encoding for URI conforming to RFC 3986.
    # Ref: http://tools.ietf.org/html/rfc3986#page-12
    def uri_escape(string)
      CGI.escape(string)
    rescue StandardError
      string
    end

    # Parse an input into a URI object, optionally resolving it
    # against a base URI if given.
    #
    # A URI object will have the following properties: scheme,
    # userinfo, host, port, registry, path, opaque, query, and
    # fragment.
    def to_uri(uri, base_uri = nil)
      case base_uri
      when nil, ''
        Utils.normalize_uri(uri.to_s)
      else
        Utils.normalize_uri(base_uri) + Utils.normalize_uri(uri.to_s)
      end
    rescue URI::Error
      nil
    end

    # Get the destination URL of a given URL by recursively following
    # redirects, up to 5 times in a row.  If a given string is not a
    # valid absolute HTTP URL or in case of too many redirects, the
    # original string is returned.  If any network/protocol error
    # occurs while following redirects, the last URL followed is
    # returned.
    def uri_expand(url, limit = 5)
      case url
      when URI
        uri = url
      else
        url = url.to_s
        begin
          uri = Utils.normalize_uri(url)
        rescue URI::Error
          return url
        end
      end

      http = Faraday.new do |builder|
        builder.adapter :net_http
        # builder.use FaradayMiddleware::FollowRedirects, limit: limit
        # ...does not handle non-HTTP URLs.
      end

      limit.times do
        begin
          case uri
          when URI::HTTP
            return uri.to_s unless uri.host

            response = http.head(uri)
            case response.status
            when 301, 302, 303, 307
              if location = response['location']
                uri += Utils.normalize_uri(location)
                next
              end
            end
          end
        rescue URI::Error, Faraday::Error, SystemCallError => e
          logger.error "#{e.class} in #{__method__}(#{url.inspect}) [uri=#{uri.to_s.inspect}]: #{e.message}:\n#{e.backtrace.join("\n")}"
        end

        return uri.to_s
      end

      logger.error "Too many rediretions in #{__method__}(#{url.inspect}) [uri=#{uri.to_s.inspect}]"

      url
    end

    # Rebase URIs contained in attributes in a given HTML fragment
    def rebase_hrefs(input, base_uri)
      Utils.rebase_hrefs(input, base_uri)
    rescue StandardError
      input
    end

    # Unescape (basic) HTML entities in a string
    #
    # This currently decodes the following entities only: "&apos;",
    # "&quot;", "&lt;", "&gt;", "&amp;", "&#dd;" and "&#xhh;".
    def unescape(input)
      CGI.unescapeHTML(input)
    rescue StandardError
      input
    end

    # Escape a string for use in XPath expression
    def to_xpath(string)
      subs = string.to_s.scan(/\G(?:\A\z|[^"]+|[^']+)/).map { |x|
        case x
        when /"/
          %('#{x}')
        else
          %("#{x}")
        end
      }
      if subs.size == 1
        subs.first
      else
        'concat(' << subs.join(', ') << ')'
      end
    end

    def regex_extract(input, regex, index = 0)
      input.to_s[Regexp.new(regex), index]
    rescue Index
      nil
    end

    def regex_replace(input, regex, replacement = nil)
      input.to_s.gsub(Regexp.new(regex), unescape_replacement(replacement.to_s))
    end

    def regex_replace_first(input, regex, replacement = nil)
      input.to_s.sub(Regexp.new(regex), unescape_replacement(replacement.to_s))
    end

    # Serializes data as JSON
    def json(input)
      JSON.dump(input)
    end

    def fromjson(input)
      JSON.parse(input.to_s)
    rescue StandardError
      nil
    end

    def hex_encode(input)
      input.to_s.unpack1('H*')
    end

    def hex_decode(input)
      [input.to_s].pack('H*')
    end

    def md5(input)
      Digest::MD5.hexdigest(input.to_s)
    end

    def sha1(input)
      Digest::SHA1.hexdigest(input.to_s)
    end

    def sha256(input)
      Digest::SHA256.hexdigest(input.to_s)
    end

    def hmac_sha1(input, key)
      OpenSSL::HMAC.hexdigest('sha1', key.to_s, input.to_s)
    end

    def hmac_sha256(input, key)
      OpenSSL::HMAC.hexdigest('sha256', key.to_s, input.to_s)
    end

    # Returns a Ruby object
    #
    # It can be used as a JSONPath replacement for Agents that only support Liquid:
    #
    # Event:   {"something": {"nested": {"data": 1}}}
    # Liquid:  {{something.nested | as_object}}
    # Returns: {"data": 1}
    #
    # Splitting up a string with Liquid filters and return the Array:
    #
    # Event:   {"data": "A,B,C"}}
    # Liquid:  {{data | split: ',' | as_object}}
    # Returns: ['A', 'B', 'C']
    #
    # as_object ALWAYS has be the last filter in a Liquid expression!
    def as_object(object)
      throw :as_object, object.as_json
    end

    # Group an array of items by a property
    #
    # Example usage:
    #
    # {% assign posts_by_author = site.posts | group_by: "author" %}
    # {% for author in posts_by_author %}
    #   <dt>{{author.name}}</dt>
    #   {% for post in author.items %}
    #   <dd><a href="{{post.url}}">{{post.title}}</a></dd>
    #   {% endfor %}
    # {% endfor %}
    def group_by(input, property)
      if input.respond_to?(:group_by)
        input.group_by { |item| item[property] }.map do |value, items|
          { 'name' => value, 'items' => items }
        end
      else
        input
      end
    end

    private

    def logger
      @@logger ||=
        if defined?(Rails)
          Rails.logger
        else
          require 'logger'
          Logger.new(STDERR)
        end
    end

    BACKSLASH = "\\".freeze

    UNESCAPE = {
      "a" => "\a",
      "b" => "\b",
      "e" => "\e",
      "f" => "\f",
      "n" => "\n",
      "r" => "\r",
      "s" => " ",
      "t" => "\t",
      "v" => "\v",
    }

    # Unescape a replacement text for use in the second argument of
    # gsub/sub.  The following escape sequences are recognized:
    #
    # - "\\" (backslash itself)
    # - "\a" (alert)
    # - "\b" (backspace)
    # - "\e" (escape)
    # - "\f" (form feed)
    # - "\n" (new line)
    # - "\r" (carriage return)
    # - "\s" (space)
    # - "\t" (horizontal tab)
    # - "\u{XXXX}" (unicode codepoint)
    # - "\v" (vertical tab)
    # - "\xXX" (hexadecimal character)
    # - "\1".."\9" (numbered capture groups)
    # - "\+" (last capture group)
    # - "\k<name>" (named capture group)
    # - "\&" or "\0" (complete matched text)
    # - "\`" (string before match)
    # - "\'" (string after match)
    #
    # Octal escape sequences are deliberately unsupported to avoid
    # conflict with numbered capture groups.  Rather obscure Emacs
    # style character codes ("\C-x", "\M-\C-x" etc.) are also omitted
    # from this implementation.
    def unescape_replacement(s)
      s.gsub(/\\(?:([\d+&`'\\]|k<\w+>)|u\{([[:xdigit:]]+)\}|x([[:xdigit:]]{2})|(.))/) {
        if c = $1
          BACKSLASH + c
        elsif c = ($2 && [$2.to_i(16)].pack('U')) ||
            ($3 && [$3.to_i(16)].pack('C'))
          if c == BACKSLASH
            BACKSLASH + c
          else
            c
          end
        else
          UNESCAPE[$4] || $4
        end
      }
    end
  end
  Liquid::Template.register_filter(LiquidInterpolatable::Filters)

  module Tags
    class Credential < Liquid::Tag
      def initialize(tag_name, name, tokens)
        super
        @credential_name = name.strip
      end

      def render(context)
        context.registers[:agent].credential(@credential_name) || ""
      end
    end

    class LineBreak < Liquid::Tag
      def render(context)
        "\n"
      end
    end

    class Uuidv4 < Liquid::Tag
      def render(context)
        SecureRandom.uuid
      end
    end
  end
  Liquid::Template.register_tag('credential', LiquidInterpolatable::Tags::Credential)
  Liquid::Template.register_tag('line_break', LiquidInterpolatable::Tags::LineBreak)
  Liquid::Template.register_tag('uuidv4', LiquidInterpolatable::Tags::Uuidv4)

  module Blocks
    # Replace every occurrence of a given regex pattern in the first
    # "in" block with the result of the "with" block in which the
    # variable `match` is set for each iteration, which can be used as
    # follows:
    #
    # - `match[0]` or just `match`: the whole matching string
    # - `match[1]`..`match[n]`: strings matching the numbered capture groups
    # - `match.size`: total number of the elements above (n+1)
    # - `match.names`: array of names of named capture groups
    # - `match[name]`..: strings matching the named capture groups
    # - `match.pre_match`: string preceding the match
    # - `match.post_match`: string following the match
    # - `match.***`: equivalent to `match['***']` unless it conflicts with the existing methods above
    #
    # If named captures (`(?<name>...)`) are used in the pattern, they
    # are also made accessible as variables.  Note that if numbered
    # captures are used mixed with named captures, you could get
    # unexpected results.
    #
    # Example usage:
    #
    #     {% regex_replace "\w+" in %}Use me like this.{% with %}{{ match | capitalize }}{% endregex_replace %}
    #     {% assign fullname = "Doe, John A." %}
    #     {% regex_replace_first "\A(?<name1>.+), (?<name2>.+)\z" in %}{{ fullname }}{% with %}{{ name2 }} {{ name1 }}{% endregex_replace_first %}
    #
    #     Use Me Like This.
    #
    #     John A. Doe
    #
    class RegexReplace < Liquid::Block
      Syntax = /\A\s*(#{Liquid::QuotedFragment})(?:\s+in)?\s*\z/

      def initialize(tag_name, markup, tokens)
        super

        case markup
        when Syntax
          @regexp = $1
        else
          raise Liquid::SyntaxError, 'Syntax Error in regex_replace tag - Valid syntax: regex_replace pattern in'
        end
        @in_block = Liquid::BlockBody.new
        @with_block = nil
      end

      def parse(tokens)
        if more = parse_body(@in_block, tokens)
          @with_block = Liquid::BlockBody.new
          parse_body(@with_block, tokens)
        end
      end

      def nodelist
        if @with_block
          [@in_block, @with_block]
        else
          [@in_block]
        end
      end

      def unknown_tag(tag, markup, tokens)
        return super unless tag == 'with'.freeze

        @with_block = Liquid::BlockBody.new
      end

      def render(context)
        begin
          regexp = Regexp.new(context[@regexp].to_s)
        rescue ::SyntaxError => e
          raise Liquid::SyntaxError, "Syntax Error in regex_replace tag - #{e.message}"
        end

        subject = @in_block.render(context)

        subject.send(first? ? :sub : :gsub, regexp) {
          next '' unless @with_block

          m = Regexp.last_match
          context.stack do
            m.names.each do |name|
              context[name] = m[name]
            end
            context['match'.freeze] = m
            @with_block.render(context)
          end
        }
      end

      def first?
        @tag_name.end_with?('_first'.freeze)
      end
    end
  end
  Liquid::Template.register_tag('regex_replace',       LiquidInterpolatable::Blocks::RegexReplace)
  Liquid::Template.register_tag('regex_replace_first', LiquidInterpolatable::Blocks::RegexReplace)
end