aspera-cli 4.25.3 → 4.25.5

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 (45) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +40 -6
  4. data/CONTRIBUTING.md +122 -111
  5. data/README.md +9 -7
  6. data/lib/aspera/agent/direct.rb +10 -8
  7. data/lib/aspera/agent/factory.rb +3 -3
  8. data/lib/aspera/agent/node.rb +1 -1
  9. data/lib/aspera/api/alee.rb +1 -0
  10. data/lib/aspera/api/aoc.rb +15 -14
  11. data/lib/aspera/api/ats.rb +1 -1
  12. data/lib/aspera/api/cos_node.rb +5 -0
  13. data/lib/aspera/api/faspex.rb +27 -20
  14. data/lib/aspera/api/httpgw.rb +19 -3
  15. data/lib/aspera/api/node.rb +122 -29
  16. data/lib/aspera/ascp/installation.rb +9 -10
  17. data/lib/aspera/cli/error.rb +8 -0
  18. data/lib/aspera/cli/formatter.rb +27 -11
  19. data/lib/aspera/cli/info.rb +2 -1
  20. data/lib/aspera/cli/main.rb +30 -12
  21. data/lib/aspera/cli/manager.rb +43 -31
  22. data/lib/aspera/cli/plugins/aoc.rb +7 -5
  23. data/lib/aspera/cli/plugins/base.rb +1 -88
  24. data/lib/aspera/cli/plugins/config.rb +2 -1
  25. data/lib/aspera/cli/plugins/faspex.rb +6 -6
  26. data/lib/aspera/cli/plugins/faspex5.rb +64 -64
  27. data/lib/aspera/cli/plugins/node.rb +33 -78
  28. data/lib/aspera/cli/plugins/shares.rb +4 -2
  29. data/lib/aspera/cli/special_values.rb +1 -0
  30. data/lib/aspera/cli/transfer_agent.rb +3 -0
  31. data/lib/aspera/cli/version.rb +1 -1
  32. data/lib/aspera/cli/wizard.rb +2 -1
  33. data/lib/aspera/dot_container.rb +10 -10
  34. data/lib/aspera/log.rb +1 -1
  35. data/lib/aspera/markdown.rb +1 -1
  36. data/lib/aspera/persistency_folder.rb +1 -1
  37. data/lib/aspera/rest.rb +39 -54
  38. data/lib/aspera/rest_list.rb +121 -0
  39. data/lib/aspera/sync/operations.rb +1 -1
  40. data/lib/aspera/transfer/parameters.rb +8 -8
  41. data/lib/aspera/transfer/spec.rb +1 -0
  42. data/lib/aspera/yaml.rb +1 -1
  43. data.tar.gz.sig +0 -0
  44. metadata +4 -3
  45. metadata.gz.sig +0 -0
@@ -7,11 +7,15 @@ require 'aspera/hash_ext'
7
7
  require 'aspera/data_repository'
8
8
  require 'aspera/transfer/spec'
9
9
  require 'aspera/api/node'
10
+ require 'aspera/rest_list'
10
11
  require 'base64'
11
12
 
12
13
  module Aspera
13
14
  module Api
15
+ # Aspera on Cloud API client
14
16
  class AoC < Rest
17
+ include RestList
18
+
15
19
  PRODUCT_NAME = 'Aspera on Cloud'
16
20
  # use default workspace if it is set, else none
17
21
  DEFAULT_WORKSPACE = ''
@@ -144,18 +148,17 @@ module Aspera
144
148
 
145
149
  # Call `block` with same query using paging and response information.
146
150
  # Block must return a 2 element `Array` with data and http response
147
- # @param query [Hash] Additionnal query parameters
148
- # @param progress [nil, Object] Uses methods: `long_operation_running` and `long_operation_terminated`
151
+ # @param query [Hash] Additional query parameters
149
152
  # @return [Hash] Items and total number of items
150
153
  # @option return [Array<Hash>] :items The list of items
151
154
  # @option return [Integer] :total The total number of items
152
- def call_paging(query: {}, progress: nil)
155
+ def call_paging(query: {})
153
156
  Aspera.assert_type(query, Hash){'query'}
154
157
  Aspera.assert(block_given?)
155
158
  # set default large page if user does not specify own parameters. AoC Caps to 1000 anyway
156
159
  query['per_page'] = 1000 unless query.key?('per_page')
157
- max_items = query.delete(Rest::MAX_ITEMS)
158
- max_pages = query.delete(Rest::MAX_PAGES)
160
+ max_items = query.delete(RestList::MAX_ITEMS)
161
+ max_pages = query.delete(RestList::MAX_PAGES)
159
162
  item_list = []
160
163
  total_count = nil
161
164
  current_page = query['page']
@@ -177,9 +180,9 @@ module Aspera
177
180
  break if !max_items.nil? && item_list.count >= max_items
178
181
  break if !max_pages.nil? && page_count >= max_pages
179
182
  break if total_count&.<=(item_list.count)
180
- progress&.long_operation_running("#{item_list.count} / #{total_count}") unless total_count.eql?(item_list.count.to_s)
183
+ RestParameters.instance.spinner_cb.call("#{item_list.count} / #{total_count}") unless total_count.eql?(item_list.count.to_s)
181
184
  end
182
- progress&.long_operation_terminated
185
+ RestParameters.instance.spinner_cb.call(action: :success)
183
186
  item_list = item_list[0..max_items - 1] if !max_items.nil? && item_list.count > max_items
184
187
  return {items: item_list, total: total_count}
185
188
  end
@@ -210,7 +213,7 @@ module Aspera
210
213
  when 'upload' then %w[mkdir write]
211
214
  when Array
212
215
  Aspera.assert_array_all(levels, String){'access_levels'}
213
- levels.each{ |level| Aspera.assert_value(level, Node::ACCESS_LEVELS){'access_level'}}
216
+ levels.each{ |level| Aspera.assert_values(level, Node::ACCESS_LEVELS){'access_level'}}
214
217
  levels
215
218
  else Aspera.error_unexpected_value(levels){"access_levels must be a list of #{Node::ACCESS_LEVELS.join(', ')} or one of edit, preview, download, upload"}
216
219
  end
@@ -232,8 +235,7 @@ module Aspera
232
235
  username: nil,
233
236
  password: nil,
234
237
  workspace: nil,
235
- secret_finder: nil,
236
- progress_disp: nil
238
+ secret_finder: nil
237
239
  )
238
240
  # Test here because link may set url
239
241
  Aspera.assert(url, 'Missing mandatory option: url', type: ParameterError)
@@ -244,7 +246,6 @@ module Aspera
244
246
  # key: access key
245
247
  # value: associated secret
246
248
  @secret_finder = secret_finder
247
- @progress_disp = progress_disp
248
249
  @workspace_name = workspace
249
250
  @cache_user_info = nil
250
251
  @cache_url_token_info = nil
@@ -309,7 +310,7 @@ module Aspera
309
310
  # @param query [nil, Hash] Additional query
310
311
  # @return [Hash] {items: , total: }
311
312
  def read_with_paging(subpath, query = nil)
312
- return self.class.call_paging(query: query, progress: @progress_disp) do |paged_query|
313
+ return self.class.call_paging(query: query) do |paged_query|
313
314
  read(subpath, query: paged_query, ret: :both)
314
315
  end
315
316
  end
@@ -495,7 +496,7 @@ module Aspera
495
496
  # in that case, the name is resolved and replaced with {type: , id: }
496
497
  # @param package_data [Hash] The whole package creation payload
497
498
  # @param rcpt_lst_field [String] The field in structure, i.e. recipients or bcc_recipients
498
- # @param new_user_option [Hash] Additionnal fields for contact creation
499
+ # @param new_user_option [Hash] Additional fields for contact creation
499
500
  # @return nil, `package_data` is modified
500
501
  def resolve_package_recipients(package_data, rcpt_lst_field, new_user_option)
501
502
  return unless package_data.key?(rcpt_lst_field)
@@ -513,7 +514,7 @@ module Aspera
513
514
  # email: user, else dropbox
514
515
  entity_type = short_recipient_info.include?('@') ? 'contacts' : 'dropboxes'
515
516
  begin
516
- full_recipient_info = lookup_by_name(entity_type, short_recipient_info, query: {'current_workspace_id' => ws_id})
517
+ full_recipient_info = lookup_by_name(entity_type, short_recipient_info, query: {'workspace_id' => ws_id})
517
518
  rescue EntityNotFound
518
519
  # dropboxes cannot be created on the fly
519
520
  Aspera.assert_values(entity_type, 'contacts', type: Error){"No such shared inbox in workspace #{ws_id}"}
@@ -5,7 +5,7 @@ require 'aspera/rest'
5
5
 
6
6
  module Aspera
7
7
  module Api
8
- # ATS API without authentication
8
+ # Aspera Transfer Service API client without authentication
9
9
  class Ats < Aspera::Rest
10
10
  SERVICE_BASE_URL = 'https://ats.aspera.io'
11
11
  # currently supported clouds
@@ -7,9 +7,11 @@ require 'xmlsimple'
7
7
 
8
8
  module Aspera
9
9
  module Api
10
+ # Cloud Object Storage Node API Client
10
11
  class CosNode < Node
11
12
  IBM_CLOUD_TOKEN_URL = 'https://iam.cloud.ibm.com/identity'
12
13
  TOKEN_FIELD = 'delegated_refresh_token'
14
+ FASP_INFO_KEYS = %w[ATSEndpoint AccessKey].freeze
13
15
  class << self
14
16
  def parameters_from_svc_credentials(service_credentials, bucket_region)
15
17
  # check necessary contents
@@ -60,6 +62,9 @@ module Aspera
60
62
  ).body
61
63
  ats_info = XmlSimple.xml_in(xml_result_text, {'ForceArray' => false})
62
64
  Log.dump(:ats_info, ats_info)
65
+ Aspera.assert_hash_all(ats_info, String, nil){'ats_info'}
66
+ Aspera.assert((FASP_INFO_KEYS - ats_info.keys).empty?){'ats_info missing required keys'}
67
+ Aspera.assert_hash_all(ats_info['AccessKey'], String, String){'ats_info'}
63
68
  @storage_credentials = {
64
69
  'type' => 'token',
65
70
  'token' => {TOKEN_FIELD => nil}
@@ -2,10 +2,11 @@
2
2
 
3
3
  require 'aspera/rest'
4
4
  require 'aspera/oauth/base'
5
+ require 'aspera/rest_list'
5
6
  require 'digest'
6
7
 
7
8
  module Aspera
8
- # Implement OAuth for Faspex public link
9
+ # OAuth for Faspex public link
9
10
  class FaspexPubLink < OAuth::Base
10
11
  class << self
11
12
  attr_accessor :additional_info
@@ -55,14 +56,14 @@ module Aspera
55
56
  end
56
57
  OAuth::Factory.instance.register_token_creator(FaspexPubLink)
57
58
  module Api
59
+ # Aspera Faspex 5 API Client
58
60
  class Faspex < Aspera::Rest
61
+ include RestList
62
+
59
63
  # endpoint for authentication API
60
64
  PATH_AUTH = 'auth'
61
65
  PATH_API_V5 = 'api/v5'
62
66
  PATH_HEALTH = 'configuration/ping'
63
- private_constant :PATH_AUTH,
64
- :PATH_API_V5,
65
- :PATH_HEALTH
66
67
  RECIPIENT_TYPES = %w[user workgroup external_user distribution_list shared_inbox].freeze
67
68
  PACKAGE_TERMINATED = %w[completed failed].freeze
68
69
  # list of supported mailbox types (to list packages)
@@ -91,7 +92,7 @@ module Aspera
91
92
  JOB_RUNNING = %w[queued working].freeze
92
93
  PATH_STANDARD_ROOT = '/aspera/faspex'
93
94
  PATH_API_DETECT = "#{PATH_API_V5}/#{PATH_HEALTH}"
94
- HEADER_ITERATION_TOKEN = 'X-Aspera-Next-Iteration-Token'
95
+ HEADER_X_NEXT_ITER_TOKEN = 'X-Aspera-Next-Iteration-Token'
95
96
  HEADER_FASPEX_VERSION = 'X-IBM-Aspera'
96
97
  EMAIL_NOTIF_LIST = %w[
97
98
  welcome_email
@@ -136,9 +137,19 @@ module Aspera
136
137
  end
137
138
  attr_reader :pub_link_context
138
139
 
140
+ # @param url Faspex URL, can be a public link
141
+ # @param auth Authentication method: :boot (token in header), :web (open browser), :jwt (client_id + private key), :public_link (context in URL)
142
+ # @param password For :boot auth, the token copied directly from browser in developer mode
143
+ # @param client_id For :web and :jwt auth, the client_id of web UI application
144
+ # @param client_secret For :web auth, the client_secret of web UI application (not needed for :jwt)
145
+ # @param redirect_uri For :web auth, the redirect_uri of web UI application (must be the same as in application configuration)
146
+ # @param username For :jwt auth, the username of the user to impersonate
147
+ # @param private_key For :jwt auth, the private key to sign JWT token
148
+ # @param passphrase For :jwt auth, the passphrase of the private key
139
149
  def initialize(
140
150
  url:,
141
151
  auth:,
152
+ root: PATH_API_V5,
142
153
  password: nil,
143
154
  client_id: nil,
144
155
  client_secret: nil,
@@ -152,23 +163,23 @@ module Aspera
152
163
  super(**
153
164
  case auth
154
165
  when :public_link
155
- # Get URL of final redirect of public link
156
- redir_url = Rest.new(base_url: url, redirect_max: 3).call(operation: 'GET', ret: :resp).uri.to_s
157
- Log.dump(:redir_url, redir_url, level: :trace1)
158
- # get context from query
159
- encoded_context = Rest.query_to_h(URI.parse(redir_url).query)['context']
166
+ # Get URL of final redirect of provided public link
167
+ final_url = Rest.new(base_url: url, redirect_max: 3).call(operation: 'GET', ret: :resp).uri.to_s
168
+ Log.dump(:final_url, final_url, level: :trace1)
169
+ # Get context from query
170
+ encoded_context = Rest.query_to_h(URI.parse(final_url).query)['context']
160
171
  raise ParameterError, 'Bad faspex5 public link, missing context in query' if encoded_context.nil?
161
172
  # public link information (contains passcode and allowed usage)
162
173
  @pub_link_context = JSON.parse(Base64.decode64(encoded_context))
163
174
  Log.dump(:pub_link_context, @pub_link_context, level: :trace1)
164
175
  # Get the base url, i.e. .../aspera/faspex
165
- base_url = redir_url.gsub(%r{/public/.*}, '').gsub(/\?.*/, '')
176
+ base_url = final_url.gsub(%r{/public/.*}, '').gsub(/\?.*/, '')
166
177
  # Get web UI client_id and redirect_uri
167
178
  # TODO: change this for something more reliable
168
179
  config = JSON.parse(Rest.new(base_url: "#{base_url}/config.js", redirect_max: 3).call(operation: 'GET').sub(/^[^=]+=/, '').gsub(/([a-z_]+):/, '"\1":').delete("\n ").tr("'", '"')).symbolize_keys
169
- Log.dump(:configjs, config)
180
+ Log.dump(:config_js, config)
170
181
  {
171
- base_url: "#{base_url}/#{PATH_API_V5}",
182
+ base_url: "#{base_url}/#{root}",
172
183
  auth: {
173
184
  type: :oauth2,
174
185
  base_url: "#{base_url}/#{PATH_AUTH}",
@@ -185,7 +196,7 @@ module Aspera
185
196
  Aspera.assert(password, type: ParameterError){'Missing password'}
186
197
  # the password here is the token copied directly from browser in developer mode
187
198
  {
188
- base_url: "#{url}/#{PATH_API_V5}",
199
+ base_url: "#{url}/#{root}",
189
200
  headers: {'Authorization' => password}
190
201
  }
191
202
  when :web
@@ -193,7 +204,7 @@ module Aspera
193
204
  Aspera.assert(redirect_uri, type: ParameterError){'Missing redirect_uri'}
194
205
  # opens a browser and ask user to auth using web
195
206
  {
196
- base_url: "#{url}/#{PATH_API_V5}",
207
+ base_url: "#{url}/#{root}",
197
208
  auth: {
198
209
  type: :oauth2,
199
210
  base_url: "#{url}/#{PATH_AUTH}",
@@ -208,7 +219,7 @@ module Aspera
208
219
  Aspera.assert(client_id, type: ParameterError){'Missing client_id'}
209
220
  Aspera.assert(private_key, type: ParameterError){'Missing private_key'}
210
221
  {
211
- base_url: "#{url}/#{PATH_API_V5}",
222
+ base_url: "#{url}/#{root}",
212
223
  auth: {
213
224
  type: :oauth2,
214
225
  base_url: "#{url}/#{PATH_AUTH}",
@@ -229,10 +240,6 @@ module Aspera
229
240
  end
230
241
  )
231
242
  end
232
-
233
- def auth_api
234
- Rest.new(**params, base_url: base_url.sub(PATH_API_V5, PATH_AUTH))
235
- end
236
243
  end
237
244
  end
238
245
  end
@@ -4,13 +4,18 @@ require 'aspera/log'
4
4
  require 'aspera/rest'
5
5
  require 'aspera/transfer/faux_file'
6
6
  require 'aspera/assert'
7
+ require 'net/protocol'
7
8
  require 'securerandom'
8
9
  require 'websocket'
9
10
  require 'base64'
10
11
  require 'json'
11
12
 
13
+ # throw exception on error, instead of error code
14
+ WebSocket.should_raise = true
15
+
12
16
  module Aspera
13
17
  module Api
18
+ # Aspera HTTP Gateway API client
14
19
  # Start a transfer using Aspera HTTP Gateway, using web socket secure for uploads
15
20
  # ref: https://api.ibm.com/explorer/catalog/aspera/product/ibm-aspera/api/http-gateway-api/doc/guides-toc
16
21
  # https://developer.ibm.com/apis/catalog?search=%22aspera%20http%22
@@ -75,6 +80,15 @@ module Aspera
75
80
  Log.log.trace2{"#{LOG_WS_SEND}counts: #{@shared_info[:count]}"}
76
81
  end
77
82
 
83
+ # Check header ourself and give precise error message, as websocket will only throw error without details
84
+ # @param [String] Response Header
85
+ # @return [String] Response Header
86
+ def validated_ws_response_header(header)
87
+ first_line = header.split("\r\n").first
88
+ raise RestCallError.new({messages: ["Unexpected: #{first_line}", 'Expected: 101 Switching Protocols']}) unless first_line.split(/\s+/, 3)[1].eql?('101')
89
+ header
90
+ end
91
+
78
92
  # message processing for read thread
79
93
  def process_received_message(message)
80
94
  Log.log.debug{"#{LOG_WS_RECV}message: [#{message}] (#{message.class})"}
@@ -153,8 +167,9 @@ module Aspera
153
167
  @ws_handshake = ::WebSocket::Handshake::Client.new(url: upload_url, headers: {})
154
168
  @ws_io.write(@ws_handshake.to_s)
155
169
  sleep(0.1)
156
- @ws_handshake << @ws_io.readuntil("\r\n\r\n")
157
- Aspera.assert(@ws_handshake.finished?){'Error in websocket handshake'}
170
+ # Get whole HTTP response header, Check and process
171
+ # no need to check `finished?` or result of `<<` (true), as we give the whole header at once
172
+ @ws_handshake << validated_ws_response_header(@ws_io.readuntil("\r\n\r\n"))
158
173
  Log.log.debug{"#{LOG_WS_SEND}handshake success"}
159
174
  # data shared between main thread and read thread
160
175
  @shared_info = {
@@ -232,14 +247,15 @@ module Aspera
232
247
  # throttling may have skipped last one
233
248
  @notify_cb&.call(:transfer, session_id: session_id, info: session_sent_bytes)
234
249
  @notify_cb&.call(:session_end, session_id: session_id)
235
- @notify_cb&.call(:end)
236
250
  ws_send(ws_type: :close, data: nil)
237
251
  Log.log.debug("Finished upload, waiting for end of #{THR_RECV} thread.")
238
252
  @ws_read_thread.join
239
253
  Log.log.debug{'Read thread joined'}
254
+ ensure
240
255
  # session no more used
241
256
  @ws_io = nil
242
257
  http_session&.finish
258
+ @notify_cb&.call(:end)
243
259
  end
244
260
 
245
261
  def download(transfer_spec)
@@ -6,16 +6,17 @@ require 'aspera/oauth'
6
6
  require 'aspera/log'
7
7
  require 'aspera/assert'
8
8
  require 'aspera/environment'
9
- require 'zlib'
10
9
  require 'base64'
11
10
  require 'openssl'
12
11
  require 'pathname'
12
+ require 'zlib'
13
13
  require 'net/ssh/buffer'
14
14
 
15
15
  module Aspera
16
16
  module Api
17
- # Provides additional functions using node API with gen4 extensions (access keys)
18
- class Node < Aspera::Rest
17
+ # Aspera Node API client
18
+ # with gen4 extensions (access keys)
19
+ class Node < Rest
19
20
  # Format of node scope : node.<access key>:<scope>
20
21
  module Scope
21
22
  # Node sub-scopes
@@ -42,9 +43,10 @@ module Aspera
42
43
  ACCESS_LEVELS = %w[delete list mkdir preview read rename write].freeze
43
44
  # Special HTTP Headers
44
45
  HEADER_X_ASPERA_ACCESS_KEY = 'X-Aspera-AccessKey'
45
- HEADER_X_TOTAL_COUNT = 'X-Total-Count'
46
46
  HEADER_X_CACHE_CONTROL = 'X-Aspera-Cache-Control'
47
+ HEADER_X_TOTAL_COUNT = 'X-Total-Count'
47
48
  HEADER_X_NEXT_ITER_TOKEN = 'X-Aspera-Next-Iteration-Token'
49
+ HEADER_ACCEPT_VERSION = 'Accept-Version'
48
50
  # / in cloud
49
51
  PATH_SEPARATOR = '/'
50
52
 
@@ -52,22 +54,36 @@ module Aspera
52
54
  OAuth::Factory.instance.register_decoder(lambda{ |token| Node.decode_bearer_token(token)})
53
55
 
54
56
  # Class instance variable, access with accessors on class
55
- @use_standard_ports = true
56
- @use_node_cache = true
57
-
58
- class << self
57
+ @api_options = {
59
58
  # Set to false to read transfer parameters from download_setup
60
- attr_accessor :use_standard_ports
59
+ standard_ports: true,
61
60
  # Set to false to bypass cache in redis
62
- attr_accessor :use_node_cache
61
+ cache: true,
62
+ accept_v4: true
63
+ }
64
+ OPTIONS = @api_options.keys.freeze
65
+
66
+ class << self
67
+ attr_reader :api_options
63
68
  attr_reader :use_dynamic_key
64
69
 
65
- # Adds cache control header, as globally specified to read request
66
- # Use like this: read(...,**cache_control)
67
- def cache_control
68
- headers = {'Accept' => Mime::JSON}
69
- headers[HEADER_X_CACHE_CONTROL] = 'no-cache' unless use_node_cache
70
- {headers: headers}
70
+ def api_options=(h)
71
+ Aspera.assert_type(h, Hash)
72
+ h.each do |k, v|
73
+ Aspera.assert(@api_options.key?(k.to_sym)){"unknown api option: #{k} (#{OPTIONS.join(', ')})"}
74
+ Aspera.assert_type(v, TrueClass, FalseClass){"api options value for #{k} should be boolean"}
75
+ @api_options[k.to_sym] = v
76
+ end
77
+ end
78
+
79
+ # Adds cache control header for node API /files/:id
80
+ # as globally specified to read request
81
+ # Use like this: read(..., headers: add_cache_control)
82
+ # @param headers [Hash] optional initial headers to add to
83
+ # @return [Hash] headers with cache control header added if needed
84
+ def add_cache_control(headers = {})
85
+ headers[HEADER_X_CACHE_CONTROL] = 'no-cache' unless api_options[:cache]
86
+ headers
71
87
  end
72
88
 
73
89
  # Set private key to be used
@@ -180,8 +196,8 @@ module Aspera
180
196
  Aspera.assert(!access_key.nil?)
181
197
  end
182
198
  return {
183
- Node::HEADER_X_ASPERA_ACCESS_KEY => access_key,
184
- 'Authorization' => bearer_auth
199
+ HEADER_X_ASPERA_ACCESS_KEY => access_key,
200
+ 'Authorization' => bearer_auth
185
201
  }
186
202
  end
187
203
  end
@@ -248,6 +264,50 @@ module Aspera
248
264
  return false
249
265
  end
250
266
 
267
+ # Read folder content, with pagination management for gen4, not recursive
268
+ # if `Accept-Version: 4.0` is not specified:
269
+ # if `page` and `per_page` are not specified, then all entries are returned.
270
+ # if either `page` or `per_page` is specified, then both are required, else 400
271
+ # if `Accept-Version: 4.0` is specified:
272
+ # those queries are not available: page (not mentioned), sort, min_size, max_size, min_modified_time, max_modified_time, target_id, target_node_id, files_prefetch_count, page, name_iglob : either ignored or result in API error 400.
273
+ # query include is accepted, but seems to do nothing as access_levels and recursive_counts are already included in results.
274
+ # query `iteration_token` is accepted and allows to get paginated results, with `X-Aspera-Next-Iteration-Token` header in response to get next page token. `X-Aspera-Total-Count` header gives total count of entries.
275
+ def read_folder_content(file_id, query = nil, exception: true, path: nil)
276
+ folder_items = []
277
+ begin
278
+ query ||= {}
279
+ headers = self.class.add_cache_control
280
+ use_v4 = self.class.api_options[:accept_v4]
281
+ return read("files/#{file_id}/files", query, headers: headers) unless use_v4 || query.key?('page') || query.key?('per_page')
282
+ if use_v4
283
+ headers[HEADER_ACCEPT_VERSION] = '4.0'
284
+ query['per_page'] = 1000 unless query.key?('per_page')
285
+ elsif query.key?('per_page') && !query.key?('page')
286
+ query['page'] = 0
287
+ end
288
+ loop do
289
+ RestParameters.instance.spinner_cb.call(folder_items.count)
290
+ data, http = read("files/#{file_id}/files", query, headers: headers, ret: :both)
291
+ folder_items.concat(data)
292
+ if use_v4
293
+ iteration_token = http[HEADER_X_NEXT_ITER_TOKEN]
294
+ break if iteration_token.nil? || iteration_token.empty?
295
+ query['iteration_token'] = iteration_token
296
+ else
297
+ break if data['item_count'].eql?(0)
298
+ query['offset'] += data['item_count']
299
+ end
300
+ end
301
+ rescue StandardError => e
302
+ raise e if exception
303
+ Log.log.warn{"#{path}: #{e.class} #{e.message}"}
304
+ Log.log.debug{(['Backtrace:'] + e.backtrace).join("\n")}
305
+ ensure
306
+ RestParameters.instance.spinner_cb.call(folder_items.count, action: :success)
307
+ end
308
+ folder_items
309
+ end
310
+
251
311
  # Recursively browse in a folder (with non-recursive method)
252
312
  # Entries of folders are processed if the processing method returns true
253
313
  # Links are processed on the respective node
@@ -267,14 +327,7 @@ module Aspera
267
327
  current_item = folders_to_explore.shift
268
328
  Log.log.debug{"Exploring #{current_item[:path]}".bg_green}
269
329
  # Get folder content
270
- folder_contents =
271
- begin
272
- # TODO: use header
273
- read("files/#{current_item[:id]}/files", query, **self.class.cache_control)
274
- rescue StandardError => e
275
- Log.log.warn{"#{current_item[:path]}: #{e.class} #{e.message}"}
276
- []
277
- end
330
+ folder_contents = read_folder_content(current_item[:id], query, exception: false, path: current_item[:path])
278
331
  Log.dump(:folder_contents, folder_contents)
279
332
  folder_contents.each do |entry|
280
333
  if entry.key?('error')
@@ -309,7 +362,7 @@ module Aspera
309
362
  # @param path [String] file or folder path (end with "/" is like setting process_last_link)
310
363
  # @param process_last_link [Boolean] if true, follow the last link
311
364
  # @return [Hash] Result data
312
- # @option return [Aspera::Rest] :api REST client instance
365
+ # @option return [Rest] :api REST client instance
313
366
  # @option return [String] :file_id File identifier
314
367
  def resolve_api_fid(top_file_id, path, process_last_link = false)
315
368
  Aspera.assert_type(top_file_id, String)
@@ -445,7 +498,7 @@ module Aspera
445
498
  # Add application specific tags (AoC)
446
499
  @app_info[:api].add_ts_tags(transfer_spec: transfer_spec, app_info: @app_info) unless @app_info.nil?
447
500
  # Add remote host info
448
- if self.class.use_standard_ports
501
+ if self.class.api_options[:standard_ports]
449
502
  # Get default TCP/UDP ports and transfer user
450
503
  transfer_spec.merge!(Transfer::Spec::AK_TSPEC_BASE)
451
504
  # By default: same address as node API
@@ -456,7 +509,7 @@ module Aspera
456
509
  # Get the transfer user from info on access key
457
510
  transfer_spec['remote_user'] = info['transfer_user'] if info['transfer_user']
458
511
  # Get settings from name.value array to hash key.value
459
- settings = info['settings']&.each_with_object({}){ |i, h| h[i['name']] = i['value']}
512
+ settings = info['settings']&.to_h{ |i| [i['name'], i['value']]}
460
513
  # Check WSS ports
461
514
  Transfer::Spec::WSS_FIELDS.each do |i|
462
515
  transfer_spec[i] = settings[i] if settings.key?(i)
@@ -468,6 +521,46 @@ module Aspera
468
521
  return transfer_spec
469
522
  end
470
523
 
524
+ # Executes `GET` call in loop using `iteration_token` (`/ops/transfers`)
525
+ # @param iteration [Array] a single element array with the iteration token or nil
526
+ # @param call_args [Hash] additional arguments to pass to `Rest.call`
527
+ # @return [Array] list of items returned by the API call
528
+ def read_with_paging(subpath, query = nil, iteration: nil, **call_args)
529
+ Aspera.assert_type(iteration, Array, NilClass){'iteration'}
530
+ Aspera.assert_type(query, Hash, NilClass){'query'}
531
+ Aspera.assert(!call_args.key?(:query))
532
+ query = {} if query.nil?
533
+ query[:iteration_token] = iteration[0] unless iteration.nil?
534
+ max = query.delete(RestList::MAX_ITEMS)
535
+ item_list = []
536
+ loop do
537
+ data, http = read(subpath, query, **call_args, ret: :both)
538
+ Aspera.assert_type(data, Array){"Expected data to be an Array, got: #{data.class}"}
539
+ # no data
540
+ break if data.empty?
541
+ # get next iteration token from link
542
+ next_iteration_token = nil
543
+ link_info = http['Link']
544
+ unless link_info.nil?
545
+ m = link_info.match(/<([^>]+)>/)
546
+ Aspera.assert(m){"Cannot parse iteration in Link: #{link_info}"}
547
+ next_iteration_token = Rest.query_to_h(URI.parse(m[1]).query)['iteration_token']
548
+ end
549
+ # same as last iteration: stop
550
+ break if next_iteration_token&.eql?(query[:iteration_token])
551
+ query[:iteration_token] = next_iteration_token
552
+ item_list.concat(data)
553
+ if max&.<=(item_list.length)
554
+ item_list = item_list.slice(0, max)
555
+ break
556
+ end
557
+ break if next_iteration_token.nil?
558
+ end
559
+ # save iteration token if needed
560
+ iteration[0] = query[:iteration_token] unless iteration.nil?
561
+ item_list
562
+ end
563
+
471
564
  private
472
565
 
473
566
  # Method called in loop for each entry for `resolve_api_fid`
@@ -83,16 +83,15 @@ module Aspera
83
83
 
84
84
  # @return [Hash] with key = file name (String), and value = path to file
85
85
  def file_paths
86
- return SDK_FILES.each_with_object({}) do |v, m|
87
- m[v.to_s] =
88
- begin
89
- path(v)
90
- rescue Errno::ENOENT => e
91
- e.message.gsub(/.*assertion failed: /, '').gsub(/\): .*/, ')')
92
- rescue => e
93
- e.message
94
- end
95
- end
86
+ return SDK_FILES.to_h do |v|
87
+ [v.to_s, begin
88
+ path(v)
89
+ rescue Errno::ENOENT => e
90
+ e.message.gsub(/.*assertion failed: /, '').gsub(/\): .*/, ')')
91
+ rescue => e
92
+ e.message
93
+ end]
94
+ end
96
95
  end
97
96
 
98
97
  # TODO: if using another product than SDK, should use files from there
@@ -9,7 +9,15 @@ module Aspera
9
9
  class MissingArgument < Error; end
10
10
  class NoSuchElement < Error; end
11
11
 
12
+ # Raised when a lookup for a specific entity fails to return exactly one result.
13
+ #
14
+ # Provides a formatted message indicating whether the entity was missing
15
+ # or if multiple matches were found (ambiguity).
12
16
  class BadIdentifier < Error
17
+ # @param res_type [String] The type of entity being looked up (e.g., 'user').
18
+ # @param res_id [String] The value of the identifier that failed.
19
+ # @param field [String] The name of the field used for lookup (defaults to 'identifier').
20
+ # @param count [Integer] The number of matches found (0 for not found, >1 for ambiguous).
13
21
  def initialize(res_type, res_id, field: 'identifier', count: 0)
14
22
  msg = count.eql?(0) ? 'not found' : "found #{count}"
15
23
  super("#{res_type} with #{field}=#{res_id}: #{msg}")