app/concerns/liquid_interpolatable.rb
# :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: "'",
# """, "<", ">", "&", "&#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