aspera-cli 4.27.1 → 4.27.3

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 (133) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +67 -1
  4. data/bin/ascli +2 -1
  5. data/docs/README.md +805 -747
  6. data/lib/aspera/agent/connect.rb +6 -4
  7. data/lib/aspera/agent/desktop.rb +2 -2
  8. data/lib/aspera/agent/direct.rb +3 -1
  9. data/lib/aspera/agent/node.rb +3 -3
  10. data/lib/aspera/api/alee.rb +1 -1
  11. data/lib/aspera/api/aoc.rb +14 -12
  12. data/lib/aspera/api/ats.rb +1 -1
  13. data/lib/aspera/api/cos_node.rb +2 -2
  14. data/lib/aspera/api/faspex.rb +9 -7
  15. data/lib/aspera/api/httpgw.rb +37 -33
  16. data/lib/aspera/api/node.rb +38 -33
  17. data/lib/aspera/ascmd.rb +3 -1
  18. data/lib/aspera/ascp/installation.rb +62 -27
  19. data/lib/aspera/ascp/management.rb +1 -0
  20. data/lib/aspera/assert.rb +4 -0
  21. data/lib/aspera/cli/ascp_actions.rb +20 -41
  22. data/lib/aspera/cli/async_transfer_store.rb +2 -2
  23. data/lib/aspera/cli/bootstrapper.rb +11 -15
  24. data/lib/aspera/cli/command_line.rb +252 -0
  25. data/lib/aspera/cli/command_registry.rb +149 -33
  26. data/lib/aspera/cli/command_spec.rb +103 -14
  27. data/lib/aspera/cli/completion/ascli.bash +12 -0
  28. data/lib/aspera/cli/completion/ascli.fish +16 -0
  29. data/lib/aspera/cli/completion/ascli.zsh +19 -0
  30. data/lib/aspera/cli/context.rb +3 -0
  31. data/lib/aspera/cli/deprecation.rb +37 -0
  32. data/lib/aspera/cli/extended_value.rb +2 -0
  33. data/lib/aspera/cli/formatter.rb +87 -75
  34. data/lib/aspera/cli/gem_checker.rb +1 -1
  35. data/lib/aspera/cli/hints.rb +7 -6
  36. data/lib/aspera/cli/http.rb +21 -21
  37. data/lib/aspera/cli/info.rb +3 -0
  38. data/lib/aspera/cli/mcp_tool.rb +47 -83
  39. data/lib/aspera/cli/option_declarator.rb +33 -42
  40. data/lib/aspera/cli/option_registry.rb +69 -0
  41. data/lib/aspera/cli/option_types.rb +103 -0
  42. data/lib/aspera/cli/option_value.rb +281 -0
  43. data/lib/aspera/cli/options.schema.yaml +38 -5
  44. data/lib/aspera/cli/parser.rb +307 -848
  45. data/lib/aspera/cli/plugins/alee.rb +7 -4
  46. data/lib/aspera/cli/plugins/aoc.rb +435 -380
  47. data/lib/aspera/cli/plugins/ats.rb +58 -73
  48. data/lib/aspera/cli/plugins/base.rb +190 -240
  49. data/lib/aspera/cli/plugins/basic_auth.rb +2 -10
  50. data/lib/aspera/cli/plugins/config.rb +244 -178
  51. data/lib/aspera/cli/plugins/console.rb +102 -38
  52. data/lib/aspera/cli/plugins/cos.rb +6 -23
  53. data/lib/aspera/cli/plugins/factory.rb +3 -0
  54. data/lib/aspera/cli/plugins/faspex5.rb +176 -173
  55. data/lib/aspera/cli/plugins/faspio.rb +5 -10
  56. data/lib/aspera/cli/plugins/httpgw.rb +8 -11
  57. data/lib/aspera/cli/plugins/mcp.rb +20 -55
  58. data/lib/aspera/cli/plugins/node.rb +277 -311
  59. data/lib/aspera/cli/plugins/orchestrator.rb +90 -77
  60. data/lib/aspera/cli/plugins/preview.rb +79 -90
  61. data/lib/aspera/cli/plugins/server.rb +76 -50
  62. data/lib/aspera/cli/plugins/shares.rb +68 -116
  63. data/lib/aspera/cli/preset_actions.rb +17 -10
  64. data/lib/aspera/cli/preset_manager.rb +12 -2
  65. data/lib/aspera/cli/prompt.rb +35 -0
  66. data/lib/aspera/cli/result.rb +13 -18
  67. data/lib/aspera/cli/runner.rb +31 -54
  68. data/lib/aspera/cli/special_values.rb +5 -0
  69. data/lib/aspera/cli/sync_actions.rb +41 -37
  70. data/lib/aspera/cli/terminal_formatter.rb +9 -3
  71. data/lib/aspera/cli/transfer_actions.rb +0 -6
  72. data/lib/aspera/cli/transfer_agent.rb +29 -35
  73. data/lib/aspera/cli/vault_manager.rb +0 -17
  74. data/lib/aspera/cli/version.rb +1 -1
  75. data/lib/aspera/cli/wizard.rb +4 -2
  76. data/lib/aspera/command_line_builder.rb +1 -0
  77. data/lib/aspera/coverage.rb +1 -0
  78. data/lib/aspera/environment.rb +7 -1
  79. data/lib/aspera/faspex_gw.rb +2 -1
  80. data/lib/aspera/faspex_postproc.rb +1 -0
  81. data/lib/aspera/graphql.rb +5 -5
  82. data/lib/aspera/json_rpc/client.rb +5 -5
  83. data/lib/aspera/keychain/encrypted_hash.rb +1 -1
  84. data/lib/aspera/keychain/one_password_api.rb +1 -1
  85. data/lib/aspera/link_header.rb +2 -2
  86. data/lib/aspera/log.rb +22 -25
  87. data/lib/aspera/markdown.rb +2 -0
  88. data/lib/aspera/mime.rb +25 -0
  89. data/lib/aspera/node_simulator.rb +1 -0
  90. data/lib/aspera/oauth/base.rb +35 -25
  91. data/lib/aspera/oauth/factory.rb +1 -0
  92. data/lib/aspera/oauth/generic.rb +1 -1
  93. data/lib/aspera/oauth/jwt.rb +1 -1
  94. data/lib/aspera/oauth/web.rb +9 -8
  95. data/lib/aspera/preview/file_types.rb +4 -4
  96. data/lib/aspera/preview/generator.rb +7 -0
  97. data/lib/aspera/preview/options.rb +4 -4
  98. data/lib/aspera/preview/terminal.rb +4 -3
  99. data/lib/aspera/preview/utils.rb +9 -6
  100. data/lib/aspera/products/connect.rb +1 -1
  101. data/lib/aspera/rainbow.rb +7 -0
  102. data/lib/aspera/rest/aspera_errors.rb +60 -0
  103. data/lib/aspera/rest/call_error.rb +27 -0
  104. data/lib/aspera/rest/client.rb +514 -0
  105. data/lib/aspera/rest/error_analyzer.rb +113 -0
  106. data/lib/aspera/rest/list.rb +143 -0
  107. data/lib/aspera/rest/parameters.rb +55 -0
  108. data/lib/aspera/rest/util.rb +176 -0
  109. data/lib/aspera/rest.rb +7 -621
  110. data/lib/aspera/schema/IBM Aspera Console-enhanced.yaml +1125 -0
  111. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +39 -0
  112. data/lib/aspera/schema/IBM Aspera on Cloud Automation API-1.0.5-enhanced.yaml +2395 -0
  113. data/lib/aspera/schema/documentation.rb +13 -3
  114. data/lib/aspera/schema/registry.rb +18 -1
  115. data/lib/aspera/schema/validator.rb +92 -0
  116. data/lib/aspera/secret_hider.rb +36 -25
  117. data/lib/aspera/string_ext.rb +15 -0
  118. data/lib/aspera/temp_file_manager.rb +6 -5
  119. data/lib/aspera/transfer/parameters.rb +2 -0
  120. data/lib/aspera/transfer/spec.rb +1 -0
  121. data/lib/aspera/transfer/spec.schema.yaml +1 -0
  122. data/lib/aspera/uri_reader.rb +11 -11
  123. data/lib/aspera/web_auth/index.html +147 -0
  124. data/lib/aspera/web_auth/server.rb +81 -0
  125. data.tar.gz.sig +0 -0
  126. metadata +39 -7
  127. metadata.gz.sig +0 -0
  128. data/lib/aspera/colors.rb +0 -79
  129. data/lib/aspera/rest_call_error.rb +0 -25
  130. data/lib/aspera/rest_error_analyzer.rb +0 -111
  131. data/lib/aspera/rest_errors_aspera.rb +0 -58
  132. data/lib/aspera/rest_list.rb +0 -136
  133. data/lib/aspera/web_auth.rb +0 -211
@@ -25,6 +25,9 @@ module Aspera
25
25
  private_constant :EXTERNAL_TOOLS, :TEMP_FORMAT, :FFMPEG_DEFAULT_PARAMS, :H264_ENCODER_PREFERENCE
26
26
 
27
27
  class << self
28
+ # @param tool [Symbol] either `unoconv` or `soffice`
29
+ attr_accessor :office_tool
30
+
28
31
  # Return the first H.264 encoder available in the local ffmpeg installation.
29
32
  # Result is memoized after the first call.
30
33
  # @return [String] encoder name (e.g. 'libx264', 'libopenh264')
@@ -32,8 +35,10 @@ module Aspera
32
35
  def available_h264_encoder
33
36
  return @available_h264_encoder if defined?(@available_h264_encoder)
34
37
  stdout, = execute(:ffmpeg, '-encoders', mode: :capture, exception: false)
35
- @available_h264_encoder = H264_ENCODER_PREFERENCE.find { |enc| stdout.include?(enc) } ||
36
- raise("No supported H.264 encoder found in ffmpeg. Available: #{stdout.lines.grep(/h264/i).map(&:strip).join(', ')}")
38
+ available = stdout.lines.grep(/h264/i).map { |l| l.split[1] }
39
+ @available_h264_encoder = H264_ENCODER_PREFERENCE.find { |enc| available.include?(enc) }
40
+ Aspera.assert(@available_h264_encoder) { "No supported H.264 encoder found in ffmpeg. Available: #{available.join(', ')}" }
41
+ @available_h264_encoder
37
42
  end
38
43
 
39
44
  # Check that external tools can be executed.
@@ -42,10 +47,8 @@ module Aspera
42
47
  # @return [nil]
43
48
  def check_tools(skip_types = [])
44
49
  tools_to_check = EXTERNAL_TOOLS.dup
45
- if skip_types.include?(:office)
46
- tools_to_check.delete(:unoconv)
47
- tools_to_check.delete(:soffice)
48
- end
50
+ tools_to_check.delete(:unoconv) if skip_types.include?(:office) || office_tool.eql?(:soffice)
51
+ tools_to_check.delete(:soffice) if skip_types.include?(:office) || office_tool.eql?(:unoconv)
49
52
  # Check for binaries
50
53
  tools_to_check.each do |command_sym|
51
54
  silent_execute(command_sym, '-h')
@@ -53,7 +53,7 @@ module Aspera
53
53
 
54
54
  # Base URL for CDN of Connect
55
55
  def cdn_api
56
- Rest.new(base_url: CDN_BASE_URL)
56
+ Rest::Client.new(base_url: CDN_BASE_URL)
57
57
  end
58
58
 
59
59
  # Retrieve structure from cloud (CDN) with all versions available
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Load Rainbow with terminal detection (tty, TERM=dumb, CLICOLOR_FORCE)
4
+ # and its String refinement.
5
+ # Each file using colors must still activate it with: using Rainbow
6
+ require 'rainbow'
7
+ require 'rainbow/refinement'
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/rest/error_analyzer'
4
+ require 'aspera/log'
5
+
6
+ module Aspera
7
+ module Rest
8
+ # REST error handlers for various Aspera REST APIs
9
+ class AsperaErrors
10
+ class << self
11
+ # handlers should probably be defined by plugins for modularity
12
+ def register_handlers
13
+ Log.log.debug('registering Aspera REST error handlers')
14
+ # Faspex 4: both user_message and internal_message, and code 200
15
+ # example: missing meta data on package creation
16
+ ErrorAnalyzer.instance.add_simple_handler(name: 'Type 1: error:user_message', path: %w[error user_message], always: true)
17
+ ErrorAnalyzer.instance.add_simple_handler(name: 'Type 2: error:description', path: %w[error description])
18
+ ErrorAnalyzer.instance.add_simple_handler(name: 'Type 3: error:internal_message', path: %w[error internal_message])
19
+ ErrorAnalyzer.instance.add_simple_handler(name: 'Type 5', path: ['error_description'])
20
+ ErrorAnalyzer.instance.add_simple_handler(name: 'Type 6', path: ['message'])
21
+ ErrorAnalyzer.instance.add_simple_handler(name: 'Type 6', path: ['failure'], always: true)
22
+ # AoC Automation
23
+ ErrorAnalyzer.instance.add_simple_handler(name: 'AoC Automation', path: ['error'])
24
+ ErrorAnalyzer.instance.add_handler('Type 7: errors[]') do |type, context|
25
+ next unless context[:data].is_a?(Hash) && context[:data]['errors'].is_a?(Hash)
26
+ # special for Shares: false positive ? (update global transfer_settings)
27
+ next if context[:data].key?('min_connect_version')
28
+ context[:data]['errors'].each do |k, v|
29
+ ErrorAnalyzer.add_error(context, type, "#{k}: #{v}")
30
+ end
31
+ end
32
+ # call to upload_setup and download_setup of node api
33
+ ErrorAnalyzer.instance.add_handler('T8:node: *_setup') do |type, context|
34
+ next unless context[:data].is_a?(Hash)
35
+ d_t_s = context[:data]['transfer_specs']
36
+ next unless d_t_s.is_a?(Array)
37
+ d_t_s.each do |res|
38
+ r_err = res.dig(*%w[transfer_spec error]) || res['error']
39
+ next unless r_err.is_a?(Hash)
40
+ ErrorAnalyzer.add_error(context, type, r_err.values.join(': '))
41
+ end
42
+ end
43
+ ErrorAnalyzer.instance.add_simple_handler(name: 'T9:IBM cloud IAM', path: ['errorMessage'])
44
+ ErrorAnalyzer.instance.add_simple_handler(name: 'T10:faspex v4', path: ['user_message'])
45
+ ErrorAnalyzer.instance.add_handler('Orchestrator') do |type, context|
46
+ next if context[:response].code.start_with?('2')
47
+ data = context[:data]
48
+ next unless data.is_a?(Hash)
49
+ work_order = data['work_order']
50
+ next unless work_order.is_a?(Hash)
51
+ ErrorAnalyzer.add_error(context, type, work_order['statusDetails'])
52
+ data['missing_parameters']&.each do |param|
53
+ ErrorAnalyzer.add_error(context, type, "missing parameter: #{param}")
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aspera
4
+ module Rest
5
+ # raised on error after REST call
6
+ class CallError < StandardError
7
+ def request
8
+ @context[:request]
9
+ end
10
+
11
+ def response
12
+ @context[:response]
13
+ end
14
+
15
+ def data
16
+ @context[:data]
17
+ end
18
+
19
+ # @param context [Hash,String] with keys :messages, :request, :response, :data
20
+ def initialize(context)
21
+ context = {messages: [context]} if context.is_a?(String)
22
+ @context = context
23
+ super(@context[:messages].join("\n"))
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,514 @@
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
+ Parameters.instance.progress_bar&.event(:session_start, session_id: session_id)
405
+ Parameters.instance.progress_bar&.event(:session_size, session_id: session_id, info: total_size) if total_size
406
+ limiter = TimerLimiter.new(0.5)
407
+ if target_file.respond_to?(:write)
408
+ # IO object: stream directly into it
409
+ response.read_body do |fragment|
410
+ target_file.write(fragment)
411
+ written_size += fragment.length
412
+ Parameters.instance.progress_bar&.event(:transfer, session_id: session_id, info: written_size) if limiter.trigger?
413
+ end
414
+ else
415
+ # file path: download to partial name first, then rename atomically
416
+ target_file_tmp = "#{target_file}#{Parameters.instance.download_partial_suffix}"
417
+ FileUtils.mkdir_p(File.dirname(target_file_tmp))
418
+ File.open(target_file_tmp, 'wb') do |file|
419
+ response.read_body do |fragment|
420
+ file.write(fragment)
421
+ written_size += fragment.length
422
+ Parameters.instance.progress_bar&.event(:transfer, session_id: session_id, info: written_size) if limiter.trigger?
423
+ end
424
+ end
425
+ File.rename(target_file_tmp, target_file)
426
+ end
427
+ Parameters.instance.progress_bar&.event(:session_end, session_id: session_id)
428
+ Parameters.instance.progress_bar&.event(:end)
429
+ true
430
+ end
431
+
432
+ # Parse response body, according to media type
433
+ # @param result_http [Net::HTTPResponse] Response
434
+ # @param result_mime [String] Media type of response
435
+ # @return [Hash, Array, String, nil] Parsed JSON, or raw body
436
+ def parse_response(result_http, result_mime)
437
+ result_data = result_http.body
438
+ Log.dump(:result_data_raw, result_data, level: :trace1)
439
+ # TODO: Remove next 2 lines when bug in async node api is fixed. (Aspera/core/issues/4490)
440
+ node_api_bug = result_data&.index('}HTTP/1.1 400 Bad Request') if result_data.is_a?(String)
441
+ result_data = result_data[0..node_api_bug] if node_api_bug
442
+ result_data = JSON.parse(result_data) if Mime.json?(result_mime) && !result_data.nil? && !result_data.empty?
443
+ Log.dump(:result_data, result_data)
444
+ result_data
445
+ end
446
+
447
+ public
448
+
449
+ # @!group CRUD
450
+ # Simplified methods accepting JSON, and sending JSON body.
451
+ # If specific elements are needed, then use the full `call` method.
452
+
453
+ # `POST` JSON body
454
+ # @param subpath [String] Subpath of REST API
455
+ # @param params [Hash] Body
456
+ # @param kwargs [Hash] Other arguments of `call`
457
+ # @return [Object] Result of `call`
458
+ def create(subpath, params, **kwargs) = call(operation: 'POST', subpath: subpath, body: params, **json_call_args(kwargs, body: true))
459
+
460
+ # `GET`
461
+ # @param subpath [String] Subpath of REST API
462
+ # @param query [Hash, nil] Query
463
+ # @param kwargs [Hash] Other arguments of `call`
464
+ # @return [Object] Result of `call`
465
+ def read(subpath, query = nil, **kwargs) = call(operation: 'GET', subpath: subpath, query: query, **json_call_args(kwargs))
466
+
467
+ # `PUT` JSON body
468
+ # @param subpath [String] Subpath of REST API
469
+ # @param params [Hash] Body
470
+ # @param kwargs [Hash] Other arguments of `call`
471
+ # @return [Object] Result of `call`
472
+ def update(subpath, params, **kwargs) = call(operation: 'PUT', subpath: subpath, body: params, **json_call_args(kwargs, body: true))
473
+
474
+ # `DELETE`
475
+ # @param subpath [String] Subpath of REST API
476
+ # @param params [Hash, nil] Query
477
+ # @param kwargs [Hash] Other arguments of `call`
478
+ # @return [Object] Result of `call`
479
+ def delete(subpath, params = nil, **kwargs) = call(operation: 'DELETE', subpath: subpath, query: params, **json_call_args(kwargs))
480
+
481
+ # `CANCEL`
482
+ # @param subpath [String] Subpath of REST API
483
+ # @param kwargs [Hash] Other arguments of `call`
484
+ # @return [Object] Result of `call`
485
+ def cancel(subpath, **kwargs) = call(operation: 'CANCEL', subpath: subpath, **json_call_args(kwargs))
486
+
487
+ # @!endgroup
488
+
489
+ private
490
+
491
+ # Defaults of CRUD methods: accept JSON, and send JSON body. Caller's arguments are not modified.
492
+ # @param kwargs [Hash] Arguments of `call`
493
+ # @param body [Boolean] `true` if a body is sent
494
+ # @return [Hash] Arguments of `call`
495
+ def json_call_args(kwargs, body: false)
496
+ args = kwargs.merge(headers: {'Accept' => Mime::JSON}.merge(kwargs[:headers] || {}))
497
+ args[:content_type] = Mime::JSON if body && !kwargs.key?(:content_type)
498
+ args
499
+ end
500
+
501
+ # HTTP codes of service unavailable
502
+ UNAVAILABLE_CODES = ['503']
503
+ # Network errors that can be retried
504
+ NETWORK_ERRORS = [
505
+ Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout,
506
+ Errno::ECONNRESET, Errno::ECONNREFUSED, Errno::EPIPE, EOFError, OpenSSL::SSL::SSLError
507
+ ].freeze
508
+ # Headers not forwarded on redirect to another server (lower case)
509
+ CREDENTIAL_HEADERS = %w[authorization cookie].freeze
510
+
511
+ private_constant :UNAVAILABLE_CODES, :NETWORK_ERRORS, :CREDENTIAL_HEADERS
512
+ end
513
+ end
514
+ end