aspera-cli 4.25.0.pre2 → 4.25.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 (46) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +3 -0
  4. data/CONTRIBUTING.md +60 -18
  5. data/README.md +164 -133
  6. data/lib/aspera/agent/factory.rb +9 -6
  7. data/lib/aspera/agent/transferd.rb +4 -4
  8. data/lib/aspera/api/aoc.rb +33 -24
  9. data/lib/aspera/api/ats.rb +1 -0
  10. data/lib/aspera/api/faspex.rb +11 -5
  11. data/lib/aspera/ascmd.rb +1 -1
  12. data/lib/aspera/ascp/installation.rb +5 -5
  13. data/lib/aspera/cli/formatter.rb +15 -62
  14. data/lib/aspera/cli/manager.rb +8 -42
  15. data/lib/aspera/cli/plugins/aoc.rb +48 -30
  16. data/lib/aspera/cli/plugins/ats.rb +30 -36
  17. data/lib/aspera/cli/plugins/base.rb +6 -6
  18. data/lib/aspera/cli/plugins/config.rb +5 -4
  19. data/lib/aspera/cli/plugins/faspex.rb +5 -3
  20. data/lib/aspera/cli/plugins/faspex5.rb +10 -8
  21. data/lib/aspera/cli/plugins/faspio.rb +3 -1
  22. data/lib/aspera/cli/plugins/node.rb +9 -6
  23. data/lib/aspera/cli/plugins/oauth.rb +12 -11
  24. data/lib/aspera/cli/plugins/preview.rb +2 -2
  25. data/lib/aspera/cli/transfer_agent.rb +1 -2
  26. data/lib/aspera/cli/version.rb +1 -1
  27. data/lib/aspera/command_line_builder.rb +5 -5
  28. data/lib/aspera/dot_container.rb +108 -0
  29. data/lib/aspera/id_generator.rb +7 -10
  30. data/lib/aspera/oauth/base.rb +25 -38
  31. data/lib/aspera/oauth/factory.rb +5 -6
  32. data/lib/aspera/oauth/generic.rb +1 -1
  33. data/lib/aspera/oauth/jwt.rb +1 -1
  34. data/lib/aspera/oauth/url_json.rb +4 -3
  35. data/lib/aspera/oauth/web.rb +2 -2
  36. data/lib/aspera/preview/file_types.rb +1 -1
  37. data/lib/aspera/rest.rb +5 -2
  38. data/lib/aspera/ssh.rb +6 -5
  39. data/lib/aspera/sync/conf.schema.yaml +2 -2
  40. data/lib/aspera/transfer/parameters.rb +6 -6
  41. data/lib/aspera/transfer/spec.schema.yaml +3 -3
  42. data/lib/aspera/transfer/spec_doc.rb +11 -21
  43. data/lib/aspera/uri_reader.rb +17 -3
  44. data.tar.gz.sig +0 -0
  45. metadata +2 -1
  46. metadata.gz.sig +0 -0
@@ -18,10 +18,10 @@ module Aspera
18
18
  # columns for list of cloud providers
19
19
  CLOUD_TABLE = %w[id name].freeze
20
20
  private_constant :CLOUD_TABLE
21
- def initialize(**_)
22
- super
23
- @ats_api_pub = nil
24
- @ats_api_pub_v1_cache = nil
21
+ def initialize(api: nil, **base_args)
22
+ super(**base_args)
23
+ @ats_api_open = Api::Ats.new
24
+ @ats_api_auth = api
25
25
  options.declare(:ibm_api_key, 'IBM API key, see https://cloud.ibm.com/iam/apikeys')
26
26
  options.declare(:instance, 'ATS instance in ibm cloud')
27
27
  options.declare(:ats_key, 'ATS key identifier (ats_xxx)')
@@ -36,13 +36,13 @@ module Aspera
36
36
  # TODO: provide list ?
37
37
  cloud = options.get_option(:cloud, mandatory: true).upcase
38
38
  region = options.get_option(:region, mandatory: true)
39
- return @ats_api_pub.read("servers/#{cloud}/#{region}")
39
+ return @ats_api_open.read("servers/#{cloud}/#{region}")
40
40
  end
41
41
 
42
42
  # require api key only if needed
43
- def ats_api_pub_v1
44
- return @ats_api_pub_v1_cache unless @ats_api_pub_v1_cache.nil?
45
- @ats_api_pub_v1_cache = Rest.new(
43
+ def ats_api
44
+ return @ats_api_auth unless @ats_api_auth.nil?
45
+ @ats_api_auth = Rest.new(
46
46
  base_url: "#{Api::Ats::SERVICE_BASE_URL}/pub/v1",
47
47
  auth: {
48
48
  type: :basic,
@@ -73,10 +73,10 @@ module Aspera
73
73
  when 'ibm-s3'
74
74
  server_data2 = nil
75
75
  if server_data.nil?
76
- server_data2 = @ats_api_pub.all_servers.find{ |s| s['id'].eql?(params['transfer_server_id'])}
76
+ server_data2 = @ats_api_open.all_servers.find{ |s| s['id'].eql?(params['transfer_server_id'])}
77
77
  raise "no such transfer server id: #{params['transfer_server_id']}" if server_data2.nil?
78
78
  else
79
- server_data2 = @ats_api_pub.all_servers.find do |s|
79
+ server_data2 = @ats_api_open.all_servers.find do |s|
80
80
  s['cloud'].eql?(server_data['cloud']) &&
81
81
  s['region'].eql?(server_data['region']) &&
82
82
  s.key?('s3_authentication_endpoint')
@@ -88,31 +88,31 @@ module Aspera
88
88
  params['storage']['endpoint'] = server_data2['s3_authentication_endpoint'] if !params['storage'].key?('authentication_endpoint')
89
89
  end
90
90
  end
91
- res = ats_api_pub_v1.create('access_keys', params)
91
+ res = ats_api.create('access_keys', params)
92
92
  return Main.result_single_object(res)
93
93
  # TODO : action : modify, with "PUT"
94
94
  when :list
95
95
  params = query_read_delete(default: {'offset' => 0, 'max_results' => 1000})
96
- res = ats_api_pub_v1.read('access_keys', params)
96
+ res = ats_api.read('access_keys', params)
97
97
  return Main.result_object_list(res['data'], fields: ['name', 'id', 'created.at', 'modified.at'])
98
98
  when :show
99
- res = ats_api_pub_v1.read("access_keys/#{access_key_id}")
99
+ res = ats_api.read("access_keys/#{access_key_id}")
100
100
  return Main.result_single_object(res)
101
101
  when :modify
102
102
  params = value_create_modify(command: command)
103
103
  params['id'] = access_key_id
104
- ats_api_pub_v1.update("access_keys/#{access_key_id}", params)
104
+ ats_api.update("access_keys/#{access_key_id}", params)
105
105
  return Main.result_status('modified')
106
106
  when :entitlement
107
- ak = ats_api_pub_v1.read("access_keys/#{access_key_id}")
107
+ ak = ats_api.read("access_keys/#{access_key_id}")
108
108
  api_bss = Api::Alee.new(ak['license']['entitlement_id'], ak['license']['customer_id'])
109
109
  return Main.result_single_object(api_bss.read('entitlement'))
110
110
  when :delete
111
- ats_api_pub_v1.delete("access_keys/#{access_key_id}")
111
+ ats_api.delete("access_keys/#{access_key_id}")
112
112
  return Main.result_status("deleted #{access_key_id}")
113
113
  when :node
114
- ak_data = ats_api_pub_v1.read("access_keys/#{access_key_id}")
115
- server_data = @ats_api_pub.all_servers.find{ |i| i['id'].start_with?(ak_data['transfer_server_id'])}
114
+ ak_data = ats_api.read("access_keys/#{access_key_id}")
115
+ server_data = @ats_api_open.all_servers.find{ |i| i['id'].start_with?(ak_data['transfer_server_id'])}
116
116
  raise Cli::Error, 'no such server found' if server_data.nil?
117
117
  node_url = server_data['transfer_setup_url']
118
118
  api_node = Api::Node.new(
@@ -126,7 +126,7 @@ module Aspera
126
126
  command = options.get_next_command(Node::COMMANDS_GEN4)
127
127
  return Node.new(context: context, api: api_node).execute_command_gen4(command, ak_data['root_file_id'])
128
128
  when :cluster
129
- ats_url = ats_api_pub_v1.base_url
129
+ ats_url = ats_api.base_url
130
130
  api_ak_auth = Rest.new(
131
131
  base_url: ats_url,
132
132
  auth: {
@@ -140,19 +140,19 @@ module Aspera
140
140
  end
141
141
  end
142
142
 
143
- def execute_action_cluster_pub
143
+ def execute_action_cluster_open
144
144
  command = options.get_next_command(%i[clouds list show])
145
145
  case command
146
146
  when :clouds
147
- return Main.result_object_list(@ats_api_pub.cloud_names.map{ |k, v| CLOUD_TABLE.zip([k, v]).to_h})
147
+ return Main.result_object_list(@ats_api_open.cloud_names.map{ |k, v| CLOUD_TABLE.zip([k, v]).to_h})
148
148
  when :list
149
- return Main.result_object_list(@ats_api_pub.all_servers, fields: %w[id cloud region])
149
+ return Main.result_object_list(@ats_api_open.all_servers, fields: %w[id cloud region])
150
150
  when :show
151
151
  if options.get_option(:cloud) || options.get_option(:region)
152
152
  server_data = server_by_cloud_region
153
153
  else
154
154
  server_id = instance_identifier
155
- server_data = @ats_api_pub.all_servers.find{ |i| i['id'].eql?(server_id)}
155
+ server_data = @ats_api_open.all_servers.find{ |i| i['id'].eql?(server_id)}
156
156
  raise BadIdentifier.new('server', server_id) if server_data.nil?
157
157
  end
158
158
  return Main.result_single_object(server_data)
@@ -170,7 +170,9 @@ module Aspera
170
170
  # does not work: base_url: 'https://iam.cloud.ibm.com/identity',
171
171
  grant_type: 'urn:ibm:params:oauth:grant-type:apikey',
172
172
  response_type: 'cloud_iam',
173
- apikey: options.get_option(:ibm_api_key, mandatory: true)
173
+ params: {
174
+ apikey: options.get_option(:ibm_api_key, mandatory: true)
175
+ }
174
176
  }
175
177
  )
176
178
  end
@@ -213,31 +215,23 @@ module Aspera
213
215
  ACTIONS = %i[cluster access_key api_key aws_trust_policy].freeze
214
216
 
215
217
  # called for legacy and AoC
216
- def execute_action_gen(ats_api_arg)
218
+ def execute_action
217
219
  actions = ACTIONS.dup
218
- actions.delete(:api_key) unless ats_api_arg.nil?
220
+ actions.delete(:api_key) unless @ats_api_auth.nil?
219
221
  command = options.get_next_command(actions)
220
- @ats_api_pub_v1_cache = ats_api_arg
221
- # keep as member variable as we may want to use the api in AoC name space
222
- @ats_api_pub = Api::Ats.new
223
222
  case command
224
223
  when :cluster # display general ATS cluster information, this uses public API, no auth
225
- return execute_action_cluster_pub
224
+ return execute_action_cluster_open
226
225
  when :access_key
227
226
  return execute_action_access_key
228
227
  when :api_key # manage credential to access ATS API
229
228
  return execute_action_api_key
230
229
  when :aws_trust_policy
231
- res = ats_api_pub_v1.read('aws/trustpolicy', {region: options.get_option(:region, mandatory: true)})
230
+ res = ats_api.read('aws/trustpolicy', {region: options.get_option(:region, mandatory: true)})
232
231
  return Main.result_single_object(res)
233
232
  else Aspera.error_unexpected_value(command)
234
233
  end
235
234
  end
236
-
237
- # called for legacy ATS only
238
- def execute_action
239
- execute_action_gen(nil)
240
- end
241
235
  end
242
236
  end
243
237
  end
@@ -136,12 +136,12 @@ module Aspera
136
136
  # @param command [Symbol] command to execute: create show list modify delete
137
137
  # @param display_fields [Array] fields to display by default
138
138
  # @param items_key [String] result is in a sub key of the json
139
- # @param delete_style [String] if set, the delete operation by array in payload
140
- # @param id_as_arg [String] if set, the id is provided as url argument ?<id_as_arg>=<id>
141
- # @param is_singleton [Boolean] if true, entity is the full path to the resource
142
- # @param tclo [Bool] if set, :list use paging with total_count, limit, offset
143
- # @param block [Proc] block to search for identifier based on attribute value
144
- # @return result suitable for CLI result
139
+ # @param delete_style [String] If set, the delete operation by array in payload
140
+ # @param id_as_arg [String] If set, the id is provided as url argument ?<id_as_arg>=<id>
141
+ # @param is_singleton [Boolean] If `true`, entity is the full path to the resource
142
+ # @param tclo [Boolean] If `true`, :list use paging with total_count, limit, offset
143
+ # @param block [Proc] Block to search for identifier based on attribute value
144
+ # @return [Hash] Result suitable for CLI result
145
145
  def entity_execute(
146
146
  api:,
147
147
  entity:,
@@ -1170,16 +1170,17 @@ module Aspera
1170
1170
  end
1171
1171
 
1172
1172
  # Lookup the corresponding secret for the given URL and usernames
1173
- # @raise Exception if mandatory and not found
1174
- def lookup_secret(url:, username:, mandatory: false)
1173
+ # @param url [String] Server URL
1174
+ # @param username [String] Username
1175
+ # @return [String, nil] Secret if found
1176
+ def lookup_secret(url:, username:)
1175
1177
  secret = options.get_option(:secret)
1176
- if secret.nil?
1178
+ if secret.eql?('PRESET')
1177
1179
  conf = lookup_preset(url: url, username: username)
1178
1180
  if conf.is_a?(Hash)
1179
1181
  Log.log.debug{"Found preset #{conf} with URL and username"}
1180
1182
  secret = conf['password']
1181
1183
  end
1182
- raise "Please provide secret for #{username} using option: secret or by setting a preset for #{username}@#{url}." if secret.nil? && mandatory
1183
1184
  end
1184
1185
  return secret
1185
1186
  end
@@ -150,7 +150,9 @@ module Aspera
150
150
  grant_method: :generic,
151
151
  base_url: "#{faspex_api_base}/auth/oauth2",
152
152
  auth: {type: :basic, username: options.get_option(:username, mandatory: true), password: options.get_option(:password, mandatory: true)},
153
- scope: 'admin',
153
+ params: {
154
+ scope: 'admin'
155
+ },
154
156
  grant_type: 'password'
155
157
  }
156
158
  )
@@ -337,12 +339,12 @@ module Aspera
337
339
  skip_ids_persistency = PersistencyActionOnce.new(
338
340
  manager: persistency,
339
341
  data: skip_ids_data,
340
- id: IdGenerator.from_list([
342
+ id: IdGenerator.from_list(
341
343
  'faspex_recv',
342
344
  options.get_option(:url, mandatory: true),
343
345
  options.get_option(:username, mandatory: true),
344
346
  options.get_option(:box, mandatory: true).to_s
345
- ])
347
+ )
346
348
  )
347
349
  end
348
350
  # get command line parameters
@@ -79,12 +79,14 @@ module Aspera
79
79
  )
80
80
  return {
81
81
  preset_value: {
82
- url: app_url,
83
- username: wiz_username,
84
- auth: :jwt.to_s,
85
- private_key: "@file:#{private_key_path}",
86
- client_id: client_id,
87
- client_secret: client_secret
82
+ url: app_url,
83
+ username: wiz_username,
84
+ auth: :jwt.to_s,
85
+ private_key: "@file:#{private_key_path}",
86
+ params: {
87
+ client_id: client_id,
88
+ client_secret: client_secret
89
+ }
88
90
  },
89
91
  test_args: 'user profile show'
90
92
  }
@@ -198,12 +200,12 @@ module Aspera
198
200
  skip_ids_persistency = PersistencyActionOnce.new(
199
201
  manager: persistency,
200
202
  data: [],
201
- id: IdGenerator.from_list([
203
+ id: IdGenerator.from_list(
202
204
  'faspex_recv',
203
205
  options.get_option(:url, mandatory: true),
204
206
  options.get_option(:username, mandatory: true),
205
207
  options.get_option(:box, mandatory: true)
206
- ])
208
+ )
207
209
  )
208
210
  end
209
211
  packages = []
@@ -64,7 +64,9 @@ module Aspera
64
64
  type: :oauth2,
65
65
  grant_method: :jwt,
66
66
  base_url: "#{base_url}/auth",
67
- client_id: app_client_id,
67
+ params: {
68
+ client_id: app_client_id
69
+ },
68
70
  use_query: true,
69
71
  payload: {
70
72
  iss: app_client_id, # issuer
@@ -415,10 +415,12 @@ module Aspera
415
415
  root_file_id = options.get_option(:root_id)
416
416
  if root_file_id.nil?
417
417
  ak_info = @api_node.read("access_keys/#{access_key_id}")
418
+ ak_secret = config.lookup_secret(url: @api_node.base_url, username: ak_info['id'])
418
419
  # change API credentials if different access key
419
420
  if !access_key_id.eql?('self')
421
+ Aspera.assert(ak_secret, type: Cli::MissingArgument){"Please provide secret for #{ak_info['id']} using option: secret or by setting a preset for #{ak_info['id']}@#{@api_node.base_url}."}
420
422
  @api_node.auth_params[:username] = ak_info['id']
421
- @api_node.auth_params[:password] = config.lookup_secret(url: @api_node.base_url, username: ak_info['id'], mandatory: true)
423
+ @api_node.auth_params[:password] = ak_secret
422
424
  end
423
425
  root_file_id = ak_info['root_file_id']
424
426
  end
@@ -517,7 +519,8 @@ module Aspera
517
519
  else Aspera.error_unreachable_line
518
520
  end
519
521
  return Main.result_single_object(result) if command_repo.eql?(:node_info)
520
- raise BadArgument, 'Cannot get bearer token if authenticating with secret' unless apifid[:api].auth_params[:type].eql?(:oauth2)
522
+ Log.dump(:result, result)
523
+ raise BadArgument, "Cannot get bearer token if authenticating with secret (#{apifid[:api].auth_params[:type]})" unless apifid[:api].auth_params[:type].eql?(:oauth2)
521
524
  Aspera.assert(OAuth::Factory.bearer_auth?(result[:password])){'Not using bearer token auth'}
522
525
  return Main.result_text(result[:password])
523
526
  when :browse
@@ -728,12 +731,12 @@ module Aspera
728
731
  skip_ids_persistency = PersistencyActionOnce.new(
729
732
  manager: persistency,
730
733
  data: iteration_data,
731
- id: IdGenerator.from_list([
734
+ id: IdGenerator.from_list(
732
735
  'sync_files',
733
736
  options.get_option(:url, mandatory: true),
734
737
  options.get_option(:username, mandatory: true),
735
738
  async_id
736
- ])
739
+ )
737
740
  )
738
741
  data.select!{ |l| l['fnid'].to_i > iteration_data.first} unless iteration_data.first.nil?
739
742
  iteration_data[0] = data.last['fnid'].to_i unless data.empty?
@@ -873,11 +876,11 @@ module Aspera
873
876
  iteration_persistency = PersistencyActionOnce.new(
874
877
  manager: persistency,
875
878
  data: [],
876
- id: IdGenerator.from_list([
879
+ id: IdGenerator.from_list(
877
880
  'node_transfers',
878
881
  options.get_option(:url, mandatory: true),
879
882
  options.get_option(:username, mandatory: true)
880
- ])
883
+ )
881
884
  )
882
885
  if transfer_filter.delete('reset')
883
886
  iteration_persistency.data.clear
@@ -10,7 +10,7 @@ module Aspera
10
10
  # OAuth methods supported
11
11
  AUTH_TYPES = %i[web jwt boot].freeze
12
12
  # Options used for authentication
13
- AUTH_OPTIONS = %i[url auth client_id client_secret scope redirect_uri private_key passphrase username password].freeze
13
+ AUTH_OPTIONS = %i[url auth client_id client_secret redirect_uri private_key passphrase username password].freeze
14
14
  def initialize(**_)
15
15
  super
16
16
  options.declare(:auth, 'OAuth type of authentication', allowed: AUTH_TYPES, default: :jwt)
@@ -19,22 +19,23 @@ module Aspera
19
19
  options.declare(:redirect_uri, 'OAuth (Web) redirect URI for web authentication')
20
20
  options.declare(:private_key, 'OAuth (JWT) RSA private key PEM value (prefix file path with @file:)')
21
21
  options.declare(:passphrase, 'OAuth (JWT) RSA private key passphrase')
22
- options.declare(:scope, 'OAuth scope for API calls')
23
22
  end
24
23
 
25
- # Get all options specified by AUTH_OPTIONS and add.keys
26
- # Adds those not nil to the `base`.
24
+ # Get command line options specified by `AUTH_OPTIONS` and `option.keys` (value is default).
25
+ # Adds those not nil to the `kwargs`.
27
26
  # Instantiate the provided `klass` with those kwargs.
28
- # `add` can specify a default value (not `nil`)
29
- # @param klass [Class] API object to create
30
- # @param base [Hash] The base options for creation
31
- # @param add [Hash] Additional options, key=symbol, value:default value or nil
32
- def new_with_options(klass, base: {}, add: {})
27
+ # `option` can specify a default value (not `nil`)
28
+ # @param klass [Class] API object to create
29
+ # @param kwargs [Hash] The fixed keyword arguments for creation
30
+ # @param option [Hash] Additional options, key=symbol, value:default value or nil
31
+ # @return [Object] instance of `klass`
32
+ # @raise [Cli::Error] if a required option is missing
33
+ def new_with_options(klass, kwargs: {}, option: {})
33
34
  klass.new(**
34
- (AUTH_OPTIONS + add.keys).each_with_object(base) do |i, m|
35
+ (AUTH_OPTIONS + option.keys).each_with_object(kwargs) do |i, m|
35
36
  v = options.get_option(i)
36
37
  m[i] = v unless v.nil?
37
- m[i] = add[i] unless !m[i].nil? || add[i].nil?
38
+ m[i] = option[i] unless !m[i].nil? || option[i].nil?
38
39
  end)
39
40
  rescue ::ArgumentError => e
40
41
  if (m = e.message.match(/missing keyword: :(.*)$/))
@@ -451,12 +451,12 @@ module Aspera
451
451
  iteration_persistency = PersistencyActionOnce.new(
452
452
  manager: persistency,
453
453
  data: [],
454
- id: IdGenerator.from_list([
454
+ id: IdGenerator.from_list(
455
455
  'preview_iteration',
456
456
  command.to_s,
457
457
  options.get_option(:url, mandatory: true),
458
458
  options.get_option(:username, mandatory: true)
459
- ])
459
+ )
460
460
  )
461
461
  end
462
462
  # call processing method specified by command line command
@@ -31,7 +31,6 @@ module Aspera
31
31
  :FILE_LIST_FROM_TRANSFER_SPEC,
32
32
  :FILE_LIST_OPTIONS,
33
33
  :DEFAULT_TRANSFER_NOTIFY_TEMPLATE
34
- TRANSFER_AGENTS = Agent::Factory.instance.list.freeze
35
34
 
36
35
  class << self
37
36
  # @return :success if all sessions statuses returned by "start" are success
@@ -65,7 +64,7 @@ module Aspera
65
64
  @opt_mgr.declare(:to_folder, 'Destination folder for transferred files')
66
65
  @opt_mgr.declare(:sources, "How list of transferred files is provided (#{FILE_LIST_OPTIONS.join(',')})", default: FILE_LIST_FROM_ARGS)
67
66
  @opt_mgr.declare(:src_type, 'Type of file list', allowed: %i[list pair], default: :list)
68
- @opt_mgr.declare(:transfer, 'Type of transfer agent', allowed: TRANSFER_AGENTS, default: :direct)
67
+ @opt_mgr.declare(:transfer, 'Type of transfer agent', allowed: Agent::Factory::ALL.keys, default: :direct)
69
68
  @opt_mgr.declare(:transfer_info, 'Parameters for transfer agent', allowed: Hash, handler: {o: self, m: :transfer_info})
70
69
  @opt_mgr.parse_options!
71
70
  @notification_cb = nil
@@ -4,6 +4,6 @@ module Aspera
4
4
  module Cli
5
5
  # For beta add extension : .beta1
6
6
  # For dev version add extension : .pre
7
- VERSION = '4.25.0.pre2'
7
+ VERSION = '4.25.0'
8
8
  end
9
9
  end
@@ -22,11 +22,11 @@ module Aspera
22
22
  'x-cli-envvar', # [String] Name of env var
23
23
  'x-cli-option', # [String] Command line option (starts with "-")
24
24
  'x-cli-short', # [String] Command line option (starts with "-")
25
- 'x-cli-switch', # [Bool] `true` if option has no arg, else by default option has a value
26
- 'x-cli-special', # [Bool] `true` if special handling (deferred)
25
+ 'x-cli-switch', # [Boolean] `true` if option has no arg, else by default option has a value
26
+ 'x-cli-special', # [Boolean] `true` if special handling (deferred)
27
27
  'x-cli-convert', # [String,Hash] Method name for Convert object or Conversion for enum ts to arg
28
28
  'x-agents', # [Array] Supported agents (for doc only), if not specified: all
29
- 'x-ts-name', # [Bool,String] (async) true if same name in transfer spec, else real name in transfer spec, else ignored
29
+ 'x-ts-name', # [Boolean,String] (async) true if same name in transfer spec, else real name in transfer spec, else ignored
30
30
  'x-ts-convert', # [String] (async) Name of methods to convert value from transfer spec to `conf` API.
31
31
  'x-deprecation' # [String] Deprecation message for doc
32
32
  ].freeze
@@ -50,8 +50,8 @@ module Aspera
50
50
  private
51
51
 
52
52
  # Fill default values for some fields in the schema
53
- # @param schema [Hash] The JSON schema
54
- # @param ascp [Bool] `true` if ascp
53
+ # @param schema [Hash] The JSON schema
54
+ # @param ascp [Boolean] `true` if ascp
55
55
  def validate_schema(schema, ascp: false)
56
56
  direct_props = %w[x-cli-option x-cli-envvar x-cli-special].freeze
57
57
  schema['properties'].each do |name, info|
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/assert'
4
+
5
+ module Aspera
6
+ # Convert dotted-path to/from nested Hash/Array container
7
+ class DotContainer
8
+ class << self
9
+ # Insert extended value `value` into struct `result` at `path`
10
+ # @param path [String] Dotted path in container
11
+ # @param value [String] Last value to insert
12
+ # @param result [NilClass, Hash, Array] current value
13
+ # @return [Hash, Array]
14
+ def dotted_to_container(path, value, result = nil)
15
+ # Typed keys
16
+ keys = path.split(OPTION_DOTTED_SEPARATOR).map{ |k| int_or_string(k)}
17
+ # Create, or re-use first level container
18
+ current = (result ||= new_hash_or_array_from_key(keys.first))
19
+ # walk the path, and create sub-containers if necessary
20
+ keys.each_cons(2) do |k, next_k|
21
+ array_requires_integer_index!(current, k)
22
+ current = (current[k] ||= new_hash_or_array_from_key(next_k))
23
+ end
24
+ # Assign value at last index
25
+ array_requires_integer_index!(current, keys.last)
26
+ current[keys.last] = value
27
+ result
28
+ end
29
+
30
+ private
31
+
32
+ # Convert `String` to `Integer`, or keep `String` if not `Integer`
33
+ def int_or_string(value)
34
+ Integer(value, exception: false) || value
35
+ end
36
+
37
+ # Assert that if `container` is an `Array`, then `index` is an `Integer`
38
+ # @param container [Hash, Array]
39
+ # @param index [String, Integer]
40
+ def array_requires_integer_index!(container, index)
41
+ Aspera.assert(container.is_a?(Hash) || index.is_a?(Integer)){'Using String index when Integer index used previously'}
42
+ end
43
+
44
+ # Create a new `Hash` or `Array` depending on type of `key`
45
+ def new_hash_or_array_from_key(key)
46
+ key.is_a?(Integer) ? [] : {}
47
+ end
48
+ end
49
+
50
+ # @param [Hash,Array] Container object
51
+ def initialize(container)
52
+ Aspera.assert_type(container, Hash)
53
+ # tail (pop,push) contains the next element to display
54
+ # elements are [path, value]
55
+ @stack = container.empty? ? [] : [[[], container]]
56
+ end
57
+
58
+ # Convert nested Hash/Array container to dotted-path Hash
59
+ # @return [Hash] Dotted-path Hash
60
+ def to_dotted
61
+ result = {}
62
+ until @stack.empty?
63
+ path, current = @stack.pop
64
+ to_insert = nil
65
+ # empty things are left intact
66
+ if current.respond_to?(:empty?) && current.empty?
67
+ to_insert = current
68
+ else
69
+ case current
70
+ when Hash
71
+ add_elements(path, current)
72
+ when Array
73
+ # Array has no nested structures -> list of Strings
74
+ if current.none?{ |i| i.is_a?(Array) || i.is_a?(Hash)}
75
+ to_insert = current.map(&:to_s)
76
+ # Array of Hashes with only 'name' keys -> list of Strings
77
+ elsif current.all?{ |i| i.is_a?(Hash) && i.keys == ['name']}
78
+ to_insert = current.map{ |i| i['name']}
79
+ # Array of Hashes with only 'name' and 'value' keys -> Hash of key/values
80
+ elsif current.all?{ |i| i.is_a?(Hash) && i.keys.sort == %w[name value]}
81
+ add_elements(path, current.each_with_object({}){ |i, h| h[i['name']] = i['value']})
82
+ else
83
+ add_elements(path, current.each_with_index.map{ |v, i| [i, v]})
84
+ end
85
+ else
86
+ to_insert = current
87
+ end
88
+ end
89
+ result[path.map(&:to_s).join(OPTION_DOTTED_SEPARATOR)] = to_insert unless to_insert.nil?
90
+ end
91
+ result
92
+ end
93
+
94
+ private
95
+
96
+ # Add elements of enumerator to the @stack, in reverse order
97
+ def add_elements(path, enum)
98
+ enum.reverse_each do |key, value|
99
+ @stack.push([path + [key], value])
100
+ end
101
+ nil
102
+ end
103
+
104
+ # "."
105
+ OPTION_DOTTED_SEPARATOR = '.'
106
+ private_constant :OPTION_DOTTED_SEPARATOR
107
+ end
108
+ end
@@ -9,19 +9,16 @@ module Aspera
9
9
  class << self
10
10
  # Generate an ID from a list of object IDs
11
11
  # The generated ID is safe as file name
12
- # @param object_id [Array<String>, String] the object IDs
12
+ # @param ids [Array] the object IDs (can be nested, will be flattened, and nils removed)
13
13
  # @return [String] the generated ID
14
- def from_list(object_id)
14
+ def from_list(*ids)
15
15
  safe_char = Environment.instance.safe_filename_character
16
- if object_id.is_a?(Array)
17
- # compact: remove nils
18
- object_id = object_id.flatten.compact.map do |i|
19
- i.is_a?(String) && i.start_with?('https://') ? URI.parse(i).host : i.to_s
20
- end.join(safe_char)
21
- end
22
- Aspera.assert_type(object_id, String)
16
+ # compact: remove nils
17
+ id = ids.flatten.compact.map do |i|
18
+ i.is_a?(String) && i.start_with?('https://') ? URI.parse(i).host : i.to_s
19
+ end.join(safe_char)
23
20
  # keep dot for extension only (nicer)
24
- return Environment.instance.sanitized_filename(object_id.gsub('.', safe_char)).downcase
21
+ return Environment.instance.sanitized_filename(id.gsub('.', safe_char)).downcase
25
22
  end
26
23
  end
27
24
  end