4me-sdk 1.1.2

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,342 @@
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 'sdk4me'
9
+
10
+ require 'sdk4me/client/version'
11
+ require 'sdk4me/client/response'
12
+ require 'sdk4me/client/multipart'
13
+ require 'sdk4me/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 Sdk4me
22
+ class Client
23
+ MAX_PAGE_SIZE = 100
24
+ DEFAULT_HEADER = {'Content-Type' => 'application/json'}
25
+
26
+ # Create a new 4me SDK Client
27
+ #
28
+ # Shared configuration for all 4me SDK Clients:
29
+ # Sdk4me.configure do |config|
30
+ # config.api_token = 'd41f5868feb65fc87fa2311a473a8766ea38bc40'
31
+ # config.account = 'my-sandbox'
32
+ # ...
33
+ # end
34
+ #
35
+ # Override configuration per 4me SDK Client:
36
+ # sdk4me = Sdk4me::Client.new(account: 'trusted-sandbox')
37
+ #
38
+ # All options available:
39
+ # - logger: The Ruby Logger instance, default: Logger.new(STDOUT)
40
+ # - host: The 4me API host, default: 'https://api.4me.com'
41
+ # - api_version: The 4me API version, default: 'v1'
42
+ # - api_token: *required* The 4me API token
43
+ # - account: Specify a different (trusted) account to work with
44
+ # @see http://developer.4me.com/v1/#multiple-accounts
45
+ # - source: The Source used when creating new records
46
+ # @see http://developer.4me.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.4me.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 = Sdk4me.configuration.current.merge(options)
62
+ [:host, :api_version, :api_token].each do |required_option|
63
+ raise ::Sdk4me::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
+ @ssl_verify_none = options[:ssl_verify_none]
67
+ @logger = @options[:logger]
68
+ end
69
+
70
+ # Retrieve an option
71
+ def option(key)
72
+ @options[key]
73
+ end
74
+
75
+ # Yield all retrieved resources one-by-one for the given (paged) API query.
76
+ # Raises an ::Sdk4me::Exception with the response retrieved from 4me is invalid
77
+ # Returns total nr of resources yielded (for logging)
78
+ def each(path, params = {}, header = {}, &block)
79
+ # retrieve the resources using the max page size (least nr of API calls)
80
+ next_path = expand_path(path, {per_page: MAX_PAGE_SIZE, page: 1}.merge(params))
81
+ size = 0
82
+ while next_path
83
+ # retrieve the records (with retry and optionally wait for rate-limit)
84
+ response = get(next_path, {}, header)
85
+ # raise exception in case the response is invalid
86
+ raise ::Sdk4me::Exception.new(response.message) unless response.valid?
87
+ # yield the resources
88
+ response.json.each{ |resource| yield resource }
89
+ size += response.json.size
90
+ # go to the next page
91
+ next_path = response.pagination_relative_link(:next)
92
+ end
93
+ size
94
+ end
95
+
96
+ # send HTTPS GET request and return instance of Sdk4me::Response
97
+ def get(path, params = {}, header = {})
98
+ _send(Net::HTTP::Get.new(expand_path(path, params), expand_header(header)))
99
+ end
100
+
101
+ # send HTTPS DELETE request and return instance of Sdk4me::Response
102
+ def delete(path, params = {}, header = {})
103
+ _send(Net::HTTP::Delete.new(expand_path(path, params), expand_header(header)))
104
+ end
105
+
106
+ # send HTTPS PATCH request and return instance of Sdk4me::Response
107
+ def put(path, data = {}, header = {})
108
+ _send(json_request(Net::HTTP::Patch, path, data, header))
109
+ end
110
+ alias_method :patch, :put
111
+
112
+ # send HTTPS POST request and return instance of Sdk4me::Response
113
+ def post(path, data = {}, header = {})
114
+ _send(json_request(Net::HTTP::Post, path, data, header))
115
+ end
116
+
117
+ # upload a CSV file to import
118
+ # @param csv: The CSV File or the location of the CSV file
119
+ # @param type: The type, e.g. person, organization, people_contact_details
120
+ # @raise Sdk4me::UploadFailed in case the file could was not accepted by SDK4ME and +block_until_completed+ is +true+
121
+ # @raise Sdk4me::Exception in case the import progress could not be monitored
122
+ def import(csv, type, block_until_completed = false)
123
+ csv = File.open(csv, 'rb') unless csv.respond_to?(:path) && csv.respond_to?(:read)
124
+ data, headers = Sdk4me::Multipart::Post.prepare_query(type: type, file: csv)
125
+ request = Net::HTTP::Post.new(expand_path('/import'), expand_header(headers))
126
+ request.body = data
127
+ response = _send(request)
128
+ @logger.info { "Import file '#{csv.path}' successfully uploaded with token '#{response[:token]}'." } if response.valid?
129
+
130
+ if block_until_completed
131
+ raise ::Sdk4me::UploadFailed.new("Failed to queue #{type} import. #{response.message}") unless response.valid?
132
+ token = response[:token]
133
+ while true
134
+ response = get("/import/#{token}")
135
+ unless response.valid?
136
+ sleep(5)
137
+ response = get("/import/#{token}") # single retry to recover from a network error
138
+ raise ::Sdk4me::Exception.new("Unable to monitor progress for #{type} import. #{response.message}") unless response.valid?
139
+ end
140
+ # wait 30 seconds while the response is OK and import is still busy
141
+ break unless ['queued', 'processing'].include?(response[:state])
142
+ @logger.debug { "Import of '#{csv.path}' is #{response[:state]}. Checking again in 30 seconds." }
143
+ sleep(30)
144
+ end
145
+ end
146
+
147
+ response
148
+ end
149
+
150
+ # Export CSV files
151
+ # @param types: The types to export, e.g. person, organization, people_contact_details
152
+ # @param from: Retrieve all files since a given data and time
153
+ # @param block_until_completed: Set to true to monitor the export progress
154
+ # @raise Sdk4me::Exception in case the export progress could not be monitored
155
+ def export(types, from = nil, block_until_completed = false)
156
+ data = {type: [types].flatten.join(',')}
157
+ data[:from] = from unless from.blank?
158
+ response = post('/export', data)
159
+ if response.valid?
160
+ if response.raw.code.to_s == '204'
161
+ @logger.info { "No changed records for '#{data[:type]}' since #{data[:from]}." }
162
+ return response
163
+ end
164
+ @logger.info { "Export for '#{data[:type]}' successfully queued with token '#{response[:token]}'." }
165
+ end
166
+
167
+ if block_until_completed
168
+ raise ::Sdk4me::UploadFailed.new("Failed to queue '#{data[:type]}' export. #{response.message}") unless response.valid?
169
+ token = response[:token]
170
+ while true
171
+ response = get("/export/#{token}")
172
+ unless response.valid?
173
+ sleep(5)
174
+ response = get("/export/#{token}") # single retry to recover from a network error
175
+ raise ::Sdk4me::Exception.new("Unable to monitor progress for '#{data[:type]}' export. #{response.message}") unless response.valid?
176
+ end
177
+ # wait 30 seconds while the response is OK and export is still busy
178
+ break unless ['queued', 'processing'].include?(response[:state])
179
+ @logger.debug { "Export of '#{data[:type]}' is #{response[:state]}. Checking again in 30 seconds." }
180
+ sleep(30)
181
+ end
182
+ end
183
+
184
+ response
185
+ end
186
+
187
+ def logger
188
+ @logger
189
+ end
190
+
191
+ private
192
+
193
+ # create a request (place data in body if the request becomes too large)
194
+ def json_request(request_class, path, data = {}, header = {})
195
+ Sdk4me::Attachments.new(self).upload_attachments!(path, data)
196
+ request = request_class.new(expand_path(path), expand_header(header))
197
+ body = {}
198
+ data.each{ |k,v| body[k.to_s] = typecast(v, false) }
199
+ request.body = body.to_json
200
+ request
201
+ end
202
+
203
+ URI_ESCAPE_PATTERN = Regexp.new("[^#{URI::PATTERN::UNRESERVED}]")
204
+ def uri_escape(value)
205
+ URI.escape(value, URI_ESCAPE_PATTERN).gsub('.', '%2E')
206
+ end
207
+
208
+ # Expand the given header with the default header
209
+ def expand_header(header = {})
210
+ header = DEFAULT_HEADER.merge(header)
211
+ header['X-4me-Account'] = option(:account) if option(:account)
212
+ header['AUTHORIZATION'] = 'Basic ' + ["#{option(:api_token)}:x"].pack('m*').gsub(/\s/, '')
213
+ if option(:source)
214
+ header['X-4me-Source'] = option(:source)
215
+ header['HTTP_USER_AGENT'] = option(:source)
216
+ end
217
+ header
218
+ end
219
+
220
+ # Expand the given path with the parameters
221
+ # Examples:
222
+ # person_id: 5
223
+ # :"updated_at=>" => yesterday
224
+ # fields: ['id', 'created_at', 'sourceID']
225
+ def expand_path(path, params = {})
226
+ path = path.dup
227
+ path = "/#{path}" unless path =~ /^\// # make sure path starts with /
228
+ path = "/#{option(:api_version)}#{path}" unless path =~ /^\/v[\d.]+\// # preprend api version
229
+ params.each do |key, value|
230
+ path << (path['?'] ? '&' : '?')
231
+ path << expand_param(key, value)
232
+ end
233
+ path
234
+ end
235
+
236
+ # Expand one parameter, e.g. (:"created_at=>", DateTime.now) to "created_at=%3E22011-12-16T12:24:41%2B01:00"
237
+ def expand_param(key, value)
238
+ param = uri_escape(key.to_s).gsub('%3D', '=') # handle :"updated_at=>" or :"person_id!=" parameters
239
+ param << '=' unless key['=']
240
+ param << typecast(value)
241
+ param
242
+ end
243
+
244
+ # Parameter value typecasting
245
+ def typecast(value, escape = true)
246
+ case value.class.name.to_sym
247
+ when :NilClass then ''
248
+ when :String then escape ? uri_escape(value) : value
249
+ when :TrueClass then 'true'
250
+ when :FalseClass then 'false'
251
+ when :DateTime then datetime = value.new_offset(0).iso8601; escape ? uri_escape(datetime) : datetime
252
+ when :Date then value.strftime("%Y-%m-%d")
253
+ when :Time then value.strftime("%H:%M")
254
+ # do not convert arrays in put/post requests as squashing arrays is only used in filtering
255
+ when :Array then escape ? value.map{ |v| typecast(v, escape) }.join(',') : value
256
+ # TODO: temporary for special constructions to update contact details, see Request #1444166
257
+ when :Hash then escape ? value.to_s : value
258
+ else escape ? value.to_json : value.to_s
259
+ end
260
+ end
261
+
262
+ # Send a request to 4me and wrap the HTTP Response in an Sdk4me::Response
263
+ # Guaranteed to return a Response, thought it may be +empty?+
264
+ def _send(request, domain = @domain, port = @port, ssl = @ssl)
265
+ @logger.debug { "Sending #{request.method} request to #{domain}:#{port}#{request.path}" }
266
+ _response = begin
267
+ http_with_proxy = option(:proxy_host).blank? ? Net::HTTP : Net::HTTP::Proxy(option(:proxy_host), option(:proxy_port), option(:proxy_user), option(:proxy_password))
268
+ http = http_with_proxy.new(domain, port)
269
+ http.read_timeout = option(:read_timeout)
270
+ http.use_ssl = ssl
271
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE if @ssl_verify_none
272
+ http.start{ |_http| _http.request(request) }
273
+ rescue ::Exception => e
274
+ Struct.new(:body, :message, :code, :header).new(nil, "No Response from Server - #{e.message} for '#{domain}:#{port}#{request.path}'", 500, {})
275
+ end
276
+ response = Sdk4me::Response.new(request, _response)
277
+ if response.valid?
278
+ @logger.debug { "Response:\n#{JSON.pretty_generate(response.json)}" }
279
+ elsif response.raw.body =~ /^\s*<\?xml/i
280
+ @logger.debug { "XML response:\n#{response.raw.body}" }
281
+ elsif '303' == response.raw.code.to_s
282
+ @logger.debug { "Redirect: #{response.raw.header['Location']}" }
283
+ else
284
+ @logger.error { "Request failed: #{response.message}" }
285
+ end
286
+ response
287
+ end
288
+
289
+ # parse the given URI to [domain, port, ssl, path]
290
+ def ssl_domain_port_path(uri)
291
+ uri = URI.parse(uri)
292
+ ssl = uri.scheme == 'https'
293
+ [ssl, uri.host, uri.port, uri.path]
294
+ end
295
+
296
+ end
297
+
298
+ module SendWithRateLimitBlock
299
+ # Wraps the _send method with retries when the server does not responsd, see +initialize+ option +:rate_limit_block+
300
+ def _send(request, domain = @domain, port = @port, ssl = @ssl)
301
+ return super(request, domain, port, ssl) unless option(:block_at_rate_limit)
302
+ now = Time.now
303
+ begin
304
+ _response = super(request, domain, port, ssl)
305
+ @logger.warn { "Request throttled, trying again in 5 minutes: #{_response.message}" } and sleep(300) if _response.throttled?
306
+ end while _response.throttled? && (Time.now - now) < 3660 # max 1 hour and 1 minute
307
+ _response
308
+ end
309
+ end
310
+ Client.send(:prepend, SendWithRateLimitBlock)
311
+
312
+ module SendWithRetries
313
+ # Wraps the _send method with retries when the server does not respond, see +initialize+ option +:retries+
314
+ def _send(request, domain = @domain, port = @port, ssl = @ssl)
315
+ retries = 0
316
+ sleep_time = 2
317
+ total_retry_time = 0
318
+ begin
319
+ _response = super(request, domain, port, ssl)
320
+ @logger.warn { "Request failed, retry ##{retries += 1} in #{sleep_time} seconds: #{_response.message}" } and sleep(sleep_time) if (_response.raw.code.to_s != '204' && _response.empty?) && option(:max_retry_time) > 0
321
+ total_retry_time += sleep_time
322
+ sleep_time *= 2
323
+ end while (_response.raw.code.to_s != '204' && _response.empty?) && total_retry_time < option(:max_retry_time)
324
+ _response
325
+ end
326
+ end
327
+ Client.send(:prepend, SendWithRetries)
328
+ end
329
+
330
+ # HTTPS with certificate bundle
331
+ module Net
332
+ class HTTP
333
+ alias_method :original_use_ssl=, :use_ssl=
334
+
335
+ def use_ssl=(flag)
336
+ self.ca_file = File.expand_path(Sdk4me.configuration.current[:ca_file], __FILE__) if flag
337
+ self.verify_mode = OpenSSL::SSL::VERIFY_PEER
338
+ self.original_use_ssl = flag
339
+ end
340
+ end
341
+ end
342
+
@@ -0,0 +1,109 @@
1
+ module Sdk4me
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 4me 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 4me
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[attachments_field(path)] = attachments.map {|attachment| upload_attachment(storage, attachment, raise_exceptions) }.compact.to_json
23
+ end
24
+
25
+ private
26
+
27
+ def attachments_field(path)
28
+ case path
29
+ when /cis/, /contracts/, /flsas/, /service_instances/, /slas/
30
+ :remarks_attachments
31
+ when /service_offerings/
32
+ :summary_attachments
33
+ else
34
+ :note_attachments
35
+ end
36
+ end
37
+
38
+ def report_error(message, raise_exceptions)
39
+ if raise_exceptions
40
+ raise Sdk4me::UploadFailed.new(message)
41
+ else
42
+ @client.logger.error{ message }
43
+ end
44
+ end
45
+
46
+ # upload a single attachment and return the data for the note_attachments
47
+ # returns nil and provides an error in case the attachment upload failed
48
+ def upload_attachment(storage, attachment, raise_exceptions)
49
+ begin
50
+ # attachment is already a file or we need to open the file from disk
51
+ unless attachment.respond_to?(:path) && attachment.respond_to?(:read)
52
+ raise "file does not exist: #{attachment}" unless File.exists?(attachment)
53
+ attachment = File.open(attachment, 'rb')
54
+ end
55
+
56
+ # there are two different upload methods: AWS S3 and 4me local storage
57
+ key_template = "#{storage[:upload_path]}#{FILENAME_TEMPLATE}"
58
+ key = key_template.gsub(FILENAME_TEMPLATE, File.basename(attachment.path))
59
+ upload_method = storage[:provider] == AWS_PROVIDER ? :aws_upload : :upload_to_4me
60
+ send(upload_method, storage, key_template, key, attachment)
61
+
62
+ # return the values for the note_attachments param
63
+ {key: key, filesize: File.size(attachment.path)}
64
+ rescue ::Exception => e
65
+ report_error("Attachment upload failed: #{e.message}", raise_exceptions)
66
+ nil
67
+ end
68
+ end
69
+
70
+ def aws_upload(aws, key_template, key, attachment)
71
+ # upload the file to AWS
72
+ response = send_file(aws[:upload_uri], {
73
+ :'x-amz-server-side-encryption' => 'AES256',
74
+ key: key_template,
75
+ AWSAccessKeyId: aws[:access_key],
76
+ acl: 'private',
77
+ signature: aws[:signature],
78
+ success_action_status: 201,
79
+ policy: aws[:policy],
80
+ file: attachment # file must be last
81
+ })
82
+ # this is a bit of a hack, but Amazon S3 returns only XML :(
83
+ xml = response.raw.body || ''
84
+ error = xml[/<Error>.*<Message>(.*)<\/Message>.*<\/Error>/, 1]
85
+ raise "AWS upload to #{aws[:upload_uri]} for #{key} failed: #{error}" if error
86
+
87
+ # inform 4me of the successful upload
88
+ response = @client.get(aws[:success_url].split('/').last, {key: key}, @client.send(:expand_header))
89
+ raise "4me confirmation #{aws[:success_url].split('/').last} for #{key} failed: #{response.message}" unless response.valid?
90
+ end
91
+
92
+ # upload the file directly to 4me
93
+ def upload_to_4me(storage, key_template, key, attachment)
94
+ uri = storage[:upload_uri] =~ /\/v1/ ? storage[:upload_uri] : storage[:upload_uri].gsub('/attachments', '/v1/attachments')
95
+ response = send_file(uri, {file: attachment, key: key_template}, @client.send(:expand_header))
96
+ raise "4me upload to #{storage[:upload_uri]} for #{key} failed: #{response.message}" unless response.valid?
97
+ end
98
+
99
+ def send_file(uri, params, basic_auth_header = {})
100
+ params = {:'Content-Type' => MIME::Types.type_for(params[:key])[0] || MIME::Types["application/octet-stream"][0]}.merge(params)
101
+ data, header = Sdk4me::Multipart::Post.prepare_query(params)
102
+ ssl, domain, port, path = @client.send(:ssl_domain_port_path, uri)
103
+ request = Net::HTTP::Post.new(path, basic_auth_header.merge(header))
104
+ request.body = data
105
+ @client.send(:_send, request, domain, port, ssl)
106
+ end
107
+
108
+ end
109
+ end