liquid_interpolatable.rb 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515
  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: 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 md5(input)
  235. Digest::MD5.hexdigest(input.to_s)
  236. end
  237. def sha1(input)
  238. Digest::SHA1.hexdigest(input.to_s)
  239. end
  240. def sha256(input)
  241. Digest::SHA256.hexdigest(input.to_s)
  242. end
  243. def hmac_sha1(input, key)
  244. OpenSSL::HMAC.hexdigest('sha1', key.to_s, input.to_s)
  245. end
  246. def hmac_sha256(input, key)
  247. OpenSSL::HMAC.hexdigest('sha256', key.to_s, input.to_s)
  248. end
  249. # Returns a Ruby object
  250. #
  251. # It can be used as a JSONPath replacement for Agents that only support Liquid:
  252. #
  253. # Event: {"something": {"nested": {"data": 1}}}
  254. # Liquid: {{something.nested | as_object}}
  255. # Returns: {"data": 1}
  256. #
  257. # Splitting up a string with Liquid filters and return the Array:
  258. #
  259. # Event: {"data": "A,B,C"}}
  260. # Liquid: {{data | split: ',' | as_object}}
  261. # Returns: ['A', 'B', 'C']
  262. #
  263. # as_object ALWAYS has be the last filter in a Liquid expression!
  264. def as_object(object)
  265. throw :as_object, object.as_json
  266. end
  267. # Group an array of items by a property
  268. #
  269. # Example usage:
  270. #
  271. # {% assign posts_by_author = site.posts | group_by: "author" %}
  272. # {% for author in posts_by_author %}
  273. # <dt>{{author.name}}</dt>
  274. # {% for post in author.items %}
  275. # <dd><a href="{{post.url}}">{{post.title}}</a></dd>
  276. # {% endfor %}
  277. # {% endfor %}
  278. def group_by(input, property)
  279. if input.respond_to?(:group_by)
  280. input.group_by { |item| item[property] }.map do |value, items|
  281. { 'name' => value, 'items' => items }
  282. end
  283. else
  284. input
  285. end
  286. end
  287. private
  288. def logger
  289. @@logger ||=
  290. if defined?(Rails)
  291. Rails.logger
  292. else
  293. require 'logger'
  294. Logger.new(STDERR)
  295. end
  296. end
  297. BACKSLASH = "\\".freeze
  298. UNESCAPE = {
  299. "a" => "\a",
  300. "b" => "\b",
  301. "e" => "\e",
  302. "f" => "\f",
  303. "n" => "\n",
  304. "r" => "\r",
  305. "s" => " ",
  306. "t" => "\t",
  307. "v" => "\v",
  308. }
  309. # Unescape a replacement text for use in the second argument of
  310. # gsub/sub. The following escape sequences are recognized:
  311. #
  312. # - "\\" (backslash itself)
  313. # - "\a" (alert)
  314. # - "\b" (backspace)
  315. # - "\e" (escape)
  316. # - "\f" (form feed)
  317. # - "\n" (new line)
  318. # - "\r" (carriage return)
  319. # - "\s" (space)
  320. # - "\t" (horizontal tab)
  321. # - "\u{XXXX}" (unicode codepoint)
  322. # - "\v" (vertical tab)
  323. # - "\xXX" (hexadecimal character)
  324. # - "\1".."\9" (numbered capture groups)
  325. # - "\+" (last capture group)
  326. # - "\k<name>" (named capture group)
  327. # - "\&" or "\0" (complete matched text)
  328. # - "\`" (string before match)
  329. # - "\'" (string after match)
  330. #
  331. # Octal escape sequences are deliberately unsupported to avoid
  332. # conflict with numbered capture groups. Rather obscure Emacs
  333. # style character codes ("\C-x", "\M-\C-x" etc.) are also omitted
  334. # from this implementation.
  335. def unescape_replacement(s)
  336. s.gsub(/\\(?:([\d+&`'\\]|k<\w+>)|u\{([[:xdigit:]]+)\}|x([[:xdigit:]]{2})|(.))/) {
  337. if c = $1
  338. BACKSLASH + c
  339. elsif c = ($2 && [$2.to_i(16)].pack('U')) ||
  340. ($3 && [$3.to_i(16)].pack('C'))
  341. if c == BACKSLASH
  342. BACKSLASH + c
  343. else
  344. c
  345. end
  346. else
  347. UNESCAPE[$4] || $4
  348. end
  349. }
  350. end
  351. end
  352. Liquid::Template.register_filter(LiquidInterpolatable::Filters)
  353. module Tags
  354. class Credential < Liquid::Tag
  355. def initialize(tag_name, name, tokens)
  356. super
  357. @credential_name = name.strip
  358. end
  359. def render(context)
  360. context.registers[:agent].credential(@credential_name) || ""
  361. end
  362. end
  363. class LineBreak < Liquid::Tag
  364. def render(context)
  365. "\n"
  366. end
  367. end
  368. end
  369. Liquid::Template.register_tag('credential', LiquidInterpolatable::Tags::Credential)
  370. Liquid::Template.register_tag('line_break', LiquidInterpolatable::Tags::LineBreak)
  371. module Blocks
  372. # Replace every occurrence of a given regex pattern in the first
  373. # "in" block with the result of the "with" block in which the
  374. # variable `match` is set for each iteration, which can be used as
  375. # follows:
  376. #
  377. # - `match[0]` or just `match`: the whole matching string
  378. # - `match[1]`..`match[n]`: strings matching the numbered capture groups
  379. # - `match.size`: total number of the elements above (n+1)
  380. # - `match.names`: array of names of named capture groups
  381. # - `match[name]`..: strings matching the named capture groups
  382. # - `match.pre_match`: string preceding the match
  383. # - `match.post_match`: string following the match
  384. # - `match.***`: equivalent to `match['***']` unless it conflicts with the existing methods above
  385. #
  386. # If named captures (`(?<name>...)`) are used in the pattern, they
  387. # are also made accessible as variables. Note that if numbered
  388. # captures are used mixed with named captures, you could get
  389. # unexpected results.
  390. #
  391. # Example usage:
  392. #
  393. # {% regex_replace "\w+" in %}Use me like this.{% with %}{{ match | capitalize }}{% endregex_replace %}
  394. # {% assign fullname = "Doe, John A." %}
  395. # {% regex_replace_first "\A(?<name1>.+), (?<name2>.+)\z" in %}{{ fullname }}{% with %}{{ name2 }} {{ name1 }}{% endregex_replace_first %}
  396. #
  397. # Use Me Like This.
  398. #
  399. # John A. Doe
  400. #
  401. class RegexReplace < Liquid::Block
  402. Syntax = /\A\s*(#{Liquid::QuotedFragment})(?:\s+in)?\s*\z/
  403. def initialize(tag_name, markup, tokens)
  404. super
  405. case markup
  406. when Syntax
  407. @regexp = $1
  408. else
  409. raise Liquid::SyntaxError, 'Syntax Error in regex_replace tag - Valid syntax: regex_replace pattern in'
  410. end
  411. @in_block = Liquid::BlockBody.new
  412. @with_block = nil
  413. end
  414. def parse(tokens)
  415. if more = parse_body(@in_block, tokens)
  416. @with_block = Liquid::BlockBody.new
  417. parse_body(@with_block, tokens)
  418. end
  419. end
  420. def nodelist
  421. if @with_block
  422. [@in_block, @with_block]
  423. else
  424. [@in_block]
  425. end
  426. end
  427. def unknown_tag(tag, markup, tokens)
  428. return super unless tag == 'with'.freeze
  429. @with_block = Liquid::BlockBody.new
  430. end
  431. def render(context)
  432. begin
  433. regexp = Regexp.new(context[@regexp].to_s)
  434. rescue ::SyntaxError => e
  435. raise Liquid::SyntaxError, "Syntax Error in regex_replace tag - #{e.message}"
  436. end
  437. subject = @in_block.render(context)
  438. subject.send(first? ? :sub : :gsub, regexp) {
  439. next '' unless @with_block
  440. m = Regexp.last_match
  441. context.stack do
  442. m.names.each do |name|
  443. context[name] = m[name]
  444. end
  445. context['match'.freeze] = m
  446. @with_block.render(context)
  447. end
  448. }
  449. end
  450. def first?
  451. @tag_name.end_with?('_first'.freeze)
  452. end
  453. end
  454. end
  455. Liquid::Template.register_tag('regex_replace', LiquidInterpolatable::Blocks::RegexReplace)
  456. Liquid::Template.register_tag('regex_replace_first', LiquidInterpolatable::Blocks::RegexReplace)
  457. end