4me-sdk 1.1.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -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