aspera-cli 4.21.2 → 4.23.0

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 (105) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/BUGS.md +1 -1
  4. data/CHANGELOG.md +402 -374
  5. data/CONTRIBUTING.md +6 -10
  6. data/README.md +1018 -687
  7. data/lib/aspera/agent/base.rb +9 -5
  8. data/lib/aspera/agent/connect.rb +30 -28
  9. data/lib/aspera/agent/desktop.rb +29 -25
  10. data/lib/aspera/agent/direct.rb +137 -125
  11. data/lib/aspera/agent/httpgw.rb +22 -26
  12. data/lib/aspera/agent/node.rb +14 -11
  13. data/lib/aspera/agent/transferd.rb +6 -2
  14. data/lib/aspera/api/aoc.rb +15 -18
  15. data/lib/aspera/api/cos_node.rb +1 -1
  16. data/lib/aspera/api/httpgw.rb +15 -7
  17. data/lib/aspera/api/node.rb +6 -4
  18. data/lib/aspera/ascmd.rb +17 -9
  19. data/lib/aspera/ascp/installation.rb +21 -19
  20. data/lib/aspera/ascp/management.rb +1 -1
  21. data/lib/aspera/assert.rb +14 -5
  22. data/lib/aspera/cli/error.rb +2 -2
  23. data/lib/aspera/cli/extended_value.rb +38 -19
  24. data/lib/aspera/cli/formatter.rb +48 -48
  25. data/lib/aspera/cli/hints.rb +10 -2
  26. data/lib/aspera/cli/main.rb +190 -168
  27. data/lib/aspera/cli/manager.rb +16 -16
  28. data/lib/aspera/cli/plugin.rb +24 -21
  29. data/lib/aspera/cli/plugin_factory.rb +1 -1
  30. data/lib/aspera/cli/plugins/alee.rb +1 -1
  31. data/lib/aspera/cli/plugins/aoc.rb +173 -126
  32. data/lib/aspera/cli/plugins/ats.rb +19 -17
  33. data/lib/aspera/cli/plugins/config.rb +87 -98
  34. data/lib/aspera/cli/plugins/console.rb +5 -3
  35. data/lib/aspera/cli/plugins/faspex.rb +39 -35
  36. data/lib/aspera/cli/plugins/faspex5.rb +104 -80
  37. data/lib/aspera/cli/plugins/faspio.rb +13 -1
  38. data/lib/aspera/cli/plugins/httpgw.rb +13 -1
  39. data/lib/aspera/cli/plugins/node.rb +336 -205
  40. data/lib/aspera/cli/plugins/orchestrator.rb +34 -40
  41. data/lib/aspera/cli/plugins/preview.rb +3 -3
  42. data/lib/aspera/cli/plugins/server.rb +7 -6
  43. data/lib/aspera/cli/plugins/shares.rb +5 -5
  44. data/lib/aspera/cli/sync_actions.rb +19 -18
  45. data/lib/aspera/cli/transfer_agent.rb +11 -15
  46. data/lib/aspera/cli/transfer_progress.rb +2 -2
  47. data/lib/aspera/cli/version.rb +1 -1
  48. data/lib/aspera/command_line_builder.rb +116 -95
  49. data/lib/aspera/coverage.rb +4 -3
  50. data/lib/aspera/data_repository.rb +1 -0
  51. data/lib/aspera/environment.rb +7 -6
  52. data/lib/aspera/faspex_gw.rb +14 -14
  53. data/lib/aspera/faspex_postproc.rb +7 -6
  54. data/lib/aspera/hash_ext.rb +2 -2
  55. data/lib/aspera/json_rpc.rb +1 -1
  56. data/lib/aspera/keychain/encrypted_hash.rb +47 -34
  57. data/lib/aspera/keychain/factory.rb +41 -0
  58. data/lib/aspera/keychain/hashicorp_vault.rb +71 -0
  59. data/lib/aspera/keychain/macos_security.rb +19 -11
  60. data/lib/aspera/log.rb +29 -34
  61. data/lib/aspera/nagios.rb +6 -6
  62. data/lib/aspera/node_simulator.rb +8 -8
  63. data/lib/aspera/oauth/base.rb +10 -6
  64. data/lib/aspera/oauth/factory.rb +6 -6
  65. data/lib/aspera/oauth/url_json.rb +6 -6
  66. data/lib/aspera/persistency_action_once.rb +6 -4
  67. data/lib/aspera/persistency_folder.rb +2 -2
  68. data/lib/aspera/preview/file_types.rb +40 -33
  69. data/lib/aspera/preview/generator.rb +1 -1
  70. data/lib/aspera/preview/options.rb +16 -16
  71. data/lib/aspera/preview/terminal.rb +3 -3
  72. data/lib/aspera/preview/utils.rb +11 -13
  73. data/lib/aspera/products/connect.rb +2 -1
  74. data/lib/aspera/products/desktop.rb +1 -1
  75. data/lib/aspera/products/transferd.rb +1 -1
  76. data/lib/aspera/proxy_auto_config.rb +2 -2
  77. data/lib/aspera/rest.rb +70 -50
  78. data/lib/aspera/rest_error_analyzer.rb +1 -0
  79. data/lib/aspera/rest_errors_aspera.rb +1 -1
  80. data/lib/aspera/secret_hider.rb +5 -5
  81. data/lib/aspera/ssh.rb +5 -5
  82. data/lib/aspera/temp_file_manager.rb +1 -0
  83. data/lib/aspera/timer_limiter.rb +7 -5
  84. data/lib/aspera/transfer/async_conf.schema.yaml +716 -0
  85. data/lib/aspera/transfer/convert.rb +29 -0
  86. data/lib/aspera/transfer/error_info.rb +66 -66
  87. data/lib/aspera/transfer/parameters.rb +13 -68
  88. data/lib/aspera/transfer/spec.rb +5 -6
  89. data/lib/aspera/transfer/spec.schema.yaml +753 -0
  90. data/lib/aspera/transfer/spec_doc.rb +62 -0
  91. data/lib/aspera/transfer/sync.rb +37 -76
  92. data/lib/aspera/transfer/sync_instance.schema.yaml +20 -0
  93. data/lib/aspera/transfer/sync_session.schema.yaml +86 -0
  94. data/lib/aspera/transfer/uri.rb +6 -6
  95. data/lib/aspera/uri_reader.rb +1 -1
  96. data/lib/aspera/web_auth.rb +1 -1
  97. data/lib/aspera/web_server_simple.rb +53 -44
  98. data.tar.gz.sig +0 -0
  99. metadata +38 -7
  100. metadata.gz.sig +0 -0
  101. data/examples/build_package.sh +0 -28
  102. data/examples/dascli +0 -30
  103. data/examples/get_proto_file.rb +0 -8
  104. data/examples/proxy.pac +0 -60
  105. data/lib/aspera/transfer/spec.yaml +0 -718
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'aspera/log'
4
+ require 'aspera/assert'
4
5
  require 'singleton'
5
6
  require 'mime/types'
6
7
 
@@ -9,6 +10,7 @@ module Aspera
9
10
  # function conversion_type returns one of the types: CONVERSION_TYPES
10
11
  class FileTypes
11
12
  include Singleton
13
+
12
14
  # values for conversion_type : input format
13
15
  CONVERSION_TYPES = %i[image office pdf plaintext video].freeze
14
16
 
@@ -22,6 +24,8 @@ module Aspera
22
24
  'application/mxf' => :video,
23
25
  'application/mac-binhex40' => :office,
24
26
  'application/msword' => :office,
27
+ 'application/vnd.ms-excel' => :office,
28
+ 'application/vnd.ms-powerpoint' => :office,
25
29
  'application/rtf' => :office,
26
30
  'application/x-abiword' => :office,
27
31
  'application/x-mspublisher' => :office,
@@ -56,17 +60,43 @@ module Aspera
56
60
  end
57
61
 
58
62
  # @param mimetype [String] mime type
59
- # @return file type, one of enum CONVERSION_TYPES
63
+ # @return file type, one of enum CONVERSION_TYPES, or nil if not found
60
64
  def mime_to_type(mimetype)
65
+ Aspera.assert_type(mimetype, String)
61
66
  return SUPPORTED_MIME_TYPES[mimetype] if SUPPORTED_MIME_TYPES.key?(mimetype)
62
- return :office if mimetype.start_with?('application/vnd.')
67
+ return :office if mimetype.start_with?('application/vnd.ms-')
68
+ return :office if mimetype.start_with?('application/vnd.openxmlformats-officedocument')
63
69
  return :video if mimetype.start_with?('video/')
64
70
  return :image if mimetype.start_with?('image/')
65
71
  return nil
66
72
  end
67
73
 
68
- # use mime magic to find mime type based on file content (magic numbers)
69
- def file_to_mime(filepath)
74
+ # @param filepath [String] full path to file
75
+ # @param mimetype [String] provided by node API
76
+ # @return file type, one of enum CONVERSION_TYPES
77
+ # @raise [RuntimeError] if no conversion type found
78
+ def conversion_type(filepath, mimetype)
79
+ Log.log.debug{"conversion_type(#{filepath},mime=#{mimetype},magic=#{@use_mimemagic})"}
80
+ mimetype = nil if mimetype.is_a?(String) && (mimetype == 'application/octet-stream' || mimetype.empty?)
81
+ # Use mimemagic if available
82
+ mimetype ||= mime_using_mimemagic(filepath)
83
+ mimetype ||= mime_using_file(filepath)
84
+ # from extensions, using local mapping
85
+ mimetype ||= MIME::Types.of(File.basename(filepath)).first
86
+ raise "no MIME type found for #{File.basename(filepath)}" if mimetype.nil?
87
+ conversion_type = mime_to_type(mimetype)
88
+ raise "no conversion type found for #{File.basename(filepath)}" if conversion_type.nil?
89
+ Log.log.trace1{"conversion_type(#{File.basename(filepath)}): #{conversion_type.class.name} [#{conversion_type}]"}
90
+ return conversion_type
91
+ end
92
+
93
+ private
94
+
95
+ # Use mime magic to find mime type based on file content (magic numbers)
96
+ # @param filepath [String] full path to file
97
+ # @return [String] mime type, or nil if not found
98
+ def mime_using_mimemagic(filepath)
99
+ return unless @use_mimemagic
70
100
  # moved here, as `mimemagic` can cause installation issues
71
101
  require 'mimemagic'
72
102
  require 'mimemagic/version'
@@ -83,35 +113,12 @@ module Aspera
83
113
  return detected_mime
84
114
  end
85
115
 
86
- # @param filepath [String] full path to file
87
- # @param mimetype [String] provided by node API
88
- # @return file type, one of enum CONVERSION_TYPES
89
- # @raise [RuntimeError] if no conversion type found
90
- def conversion_type(filepath, mimetype)
91
- Log.log.debug{"conversion_type(#{filepath},m=#{mimetype},t=#{@use_mimemagic})"}
92
- # 1- get type from provided mime type, using local mapping
93
- conversion_type = mime_to_type(mimetype) if !mimetype.nil?
94
- # 2- else, from computed mime type (if available)
95
- if conversion_type.nil? && @use_mimemagic
96
- detected_mime = file_to_mime(filepath)
97
- if !detected_mime.nil?
98
- conversion_type = mime_to_type(detected_mime)
99
- if !mimetype.nil?
100
- if mimetype.eql?(detected_mime)
101
- Log.log.debug('matching mime type per magic number')
102
- else
103
- # NOTE: detected can be nil
104
- Log.log.debug{"non matching mime types: node=[#{mimetype}], magic=[#{detected_mime}]"}
105
- end
106
- end
107
- end
108
- end
109
- # 3- else, from extensions, using local mapping
110
- mime_by_ext = MIME::Types.of(File.basename(filepath)).first
111
- conversion_type = mime_to_type(mime_by_ext.to_s) if conversion_type.nil? && !mime_by_ext.nil?
112
- raise "no conversion type found for #{File.basename(filepath)}" if conversion_type.nil?
113
- Log.log.trace1{"conversion_type(#{File.basename(filepath)}): #{conversion_type.class.name} [#{conversion_type}]"}
114
- return conversion_type
116
+ # Use 'file' command to find mime type based on file content (Unix)
117
+ def mime_using_file(filepath)
118
+ return Environment.secure_capture(exec: 'file', args: ['--mime-type', '--brief', filepath]).strip
119
+ rescue => e
120
+ Log.log.error{"error using 'file' command: #{e.message}"}
121
+ return nil
115
122
  end
116
123
  end
117
124
  end
@@ -240,7 +240,7 @@ module Aspera
240
240
  # text to png
241
241
  def convert_plaintext_to_png
242
242
  # get 100 first lines of text file
243
- first_lines = File.open(@source_file_path){|f|Array.new(100){f.readline rescue ''}.join}
243
+ first_lines = File.foreach(@source_file_path).first(100).join
244
244
  Utils.external_command(:magick, [
245
245
  'convert',
246
246
  '-size', "#{@options.thumb_img_size}x#{@options.thumb_img_size}",
@@ -15,22 +15,22 @@ module Aspera
15
15
  # iw/ih : input width or height
16
16
  # -x : keep aspect ratio, having value a multiple of x
17
17
  DESCRIPTIONS = [
18
- { name: :max_size, default: 1 << 24, description: 'maximum size (in bytes) of preview file' },
19
- { name: :thumb_vid_scale, default: "-1:'min(ih,100)'", description: 'png: video: size (ffmpeg scale argument)' },
20
- { name: :thumb_vid_fraction, default: 0.1, description: 'png: video: time percent position of snapshot' },
21
- { name: :thumb_img_size, default: 800, description: 'png: non-video: height (and width)' },
22
- { name: :thumb_text_font, default: 'Courier', description: 'png: plaintext: font for text rendering: `magick identify -list font`'},
23
- { name: :video_conversion, default: :reencode, description: 'mp4: method for preview generation', values: VIDEO_CONVERSION_METHODS },
24
- { name: :video_png_conv, default: :fixed, description: 'mp4: method for thumbnail generation', values: VIDEO_THUMBNAIL_METHODS },
25
- { name: :video_scale, default: "'min(iw,360)':-2", description: 'mp4: all: video scale (ffmpeg scale argument)' },
26
- { name: :video_start_sec, default: 10, description: 'mp4: all: start offset (seconds) of video preview' },
27
- { name: :reencode_ffmpeg, default: {}, description: 'mp4: reencode: options to ffmpeg' },
28
- { name: :blend_keyframes, default: 30, description: 'mp4: blend: # key frames' },
29
- { name: :blend_pauseframes, default: 3, description: 'mp4: blend: # pause frames' },
30
- { name: :blend_transframes, default: 5, description: 'mp4: blend: # transition blend frames' },
31
- { name: :blend_fps, default: 15, description: 'mp4: blend: frame per second' },
32
- { name: :clips_count, default: 5, description: 'mp4: clips: number of clips' },
33
- { name: :clips_length, default: 5, description: 'mp4: clips: length in seconds of each clips' }
18
+ {name: :max_size, default: 1 << 24, description: 'maximum size (in bytes) of preview file'},
19
+ {name: :thumb_vid_scale, default: "-1:'min(ih,100)'", description: 'png: video: size (ffmpeg scale argument)'},
20
+ {name: :thumb_vid_fraction, default: 0.1, description: 'png: video: time percent position of snapshot'},
21
+ {name: :thumb_img_size, default: 800, description: 'png: non-video: height (and width)'},
22
+ {name: :thumb_text_font, default: 'Courier', description: 'png: plaintext: font for text rendering: `magick identify -list font`'},
23
+ {name: :video_conversion, default: :reencode, description: 'mp4: method for preview generation', values: VIDEO_CONVERSION_METHODS},
24
+ {name: :video_png_conv, default: :fixed, description: 'mp4: method for thumbnail generation', values: VIDEO_THUMBNAIL_METHODS},
25
+ {name: :video_scale, default: "'min(iw,360)':-2", description: 'mp4: all: video scale (ffmpeg scale argument)'},
26
+ {name: :video_start_sec, default: 10, description: 'mp4: all: start offset (seconds) of video preview'},
27
+ {name: :reencode_ffmpeg, default: {}, description: 'mp4: reencode: options to ffmpeg'},
28
+ {name: :blend_keyframes, default: 30, description: 'mp4: blend: # key frames'},
29
+ {name: :blend_pauseframes, default: 3, description: 'mp4: blend: # pause frames'},
30
+ {name: :blend_transframes, default: 5, description: 'mp4: blend: # transition blend frames'},
31
+ {name: :blend_fps, default: 15, description: 'mp4: blend: frame per second'},
32
+ {name: :clips_count, default: 5, description: 'mp4: clips: number of clips'},
33
+ {name: :clips_length, default: 5, description: 'mp4: clips: length in seconds of each clips'}
34
34
  ].freeze
35
35
  # add accessors
36
36
  DESCRIPTIONS.each do |opt|
@@ -50,7 +50,7 @@ module Aspera
50
50
  pixel_colors = []
51
51
  image.each_pixel do |pixel, col, row|
52
52
  pixel_rgb = [pixel.red, pixel.green, pixel.blue]
53
- pixel_rgb = pixel_rgb.map { |color| color >> shift_for_8_bit } unless shift_for_8_bit.eql?(0)
53
+ pixel_rgb = pixel_rgb.map{ |color| color >> shift_for_8_bit} unless shift_for_8_bit.eql?(0)
54
54
  # init 2-dim array
55
55
  pixel_colors[row] ||= []
56
56
  pixel_colors[row][col] = pixel_rgb
@@ -82,7 +82,7 @@ module Aspera
82
82
  size: blob.length
83
83
  # width: image.columns,
84
84
  # height: image.rows
85
- }.map { |k, v| "#{k}=#{v}" }.join(';')
85
+ }.map{ |k, v| "#{k}=#{v}"}.join(';')
86
86
  # \a is BEL, \e is ESC : https://github.com/ruby/ruby/blob/master/doc/syntax/literals.rdoc#label-Strings
87
87
  # escape sequence for iTerm2 image display
88
88
  return "\e]1337;File=#{arguments}:#{Base64.encode64(blob)}\a"
@@ -91,7 +91,7 @@ module Aspera
91
91
  # @return [Boolean] true if the terminal supports iTerm2 image display
92
92
  def iterm_supported?
93
93
  TERM_ENV_VARS.each do |env_var|
94
- return true if ITERM_NAMES.any? { |term| ENV[env_var]&.include?(term) }
94
+ return true if ITERM_NAMES.any?{ |term| ENV[env_var]&.include?(term)}
95
95
  end
96
96
  false
97
97
  end
@@ -16,14 +16,18 @@ module Aspera
16
16
  # external binaries used
17
17
  EXTERNAL_TOOLS = %i[ffmpeg ffprobe magick optipng unoconv].freeze
18
18
  TEMP_FORMAT = 'img%04d.jpg'
19
+ FFMPEG_DEFAULT_PARAMS = [
20
+ '-y', # overwrite output without asking
21
+ '-loglevel', 'error' # show only errors and up
22
+ ].freeze
19
23
  private_constant :BASH_SPECIAL_CHARACTERS, :EXTERNAL_TOOLS, :TEMP_FORMAT
20
24
 
21
25
  class << self
22
26
  # returns string with single quotes suitable for bash if there is any bash meta-character
23
27
  def shell_quote(argument)
24
- return argument unless argument.chars.any?{|c|BASH_SPECIAL_CHARACTERS.include?(c)}
28
+ return argument unless argument.chars.any?{ |c| BASH_SPECIAL_CHARACTERS.include?(c)}
25
29
  # surround with single quotes, and escape single quotes
26
- return %Q{'#{argument.gsub("'"){|_s| %q{'"'"'}}}'}
30
+ return %Q{'#{argument.gsub("'"){ |_s| %q{'"'"'}}}'}
27
31
  end
28
32
 
29
33
  # check that external tools can be executed
@@ -53,17 +57,11 @@ module Aspera
53
57
  return Environment.secure_capture(exec: command_sym.to_s, args: command_args.map(&:to_s))
54
58
  end
55
59
 
56
- def ffmpeg(a)
57
- Aspera.assert_type(a, Hash)
58
- # input_file,input_args,output_file,output_args
59
- a[:gl_p] ||= [
60
- '-y', # overwrite output without asking
61
- '-loglevel', 'error' # show only errors and up
62
- ]
63
- a[:in_p] ||= []
64
- a[:out_p] ||= []
65
- Aspera.assert(%i[gl_p in_f in_p out_f out_p].eql?(a.keys.sort)){"wrong params (#{a.keys.sort})"}
66
- external_command(:ffmpeg, [a[:gl_p], a[:in_p], '-i', a[:in_f], a[:out_p], a[:out_f]].flatten)
60
+ def ffmpeg(gl_p: FFMPEG_DEFAULT_PARAMS, in_p: [], in_f:, out_p: [], out_f:)
61
+ Aspera.assert_type(gl_p, Array)
62
+ Aspera.assert_type(in_p, Array)
63
+ Aspera.assert_type(out_p, Array)
64
+ external_command(:ffmpeg, gl_p + in_p + ['-i', in_f] + out_p + [out_f])
67
65
  end
68
66
 
69
67
  # @return Float in seconds
@@ -7,6 +7,7 @@ module Aspera
7
7
  module Products
8
8
  class Connect
9
9
  include Singleton
10
+
10
11
  APP_NAME = 'IBM Aspera Connect'
11
12
 
12
13
  class << self
@@ -43,7 +44,7 @@ module Aspera
43
44
  app_root: File.join(Dir.home, '.aspera', 'connect'),
44
45
  run_root: File.join(Dir.home, '.aspera', 'connect')
45
46
  }]
46
- end.map { |i| i.merge({ expected: APP_NAME }) }
47
+ end.map{ |i| i.merge({expected: APP_NAME})}
47
48
  end
48
49
  end
49
50
 
@@ -18,7 +18,7 @@ module Aspera
18
18
  sub_bin: File.join('Contents', 'Resources', 'transferd', 'bin')
19
19
  }]
20
20
  else []
21
- end.map { |i| i.merge({ expected: APP_NAME }) }
21
+ end.map{ |i| i.merge({expected: APP_NAME})}
22
22
  end
23
23
 
24
24
  def log_file
@@ -15,7 +15,7 @@ module Aspera
15
15
  [{
16
16
  app_root: sdk_directory,
17
17
  sub_bin: ''
18
- }].map { |i| i.merge({ expected: APP_NAME }) }
18
+ }].map{ |i| i.merge({expected: APP_NAME})}
19
19
  end
20
20
 
21
21
  # location of SDK files
@@ -13,7 +13,7 @@ module URI
13
13
  def register_proxy_finder
14
14
  Aspera.assert(block_given?)
15
15
  # overload the method in URI : call user's provided block and fallback to original method
16
- define_method(:find_proxy) {|env_vars=ENV| yield(to_s) || find_proxy_orig(env_vars)}
16
+ define_method(:find_proxy){ |env_vars=ENV| yield(to_s) || find_proxy_orig(env_vars)}
17
17
  end
18
18
  end
19
19
  end
@@ -70,7 +70,7 @@ END_OF_JAVASCRIPT
70
70
  end
71
71
 
72
72
  def register_uri_generic
73
- URI::Generic.register_proxy_finder{|url_str|get_proxies(url_str).first}
73
+ URI::Generic.register_proxy_finder{ |url_str| get_proxies(url_str).first}
74
74
  # allow chaining
75
75
  return self
76
76
  end
data/lib/aspera/rest.rb CHANGED
@@ -6,12 +6,14 @@ require 'aspera/log'
6
6
  require 'aspera/assert'
7
7
  require 'aspera/oauth'
8
8
  require 'aspera/hash_ext'
9
+ require 'aspera/timer_limiter'
9
10
  require 'net/http'
10
11
  require 'net/https'
11
12
  require 'json'
12
13
  require 'base64'
13
14
  require 'singleton'
14
15
  require 'securerandom'
16
+ require 'fileutils'
15
17
 
16
18
  # Cancel method for HTTP
17
19
  class Net::HTTP::Cancel < Net::HTTPRequest # rubocop:disable Style/ClassAndModuleChildren
@@ -31,15 +33,18 @@ module Aspera
31
33
  class RestParameters
32
34
  include Singleton
33
35
 
34
- attr_accessor :user_agent, :download_partial_suffix, :retry_on_error, :retry_sleep, :session_cb, :progress_bar
36
+ attr_accessor :user_agent, :download_partial_suffix, :retry_on_error, :retry_on_timeout, :retry_on_unavailable, :retry_max, :retry_sleep, :session_cb, :progress_bar
35
37
 
36
38
  private
37
39
 
38
40
  def initialize
39
41
  @user_agent = 'RubyAsperaRest'
40
42
  @download_partial_suffix = '.http_partial'
41
- @retry_on_error = 0
42
- @retry_sleep = nil
43
+ @retry_on_error = false
44
+ @retry_on_timeout = true
45
+ @retry_on_unavailable = true
46
+ @retry_max = 1
47
+ @retry_sleep = 4
43
48
  @session_cb = nil
44
49
  @progress_bar = nil
45
50
  end
@@ -57,8 +62,14 @@ module Aspera
57
62
  # error message when entity not found (TODO: use specific exception)
58
63
  ENTITY_NOT_FOUND = 'No such'
59
64
 
65
+ MIME_JSON = 'application/json'
66
+ MIME_WWW = 'application/x-www-form-urlencoded'
67
+ MIME_TEXT = 'text/plain'
68
+
60
69
  # Content-Type that are JSON
61
- JSON_DECODE = ['application/json', 'application/vnd.api+json', 'application/x-javascript'].freeze
70
+ JSON_DECODE = [MIME_JSON, 'application/vnd.api+json', 'application/x-javascript'].freeze
71
+
72
+ UNAVAILABLE_CODES = ['503']
62
73
 
63
74
  class << self
64
75
  # @return [String] Basic auth token
@@ -98,7 +109,7 @@ module Aspera
98
109
  end
99
110
  end
100
111
  when Array
101
- Aspera.assert(query.all?{|i| i.is_a?(Array) && i.length.eql?(2)}) {'Query must be array of arrays or 2 elements'}
112
+ Aspera.assert(query.all?{ |i| i.is_a?(Array) && i.length.eql?(2)}){'Query must be array of arrays or 2 elements'}
102
113
  query_array = query
103
114
  else
104
115
  raise "Query must be Hash or Array, not #{query.class}"
@@ -163,6 +174,10 @@ module Aspera
163
174
  return result
164
175
  end
165
176
 
177
+ # Parse a header string as returned by HTTP
178
+ # @param header [String] header string, e.g. "application/json; charset=utf-8"
179
+ # @return [Hash] parsed header with type and parameters
180
+ # {type: 'application/json', parameters: {charset: 'utf-8'}}
166
181
  def parse_header(header)
167
182
  type, *params = header.split(/;\s*/)
168
183
  parameters = params.map do |param|
@@ -171,7 +186,7 @@ module Aspera
171
186
  one[1] = one[1].gsub(/\A"|"\z/, '')
172
187
  one
173
188
  end.to_h
174
- { type: type.downcase, parameters: parameters }
189
+ {type: type.downcase, parameters: parameters}
175
190
  end
176
191
  end
177
192
 
@@ -189,6 +204,7 @@ module Aspera
189
204
 
190
205
  attr_reader :base_url
191
206
  attr_reader :auth_params
207
+ attr_reader :headers
192
208
 
193
209
  # @return creation parameters
194
210
  def params
@@ -238,7 +254,7 @@ module Aspera
238
254
  @http_session = nil
239
255
  @redirect_max = redirect_max
240
256
  Aspera.assert_type(@redirect_max, Integer)
241
- @headers = headers
257
+ @headers = headers.clone
242
258
  Aspera.assert_type(@headers, Hash)
243
259
  @headers['User-Agent'] ||= RestParameters.instance.user_agent
244
260
  # OAuth object (created on demand)
@@ -249,7 +265,7 @@ module Aspera
249
265
  def oauth
250
266
  if @oauth.nil?
251
267
  Aspera.assert(@auth_params[:type].eql?(:oauth2)){'no OAuth defined'}
252
- oauth_parameters = @auth_params.reject { |k, _v| k.eql?(:type) }
268
+ oauth_parameters = @auth_params.reject{ |k, _v| k.eql?(:type)}
253
269
  Log.log.debug{Log.dump('oauth parameters', oauth_parameters)}
254
270
  @oauth = OAuth::Factory.instance.create(**oauth_parameters)
255
271
  end
@@ -260,20 +276,20 @@ module Aspera
260
276
  # @param operation [String] HTTP operation (GET, POST, PUT, DELETE)
261
277
  # @param subpath [String] subpath of REST API
262
278
  # @param query [Hash] URL parameters
279
+ # @param content_type [String,nil] Type of body parameters (one of MIME_*) and serialization, else use headers
263
280
  # @param body [Hash, String] body parameters
264
- # @param body_type [Symbol] type of body parameters (:json, :www, :text, nil)
281
+ # @param headers [Hash] additional headers (override Content-Type)
265
282
  # @param save_to_file (filepath)
266
283
  # @param return_error (bool)
267
- # @param headers [Hash] additional headers
268
284
  def call(
269
285
  operation:,
270
286
  subpath: nil,
271
287
  query: nil,
288
+ content_type: nil,
272
289
  body: nil,
273
- body_type: nil,
290
+ headers: nil,
274
291
  save_to_file: nil,
275
- return_error: false,
276
- headers: nil
292
+ return_error: false
277
293
  )
278
294
  subpath = subpath.to_s if subpath.is_a?(Symbol)
279
295
  subpath = '' if subpath.nil?
@@ -317,19 +333,18 @@ module Aspera
317
333
  rescue NameError
318
334
  raise "unsupported operation : #{operation}"
319
335
  end
320
- case body_type
321
- when :json
336
+ case content_type
337
+ when nil # ignore
338
+ when MIME_JSON
322
339
  req.body = JSON.generate(body) # , ascii_only: true
323
- req['Content-Type'] = 'application/json'
324
- when :www
340
+ req['Content-Type'] = MIME_JSON
341
+ when MIME_WWW
325
342
  req.body = URI.encode_www_form(body)
326
- req['Content-Type'] = 'application/x-www-form-urlencoded'
327
- when :text
343
+ req['Content-Type'] = MIME_WWW
344
+ when MIME_TEXT
328
345
  req.body = body
329
- req['Content-Type'] = 'text/plain'
330
- when nil
331
- else
332
- raise "unsupported body type : #{body_type}"
346
+ req['Content-Type'] = MIME_TEXT
347
+ else Aspera.error_unexpected_value(content_type){'body type'}
333
348
  end
334
349
  # set headers
335
350
  headers.each do |key, value|
@@ -338,10 +353,8 @@ module Aspera
338
353
  # :type = :basic
339
354
  req.basic_auth(@auth_params[:username], @auth_params[:password]) if @auth_params[:type].eql?(:basic)
340
355
  Log.log.trace1{Log.dump(:req_body, req.body)}
341
- # we try the call, and will retry only if oauth, as we can, first with refresh, and then re-auth if refresh is bad
342
- oauth_tries ||= 2
343
- timeout_tries ||= 5
344
- general_tries ||= 1 + RestParameters.instance.retry_on_error
356
+ # we try the call, and will retry on some error types
357
+ error_tries ||= 1 + RestParameters.instance.retry_max
345
358
  # initialize with number of initial retries allowed, nil gives zero
346
359
  tries_remain_redirect = @redirect_max if tries_remain_redirect.nil?
347
360
  Log.log.debug("send request (retries=#{tries_remain_redirect})")
@@ -350,19 +363,19 @@ module Aspera
350
363
  # make http request (pipelined)
351
364
  http_session.request(req) do |response|
352
365
  result[:http] = response
353
- result_mime = self.class.parse_header(result[:http]['Content-Type'] || 'text/plain')[:type]
366
+ result_mime = self.class.parse_header(result[:http]['Content-Type'] || MIME_TEXT)[:type]
367
+ Log.log.debug{"response: code=#{result[:http].code}, mime=#{result_mime}, mime2= #{response['Content-Type']}"}
354
368
  # JSON data needs to be parsed, in case it contains an error code
355
369
  if !save_to_file.nil? &&
356
370
  result[:http].code.to_s.start_with?('2') &&
357
- !result[:http]['Content-Length'].nil? &&
358
371
  !JSON_DECODE.include?(result_mime)
359
- total_size = result[:http]['Content-Length'].to_i
372
+ total_size = result[:http]['Content-Length']&.to_i
360
373
  Log.log.debug('before write file')
361
374
  target_file = save_to_file
362
375
  # override user's path to path in header
363
376
  if !response['Content-Disposition'].nil?
364
377
  disposition = self.class.parse_header(response['Content-Disposition'])
365
- if disposition[:parameters].key?(:filename)
378
+ if disposition[:parameters].key?(:filename) && !disposition[:parameters][:filename].eql?('.')
366
379
  target_file = File.join(File.dirname(target_file), disposition[:parameters][:filename])
367
380
  end
368
381
  end
@@ -372,12 +385,14 @@ module Aspera
372
385
  written_size = 0
373
386
  session_id = SecureRandom.uuid.freeze
374
387
  RestParameters.instance.progress_bar&.event(:session_start, session_id: session_id)
375
- RestParameters.instance.progress_bar&.event(:session_size, session_id: session_id, info: total_size)
388
+ RestParameters.instance.progress_bar&.event(:session_size, session_id: session_id, info: total_size) if total_size
389
+ FileUtils.mkdir_p(File.dirname(target_file_tmp))
390
+ limiter = TimerLimiter.new(0.5)
376
391
  File.open(target_file_tmp, 'wb') do |file|
377
392
  result[:http].read_body do |fragment|
378
393
  file.write(fragment)
379
394
  written_size += fragment.length
380
- RestParameters.instance.progress_bar&.event(:transfer, session_id: session_id, info: written_size)
395
+ RestParameters.instance.progress_bar&.event(:transfer, session_id: session_id, info: written_size) if limiter.trigger?
381
396
  end
382
397
  end
383
398
  RestParameters.instance.progress_bar&.event(:end, session_id: session_id)
@@ -394,17 +409,22 @@ module Aspera
394
409
  when *JSON_DECODE
395
410
  result[:data] = JSON.parse(result[:http].body) rescue result[:http].body
396
411
  Log.log.debug{Log.dump('result_data', result[:data])}
397
- else # when 'text/plain'
412
+ else # when MIME_TEXT
398
413
  result[:data] = result[:http].body
399
414
  end
400
415
  RestErrorAnalyzer.instance.raise_on_error(req, result)
401
- File.write(save_to_file, result[:http].body, binmode: true) unless file_saved || save_to_file.nil?
416
+ unless file_saved || save_to_file.nil?
417
+ FileUtils.mkdir_p(File.dirname(save_to_file))
418
+ File.write(save_to_file, result[:http].body, binmode: true)
419
+ end
402
420
  rescue RestCallError => e
403
421
  do_retry = false
404
422
  # AoC have some timeout , like Connect to platform.bss.asperasoft.com:443 ...
405
- do_retry = true if e.response.body.include?('failed: connect timed out') && (timeout_tries -= 1).positive?
423
+ do_retry ||= true if e.response.body.include?('failed: connect timed out') && RestParameters.instance.retry_on_timeout
424
+ # AoC sometimes not available
425
+ do_retry ||= true if RestParameters.instance.retry_on_unavailable && UNAVAILABLE_CODES.include?(result[:http].code.to_s)
406
426
  # possibility to retry anything if it fails
407
- do_retry = true if (general_tries -= 1).positive?
427
+ do_retry ||= true if RestParameters.instance.retry_on_error
408
428
  # not authorized: oauth token expired
409
429
  if @not_auth_codes.include?(result[:http].code.to_s) && @auth_params[:type].eql?(:oauth2)
410
430
  begin
@@ -417,10 +437,10 @@ module Aspera
417
437
  req['Authorization'] = oauth.authorization(cache: false)
418
438
  end
419
439
  Log.log.debug{"using new token=#{headers['Authorization']}"}
420
- do_retry = true if (oauth_tries -= 1).positive?
440
+ do_retry ||= true
421
441
  end
422
- if do_retry
423
- sleep(RestParameters.instance.retry_sleep) unless RestParameters.instance.retry_sleep.nil?
442
+ if do_retry && (error_tries -= 1).positive?
443
+ sleep(RestParameters.instance.retry_sleep) unless RestParameters.instance.retry_sleep.eql?(0)
424
444
  retry
425
445
  end
426
446
  # redirect ? (any code beginning with 3)
@@ -436,13 +456,13 @@ module Aspera
436
456
  end
437
457
  # forwards the request to the new location
438
458
  return self.class.new(base_url: new_url, redirect_max: tries_remain_redirect).call(
439
- operation: operation, query: query, body: body, body_type: body_type,
459
+ operation: operation, query: query, body: body, content_type: content_type,
440
460
  save_to_file: save_to_file, return_error: return_error, headers: headers)
441
461
  end
442
462
  # raise exception if could not retry and not return error in result
443
463
  raise e unless return_error
444
464
  end
445
- Log.log.debug{"result=#{result}"}
465
+ Log.log.debug{"result=http:#{result[:http]}, data:#{result[:data].class}"}
446
466
  return result
447
467
  end
448
468
 
@@ -452,23 +472,23 @@ module Aspera
452
472
  #
453
473
 
454
474
  def create(subpath, params)
455
- return call(operation: 'POST', subpath: subpath, headers: {'Accept' => 'application/json'}, body: params, body_type: :json)[:data]
475
+ return call(operation: 'POST', subpath: subpath, headers: {'Accept' => MIME_JSON}, body: params, content_type: MIME_JSON)[:data]
456
476
  end
457
477
 
458
478
  def read(subpath, query=nil)
459
- return call(operation: 'GET', subpath: subpath, headers: {'Accept' => 'application/json'}, query: query)[:data]
479
+ return call(operation: 'GET', subpath: subpath, headers: {'Accept' => MIME_JSON}, query: query)[:data]
460
480
  end
461
481
 
462
482
  def update(subpath, params)
463
- return call(operation: 'PUT', subpath: subpath, headers: {'Accept' => 'application/json'}, body: params, body_type: :json)[:data]
483
+ return call(operation: 'PUT', subpath: subpath, headers: {'Accept' => MIME_JSON}, body: params, content_type: MIME_JSON)[:data]
464
484
  end
465
485
 
466
486
  def delete(subpath, params=nil)
467
- return call(operation: 'DELETE', subpath: subpath, headers: {'Accept' => 'application/json'}, query: params)[:data]
487
+ return call(operation: 'DELETE', subpath: subpath, headers: {'Accept' => MIME_JSON}, query: params)[:data]
468
488
  end
469
489
 
470
490
  def cancel(subpath)
471
- return call(operation: 'CANCEL', subpath: subpath, headers: {'Accept' => 'application/json'})[:data]
491
+ return call(operation: 'CANCEL', subpath: subpath, headers: {'Accept' => MIME_JSON})[:data]
472
492
  end
473
493
 
474
494
  # Query entity by general search (read with parameter `q`)
@@ -490,11 +510,11 @@ module Aspera
490
510
  else
491
511
  # multiple case insensitive partial matches, try case insensitive full match
492
512
  # (anyway AoC does not allow creation of 2 entities with same case insensitive name)
493
- name_matches = matching_items.select{|i|i['name'].casecmp?(search_name)}
513
+ name_matches = matching_items.select{ |i| i['name'].casecmp?(search_name)}
494
514
  case name_matches.length
495
515
  when 1 then return name_matches.first
496
- when 0 then raise %Q(#{subpath}: multiple case insensitive partial match for: "#{search_name}": #{matching_items.map{|i|i['name']}} but no case insensitive full match. Please be more specific or give exact name.) # rubocop:disable Layout/LineLength
497
- else raise "Two entities cannot have the same case insensitive name: #{name_matches.map{|i|i['name']}}"
516
+ when 0 then raise %Q(#{subpath}: multiple case insensitive partial match for: "#{search_name}": #{matching_items.map{ |i| i['name']}} but no case insensitive full match. Please be more specific or give exact name.) # rubocop:disable Layout/LineLength
517
+ else raise "Two entities cannot have the same case insensitive name: #{name_matches.map{ |i| i['name']}}"
498
518
  end
499
519
  end
500
520
  end
@@ -8,6 +8,7 @@ module Aspera
8
8
  # analyze error codes returned by REST calls and raise ruby exception
9
9
  class RestErrorAnalyzer
10
10
  include Singleton
11
+
11
12
  attr_accessor :log_file
12
13
 
13
14
  # the singleton object is registered with application specific handlers
@@ -35,7 +35,7 @@ module Aspera
35
35
  d_t_s.each do |res|
36
36
  r_err = res.dig(*%w[transfer_spec error]) || res['error']
37
37
  next unless r_err.is_a?(Hash)
38
- RestErrorAnalyzer.add_error(call_context, type, "#{r_err['code']}: #{r_err['reason']}: #{r_err['user_message']}")
38
+ RestErrorAnalyzer.add_error(call_context, type, r_err.values.join(': '))
39
39
  end
40
40
  end
41
41
  RestErrorAnalyzer.instance.add_simple_handler(name: 'T9:IBM cloud IAM', path: ['errorMessage'])