itrp-client 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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