post_agent.rb 8.5 KB

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