post_agent.rb 7.5KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. module Agents
  2. class PostAgent < Agent
  3. include WebRequestConcern
  4. MIME_RE = /\A\w+\/.+\z/
  5. can_dry_run!
  6. no_bulk_receive!
  7. default_schedule "never"
  8. description <<-MD
  9. A Post Agent receives events from other agents (or runs periodically), merges those events with the [Liquid-interpolated](https://github.com/cantino/huginn/wiki/Formatting-Events-using-Liquid) contents of `payload`, and sends the results as POST (or GET) requests to a specified url. To skip merging in the incoming event, but still send the interpolated payload, set `no_merge` to `true`.
  10. The `post_url` field must specify where you would like to send requests. Please include the URI scheme (`http` or `https`).
  11. The `method` used can be any of `get`, `post`, `put`, `patch`, and `delete`.
  12. By default, non-GETs will be sent with form encoding (`application/x-www-form-urlencoded`).
  13. Change `content_type` to `json` to send JSON instead.
  14. Change `content_type` to `xml` to send XML, where the name of the root element may be specified using `xml_root`, defaulting to `post`.
  15. When `content_type` contains a [MIME](https://en.wikipedia.org/wiki/Media_type) type, and `payload` is a string, its interpolated value will be sent as a string in the HTTP request's body and the request's `Content-Type` HTTP header will be set to `content_type`. When `payload` is a string `no_merge` has to be set to `true`.
  16. If `emit_events` is set to `true`, the server response will be emitted as an Event and can be fed to a WebsiteAgent for parsing (using its `data_from_event` and `type` options). No data processing
  17. will be attempted by this Agent, so the Event's "body" value will always be raw text.
  18. The Event will also have a "headers" hash and a "status" integer value.
  19. Set `event_headers_style` to one of the following values to normalize the keys of "headers" for downstream agents' convenience:
  20. * `capitalized` (default) - Header names are capitalized; e.g. "Content-Type"
  21. * `downcased` - Header names are downcased; e.g. "content-type"
  22. * `snakecased` - Header names are snakecased; e.g. "content_type"
  23. * `raw` - Backward compatibility option to leave them unmodified from what the underlying HTTP library returns.
  24. Other Options:
  25. * `headers` - When present, it should be a hash of headers to send with the request.
  26. * `basic_auth` - Specify HTTP basic auth parameters: `"username:password"`, or `["username", "password"]`.
  27. * `disable_ssl_verification` - Set to `true` to disable ssl verification.
  28. * `user_agent` - A custom User-Agent name (default: "Faraday v#{Faraday::VERSION}").
  29. MD
  30. event_description <<-MD
  31. Events look like this:
  32. {
  33. "status": 200,
  34. "headers": {
  35. "Content-Type": "text/html",
  36. ...
  37. },
  38. "body": "<html>Some data...</html>"
  39. }
  40. MD
  41. def default_options
  42. {
  43. 'post_url' => "http://www.example.com",
  44. 'expected_receive_period_in_days' => '1',
  45. 'content_type' => 'form',
  46. 'method' => 'post',
  47. 'payload' => {
  48. 'key' => 'value',
  49. 'something' => 'the event contained {{ somekey }}'
  50. },
  51. 'headers' => {},
  52. 'emit_events' => 'false',
  53. 'no_merge' => 'false'
  54. }
  55. end
  56. def working?
  57. last_receive_at && last_receive_at > interpolated['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
  58. end
  59. def method
  60. (interpolated['method'].presence || 'post').to_s.downcase
  61. end
  62. def validate_options
  63. unless options['post_url'].present? && options['expected_receive_period_in_days'].present?
  64. errors.add(:base, "post_url and expected_receive_period_in_days are required fields")
  65. end
  66. if options['payload'].present? && %w[get delete].include?(method) && !options['payload'].is_a?(Hash)
  67. errors.add(:base, "if provided, payload must be a hash")
  68. end
  69. if options['payload'].present? && %w[post put patch].include?(method)
  70. if !options['payload'].is_a?(Hash) && options['content_type'] !~ MIME_RE
  71. errors.add(:base, "if provided, payload must be a hash")
  72. end
  73. if options['content_type'] =~ MIME_RE && options['payload'].is_a?(String) && boolify(options['no_merge']) != true
  74. errors.add(:base, "when the payload is a string, `no_merge` has to be set to `true`")
  75. end
  76. end
  77. if options.has_key?('emit_events') && boolify(options['emit_events']).nil?
  78. errors.add(:base, "if provided, emit_events must be true or false")
  79. end
  80. begin
  81. normalize_response_headers({})
  82. rescue ArgumentError => e
  83. errors.add(:base, e.message)
  84. end
  85. unless %w[post get put delete patch].include?(method)
  86. errors.add(:base, "method must be 'post', 'get', 'put', 'delete', or 'patch'")
  87. end
  88. if options['no_merge'].present? && !%[true false].include?(options['no_merge'].to_s)
  89. errors.add(:base, "if provided, no_merge must be 'true' or 'false'")
  90. end
  91. unless headers.is_a?(Hash)
  92. errors.add(:base, "if provided, headers must be a hash")
  93. end
  94. validate_web_request_options!
  95. end
  96. def receive(incoming_events)
  97. incoming_events.each do |event|
  98. outgoing = interpolated(event)['payload'].presence || {}
  99. if boolify(interpolated['no_merge'])
  100. handle outgoing, event.payload
  101. else
  102. handle outgoing.merge(event.payload), event.payload
  103. end
  104. end
  105. end
  106. def check
  107. handle interpolated['payload'].presence || {}
  108. end
  109. private
  110. def normalize_response_headers(headers)
  111. case interpolated['event_headers_style']
  112. when nil, '', 'capitalized'
  113. normalize = ->name {
  114. name.gsub(/(?:\A|(?<=-))([[:alpha:]])|([[:alpha:]]+)/) {
  115. $1 ? $1.upcase : $2.downcase
  116. }
  117. }
  118. when 'downcased'
  119. normalize = :downcase.to_proc
  120. when 'snakecased', nil
  121. normalize = ->name { name.tr('A-Z-', 'a-z_') }
  122. when 'raw'
  123. normalize = ->name { name } # :itself.to_proc in Ruby >= 2.2
  124. else
  125. raise ArgumentError, "if provided, event_headers_style must be 'capitalized', 'downcased', 'snakecased' or 'raw'"
  126. end
  127. headers.each_with_object({}) { |(key, value), hash|
  128. hash[normalize[key]] = value
  129. }
  130. end
  131. def handle(data, payload = {})
  132. url = interpolated(payload)[:post_url]
  133. headers = headers()
  134. case method
  135. when 'get', 'delete'
  136. params, body = data, nil
  137. when 'post', 'put', 'patch'
  138. params = nil
  139. case (content_type = interpolated(payload)['content_type'])
  140. when 'json'
  141. headers['Content-Type'] = 'application/json; charset=utf-8'
  142. body = data.to_json
  143. when 'xml'
  144. headers['Content-Type'] = 'text/xml; charset=utf-8'
  145. body = data.to_xml(root: (interpolated(payload)[:xml_root] || 'post'))
  146. when MIME_RE
  147. headers['Content-Type'] = content_type
  148. body = data.to_s
  149. else
  150. body = data
  151. end
  152. else
  153. error "Invalid method '#{method}'"
  154. end
  155. response = faraday.run_request(method.to_sym, url, body, headers) { |request|
  156. request.params.update(params) if params
  157. }
  158. if boolify(interpolated['emit_events'])
  159. create_event payload: {
  160. body: response.body,
  161. headers: normalize_response_headers(response.headers),
  162. status: response.status
  163. }
  164. end
  165. end
  166. end
  167. end