liquid_interpolatable.rb 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535
  1. # :markup: markdown
  2. module LiquidInterpolatable
  3. extend ActiveSupport::Concern
  4. included do
  5. validate :validate_interpolation
  6. end
  7. def valid?(context = nil)
  8. super
  9. rescue Liquid::Error
  10. errors.empty?
  11. end
  12. def validate_interpolation
  13. interpolated
  14. rescue Liquid::ZeroDivisionError => e
  15. # Ignore error (likely due to possibly missing variables on "divided_by")
  16. rescue Liquid::Error => e
  17. errors.add(:options, "has an error with Liquid templating: #{e.message}")
  18. rescue StandardError
  19. # Calling `interpolated` without an incoming may naturally fail
  20. # with various errors when an agent expects one.
  21. end
  22. # Return the current interpolation context. Use this in your Agent
  23. # class to manipulate interpolation context for user.
  24. #
  25. # For example, to provide local variables:
  26. #
  27. # # Create a new scope to define variables in:
  28. # interpolation_context.stack {
  29. # interpolation_context['_something_'] = 42
  30. # # And user can say "{{_something_}}" in their options.
  31. # value = interpolated['some_key']
  32. # }
  33. #
  34. def interpolation_context
  35. @interpolation_context ||= Context.new(self)
  36. end
  37. # Take the given object as "self" in the current interpolation
  38. # context while running a given block.
  39. #
  40. # The most typical use case for this is to evaluate options for each
  41. # received event like this:
  42. #
  43. # def receive(incoming_events)
  44. # incoming_events.each do |event|
  45. # interpolate_with(event) do
  46. # # Handle each event based on "interpolated" options.
  47. # end
  48. # end
  49. # end
  50. def interpolate_with(self_object)
  51. case self_object
  52. when nil
  53. yield
  54. else
  55. context = interpolation_context
  56. begin
  57. context.environments.unshift(self_object.to_liquid)
  58. yield
  59. ensure
  60. context.environments.shift
  61. end
  62. end
  63. end
  64. def interpolate_with_each(array)
  65. array.each do |object|
  66. interpolate_with(object) do
  67. self.current_event = object
  68. yield object
  69. end
  70. end
  71. end
  72. def interpolate_options(options, self_object = nil)
  73. interpolate_with(self_object) do
  74. case options
  75. when String
  76. interpolate_string(options)
  77. when ActiveSupport::HashWithIndifferentAccess, Hash
  78. options.each_with_object(ActiveSupport::HashWithIndifferentAccess.new) { |(key, value), memo|
  79. memo[key] = interpolate_options(value)
  80. }
  81. when Array
  82. options.map { |value| interpolate_options(value) }
  83. else
  84. options
  85. end
  86. end
  87. end
  88. def interpolated(self_object = nil)
  89. interpolate_with(self_object) do
  90. (@interpolated_cache ||= {})[[options, interpolation_context].hash] ||=
  91. interpolate_options(options)
  92. end
  93. end
  94. def interpolate_string(string, self_object = nil)
  95. interpolate_with(self_object) do
  96. catch :as_object do
  97. Liquid::Template.parse(string).render!(interpolation_context)
  98. end
  99. end
  100. end
  101. class Context < Liquid::Context
  102. def initialize(agent)
  103. outer_scope = { '_agent_' => agent }
  104. Agents::KeyValueStoreAgent.merge(agent.controllers).find_each do |kvs|
  105. outer_scope[kvs.options[:variable]] = kvs.memory
  106. end
  107. super({}, outer_scope, { agent: }, true)
  108. end
  109. def hash
  110. [@environments, @scopes, @registers].hash
  111. end
  112. def eql?(other)
  113. other.environments == @environments &&
  114. other.scopes == @scopes &&
  115. other.registers == @registers
  116. end
  117. end
  118. require 'uri'
  119. module Filters
  120. # Percent encoding for URI conforming to RFC 3986.
  121. # Ref: http://tools.ietf.org/html/rfc3986#page-12
  122. def uri_escape(string)
  123. CGI.escape(string)
  124. rescue StandardError
  125. string
  126. end
  127. # Parse an input into a URI object, optionally resolving it
  128. # against a base URI if given.
  129. #
  130. # A URI object will have the following properties: scheme,
  131. # userinfo, host, port, registry, path, opaque, query, and
  132. # fragment.
  133. def to_uri(uri, base_uri = nil)
  134. case base_uri
  135. when nil, ''
  136. Utils.normalize_uri(uri.to_s)
  137. else
  138. Utils.normalize_uri(base_uri) + Utils.normalize_uri(uri.to_s)
  139. end
  140. rescue URI::Error
  141. nil
  142. end
  143. # Get the destination URL of a given URL by recursively following
  144. # redirects, up to 5 times in a row. If a given string is not a
  145. # valid absolute HTTP URL or in case of too many redirects, the
  146. # original string is returned. If any network/protocol error
  147. # occurs while following redirects, the last URL followed is
  148. # returned.
  149. def uri_expand(url, limit = 5)
  150. case url
  151. when URI
  152. uri = url
  153. else
  154. url = url.to_s
  155. begin
  156. uri = Utils.normalize_uri(url)
  157. rescue URI::Error
  158. return url
  159. end
  160. end
  161. http = Faraday.new do |builder|
  162. builder.adapter :net_http
  163. # The follow_redirects middleware does not handle non-HTTP URLs.
  164. end
  165. limit.times do
  166. begin
  167. case uri
  168. when URI::HTTP
  169. return uri.to_s unless uri.host
  170. response = http.head(uri)
  171. case response.status
  172. when 301, 302, 303, 307
  173. if location = response['location']
  174. uri += Utils.normalize_uri(location)
  175. next
  176. end
  177. end
  178. end
  179. rescue URI::Error, Faraday::Error, SystemCallError => e
  180. logger.error "#{e.class} in #{__method__}(#{url.inspect}) [uri=#{uri.to_s.inspect}]: #{e.message}:\n#{e.backtrace.join("\n")}"
  181. end
  182. return uri.to_s
  183. end
  184. logger.error "Too many rediretions in #{__method__}(#{url.inspect}) [uri=#{uri.to_s.inspect}]"
  185. url
  186. end
  187. # Rebase URIs contained in attributes in a given HTML fragment
  188. def rebase_hrefs(input, base_uri)
  189. Utils.rebase_hrefs(input, base_uri)
  190. rescue StandardError
  191. input
  192. end
  193. # Unescape (basic) HTML entities in a string
  194. #
  195. # This currently decodes the following entities only: "&apos;",
  196. # "&quot;", "&lt;", "&gt;", "&amp;", "&#dd;" and "&#xhh;".
  197. def unescape(input)
  198. CGI.unescapeHTML(input)
  199. rescue StandardError
  200. input
  201. end
  202. # Escape a string for use in XPath expression
  203. def to_xpath(string)
  204. subs = string.to_s.scan(/\G(?:\A\z|[^"]+|[^']+)/).map { |x|
  205. case x
  206. when /"/
  207. %('#{x}')
  208. else
  209. %("#{x}")
  210. end
  211. }
  212. if subs.size == 1
  213. subs.first
  214. else
  215. 'concat(' << subs.join(', ') << ')'
  216. end
  217. end
  218. def regex_extract(input, regex, index = 0)
  219. input.to_s[Regexp.new(regex), index]
  220. rescue Index
  221. nil
  222. end
  223. def regex_replace(input, regex, replacement = nil)
  224. input.to_s.gsub(Regexp.new(regex), unescape_replacement(replacement.to_s))
  225. end
  226. def regex_replace_first(input, regex, replacement = nil)
  227. input.to_s.sub(Regexp.new(regex), unescape_replacement(replacement.to_s))
  228. end
  229. # Serializes data as JSON
  230. def json(input)
  231. JSON.dump(input)
  232. end
  233. def fromjson(input)
  234. JSON.parse(input.to_s)
  235. rescue StandardError
  236. nil
  237. end
  238. def hex_encode(input)
  239. input.to_s.unpack1('H*')
  240. end
  241. def hex_decode(input)
  242. [input.to_s].pack('H*')
  243. end
  244. def md5(input)
  245. Digest::MD5.hexdigest(input.to_s)
  246. end
  247. def sha1(input)
  248. Digest::SHA1.hexdigest(input.to_s)
  249. end
  250. def sha256(input)
  251. Digest::SHA256.hexdigest(input.to_s)
  252. end
  253. def hmac_sha1(input, key)
  254. OpenSSL::HMAC.hexdigest('sha1', key.to_s, input.to_s)
  255. end
  256. def hmac_sha256(input, key)
  257. OpenSSL::HMAC.hexdigest('sha256', key.to_s, input.to_s)
  258. end
  259. # Returns a Ruby object
  260. #
  261. # It can be used as a JSONPath replacement for Agents that only support Liquid:
  262. #
  263. # Event: {"something": {"nested": {"data": 1}}}
  264. # Liquid: {{something.nested | as_object}}
  265. # Returns: {"data": 1}
  266. #
  267. # Splitting up a string with Liquid filters and return the Array:
  268. #
  269. # Event: {"data": "A,B,C"}}
  270. # Liquid: {{data | split: ',' | as_object}}
  271. # Returns: ['A', 'B', 'C']
  272. #
  273. # as_object ALWAYS has be the last filter in a Liquid expression!
  274. def as_object(object)
  275. throw :as_object, object.as_json
  276. end
  277. # Group an array of items by a property
  278. #
  279. # Example usage:
  280. #
  281. # {% assign posts_by_author = site.posts | group_by: "author" %}
  282. # {% for author in posts_by_author %}
  283. # <dt>{{author.name}}</dt>
  284. # {% for post in author.items %}
  285. # <dd><a href="{{post.url}}">{{post.title}}</a></dd>
  286. # {% endfor %}
  287. # {% endfor %}
  288. def group_by(input, property)
  289. if input.respond_to?(:group_by)
  290. input.group_by { |item| item[property] }.map do |value, items|
  291. { 'name' => value, 'items' => items }
  292. end
  293. else
  294. input
  295. end
  296. end
  297. private
  298. def logger
  299. @@logger ||=
  300. if defined?(Rails)
  301. Rails.logger
  302. else
  303. require 'logger'
  304. Logger.new(STDERR)
  305. end
  306. end
  307. BACKSLASH = "\\".freeze
  308. UNESCAPE = {
  309. "a" => "\a",
  310. "b" => "\b",
  311. "e" => "\e",
  312. "f" => "\f",
  313. "n" => "\n",
  314. "r" => "\r",
  315. "s" => " ",
  316. "t" => "\t",
  317. "v" => "\v",
  318. }
  319. # Unescape a replacement text for use in the second argument of
  320. # gsub/sub. The following escape sequences are recognized:
  321. #
  322. # - "\\" (backslash itself)
  323. # - "\a" (alert)
  324. # - "\b" (backspace)
  325. # - "\e" (escape)
  326. # - "\f" (form feed)
  327. # - "\n" (new line)
  328. # - "\r" (carriage return)
  329. # - "\s" (space)
  330. # - "\t" (horizontal tab)
  331. # - "\u{XXXX}" (unicode codepoint)
  332. # - "\v" (vertical tab)
  333. # - "\xXX" (hexadecimal character)
  334. # - "\1".."\9" (numbered capture groups)
  335. # - "\+" (last capture group)
  336. # - "\k<name>" (named capture group)
  337. # - "\&" or "\0" (complete matched text)
  338. # - "\`" (string before match)
  339. # - "\'" (string after match)
  340. #
  341. # Octal escape sequences are deliberately unsupported to avoid
  342. # conflict with numbered capture groups. Rather obscure Emacs
  343. # style character codes ("\C-x", "\M-\C-x" etc.) are also omitted
  344. # from this implementation.
  345. def unescape_replacement(s)
  346. s.gsub(/\\(?:([\d+&`'\\]|k<\w+>)|u\{([[:xdigit:]]+)\}|x([[:xdigit:]]{2})|(.))/) {
  347. if c = $1
  348. BACKSLASH + c
  349. elsif c = ($2 && [$2.to_i(16)].pack('U')) ||
  350. ($3 && [$3.to_i(16)].pack('C'))
  351. if c == BACKSLASH
  352. BACKSLASH + c
  353. else
  354. c
  355. end
  356. else
  357. UNESCAPE[$4] || $4
  358. end
  359. }
  360. end
  361. end
  362. Liquid::Template.register_filter(LiquidInterpolatable::Filters)
  363. module Tags
  364. class Credential < Liquid::Tag
  365. def initialize(tag_name, name, tokens)
  366. super
  367. @credential_name = name.strip
  368. end
  369. def render(context)
  370. context.registers[:agent].credential(@credential_name) || ""
  371. end
  372. end
  373. class LineBreak < Liquid::Tag
  374. def render(context)
  375. "\n"
  376. end
  377. end
  378. class Uuidv4 < Liquid::Tag
  379. def render(context)
  380. SecureRandom.uuid
  381. end
  382. end
  383. end
  384. Liquid::Template.register_tag('credential', LiquidInterpolatable::Tags::Credential)
  385. Liquid::Template.register_tag('line_break', LiquidInterpolatable::Tags::LineBreak)
  386. Liquid::Template.register_tag('uuidv4', LiquidInterpolatable::Tags::Uuidv4)
  387. module Blocks
  388. # Replace every occurrence of a given regex pattern in the first
  389. # "in" block with the result of the "with" block in which the
  390. # variable `match` is set for each iteration, which can be used as
  391. # follows:
  392. #
  393. # - `match[0]` or just `match`: the whole matching string
  394. # - `match[1]`..`match[n]`: strings matching the numbered capture groups
  395. # - `match.size`: total number of the elements above (n+1)
  396. # - `match.names`: array of names of named capture groups
  397. # - `match[name]`..: strings matching the named capture groups
  398. # - `match.pre_match`: string preceding the match
  399. # - `match.post_match`: string following the match
  400. # - `match.***`: equivalent to `match['***']` unless it conflicts with the existing methods above
  401. #
  402. # If named captures (`(?<name>...)`) are used in the pattern, they
  403. # are also made accessible as variables. Note that if numbered
  404. # captures are used mixed with named captures, you could get
  405. # unexpected results.
  406. #
  407. # Example usage:
  408. #
  409. # {% regex_replace "\w+" in %}Use me like this.{% with %}{{ match | capitalize }}{% endregex_replace %}
  410. # {% assign fullname = "Doe, John A." %}
  411. # {% regex_replace_first "\A(?<name1>.+), (?<name2>.+)\z" in %}{{ fullname }}{% with %}{{ name2 }} {{ name1 }}{% endregex_replace_first %}
  412. #
  413. # Use Me Like This.
  414. #
  415. # John A. Doe
  416. #
  417. class RegexReplace < Liquid::Block
  418. Syntax = /\A\s*(#{Liquid::QuotedFragment})(?:\s+in)?\s*\z/
  419. def initialize(tag_name, markup, tokens)
  420. super
  421. case markup
  422. when Syntax
  423. @regexp = $1
  424. else
  425. raise Liquid::SyntaxError, 'Syntax Error in regex_replace tag - Valid syntax: regex_replace pattern in'
  426. end
  427. @in_block = Liquid::BlockBody.new
  428. @with_block = nil
  429. end
  430. def parse(tokens)
  431. if more = parse_body(@in_block, tokens)
  432. @with_block = Liquid::BlockBody.new
  433. parse_body(@with_block, tokens)
  434. end
  435. end
  436. def nodelist
  437. if @with_block
  438. [@in_block, @with_block]
  439. else
  440. [@in_block]
  441. end
  442. end
  443. def unknown_tag(tag, markup, tokens)
  444. return super unless tag == 'with'.freeze
  445. @with_block = Liquid::BlockBody.new
  446. end
  447. def render(context)
  448. begin
  449. regexp = Regexp.new(context[@regexp].to_s)
  450. rescue ::SyntaxError => e
  451. raise Liquid::SyntaxError, "Syntax Error in regex_replace tag - #{e.message}"
  452. end
  453. subject = @in_block.render(context)
  454. subject.send(first? ? :sub : :gsub, regexp) {
  455. next '' unless @with_block
  456. m = Regexp.last_match
  457. context.stack do
  458. m.names.each do |name|
  459. context[name] = m[name]
  460. end
  461. context['match'.freeze] = m
  462. @with_block.render(context)
  463. end
  464. }
  465. end
  466. def first?
  467. @tag_name.end_with?('_first'.freeze)
  468. end
  469. end
  470. end
  471. Liquid::Template.register_tag('regex_replace', LiquidInterpolatable::Blocks::RegexReplace)
  472. Liquid::Template.register_tag('regex_replace_first', LiquidInterpolatable::Blocks::RegexReplace)
  473. end