aspera-cli 4.27.2 → 4.27.4

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.
Files changed (145) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +133 -0
  4. data/CONTRIBUTING.md +5 -2
  5. data/bin/ascli +3 -1
  6. data/docs/README.md +1006 -766
  7. data/lib/aspera/agent/base.rb +7 -2
  8. data/lib/aspera/agent/connect.rb +6 -8
  9. data/lib/aspera/agent/desktop.rb +2 -6
  10. data/lib/aspera/agent/direct.rb +52 -22
  11. data/lib/aspera/agent/node.rb +9 -8
  12. data/lib/aspera/agent/transferd.rb +2 -2
  13. data/lib/aspera/api/alee.rb +1 -1
  14. data/lib/aspera/api/aoc.rb +14 -12
  15. data/lib/aspera/api/ats.rb +1 -1
  16. data/lib/aspera/api/cos_node.rb +2 -2
  17. data/lib/aspera/api/faspex.rb +11 -7
  18. data/lib/aspera/api/httpgw.rb +38 -35
  19. data/lib/aspera/api/node.rb +39 -34
  20. data/lib/aspera/ascmd.rb +3 -1
  21. data/lib/aspera/ascp/installation.rb +63 -28
  22. data/lib/aspera/ascp/management.rb +1 -0
  23. data/lib/aspera/assert.rb +4 -0
  24. data/lib/aspera/cli/ascp_actions.rb +20 -41
  25. data/lib/aspera/cli/async_transfer_store.rb +12 -11
  26. data/lib/aspera/cli/bootstrapper.rb +14 -16
  27. data/lib/aspera/cli/command_line.rb +252 -0
  28. data/lib/aspera/cli/command_registry.rb +215 -37
  29. data/lib/aspera/cli/command_spec.rb +104 -15
  30. data/lib/aspera/cli/completion/ascli.bash +12 -0
  31. data/lib/aspera/cli/completion/ascli.fish +16 -0
  32. data/lib/aspera/cli/completion/ascli.zsh +19 -0
  33. data/lib/aspera/cli/context.rb +3 -0
  34. data/lib/aspera/cli/deprecation.rb +37 -0
  35. data/lib/aspera/cli/extended_value.rb +6 -3
  36. data/lib/aspera/cli/formatter.rb +94 -80
  37. data/lib/aspera/cli/gem_checker.rb +1 -1
  38. data/lib/aspera/cli/hints.rb +7 -6
  39. data/lib/aspera/cli/http.rb +22 -34
  40. data/lib/aspera/cli/info.rb +3 -0
  41. data/lib/aspera/cli/mcp_tool.rb +47 -83
  42. data/lib/aspera/cli/option_declarator.rb +33 -42
  43. data/lib/aspera/cli/option_registry.rb +69 -0
  44. data/lib/aspera/cli/option_types.rb +105 -0
  45. data/lib/aspera/cli/option_value.rb +278 -0
  46. data/lib/aspera/cli/options.schema.yaml +124 -15
  47. data/lib/aspera/cli/parser.rb +333 -862
  48. data/lib/aspera/cli/plugins/alee.rb +7 -4
  49. data/lib/aspera/cli/plugins/aoc.rb +545 -518
  50. data/lib/aspera/cli/plugins/ats.rb +59 -80
  51. data/lib/aspera/cli/plugins/base.rb +221 -265
  52. data/lib/aspera/cli/plugins/basic_auth.rb +2 -10
  53. data/lib/aspera/cli/plugins/config.rb +263 -184
  54. data/lib/aspera/cli/plugins/console.rb +103 -39
  55. data/lib/aspera/cli/plugins/cos.rb +6 -23
  56. data/lib/aspera/cli/plugins/factory.rb +3 -0
  57. data/lib/aspera/cli/plugins/faspex5.rb +204 -182
  58. data/lib/aspera/cli/plugins/faspio.rb +6 -11
  59. data/lib/aspera/cli/plugins/httpgw.rb +8 -11
  60. data/lib/aspera/cli/plugins/mcp.rb +20 -55
  61. data/lib/aspera/cli/plugins/node.rb +300 -327
  62. data/lib/aspera/cli/plugins/orchestrator.rb +152 -110
  63. data/lib/aspera/cli/plugins/preview.rb +96 -105
  64. data/lib/aspera/cli/plugins/server.rb +78 -53
  65. data/lib/aspera/cli/plugins/shares.rb +80 -131
  66. data/lib/aspera/cli/preset_actions.rb +44 -27
  67. data/lib/aspera/cli/preset_manager.rb +44 -19
  68. data/lib/aspera/cli/prompt.rb +36 -0
  69. data/lib/aspera/cli/result.rb +42 -36
  70. data/lib/aspera/cli/runner.rb +32 -59
  71. data/lib/aspera/cli/special_values.rb +5 -0
  72. data/lib/aspera/cli/sync_actions.rb +51 -46
  73. data/lib/aspera/cli/terminal_formatter.rb +9 -3
  74. data/lib/aspera/cli/transfer_actions.rb +14 -9
  75. data/lib/aspera/cli/transfer_agent.rb +34 -38
  76. data/lib/aspera/cli/transfer_progress.rb +290 -55
  77. data/lib/aspera/cli/vault_manager.rb +0 -17
  78. data/lib/aspera/cli/version.rb +1 -1
  79. data/lib/aspera/cli/wizard.rb +5 -3
  80. data/lib/aspera/coverage.rb +1 -1
  81. data/lib/aspera/environment.rb +35 -5
  82. data/lib/aspera/faspex_gw.rb +2 -1
  83. data/lib/aspera/faspex_postproc.rb +1 -0
  84. data/lib/aspera/graphql.rb +5 -5
  85. data/lib/aspera/json_rpc/client.rb +5 -5
  86. data/lib/aspera/keychain/encrypted_hash.rb +2 -2
  87. data/lib/aspera/keychain/factory.rb +2 -1
  88. data/lib/aspera/keychain/one_password_api.rb +1 -1
  89. data/lib/aspera/link_header.rb +2 -2
  90. data/lib/aspera/log.rb +47 -27
  91. data/lib/aspera/markdown.rb +2 -0
  92. data/lib/aspera/mime.rb +25 -0
  93. data/lib/aspera/node_emulator.rb +759 -0
  94. data/lib/aspera/oauth/base.rb +37 -26
  95. data/lib/aspera/oauth/factory.rb +7 -3
  96. data/lib/aspera/oauth/generic.rb +1 -1
  97. data/lib/aspera/oauth/json_credentials.rb +34 -0
  98. data/lib/aspera/oauth/jwt.rb +4 -5
  99. data/lib/aspera/oauth/web.rb +9 -8
  100. data/lib/aspera/oauth.rb +1 -0
  101. data/lib/aspera/persistency_folder.rb +1 -3
  102. data/lib/aspera/preview/file_types.rb +4 -4
  103. data/lib/aspera/preview/generator.rb +11 -1
  104. data/lib/aspera/preview/options.schema.yaml +119 -0
  105. data/lib/aspera/preview/terminal.rb +4 -3
  106. data/lib/aspera/preview/utils.rb +9 -6
  107. data/lib/aspera/products/connect.rb +1 -1
  108. data/lib/aspera/rainbow.rb +7 -0
  109. data/lib/aspera/rest/aspera_errors.rb +72 -0
  110. data/lib/aspera/rest/call_error.rb +27 -0
  111. data/lib/aspera/rest/client.rb +523 -0
  112. data/lib/aspera/rest/error_analyzer.rb +113 -0
  113. data/lib/aspera/rest/list.rb +149 -0
  114. data/lib/aspera/rest/parameters.rb +55 -0
  115. data/lib/aspera/rest/util.rb +176 -0
  116. data/lib/aspera/rest.rb +7 -621
  117. data/lib/aspera/schema/IBM Aspera Console-enhanced.yaml +1125 -0
  118. data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +0 -20
  119. data/lib/aspera/schema/IBM Aspera Orchestrator API-v1.yaml +1784 -0
  120. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +1230 -137
  121. data/lib/aspera/schema/IBM Aspera on Cloud Automation API-1.0.5-enhanced.yaml +2395 -0
  122. data/lib/aspera/schema/IBM_Aspera_Shares.yaml +14 -13
  123. data/lib/aspera/schema/documentation.rb +13 -3
  124. data/lib/aspera/schema/reader.rb +12 -18
  125. data/lib/aspera/schema/registry.rb +23 -1
  126. data/lib/aspera/schema/validator.rb +92 -0
  127. data/lib/aspera/secret_hider.rb +36 -25
  128. data/lib/aspera/string_ext.rb +15 -0
  129. data/lib/aspera/temp_file_manager.rb +6 -5
  130. data/lib/aspera/transfer/parameters.rb +2 -0
  131. data/lib/aspera/transfer/spec.rb +1 -0
  132. data/lib/aspera/uri_reader.rb +11 -11
  133. data/lib/aspera/web_auth/index.html +147 -0
  134. data/lib/aspera/web_auth/server.rb +81 -0
  135. data.tar.gz.sig +0 -0
  136. metadata +43 -9
  137. metadata.gz.sig +0 -0
  138. data/lib/aspera/colors.rb +0 -79
  139. data/lib/aspera/node_simulator.rb +0 -344
  140. data/lib/aspera/preview/options.rb +0 -45
  141. data/lib/aspera/rest_call_error.rb +0 -25
  142. data/lib/aspera/rest_error_analyzer.rb +0 -111
  143. data/lib/aspera/rest_errors_aspera.rb +0 -58
  144. data/lib/aspera/rest_list.rb +0 -136
  145. data/lib/aspera/web_auth.rb +0 -211
@@ -0,0 +1,523 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/mime'
4
+ require 'aspera/rest/parameters'
5
+ require 'aspera/rest/util'
6
+ require 'aspera/rest/call_error'
7
+ require 'aspera/rest/error_analyzer'
8
+ require 'aspera/rest/aspera_errors'
9
+ require 'aspera/log'
10
+ require 'aspera/assert'
11
+ require 'aspera/oauth'
12
+ require 'aspera/hash_ext'
13
+ require 'aspera/timer_limiter'
14
+ require 'net/http'
15
+ require 'net/https'
16
+ require 'json'
17
+ require 'securerandom'
18
+ require 'fileutils'
19
+ require 'pathname'
20
+ require 'aspera/rainbow'
21
+ using Rainbow
22
+
23
+ # Cancel method for HTTP
24
+ class Net::HTTP::Cancel < Net::HTTPRequest # rubocop:disable Style/ClassAndModuleChildren
25
+ # HTTP method name
26
+ METHOD = 'CANCEL'
27
+ # No request body
28
+ REQUEST_HAS_BODY = false
29
+ # No response body
30
+ RESPONSE_HAS_BODY = false
31
+ end
32
+
33
+ module Aspera
34
+ module Rest
35
+ # Make HTTP calls, equivalent to rest-client
36
+ # rest call errors are raised as exception CallError
37
+ # and error are analyzed in ErrorAnalyzer
38
+ class Client
39
+ private
40
+
41
+ # Create and start keep alive connection on demand
42
+ # @return [Net::HTTP] Started HTTP session
43
+ def http_session = @http_session ||= Rest.start_http_session(@base_url)
44
+
45
+ public
46
+
47
+ # All original constructor parameters
48
+ attr_reader :auth_params
49
+
50
+ # The root URL for the API
51
+ attr_reader :base_url
52
+
53
+ # Base common headers of API
54
+ attr_reader :headers
55
+
56
+ # Parameters to create a copy of this object, e.g. `Rest::Client.new(**api.params)`
57
+ # @return [Hash] Creation parameters (copy)
58
+ def params
59
+ return {
60
+ base_url: @base_url, # String
61
+ auth: @auth_params.dup, # Hash
62
+ not_auth_codes: @not_auth_codes.dup, # Array
63
+ redirect_max: @redirect_max, # Integer
64
+ headers: @headers.dup # Hash
65
+ }
66
+ end
67
+
68
+ # Create a REST object for API calls
69
+ # HTTP sessions parameters can be modified using global parameters in Parameters
70
+ # For example, TLS verification can be skipped.
71
+ # @param base_url [String] base URL of REST API
72
+ # @param auth [Hash] authentication parameters:
73
+ # :type (:none, :basic, :url, :oauth2)
74
+ # :username [:basic]
75
+ # :password [:basic]
76
+ # :url_query [:url] a hash
77
+ # :* [:oauth2] see OAuth::Factory class
78
+ # @param not_auth_codes [Array] codes that trigger a refresh/regeneration of bearer token
79
+ # @param redirect_max [Integer] max redirection allowed
80
+ # @param headers [Hash] default headers to include in all calls
81
+ def initialize(
82
+ base_url:,
83
+ auth: {type: :none},
84
+ not_auth_codes: ['401'],
85
+ redirect_max: 0,
86
+ headers: {}
87
+ )
88
+ Aspera.assert_type(base_url, String)
89
+ # base url with no trailing slashes (note: string may be frozen)
90
+ @base_url = base_url.chomp('/')
91
+ # remove trailing port if it is 443 and scheme is https
92
+ @base_url = @base_url.gsub(/:443$/, '') if @base_url.start_with?('https://')
93
+ @base_url = @base_url.gsub(/:80$/, '') if @base_url.start_with?('http://')
94
+ Log.log.debug { "Client.new(#{@base_url})" }
95
+ # default is no auth
96
+ @auth_params = auth
97
+ Aspera.assert_type(@auth_params, Hash)
98
+ Aspera.assert(@auth_params.key?(:type), 'no auth type defined')
99
+ @not_auth_codes = not_auth_codes
100
+ Aspera.assert_type(@not_auth_codes, Array)
101
+ # persistent session
102
+ @http_session = nil
103
+ @redirect_max = redirect_max
104
+ Aspera.assert_type(@redirect_max, Integer)
105
+ @headers = headers.clone
106
+ Aspera.assert_type(@headers, Hash)
107
+ @headers['User-Agent'] ||= Parameters.instance.user_agent
108
+ # OAuth object (created on demand)
109
+ @oauth = nil
110
+ end
111
+
112
+ # OAuth object used for authorization, when auth type is `:oauth2`
113
+ # @return [OAuth::Base] the OAuth object (create, or cached if already created)
114
+ def oauth
115
+ if @oauth.nil?
116
+ Aspera.assert(@auth_params[:type].eql?(:oauth2), 'no OAuth defined')
117
+ oauth_parameters = @auth_params.reject { |k, _v| k.eql?(:type) }
118
+ Log.dump(:oauth_parameters, oauth_parameters)
119
+ @oauth = OAuth::Factory.instance.create(**oauth_parameters)
120
+ end
121
+ return @oauth
122
+ end
123
+
124
+ # HTTP/S REST call
125
+ # @param operation [String] HTTP operation (GET, POST, PUT, DELETE)
126
+ # @param subpath [String] subpath of REST API
127
+ # @param query [Hash{String,Symbol => Object}] URL parameters
128
+ # @param content_type [String, nil] Type of body parameters (one of MIME_*) and serialization, else use headers
129
+ # @param body [Hash, String, nil] Body parameters
130
+ # @param headers [Hash{String => String}] Additional headers (override Content-Type)
131
+ # @param save_to [String, Pathname, IO, nil] File path or IO object to save response body; progress bar is used when set
132
+ # @param exception [Boolean] Whether to raise an exception on HTTP error
133
+ # @param ret [Symbol] One of :data, :resp, :both - controls return value
134
+ # @return [Array(Hash, Net::HTTPResponse)] When `ret` is :both
135
+ # @return [Net::HTTPResponse] When `ret` is :resp
136
+ # @return [Hash] When `ret` is :data
137
+ # @raise [CallError] on error if `exception` is true
138
+ def call(
139
+ operation:,
140
+ subpath: nil,
141
+ query: nil,
142
+ content_type: nil,
143
+ body: nil,
144
+ headers: nil,
145
+ save_to: nil,
146
+ exception: true,
147
+ ret: :data
148
+ )
149
+ subpath = subpath.to_s if subpath.is_a?(Symbol)
150
+ subpath = '' if subpath.nil?
151
+ # File path (String or Pathname) or stream (responds to `write`)
152
+ # Pathname also responds to `write` (overwrites file), so it must not be taken as a stream
153
+ save_to = save_to.to_s if save_to.is_a?(Pathname)
154
+ Aspera.assert(save_to.nil? || save_to.is_a?(String) || save_to.respond_to?(:write)) { "save_to: unsupported type #{save_to.class}" }
155
+ Log.log.debug { "call #{operation} [#{subpath}]".red.bold.bg(:green) }
156
+ Log.dump(:body, body, level: :trace1)
157
+ Log.dump(:query, query, level: :trace1)
158
+ Log.dump(:headers, headers, level: :trace1)
159
+ Aspera.assert_type(subpath, String)
160
+ # We must have a way to check return code
161
+ Aspera.assert(exception || !ret.eql?(:data), 'ret: :data requires exception handler')
162
+ req_headers, req_query = prepare_call(headers, query)
163
+ result_http = nil
164
+ result_data = nil
165
+ # number of tries on error (first call included)
166
+ error_tries = 1 + Parameters.instance.retry_max
167
+ # OAuth token is renewed only once, independently of error retries
168
+ token_renewed = false
169
+ # start a block to be able to retry the actual HTTP request in case of OAuth token expiration
170
+ begin
171
+ Log.log.debug("send request (redirects=#{@redirect_max})")
172
+ req = build_request(operation, subpath, req_query, content_type, body, req_headers)
173
+ result_mime = nil
174
+ file_saved = false
175
+ # make http request (pipelined)
176
+ http_session.request(req) do |response|
177
+ result_http = response
178
+ result_mime = Rest.parse_header(result_http['Content-Type'] || Mime::TEXT)[:type]
179
+ Log.log.debug { "response: code=#{result_http.code}, mime=#{result_mime}, content-type=#{response['Content-Type']}" }
180
+ # JSON data needs to be parsed, in case it contains an error code
181
+ file_saved = save_response(response, result_mime, save_to)
182
+ end
183
+ Log.log.debug { "result: code=#{result_http.code} mime=#{result_mime}" }
184
+ # sometimes there is a UTF8 char (e.g. (c) )
185
+ # TODO : related to mime type encoding ?
186
+ # result_http.body.force_encoding('UTF-8') if result_http.body.is_a?(String)
187
+ # Log.log.debug{"result: body=#{result_http.body}"}
188
+ result_data = parse_response(result_http, result_mime)
189
+ ErrorAnalyzer.instance.raise_on_error(req, result_data, result_http)
190
+ unless file_saved || save_to.nil?
191
+ raise 'save_to: IO object requires a streaming response' if save_to.respond_to?(:write)
192
+ FileUtils.mkdir_p(File.dirname(save_to))
193
+ File.write(save_to, result_http.body, binmode: true)
194
+ end
195
+ rescue *NETWORK_ERRORS => e
196
+ raise unless retry_error?(e) && (error_tries -= 1).positive?
197
+ Log.log.warn { "#{e.class}: #{e.message}: retrying" }
198
+ retry_sleep
199
+ retry
200
+ rescue CallError => e
201
+ # not authorized: OAuth token expired
202
+ if !token_renewed && @not_auth_codes.include?(result_http.code.to_s) && @auth_params[:type].eql?(:oauth2)
203
+ token_renewed = true
204
+ new_authorization = renew_oauth_authorization
205
+ unless new_authorization.nil?
206
+ Log.log.debug('using new token')
207
+ req_headers['Authorization'] = new_authorization
208
+ retry
209
+ end
210
+ end
211
+ if retry_error?(e) && (error_tries -= 1).positive?
212
+ retry_sleep
213
+ retry
214
+ end
215
+ # redirect ? (any code beginning with 3)
216
+ if e.response.is_a?(Net::HTTPRedirection) && @redirect_max.positive?
217
+ return redirect_call(
218
+ req.uri,
219
+ e.response['Location'],
220
+ operation: operation,
221
+ body: body,
222
+ content_type: content_type,
223
+ save_to: save_to,
224
+ exception: exception,
225
+ headers: headers,
226
+ ret: ret
227
+ )
228
+ end
229
+ # raise exception if could not retry and not return error in result
230
+ raise e if exception
231
+ end
232
+ Log.log.debug { "result=http:#{result_http}, data:#{result_data.class}" }
233
+ return case ret
234
+ when :data then result_data
235
+ when :resp then result_http
236
+ when :both then [result_data, result_http]
237
+ else Aspera.error_unexpected_value(ret) { 'Type of result for REST' }
238
+ end
239
+ end
240
+
241
+ private
242
+
243
+ # Decide if an error is retried, according to Parameters
244
+ # @param error [Exception] Network error or CallError
245
+ # @return [Boolean] `true` if the request shall be retried
246
+ def retry_error?(error)
247
+ settings = Parameters.instance
248
+ # the request was not sent (connection) or its result is unknown (other)
249
+ return error.is_a?(Net::OpenTimeout) ? settings.retry_on_timeout : settings.retry_on_error unless error.is_a?(CallError)
250
+ response = error.response
251
+ # a redirect is followed, not retried
252
+ return false if response.is_a?(Net::HTTPRedirection)
253
+ # AoC have some timeout , like Connect to platform.bss.asperasoft.com:443 ...
254
+ (settings.retry_on_timeout && response.body&.include?('failed: connect timed out')) ||
255
+ # AoC sometimes not available
256
+ (settings.retry_on_unavailable && UNAVAILABLE_CODES.include?(response.code.to_s)) ||
257
+ # possibility to retry anything if it fails
258
+ settings.retry_on_error
259
+ end
260
+
261
+ # Wait before retry, according to Parameters
262
+ # @return [void]
263
+ def retry_sleep
264
+ sleep(Parameters.instance.retry_sleep) unless Parameters.instance.retry_sleep.eql?(0)
265
+ end
266
+
267
+ # Renew OAuth token: use refresh token, or generate a new one
268
+ # @return [String, nil] New value for header `Authorization`, or `nil` if no new token could be obtained
269
+ def renew_oauth_authorization
270
+ oauth.authorization(refresh: true)
271
+ rescue StandardError => e
272
+ Log.log.error("refresh failed: #{e.message}".bg(:red))
273
+ begin
274
+ oauth.authorization(cache: false)
275
+ rescue StandardError => e
276
+ Log.log.error("new token failed: #{e.message}".bg(:red))
277
+ nil
278
+ end
279
+ end
280
+
281
+ # Forward the call to the location of a redirect response.
282
+ # Same server: same API parameters (auth, headers). Other server: credentials are not forwarded.
283
+ # @param request_uri [URI] URI of the redirected request
284
+ # @param location [String] Header `Location` of redirect response (absolute or relative)
285
+ # @param headers [Hash, nil] Headers of the call
286
+ # @param call_args [Hash] Other arguments of `call`
287
+ # @return [Object] Result of `call` on new location
288
+ def redirect_call(request_uri, location, headers:, **call_args)
289
+ Aspera.assert(!location.nil?) { 'redirect response without Location' }
290
+ new_uri = URI.join(request_uri.to_s, location)
291
+ # query of `Location` is used as call query, so that auth query is added
292
+ query = new_uri.query
293
+ new_uri.query = nil
294
+ new_uri.fragment = nil
295
+ new_url = new_uri.to_s
296
+ Log.log.debug { "redirect to #{new_url}" }
297
+ rest_params = params.merge(base_url: new_url, redirect_max: @redirect_max - 1)
298
+ unless [new_uri.scheme, new_uri.host, new_uri.port].eql?([request_uri.scheme, request_uri.host, request_uri.port])
299
+ Log.log.debug { "redirect to other server: #{new_uri.host}, credentials not forwarded" }
300
+ rest_params[:auth] = {type: :none}
301
+ rest_params[:headers] = without_credentials(@headers)
302
+ headers = without_credentials(headers) unless headers.nil?
303
+ end
304
+ Client.new(**rest_params).call(subpath: new_url.end_with?('/') ? '/' : nil, query: query, headers: headers, **call_args)
305
+ end
306
+
307
+ # @param headers [Hash] HTTP headers
308
+ # @return [Hash] Headers without credentials
309
+ def without_credentials(headers) = headers.reject { |k, _| CREDENTIAL_HEADERS.include?(k.to_s.downcase) }
310
+
311
+ # Add base headers and authentication to call parameters
312
+ # @param headers [Hash, nil] Headers of call
313
+ # @param query [Hash, String, Array, nil] Query of call
314
+ # @return [Array(Hash, Object)] Headers and query for the request
315
+ def prepare_call(headers, query)
316
+ headers = @headers.merge(headers || {})
317
+ case @auth_params[:type]
318
+ when :none
319
+ # no auth
320
+ when :basic
321
+ Log.log.debug('using Basic auth')
322
+ # done in build_req
323
+ when :oauth2
324
+ headers['Authorization'] = oauth.authorization unless headers.key?('Authorization')
325
+ when :url
326
+ query =
327
+ case query
328
+ when nil then @auth_params[:url_query].dup
329
+ when Hash then query.merge(@auth_params[:url_query])
330
+ when String then [query, URI.encode_www_form(@auth_params[:url_query])].join('&')
331
+ else Aspera.error_unexpected_value(query.class) { 'query type with url auth' }
332
+ end
333
+ else Aspera.error_unexpected_value(@auth_params[:type])
334
+ end
335
+ [headers, query]
336
+ end
337
+
338
+ # Build HTTP request, including body and basic authentication
339
+ # @param operation [String] HTTP operation (GET, POST, ...)
340
+ # @param subpath [String] Subpath of REST API
341
+ # @param query [Hash, String, Array, nil] Query of request
342
+ # @param content_type [String, nil] One of Mime::JSON, Mime::WWW, Mime::TEXT, or `nil` for no body
343
+ # @param body [Hash, String, nil] Body of request, serialized according to `content_type`
344
+ # @param headers [Hash] Headers of request
345
+ # @return [Net::HTTPRequest] The request
346
+ def build_request(operation, subpath, query, content_type, body, headers)
347
+ # TODO: shall we percent encode subpath (spaces) test with access key delete with space in id
348
+ # URI.escape()
349
+ separator = ['', '/'].include?(subpath) ? '' : '/'
350
+ uri = Rest.build_uri("#{@base_url}#{separator}#{subpath}", query)
351
+ Log.log.debug { "URI=#{uri}" }
352
+ begin
353
+ # instantiate request object based on string name
354
+ req = Net::HTTP.const_get(operation.capitalize).new(uri)
355
+ rescue NameError
356
+ raise "unsupported operation : #{operation}"
357
+ end
358
+ case content_type
359
+ when nil # ignore
360
+ when Mime::JSON
361
+ req.body = JSON.generate(body) # , ascii_only: true
362
+ req['Content-Type'] = Mime::JSON
363
+ when Mime::WWW
364
+ req.body = URI.encode_www_form(body)
365
+ req['Content-Type'] = Mime::WWW
366
+ when Mime::TEXT
367
+ req.body = body
368
+ req['Content-Type'] = Mime::TEXT
369
+ else Aspera.error_unexpected_value(content_type) { 'body type' }
370
+ end
371
+ # set headers
372
+ headers.each do |key, value|
373
+ req[key] = value
374
+ end
375
+ # :type = :basic
376
+ req.basic_auth(@auth_params[:username], @auth_params[:password]) if @auth_params[:type].eql?(:basic)
377
+ Log.dump(:req_body, req.body, level: :trace1)
378
+ req
379
+ end
380
+
381
+ # Save response body to file or stream, if successful and not JSON (streamed download with progress)
382
+ # @param response [Net::HTTPResponse] Response, body not read yet
383
+ # @param result_mime [String] Media type of response
384
+ # @param save_to [String, IO, nil] File path or IO object
385
+ # @return [Boolean] `true` if body was saved
386
+ def save_response(response, result_mime, save_to)
387
+ return false unless !save_to.nil? && response.code.to_s.start_with?('2') && !Mime.json?(result_mime)
388
+
389
+ total_size = response['Content-Length']&.to_i
390
+ Log.log.debug('before write file')
391
+ target_file = save_to
392
+ # override user's path to path in header (only for file path, not for stream)
393
+ if target_file.is_a?(String) && !response['Content-Disposition'].nil?
394
+ disposition = Rest.parse_header(response['Content-Disposition'])
395
+ if disposition[:parameters].key?(:filename) && !disposition[:parameters][:filename].eql?('.')
396
+ # Use only the basename to prevent path traversal via a server-controlled Content-Disposition header
397
+ safe_filename = File.basename(disposition[:parameters][:filename])
398
+ target_file = File.join(File.dirname(target_file), safe_filename) unless safe_filename.empty?
399
+ end
400
+ end
401
+ Log.log.debug { "saving to: #{target_file}" }
402
+ written_size = 0
403
+ session_id = SecureRandom.uuid.freeze
404
+ progress = Parameters.instance.progress_bar
405
+ progress&.event(:session_start, session_id: session_id)
406
+ progress&.event(:session_size, session_id: session_id, info: total_size) if total_size
407
+ limiter = TimerLimiter.new(0.5)
408
+ success = false
409
+ begin
410
+ if target_file.respond_to?(:write)
411
+ # IO object: stream directly into it
412
+ response.read_body do |fragment|
413
+ target_file.write(fragment)
414
+ written_size += fragment.length
415
+ progress&.event(:transfer, session_id: session_id, info: written_size) if limiter.trigger?
416
+ end
417
+ else
418
+ # file path: download to partial name first, then rename atomically
419
+ target_file_tmp = "#{target_file}#{Parameters.instance.download_partial_suffix}"
420
+ FileUtils.mkdir_p(File.dirname(target_file_tmp))
421
+ File.open(target_file_tmp, 'wb') do |file|
422
+ response.read_body do |fragment|
423
+ file.write(fragment)
424
+ written_size += fragment.length
425
+ progress&.event(:transfer, session_id: session_id, info: written_size) if limiter.trigger?
426
+ end
427
+ end
428
+ File.rename(target_file_tmp, target_file)
429
+ end
430
+ success = true
431
+ ensure
432
+ # Last progress may have been skipped by limiter
433
+ progress&.event(:transfer, session_id: session_id, info: written_size)
434
+ progress&.event(:session_end, session_id: session_id)
435
+ progress&.event(:end, info: success)
436
+ end
437
+ true
438
+ end
439
+
440
+ # Parse response body, according to media type
441
+ # @param result_http [Net::HTTPResponse] Response
442
+ # @param result_mime [String] Media type of response
443
+ # @return [Hash, Array, String, nil] Parsed JSON, or raw body
444
+ def parse_response(result_http, result_mime)
445
+ result_data = result_http.body
446
+ Log.dump(:result_data_raw, result_data, level: :trace1)
447
+ # TODO: Remove next 2 lines when bug in async node api is fixed. (Aspera/core/issues/4490)
448
+ node_api_bug = result_data&.index('}HTTP/1.1 400 Bad Request') if result_data.is_a?(String)
449
+ result_data = result_data[0..node_api_bug] if node_api_bug
450
+ result_mime = 'application/xml' if result_data.is_a?(String) && result_data.start_with?('<?xml')
451
+ result_data = JSON.parse(result_data) if Mime.json?(result_mime) && !result_data.nil? && !result_data.empty?
452
+ Log.dump(:result_data, result_data)
453
+ result_data
454
+ end
455
+
456
+ public
457
+
458
+ # @!group CRUD
459
+ # Simplified methods accepting JSON, and sending JSON body.
460
+ # If specific elements are needed, then use the full `call` method.
461
+
462
+ # `POST` JSON body
463
+ # @param subpath [String] Subpath of REST API
464
+ # @param params [Hash] Body
465
+ # @param kwargs [Hash] Other arguments of `call`
466
+ # @return [Object] Result of `call`
467
+ def create(subpath, params, **kwargs) = call(operation: 'POST', subpath: subpath, body: params, **json_call_args(kwargs, body: true))
468
+
469
+ # `GET`
470
+ # @param subpath [String] Subpath of REST API
471
+ # @param query [Hash, nil] Query
472
+ # @param kwargs [Hash] Other arguments of `call`
473
+ # @return [Object] Result of `call`
474
+ def read(subpath, query = nil, **kwargs) = call(operation: 'GET', subpath: subpath, query: query, **json_call_args(kwargs))
475
+
476
+ # `PUT` JSON body
477
+ # @param subpath [String] Subpath of REST API
478
+ # @param params [Hash] Body
479
+ # @param kwargs [Hash] Other arguments of `call`
480
+ # @return [Object] Result of `call`
481
+ def update(subpath, params, **kwargs) = call(operation: 'PUT', subpath: subpath, body: params, **json_call_args(kwargs, body: true))
482
+
483
+ # `DELETE`
484
+ # @param subpath [String] Subpath of REST API
485
+ # @param params [Hash, nil] Query
486
+ # @param kwargs [Hash] Other arguments of `call`
487
+ # @return [Object] Result of `call`
488
+ def delete(subpath, params = nil, **kwargs) = call(operation: 'DELETE', subpath: subpath, query: params, **json_call_args(kwargs))
489
+
490
+ # `CANCEL`
491
+ # @param subpath [String] Subpath of REST API
492
+ # @param kwargs [Hash] Other arguments of `call`
493
+ # @return [Object] Result of `call`
494
+ def cancel(subpath, **kwargs) = call(operation: 'CANCEL', subpath: subpath, **json_call_args(kwargs))
495
+
496
+ # @!endgroup
497
+
498
+ private
499
+
500
+ # Defaults of CRUD methods: accept JSON, and send JSON body. Caller's arguments are not modified.
501
+ # @param kwargs [Hash] Arguments of `call`
502
+ # @param body [Boolean] `true` if a body is sent
503
+ # @return [Hash] Arguments of `call`
504
+ def json_call_args(kwargs, body: false)
505
+ args = kwargs.merge(headers: {'Accept' => Mime::JSON}.merge(kwargs[:headers] || {}))
506
+ args[:content_type] = Mime::JSON if body && !kwargs.key?(:content_type)
507
+ args
508
+ end
509
+
510
+ # HTTP codes of service unavailable
511
+ UNAVAILABLE_CODES = ['503']
512
+ # Network errors that can be retried
513
+ NETWORK_ERRORS = [
514
+ Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout,
515
+ Errno::ECONNRESET, Errno::ECONNREFUSED, Errno::EPIPE, EOFError, OpenSSL::SSL::SSLError
516
+ ].freeze
517
+ # Headers not forwarded on redirect to another server (lower case)
518
+ CREDENTIAL_HEADERS = %w[authorization cookie].freeze
519
+
520
+ private_constant :UNAVAILABLE_CODES, :NETWORK_ERRORS, :CREDENTIAL_HEADERS
521
+ end
522
+ end
523
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/rest/call_error'
4
+ require 'aspera/log'
5
+ require 'singleton'
6
+ require 'net/http'
7
+
8
+ module Aspera
9
+ module Rest
10
+ # analyze error codes returned by REST calls and raise ruby exception
11
+ #
12
+ # @!method self.instance
13
+ # Returns the singleton instance of ErrorAnalyzer
14
+ # @return [ErrorAnalyzer] the singleton instance
15
+ class ErrorAnalyzer
16
+ include Singleton
17
+
18
+ attr_accessor :log_file
19
+
20
+ # the singleton object is registered with application specific handlers
21
+ def initialize
22
+ # list of handlers
23
+ @error_handlers = []
24
+ @log_file = nil
25
+ add_handler('Type Generic') do |type, context|
26
+ if !context[:response].code.start_with?('2')
27
+ # add generic information
28
+ ErrorAnalyzer.add_error(context, type, "#{context[:request]['host']} #{context[:response].code} #{context[:response].message}")
29
+ end
30
+ end
31
+ end
32
+
33
+ # Use this method to analyze a EST result and raise an exception
34
+ # Analyzes REST call response and raises a CallError exception
35
+ # if HTTP result code is not 2XX
36
+ # @param req [Net::HTTPRequest] the HTTP request
37
+ # @param data [Object] the parsed response body
38
+ # @param http [Net::HTTPResponse] the raw HTTP response
39
+ def raise_on_error(req, data, http)
40
+ Log.log.debug { "raise_on_error #{req.method} #{req.path} #{http.code}" }
41
+ context = {
42
+ messages: [],
43
+ request: req,
44
+ response: http,
45
+ data: data
46
+ }
47
+ # multiple error messages can be found
48
+ # analyze errors from provided handlers
49
+ # note that there can be an error even if code is 2XX
50
+ @error_handlers.each do |handler|
51
+ handler[:block].call(handler[:name], context)
52
+ rescue StandardError => e
53
+ Log.log.error { "ERROR in handler:\n#{e.message}\n#{e.backtrace}" }
54
+ end
55
+ raise CallError, context unless context[:messages].empty?
56
+ end
57
+
58
+ # Add a new error handler (done at application initialization)
59
+ # @param name [String] name of error handler (for logs)
60
+ # @param block [Proc] processing of response: takes two parameters: `name`, `context`
61
+ # name is the one provided here
62
+ # context is built in method raise_on_error
63
+ def add_handler(name, &block)
64
+ @error_handlers.unshift({name: name, block: block})
65
+ nil
66
+ end
67
+
68
+ # Add a simple error handler
69
+ # Check that key exists and is string under specified path (hash)
70
+ # Adds other keys as secondary information
71
+ # @param name [String] name of error handler (for logs)
72
+ # @param always [Boolean] if true, always add error message, even if response code is 2XX
73
+ # @param path [Array] path to error message in response
74
+ def add_simple_handler(name:, always: false, path:)
75
+ path.freeze
76
+ add_handler(name) do |type, context|
77
+ if context[:data].is_a?(Hash) && (!context[:response].code.start_with?('2') || always)
78
+ # Log.log.debug{"simple_handler: #{type} #{path} #{path.last}"}
79
+ # dig and find hash containing error message
80
+ error_struct = path.length.eql?(1) ? context[:data] : context[:data].dig(*path[0..-2])
81
+ # Log.log.debug{"found: #{error_struct.class} #{error_struct}"}
82
+ if error_struct.is_a?(Hash) && error_struct[path.last].is_a?(String)
83
+ ErrorAnalyzer.add_error(context, type, error_struct[path.last])
84
+ error_struct.each do |k, v|
85
+ next if k.eql?(path.last)
86
+ ErrorAnalyzer.add_error(context, "#{type}(sub)", "#{k}: #{v}") if [String, Integer].include?(v.class)
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+
93
+ class << self
94
+ # Used by handler to add an error description to list of errors
95
+ # For logging and tracing : collect error descriptions (create file to activate)
96
+ # @param context [Hash] the result context, provided to handler
97
+ # @param type [String] type of exception, for logging purpose
98
+ # @param message [String] one error message to add to list
99
+ def add_error(context, type, message)
100
+ context[:messages].push(message)
101
+ Log.log.trace1 { "Found error: #{type}: #{message}" }
102
+ log_file = instance.log_file
103
+ # log error for further analysis (file must exist to activate)
104
+ return if log_file.nil? || !File.exist?(log_file)
105
+ File.open(log_file, 'a+') do |f|
106
+ f.write("\n=#{type}=====\n#{context[:request].method} #{context[:request].path}\n#{context[:response].code}\n" \
107
+ "#{JSON.generate(context[:data])}\n#{context[:messages].join("\n")}")
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end