itrp-client 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,314 @@
1
+ require 'net/http'
2
+ require 'json'
3
+ require 'uri'
4
+ require 'date'
5
+ require 'time'
6
+ require 'net/https'
7
+ require 'open-uri'
8
+ require 'itrp'
9
+
10
+ require 'itrp/client/version'
11
+ require 'itrp/client/response'
12
+ require 'itrp/client/multipart'
13
+ require 'itrp/client/attachments'
14
+
15
+ # cherry-pick some core extensions from active support
16
+ require 'active_support/core_ext/module/aliasing.rb'
17
+ require 'active_support/core_ext/object/blank'
18
+ require 'active_support/core_ext/object/try.rb'
19
+ require 'active_support/core_ext/hash/indifferent_access'
20
+
21
+ module Itrp
22
+ class Client
23
+ MAX_PAGE_SIZE = 100
24
+ DEFAULT_HEADER = {'Content-Type' => 'application/json'}
25
+
26
+ # Create a new ITRP Client
27
+ #
28
+ # Shared configuration for all ITRP Clients:
29
+ # Itrp.configure do |config|
30
+ # config.api_token = 'd41f5868feb65fc87fa2311a473a8766ea38bc40'
31
+ # config.account = 'my-sandbox'
32
+ # ...
33
+ # end
34
+ #
35
+ # Override configuration per ITRP Client:
36
+ # itrp = Itrp::Client.new(account: 'trusted-sandbox')
37
+ #
38
+ # All options available:
39
+ # - logger: The Ruby Logger instance, default: Logger.new(STDOUT)
40
+ # - host: The ITRP API host, default: 'https://api.itrp.com'
41
+ # - api_version: The ITRP API version, default: 'v1'
42
+ # - api_token: *required* The ITRP API token
43
+ # - account: Specify a different (trusted) account to work with
44
+ # @see http://developer.itrp.com/v1/#multiple-accounts
45
+ # - source: The Source used when creating new records
46
+ # @see http://developer.itrp.com/v1/general/source/
47
+ #
48
+ # - max_retry_time: maximum nr of seconds to wait for server to respond (default = 5400 = 1.5 hours)
49
+ # the sleep time between retries starts at 2 seconds and doubles after each retry
50
+ # retry times: 2, 6, 18, 54, 162, 486, 1458, 4374, 13122, ... seconds
51
+ # one retry will always be performed unless you set the value to -1
52
+ # - read_timeout: HTTP GET read timeout in seconds (default = 25)
53
+ # - block_at_rate_limit: Set to +true+ to block the request until the rate limit is lifted, default: +false+
54
+ # @see http://developer.itrp.com/v1/#rate-limiting
55
+ #
56
+ # - proxy_host: Define in case HTTP traffic needs to go through a proxy
57
+ # - proxy_port: Port of the proxy, defaults to 8080
58
+ # - proxy_user: Proxy user
59
+ # - proxy_password: Proxy password
60
+ def initialize(options = {})
61
+ @options = Itrp.configuration.current.merge(options)
62
+ [:host, :api_version, :api_token].each do |required_option|
63
+ raise ::Itrp::Exception.new("Missing required configuration option #{required_option}") if option(required_option).blank?
64
+ end
65
+ @ssl, @domain, @port = ssl_domain_port_path(option(:host))
66
+ @logger = @options[:logger]
67
+ end
68
+
69
+ # Retrieve an option
70
+ def option(key)
71
+ @options[key]
72
+ end
73
+
74
+ # Yield all retrieved resources one-by-one for the given (paged) API query.
75
+ # Raises an ::Itrp::Exception with the response retrieved from ITRP is invalid
76
+ # Returns total nr of resources yielded (for logging)
77
+ def each(path, params = {}, &block)
78
+ # retrieve the resources using the max page size (least nr of API calls)
79
+ next_path = expand_path(path, {per_page: MAX_PAGE_SIZE, page: 1}.merge(params))
80
+ size = 0
81
+ while next_path
82
+ # retrieve the records (with retry and optionally wait for rate-limit)
83
+ response = get(next_path)
84
+ # raise exception in case the response is invalid
85
+ raise ::Itrp::Exception.new(response.message) unless response.valid?
86
+ # yield the resources
87
+ response.json.each{ |resource| yield resource }
88
+ size += response.json.size
89
+ # go to the next page
90
+ next_path = response.pagination_relative_link(:next)
91
+ end
92
+ size
93
+ end
94
+
95
+ # send HTTPS GET request and return instance of Itrp::Response
96
+ def get(path, params = {}, header = {})
97
+ _send(Net::HTTP::Get.new(expand_path(path, params), expand_header(header)))
98
+ end
99
+
100
+ # send HTTPS PUT request and return instance of Itrp::Response
101
+ def put(path, data = {}, header = {})
102
+ _send(json_request(Net::HTTP::Put, path, data, header))
103
+ end
104
+ alias_method :patch, :put
105
+
106
+ # send HTTPS POST request and return instance of Itrp::Response
107
+ def post(path, data = {}, header = {})
108
+ _send(json_request(Net::HTTP::Post, path, data, header))
109
+ end
110
+
111
+ # upload a CSV file to import
112
+ # @param csv: The CSV File or the location of the CSV file
113
+ # @param type: The type, e.g. person, organization, people_contact_details
114
+ # @raise Itrp::UploadFailed in case the file could was not accepted by ITRP and +block_until_completed+ is +true+
115
+ # @raise Itrp::Exception in case the import progress could not be monitored
116
+ def import(csv, type, block_until_completed = false)
117
+ csv = File.open(csv, 'r') unless csv.respond_to?(:path) && csv.respond_to?(:read)
118
+ data, headers = Itrp::Multipart::Post.prepare_query(type: type, file: csv)
119
+ request = Net::HTTP::Post.new(expand_path('/import'), expand_header(headers))
120
+ request.body = data
121
+ response = _send(request)
122
+ @logger.info { "Import file '#{csv.path}' successfully uploaded with token '#{response[:token]}'." } if response.valid?
123
+
124
+ if block_until_completed
125
+ raise ::Itrp::UploadFailed.new("Failed to queue #{type} import. #{response.message}") unless response.valid?
126
+ token = response[:token]
127
+ while true
128
+ response = get("/import/#{token}")
129
+ raise ::Itrp::Exception.new("Unable to monitor progress for #{type} import. #{response.message}") unless response.valid?
130
+ # wait 30 seconds while the response is OK and import is still busy
131
+ break unless ['queued', 'processing'].include?(response[:state])
132
+ @logger.debug { "Import of '#{csv.path}' is #{response[:state]}. Checking again in 30 seconds." }
133
+ sleep(30)
134
+ end
135
+ end
136
+
137
+ response
138
+ end
139
+
140
+ # Export CSV files
141
+ # @param types: The types to export, e.g. person, organization, people_contact_details
142
+ # @param from: Retrieve all files since a given data and time
143
+ # @param block_until_completed: Set to true to monitor the export progress
144
+ # @raise Itrp::Exception in case the export progress could not be monitored
145
+ def export(types, from = nil, block_until_completed = false)
146
+ data = {type: [types].flatten.join(',')}
147
+ data[:from] = from unless from.blank?
148
+ response = post('/export', data)
149
+ @logger.info { "Export for '#{data[:type]}' successfully queued with token '#{response[:token]}'." } if response.valid?
150
+
151
+ if block_until_completed
152
+ raise ::Itrp::UploadFailed.new("Failed to queue '#{data[:type]}' export. #{response.message}") unless response.valid?
153
+ token = response[:token]
154
+ while true
155
+ response = get("/export/#{token}")
156
+ raise ::Itrp::Exception.new("Unable to monitor progress for '#{data[:type]}' export. #{response.message}") unless response.valid?
157
+ # wait 30 seconds while the response is OK and export is still busy
158
+ break unless ['queued', 'processing'].include?(response[:state])
159
+ @logger.debug { "Export of '#{data[:type]}' is #{response[:state]}. Checking again in 30 seconds." }
160
+ sleep(30)
161
+ end
162
+ end
163
+
164
+ response
165
+ end
166
+
167
+ def logger
168
+ @logger
169
+ end
170
+
171
+ private
172
+
173
+ # create a request (place data in body if the request becomes too large)
174
+ def json_request(request_class, path, data = {}, header = {})
175
+ Itrp::Attachments.new(self).upload_attachments!(path, data)
176
+ request = request_class.new(expand_path(path), expand_header(header))
177
+ body = {}
178
+ data.each{ |k,v| body[k.to_s] = typecast(v, false) }
179
+ request.body = body.to_json
180
+ request
181
+ end
182
+
183
+ URI_ESCAPE_PATTERN = Regexp.new("[^#{URI::PATTERN::UNRESERVED}]")
184
+ def uri_escape(value)
185
+ URI.escape(value, URI_ESCAPE_PATTERN).gsub('.', '%2E')
186
+ end
187
+
188
+ # Expand the given header with the default header
189
+ def expand_header(header = {})
190
+ header = DEFAULT_HEADER.merge(header)
191
+ header['X-ITRP-Account'] = option(:account) if option(:account)
192
+ header['AUTHORIZATION'] = 'Basic ' + ["#{option(:api_token)}:"].pack("m*")
193
+ if option(:source)
194
+ header['X-ITRP-Source'] = option(:source)
195
+ header['HTTP_USER_AGENT'] = option(:source)
196
+ end
197
+ header
198
+ end
199
+
200
+ # Expand the given path with the parameters
201
+ # Examples:
202
+ # person_id: 5
203
+ # :"updated_at=>" => yesterday
204
+ # fields: ["id", "created_at", "sourceID"]
205
+ def expand_path(path, params = {})
206
+ path = path.dup
207
+ path = "/#{path}" unless path =~ /^\// # make sure path starts with /
208
+ path = "/#{option(:api_version)}#{path}" unless path =~ /^\/v[\d.]+\// # preprend api version
209
+ params.each do |key, value|
210
+ path << (path['?'] ? '&' : '?')
211
+ path << expand_param(key, value)
212
+ end
213
+ path
214
+ end
215
+
216
+ # Expand one parameter, e.g. (:"created_at=>", DateTime.now) to "created_at=%3E22011-12-16T12:24:41+01:00"
217
+ def expand_param(key, value)
218
+ param = uri_escape(key.to_s).gsub('%3D', '=') # handle :"updated_at=>" or :"person_id!=" parameters
219
+ param << '=' unless key['=']
220
+ param << typecast(value)
221
+ param
222
+ end
223
+
224
+ # Parameter value typecasting
225
+ def typecast(value, escape = true)
226
+ case value.class.name.to_sym
227
+ when :NilClass then ''
228
+ when :String then escape ? URI.escape(value) : value
229
+ when :TrueClass then 'true'
230
+ when :FalseClass then 'false'
231
+ when :DateTime then value.new_offset(0).iso8601
232
+ when :Date then value.strftime("%Y-%m-%d")
233
+ when :Time then value.strftime("%H:%M")
234
+ when :Array then value.map{ |v| typecast(v, escape) }.join(',')
235
+ else value.to_s
236
+ end
237
+ end
238
+
239
+ # Send a request to ITRP and wrap the HTTP Response in an Itrp::Response
240
+ # Guaranteed to return a Response, thought it may be +empty?+
241
+ def _send(request, domain = @domain, port = @port, ssl = @ssl)
242
+ @logger.debug { "Sending #{request.method} request to #{domain}:#{port}#{request.path}" }
243
+ _response = begin
244
+ http_with_proxy = option(:proxy_host).blank? ? Net::HTTP : Net::HTTP::Proxy(option(:proxy_host), option(:proxy_port), option(:proxy_user), option(:proxy_password))
245
+ http = http_with_proxy.new(domain, port)
246
+ http.read_timeout = option(:read_timeout)
247
+ http.use_ssl = ssl
248
+ http.start{ |_http| _http.request(request) }
249
+ rescue ::Exception => e
250
+ Struct.new(:body, :message, :code, :header).new(nil, "No Response from Server - #{e.message} for '#{domain}:#{port}#{request.path}'", 500, {})
251
+ end
252
+ response = Itrp::Response.new(request, _response)
253
+ if response.valid?
254
+ @logger.debug { "Response:\n#{JSON.pretty_generate(response.json)}" }
255
+ elsif response.raw.body =~ /^\s*<\?xml/i
256
+ @logger.debug { "XML response:\n#{response.raw.body}" }
257
+ elsif '303' == response.raw.code.to_s
258
+ @logger.debug { "Redirect: #{response.raw.header['Location']}" }
259
+ else
260
+ @logger.error { "Request failed: #{response.message}" }
261
+ end
262
+ response
263
+ end
264
+
265
+ # Wraps the _send method with retries when the server does not responsd, see +initialize+ option +:rate_limit_block+
266
+ def _send_with_rate_limit_block(request, domain = @domain, port = @port, ssl = @ssl)
267
+ return _send_without_rate_limit_block(request, domain, port, ssl) unless option(:block_at_rate_limit)
268
+ now = Time.now
269
+ begin
270
+ _response = _send_without_rate_limit_block(request, domain, port, ssl)
271
+ @logger.warn { "Request throttled, trying again in 5 minutes: #{_response.message}" } and sleep(300) if _response.throttled?
272
+ end while _response.throttled? && (Time.now - now) < 3660 # max 1 hour and 1 minute
273
+ _response
274
+ end
275
+ alias_method_chain :_send, :rate_limit_block
276
+
277
+ # Wraps the _send method with retries when the server does not responsd, see +initialize+ option +:retries+
278
+ def _send_with_retries(request, domain = @domain, port = @port, ssl = @ssl)
279
+ retries = 0
280
+ sleep_time = 2
281
+ total_retry_time = 0
282
+ begin
283
+ _response = _send_without_retries(request, domain, port, ssl)
284
+ @logger.warn { "Request failed, retry ##{retries += 1} in #{sleep_time} seconds: #{_response.message}" } and sleep(sleep_time) if _response.empty? && option(:max_retry_time) > 0
285
+ total_retry_time += sleep_time
286
+ sleep_time *= 2
287
+ end while _response.empty? && total_retry_time < option(:max_retry_time)
288
+ _response
289
+ end
290
+ alias_method_chain :_send, :retries
291
+
292
+ # parse the given URI to [domain, port, ssl, path]
293
+ def ssl_domain_port_path(uri)
294
+ uri = URI.parse(uri)
295
+ ssl = uri.scheme == 'https'
296
+ [ssl, uri.host, uri.port, uri.path]
297
+ end
298
+
299
+ end
300
+ end
301
+
302
+ # HTTPS with certificate bundle
303
+ module Net
304
+ class HTTP
305
+ alias_method :original_use_ssl=, :use_ssl=
306
+
307
+ def use_ssl=(flag)
308
+ self.ca_file = File.expand_path("../ca-bundle.crt", __FILE__) if flag
309
+ self.verify_mode = OpenSSL::SSL::VERIFY_PEER
310
+ self.original_use_ssl = flag
311
+ end
312
+ end
313
+ end
314
+
@@ -0,0 +1,99 @@
1
+ module Itrp
2
+ class Attachments
3
+
4
+ AWS_PROVIDER = 'aws'
5
+ FILENAME_TEMPLATE = '${filename}'
6
+
7
+ def initialize(client)
8
+ @client = client
9
+ end
10
+
11
+ # upload the attachments in :attachments to ITRP and return the data with the uploaded attachment info
12
+ def upload_attachments!(path, data)
13
+ raise_exceptions = !!data.delete(:attachments_exception)
14
+ attachments = [data.delete(:attachments)].flatten.compact
15
+ return if attachments.empty?
16
+
17
+ # retrieve the upload configuration for this record from ITRP
18
+ storage = @client.get(path =~ /\d+$/ ? path : "#{path}/new", {attachment_upload_token: true}, @client.send(:expand_header))[:storage_upload]
19
+ report_error("Attachments not allowed for #{path}", raise_exceptions) and return unless storage
20
+
21
+ # upload each attachment and store the {key, filesize} has in the note_attachments parameter
22
+ data[:note_attachments] = attachments.map {|attachment| upload_attachment(storage, attachment, raise_exceptions) }.compact.to_json
23
+ end
24
+
25
+ private
26
+
27
+ def report_error(message, raise_exceptions)
28
+ if raise_exceptions
29
+ raise Itrp::UploadFailed.new(message)
30
+ else
31
+ @client.logger.error{ message }
32
+ end
33
+ end
34
+
35
+ # upload a single attachment and return the data for the note_attachments
36
+ # returns nil and provides an error in case the attachment upload failed
37
+ def upload_attachment(storage, attachment, raise_exceptions)
38
+ begin
39
+ # attachment is already a file or we need to open the file from disk
40
+ unless attachment.respond_to?(:path) && attachment.respond_to?(:read)
41
+ raise "file does not exist: #{attachment}" unless File.exists?(attachment)
42
+ attachment = File.open(attachment, 'r')
43
+ end
44
+
45
+ # there are two different upload methods: AWS S3 and ITRP local storage
46
+ key_template = "#{storage[:upload_path]}#{FILENAME_TEMPLATE}"
47
+ key = key_template.gsub(FILENAME_TEMPLATE, File.basename(attachment.path))
48
+ upload_method = storage[:provider] == AWS_PROVIDER ? :aws_upload : :itrp_upload
49
+ send(upload_method, storage, key_template, key, attachment)
50
+
51
+ # return the values for the note_attachments param
52
+ {key: key, filesize: File.size(attachment.path)}
53
+ rescue ::Exception => e
54
+ report_error("Attachment upload failed: #{e.message}", raise_exceptions)
55
+ nil
56
+ end
57
+ end
58
+
59
+ def aws_upload(aws, key_template, key, attachment)
60
+ # upload the file to AWS
61
+ response = send_file(aws[:upload_uri], {
62
+ key: key_template,
63
+ AWSAccessKeyId: aws[:access_key],
64
+ acl: 'private',
65
+ signature: aws[:signature],
66
+ success_action_redirect: aws[:success_url],
67
+ policy: aws[:policy],
68
+ file: attachment # file must be last (will that work in Ruby 1.9.3)?
69
+ })
70
+ # this is a bit of a hack, but Amazon S3 returns only XML :(
71
+ xml = response.raw.body || ''
72
+ error = xml[/<Error>.*<Message>(.*)<\/Message>.*<\/Error>/, 1]
73
+ raise "AWS upload to #{aws[:upload_uri]} for #{key} failed: #{error}" if error
74
+
75
+ # inform ITRP of the successful upload
76
+ response = @client.get(aws[:success_url].split('/').last, {key: key}, @client.send(:expand_header))
77
+ raise "ITRP confirmation #{aws[:success_url].split('/').last} for #{key} failed: #{response.message}" unless response.valid?
78
+ end
79
+
80
+ # upload the file directly to ITRP
81
+ def itrp_upload(itrp, key_template, key, attachment)
82
+ response = send_file(itrp[:upload_uri], {
83
+ file: attachment,
84
+ key: key_template
85
+ })
86
+ raise "ITRP upload to #{itrp[:upload_uri]} for #{key} failed: #{response.message}" unless response.valid?
87
+ end
88
+
89
+ def send_file(uri, params)
90
+ params = {:'Content-Type' => MIME::Types.type_for(params[:key])[0] || MIME::Types["application/octet-stream"][0]}.merge(params)
91
+ data, header = Itrp::Multipart::Post.prepare_query(params)
92
+ ssl, domain, port, path = @client.send(:ssl_domain_port_path, uri)
93
+ request = Net::HTTP::Post.new(path, header)
94
+ request.body = data
95
+ @client.send(:_send, request, domain, port, ssl)
96
+ end
97
+
98
+ end
99
+ end
@@ -0,0 +1,75 @@
1
+ require 'cgi'
2
+ require 'mime/types'
3
+
4
+ # Takes a hash of string and file parameters and returns a string of text
5
+ # formatted to be sent as a multipart form post.
6
+ #
7
+ # Author:: Cody Brimhall <mailto:cbrimhall@ucdavis.edu>
8
+ # Created:: 22 Feb 2008
9
+ module Itrp
10
+ module Multipart
11
+ VERSION = "1.0.0" unless const_defined?(:VERSION)
12
+
13
+ # Formats a given hash as a multipart form post
14
+ # If a hash value responds to :string or :read messages, then it is
15
+ # interpreted as a file and processed accordingly; otherwise, it is assumed
16
+ # to be a string
17
+ class Post
18
+ # We have to pretend like we're a web browser...
19
+ USERAGENT = "Mozilla/5.0 (Macintosh; U; PPC Mac OS X; en-us) AppleWebKit/523.10.6 (KHTML, like Gecko) Version/3.0.4 Safari/523.10.6" unless const_defined?(:USERAGENT)
20
+ BOUNDARY = "0123456789ABLEWASIEREISAWELBA9876543210" unless const_defined?(:BOUNDARY)
21
+ CONTENT_TYPE = "multipart/form-data; boundary=#{ BOUNDARY }" unless const_defined?(:CONTENT_TYPE)
22
+ HEADER = { "Content-Type" => CONTENT_TYPE, "User-Agent" => USERAGENT } unless const_defined?(:HEADER)
23
+
24
+ def self.prepare_query(params)
25
+ fp = []
26
+
27
+ params.each do |k, v|
28
+ if v.respond_to?(:path) && v.respond_to?(:read)
29
+ fp.push(FileParam.new(k, v.path, v.read))
30
+ else
31
+ fp.push(StringParam.new(k, v))
32
+ end
33
+ end
34
+
35
+ # Assemble the request body using the special multipart format
36
+ query = fp.map{ |p| "--#{BOUNDARY}\r\n#{p.to_multipart}" }.join + "--#{BOUNDARY}--"
37
+ return query, HEADER
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ # Formats a basic string key/value pair for inclusion with a multipart post
44
+ class StringParam
45
+ attr_accessor :k, :v
46
+
47
+ def initialize(k, v)
48
+ @k = k
49
+ @v = v
50
+ end
51
+
52
+ def to_multipart
53
+ return %(Content-Disposition: form-data; name="#{CGI::escape(k.to_s)}"\r\n\r\n#{v}\r\n)
54
+ end
55
+ end
56
+
57
+ # Formats the contents of a file or string for inclusion with a multipart
58
+ # form post
59
+ class FileParam
60
+ attr_accessor :k, :filename, :content
61
+
62
+ def initialize(k, filename, content)
63
+ @k = k
64
+ @filename = filename
65
+ @content = content
66
+ end
67
+
68
+ def to_multipart
69
+ # If we can tell the possible mime-type from the filename, use the first in the list; otherwise, use "application/octet-stream"
70
+ mime_type = MIME::Types.type_for(filename)[0] || MIME::Types["application/octet-stream"][0]
71
+ return %(Content-Disposition: form-data; name="#{CGI::escape(k.to_s)}"; filename="#{filename}"\r\nContent-Type: #{ mime_type.simplified }\r\n\r\n#{ content }\r\n)
72
+ end
73
+ end
74
+ end
75
+ end