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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +67 -1
- data/bin/ascli +2 -1
- data/docs/README.md +805 -747
- data/lib/aspera/agent/connect.rb +6 -4
- data/lib/aspera/agent/desktop.rb +2 -2
- data/lib/aspera/agent/direct.rb +3 -1
- data/lib/aspera/agent/node.rb +3 -3
- 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 +9 -7
- data/lib/aspera/api/httpgw.rb +37 -33
- data/lib/aspera/api/node.rb +38 -33
- data/lib/aspera/ascmd.rb +3 -1
- data/lib/aspera/ascp/installation.rb +62 -27
- 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 +2 -2
- data/lib/aspera/cli/bootstrapper.rb +11 -15
- data/lib/aspera/cli/command_line.rb +252 -0
- data/lib/aspera/cli/command_registry.rb +149 -33
- data/lib/aspera/cli/command_spec.rb +103 -14
- 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 +2 -0
- data/lib/aspera/cli/formatter.rb +87 -75
- data/lib/aspera/cli/gem_checker.rb +1 -1
- data/lib/aspera/cli/hints.rb +7 -6
- data/lib/aspera/cli/http.rb +21 -21
- 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 +103 -0
- data/lib/aspera/cli/option_value.rb +281 -0
- data/lib/aspera/cli/options.schema.yaml +38 -5
- data/lib/aspera/cli/parser.rb +307 -848
- data/lib/aspera/cli/plugins/alee.rb +7 -4
- data/lib/aspera/cli/plugins/aoc.rb +435 -380
- data/lib/aspera/cli/plugins/ats.rb +58 -73
- data/lib/aspera/cli/plugins/base.rb +190 -240
- data/lib/aspera/cli/plugins/basic_auth.rb +2 -10
- data/lib/aspera/cli/plugins/config.rb +244 -178
- data/lib/aspera/cli/plugins/console.rb +102 -38
- 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 +176 -173
- data/lib/aspera/cli/plugins/faspio.rb +5 -10
- 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 +277 -311
- data/lib/aspera/cli/plugins/orchestrator.rb +90 -77
- data/lib/aspera/cli/plugins/preview.rb +79 -90
- data/lib/aspera/cli/plugins/server.rb +76 -50
- data/lib/aspera/cli/plugins/shares.rb +68 -116
- data/lib/aspera/cli/preset_actions.rb +17 -10
- data/lib/aspera/cli/preset_manager.rb +12 -2
- data/lib/aspera/cli/prompt.rb +35 -0
- data/lib/aspera/cli/result.rb +13 -18
- data/lib/aspera/cli/runner.rb +31 -54
- data/lib/aspera/cli/special_values.rb +5 -0
- data/lib/aspera/cli/sync_actions.rb +41 -37
- data/lib/aspera/cli/terminal_formatter.rb +9 -3
- data/lib/aspera/cli/transfer_actions.rb +0 -6
- data/lib/aspera/cli/transfer_agent.rb +29 -35
- data/lib/aspera/cli/vault_manager.rb +0 -17
- data/lib/aspera/cli/version.rb +1 -1
- data/lib/aspera/cli/wizard.rb +4 -2
- data/lib/aspera/command_line_builder.rb +1 -0
- data/lib/aspera/coverage.rb +1 -0
- data/lib/aspera/environment.rb +7 -1
- 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 +1 -1
- data/lib/aspera/keychain/one_password_api.rb +1 -1
- data/lib/aspera/link_header.rb +2 -2
- data/lib/aspera/log.rb +22 -25
- data/lib/aspera/markdown.rb +2 -0
- data/lib/aspera/mime.rb +25 -0
- data/lib/aspera/node_simulator.rb +1 -0
- data/lib/aspera/oauth/base.rb +35 -25
- data/lib/aspera/oauth/factory.rb +1 -0
- data/lib/aspera/oauth/generic.rb +1 -1
- data/lib/aspera/oauth/jwt.rb +1 -1
- data/lib/aspera/oauth/web.rb +9 -8
- data/lib/aspera/preview/file_types.rb +4 -4
- data/lib/aspera/preview/generator.rb +7 -0
- data/lib/aspera/preview/options.rb +4 -4
- 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 +60 -0
- data/lib/aspera/rest/call_error.rb +27 -0
- data/lib/aspera/rest/client.rb +514 -0
- data/lib/aspera/rest/error_analyzer.rb +113 -0
- data/lib/aspera/rest/list.rb +143 -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 on Cloud API-0.2.6-enhanced.yaml +39 -0
- data/lib/aspera/schema/IBM Aspera on Cloud Automation API-1.0.5-enhanced.yaml +2395 -0
- data/lib/aspera/schema/documentation.rb +13 -3
- data/lib/aspera/schema/registry.rb +18 -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/transfer/spec.schema.yaml +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 +39 -7
- metadata.gz.sig +0 -0
- data/lib/aspera/colors.rb +0 -79
- 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
data/lib/aspera/preview/utils.rb
CHANGED
|
@@ -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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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')
|
|
@@ -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
|