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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +133 -0
- data/CONTRIBUTING.md +5 -2
- data/bin/ascli +3 -1
- data/docs/README.md +1006 -766
- data/lib/aspera/agent/base.rb +7 -2
- data/lib/aspera/agent/connect.rb +6 -8
- data/lib/aspera/agent/desktop.rb +2 -6
- data/lib/aspera/agent/direct.rb +52 -22
- data/lib/aspera/agent/node.rb +9 -8
- data/lib/aspera/agent/transferd.rb +2 -2
- data/lib/aspera/api/alee.rb +1 -1
- data/lib/aspera/api/aoc.rb +14 -12
- data/lib/aspera/api/ats.rb +1 -1
- data/lib/aspera/api/cos_node.rb +2 -2
- data/lib/aspera/api/faspex.rb +11 -7
- data/lib/aspera/api/httpgw.rb +38 -35
- data/lib/aspera/api/node.rb +39 -34
- data/lib/aspera/ascmd.rb +3 -1
- data/lib/aspera/ascp/installation.rb +63 -28
- data/lib/aspera/ascp/management.rb +1 -0
- data/lib/aspera/assert.rb +4 -0
- data/lib/aspera/cli/ascp_actions.rb +20 -41
- data/lib/aspera/cli/async_transfer_store.rb +12 -11
- data/lib/aspera/cli/bootstrapper.rb +14 -16
- data/lib/aspera/cli/command_line.rb +252 -0
- data/lib/aspera/cli/command_registry.rb +215 -37
- data/lib/aspera/cli/command_spec.rb +104 -15
- data/lib/aspera/cli/completion/ascli.bash +12 -0
- data/lib/aspera/cli/completion/ascli.fish +16 -0
- data/lib/aspera/cli/completion/ascli.zsh +19 -0
- data/lib/aspera/cli/context.rb +3 -0
- data/lib/aspera/cli/deprecation.rb +37 -0
- data/lib/aspera/cli/extended_value.rb +6 -3
- data/lib/aspera/cli/formatter.rb +94 -80
- data/lib/aspera/cli/gem_checker.rb +1 -1
- data/lib/aspera/cli/hints.rb +7 -6
- data/lib/aspera/cli/http.rb +22 -34
- data/lib/aspera/cli/info.rb +3 -0
- data/lib/aspera/cli/mcp_tool.rb +47 -83
- data/lib/aspera/cli/option_declarator.rb +33 -42
- data/lib/aspera/cli/option_registry.rb +69 -0
- data/lib/aspera/cli/option_types.rb +105 -0
- data/lib/aspera/cli/option_value.rb +278 -0
- data/lib/aspera/cli/options.schema.yaml +124 -15
- data/lib/aspera/cli/parser.rb +333 -862
- data/lib/aspera/cli/plugins/alee.rb +7 -4
- data/lib/aspera/cli/plugins/aoc.rb +545 -518
- data/lib/aspera/cli/plugins/ats.rb +59 -80
- data/lib/aspera/cli/plugins/base.rb +221 -265
- data/lib/aspera/cli/plugins/basic_auth.rb +2 -10
- data/lib/aspera/cli/plugins/config.rb +263 -184
- data/lib/aspera/cli/plugins/console.rb +103 -39
- data/lib/aspera/cli/plugins/cos.rb +6 -23
- data/lib/aspera/cli/plugins/factory.rb +3 -0
- data/lib/aspera/cli/plugins/faspex5.rb +204 -182
- data/lib/aspera/cli/plugins/faspio.rb +6 -11
- data/lib/aspera/cli/plugins/httpgw.rb +8 -11
- data/lib/aspera/cli/plugins/mcp.rb +20 -55
- data/lib/aspera/cli/plugins/node.rb +300 -327
- data/lib/aspera/cli/plugins/orchestrator.rb +152 -110
- data/lib/aspera/cli/plugins/preview.rb +96 -105
- data/lib/aspera/cli/plugins/server.rb +78 -53
- data/lib/aspera/cli/plugins/shares.rb +80 -131
- data/lib/aspera/cli/preset_actions.rb +44 -27
- data/lib/aspera/cli/preset_manager.rb +44 -19
- data/lib/aspera/cli/prompt.rb +36 -0
- data/lib/aspera/cli/result.rb +42 -36
- data/lib/aspera/cli/runner.rb +32 -59
- data/lib/aspera/cli/special_values.rb +5 -0
- data/lib/aspera/cli/sync_actions.rb +51 -46
- data/lib/aspera/cli/terminal_formatter.rb +9 -3
- data/lib/aspera/cli/transfer_actions.rb +14 -9
- data/lib/aspera/cli/transfer_agent.rb +34 -38
- data/lib/aspera/cli/transfer_progress.rb +290 -55
- data/lib/aspera/cli/vault_manager.rb +0 -17
- data/lib/aspera/cli/version.rb +1 -1
- data/lib/aspera/cli/wizard.rb +5 -3
- data/lib/aspera/coverage.rb +1 -1
- data/lib/aspera/environment.rb +35 -5
- data/lib/aspera/faspex_gw.rb +2 -1
- data/lib/aspera/faspex_postproc.rb +1 -0
- data/lib/aspera/graphql.rb +5 -5
- data/lib/aspera/json_rpc/client.rb +5 -5
- data/lib/aspera/keychain/encrypted_hash.rb +2 -2
- data/lib/aspera/keychain/factory.rb +2 -1
- data/lib/aspera/keychain/one_password_api.rb +1 -1
- data/lib/aspera/link_header.rb +2 -2
- data/lib/aspera/log.rb +47 -27
- data/lib/aspera/markdown.rb +2 -0
- data/lib/aspera/mime.rb +25 -0
- data/lib/aspera/node_emulator.rb +759 -0
- data/lib/aspera/oauth/base.rb +37 -26
- data/lib/aspera/oauth/factory.rb +7 -3
- data/lib/aspera/oauth/generic.rb +1 -1
- data/lib/aspera/oauth/json_credentials.rb +34 -0
- data/lib/aspera/oauth/jwt.rb +4 -5
- data/lib/aspera/oauth/web.rb +9 -8
- data/lib/aspera/oauth.rb +1 -0
- data/lib/aspera/persistency_folder.rb +1 -3
- data/lib/aspera/preview/file_types.rb +4 -4
- data/lib/aspera/preview/generator.rb +11 -1
- data/lib/aspera/preview/options.schema.yaml +119 -0
- data/lib/aspera/preview/terminal.rb +4 -3
- data/lib/aspera/preview/utils.rb +9 -6
- data/lib/aspera/products/connect.rb +1 -1
- data/lib/aspera/rainbow.rb +7 -0
- data/lib/aspera/rest/aspera_errors.rb +72 -0
- data/lib/aspera/rest/call_error.rb +27 -0
- data/lib/aspera/rest/client.rb +523 -0
- data/lib/aspera/rest/error_analyzer.rb +113 -0
- data/lib/aspera/rest/list.rb +149 -0
- data/lib/aspera/rest/parameters.rb +55 -0
- data/lib/aspera/rest/util.rb +176 -0
- data/lib/aspera/rest.rb +7 -621
- data/lib/aspera/schema/IBM Aspera Console-enhanced.yaml +1125 -0
- data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +0 -20
- data/lib/aspera/schema/IBM Aspera Orchestrator API-v1.yaml +1784 -0
- data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +1230 -137
- data/lib/aspera/schema/IBM Aspera on Cloud Automation API-1.0.5-enhanced.yaml +2395 -0
- data/lib/aspera/schema/IBM_Aspera_Shares.yaml +14 -13
- data/lib/aspera/schema/documentation.rb +13 -3
- data/lib/aspera/schema/reader.rb +12 -18
- data/lib/aspera/schema/registry.rb +23 -1
- data/lib/aspera/schema/validator.rb +92 -0
- data/lib/aspera/secret_hider.rb +36 -25
- data/lib/aspera/string_ext.rb +15 -0
- data/lib/aspera/temp_file_manager.rb +6 -5
- data/lib/aspera/transfer/parameters.rb +2 -0
- data/lib/aspera/transfer/spec.rb +1 -0
- data/lib/aspera/uri_reader.rb +11 -11
- data/lib/aspera/web_auth/index.html +147 -0
- data/lib/aspera/web_auth/server.rb +81 -0
- data.tar.gz.sig +0 -0
- metadata +43 -9
- metadata.gz.sig +0 -0
- data/lib/aspera/colors.rb +0 -79
- data/lib/aspera/node_simulator.rb +0 -344
- data/lib/aspera/preview/options.rb +0 -45
- data/lib/aspera/rest_call_error.rb +0 -25
- data/lib/aspera/rest_error_analyzer.rb +0 -111
- data/lib/aspera/rest_errors_aspera.rb +0 -58
- data/lib/aspera/rest_list.rb +0 -136
- 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
|