liquid_interpolatable.rb 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530
  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. # builder.use FaradayMiddleware::FollowRedirects, limit: limit
  164. # ...does not handle non-HTTP URLs.
  165. end
  166. limit.times do
  167. begin
  168. case uri
  169. when URI::HTTP
  170. return uri.to_s unless uri.host
  171. response = http.head(uri)
  172. case response.status
  173. when 301, 302, 303, 307
  174. if location = response['location']
  175. uri += Utils.normalize_uri(location)
  176. next
  177. end
  178. end
  179. end
  180. rescue URI::Error, Faraday::Error, SystemCallError => e
  181. logger.error "#{e.class} in #{__method__}(#{url.inspect}) [uri=#{uri.to_s.inspect}]: #{e.message}:\n#{e.backtrace.join("\n")}"
  182. end
  183. return uri.to_s
  184. end
  185. logger.error "Too many rediretions in #{__method__}(#{url.inspect}) [uri=#{uri.to_s.inspect}]"
  186. url
  187. end
  188. # Rebase URIs contained in attributes in a given HTML fragment
  189. def rebase_hrefs(input, base_uri)
  190. Utils.rebase_hrefs(input, base_uri)
  191. rescue StandardError
  192. input
  193. end
  194. # Unescape (basic) HTML entities in a string
  195. #
  196. # This currently decodes the following entities only: "&apos;",
  197. # "&quot;", "&lt;", "&gt;", "&amp;", "&#dd;" and "&#xhh;".
  198. def unescape(input)
  199. CGI.unescapeHTML(input)
  200. rescue StandardError
  201. input
  202. end
  203. # Escape a string for use in XPath expression
  204. def to_xpath(string)
  205. subs = string.to_s.scan(/\G(?:\A\z|[^"]+|[^']+)/).map { |x|
  206. case x
  207. when /"/
  208. %('#{x}')
  209. else
  210. %("#{x}")
  211. end
  212. }
  213. if subs.size == 1
  214. subs.first
  215. else
  216. 'concat(' << subs.join(', ') << ')'
  217. end
  218. end
  219. def regex_extract(input, regex, index = 0)
  220. input.to_s[Regexp.new(regex), index]
  221. rescue Index
  222. nil
  223. end
  224. def regex_replace(input, regex, replacement = nil)
  225. input.to_s.gsub(Regexp.new(regex), unescape_replacement(replacement.to_s))
  226. end
  227. def regex_replace_first(input, regex, replacement = nil)
  228. input.to_s.sub(Regexp.new(regex), unescape_replacement(replacement.to_s))
  229. end
  230. # Serializes data as JSON
  231. def json(input)
  232. JSON.dump(input)
  233. end
  234. def hex_encode(input)
  235. input.to_s.unpack1('H*')
  236. end
  237. def hex_decode(input)
  238. [input.to_s].pack('H*')
  239. end
  240. def md5(input)
  241. Digest::MD5.hexdigest(input.to_s)
  242. end
  243. def sha1(input)
  244. Digest::SHA1.hexdigest(input.to_s)
  245. end
  246. def sha256(input)
  247. Digest::SHA256.hexdigest(input.to_s)
  248. end
  249. def hmac_sha1(input, key)
  250. OpenSSL::HMAC.hexdigest('sha1', key.to_s, input.to_s)
  251. end
  252. def hmac_sha256(input, key)
  253. OpenSSL::HMAC.hexdigest('sha256', key.to_s, input.to_s)
  254. end
  255. # Returns a Ruby object
  256. #
  257. # It can be used as a JSONPath replacement for Agents that only support Liquid:
  258. #
  259. # Event: {"something": {"nested": {"data": 1}}}
  260. # Liquid: {{something.nested | as_object}}
  261. # Returns: {"data": 1}
  262. #
  263. # Splitting up a string with Liquid filters and return the Array:
  264. #
  265. # Event: {"data": "A,B,C"}}
  266. # Liquid: {{data | split: ',' | as_object}}
  267. # Returns: ['A', 'B', 'C']
  268. #
  269. # as_object ALWAYS has be the last filter in a Liquid expression!
  270. def as_object(object)
  271. throw :as_object, object.as_json
  272. end
  273. # Group an array of items by a property
  274. #
  275. # Example usage:
  276. #
  277. # {% assign posts_by_author = site.posts | group_by: "author" %}
  278. # {% for author in posts_by_author %}
  279. # <dt>{{author.name}}</dt>
  280. # {% for post in author.items %}
  281. # <dd><a href="{{post.url}}">{{post.title}}</a></dd>
  282. # {% endfor %}
  283. # {% endfor %}
  284. def group_by(input, property)
  285. if input.respond_to?(:group_by)
  286. input.group_by { |item| item[property] }.map do |value, items|
  287. { 'name' => value, 'items' => items }
  288. end
  289. else
  290. input
  291. end
  292. end
  293. private
  294. def logger
  295. @@logger ||=
  296. if defined?(Rails)
  297. Rails.logger
  298. else
  299. require 'logger'
  300. Logger.new(STDERR)
  301. end
  302. end
  303. BACKSLASH = "\\".freeze
  304. UNESCAPE = {
  305. "a" => "\a",
  306. "b" => "\b",
  307. "e" => "\e",
  308. "f" => "\f",
  309. "n" => "\n",
  310. "r" => "\r",
  311. "s" => " ",
  312. "t" => "\t",
  313. "v" => "\v",
  314. }
  315. # Unescape a replacement text for use in the second argument of
  316. # gsub/sub. The following escape sequences are recognized:
  317. #
  318. # - "\\" (backslash itself)
  319. # - "\a" (alert)
  320. # - "\b" (backspace)
  321. # - "\e" (escape)
  322. # - "\f" (form feed)
  323. # - "\n" (new line)
  324. # - "\r" (carriage return)
  325. # - "\s" (space)
  326. # - "\t" (horizontal tab)
  327. # - "\u{XXXX}" (unicode codepoint)
  328. # - "\v" (vertical tab)
  329. # - "\xXX" (hexadecimal character)
  330. # - "\1".."\9" (numbered capture groups)
  331. # - "\+" (last capture group)
  332. # - "\k<name>" (named capture group)
  333. # - "\&" or "\0" (complete matched text)
  334. # - "\`" (string before match)
  335. # - "\'" (string after match)
  336. #
  337. # Octal escape sequences are deliberately unsupported to avoid
  338. # conflict with numbered capture groups. Rather obscure Emacs
  339. # style character codes ("\C-x", "\M-\C-x" etc.) are also omitted
  340. # from this implementation.
  341. def unescape_replacement(s)
  342. s.gsub(/\\(?:([\d+&`'\\]|k<\w+>)|u\{([[:xdigit:]]+)\}|x([[:xdigit:]]{2})|(.))/) {
  343. if c = $1
  344. BACKSLASH + c
  345. elsif c = ($2 && [$2.to_i(16)].pack('U')) ||
  346. ($3 && [$3.to_i(16)].pack('C'))
  347. if c == BACKSLASH
  348. BACKSLASH + c
  349. else
  350. c
  351. end
  352. else
  353. UNESCAPE[$4] || $4
  354. end
  355. }
  356. end
  357. end
  358. Liquid::Template.register_filter(LiquidInterpolatable::Filters)
  359. module Tags
  360. class Credential < Liquid::Tag
  361. def initialize(tag_name, name, tokens)
  362. super
  363. @credential_name = name.strip
  364. end
  365. def render(context)
  366. context.registers[:agent].credential(@credential_name) || ""
  367. end
  368. end
  369. class LineBreak < Liquid::Tag
  370. def render(context)
  371. "\n"
  372. end
  373. end
  374. class Uuidv4 < Liquid::Tag
  375. def render(context)
  376. SecureRandom.uuid
  377. end
  378. end
  379. end
  380. Liquid::Template.register_tag('credential', LiquidInterpolatable::Tags::Credential)
  381. Liquid::Template.register_tag('line_break', LiquidInterpolatable::Tags::LineBreak)
  382. Liquid::Template.register_tag('uuidv4', LiquidInterpolatable::Tags::Uuidv4)
  383. module Blocks
  384. # Replace every occurrence of a given regex pattern in the first
  385. # "in" block with the result of the "with" block in which the
  386. # variable `match` is set for each iteration, which can be used as
  387. # follows:
  388. #
  389. # - `match[0]` or just `match`: the whole matching string
  390. # - `match[1]`..`match[n]`: strings matching the numbered capture groups
  391. # - `match.size`: total number of the elements above (n+1)
  392. # - `match.names`: array of names of named capture groups
  393. # - `match[name]`..: strings matching the named capture groups
  394. # - `match.pre_match`: string preceding the match
  395. # - `match.post_match`: string following the match
  396. # - `match.***`: equivalent to `match['***']` unless it conflicts with the existing methods above
  397. #
  398. # If named captures (`(?<name>...)`) are used in the pattern, they
  399. # are also made accessible as variables. Note that if numbered
  400. # captures are used mixed with named captures, you could get
  401. # unexpected results.
  402. #
  403. # Example usage:
  404. #
  405. # {% regex_replace "\w+" in %}Use me like this.{% with %}{{ match | capitalize }}{% endregex_replace %}
  406. # {% assign fullname = "Doe, John A." %}
  407. # {% regex_replace_first "\A(?<name1>.+), (?<name2>.+)\z" in %}{{ fullname }}{% with %}{{ name2 }} {{ name1 }}{% endregex_replace_first %}
  408. #
  409. # Use Me Like This.
  410. #
  411. # John A. Doe
  412. #
  413. class RegexReplace < Liquid::Block
  414. Syntax = /\A\s*(#{Liquid::QuotedFragment})(?:\s+in)?\s*\z/
  415. def initialize(tag_name, markup, tokens)
  416. super
  417. case markup
  418. when Syntax
  419. @regexp = $1
  420. else
  421. raise Liquid::SyntaxError, 'Syntax Error in regex_replace tag - Valid syntax: regex_replace pattern in'
  422. end
  423. @in_block = Liquid::BlockBody.new
  424. @with_block = nil
  425. end
  426. def parse(tokens)
  427. if more = parse_body(@in_block, tokens)
  428. @with_block = Liquid::BlockBody.new
  429. parse_body(@with_block, tokens)
  430. end
  431. end
  432. def nodelist
  433. if @with_block
  434. [@in_block, @with_block]
  435. else
  436. [@in_block]
  437. end
  438. end
  439. def unknown_tag(tag, markup, tokens)
  440. return super unless tag == 'with'.freeze
  441. @with_block = Liquid::BlockBody.new
  442. end
  443. def render(context)
  444. begin
  445. regexp = Regexp.new(context[@regexp].to_s)
  446. rescue ::SyntaxError => e
  447. raise Liquid::SyntaxError, "Syntax Error in regex_replace tag - #{e.message}"
  448. end
  449. subject = @in_block.render(context)
  450. subject.send(first? ? :sub : :gsub, regexp) {
  451. next '' unless @with_block
  452. m = Regexp.last_match
  453. context.stack do
  454. m.names.each do |name|
  455. context[name] = m[name]
  456. end
  457. context['match'.freeze] = m
  458. @with_block.render(context)
  459. end
  460. }
  461. end
  462. def first?
  463. @tag_name.end_with?('_first'.freeze)
  464. end
  465. end
  466. end
  467. Liquid::Template.register_tag('regex_replace', LiquidInterpolatable::Blocks::RegexReplace)
  468. Liquid::Template.register_tag('regex_replace_first', LiquidInterpolatable::Blocks::RegexReplace)
  469. end