aspera-cli 4.27.2 → 4.27.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +133 -0
  4. data/CONTRIBUTING.md +5 -2
  5. data/bin/ascli +3 -1
  6. data/docs/README.md +1006 -766
  7. data/lib/aspera/agent/base.rb +7 -2
  8. data/lib/aspera/agent/connect.rb +6 -8
  9. data/lib/aspera/agent/desktop.rb +2 -6
  10. data/lib/aspera/agent/direct.rb +52 -22
  11. data/lib/aspera/agent/node.rb +9 -8
  12. data/lib/aspera/agent/transferd.rb +2 -2
  13. data/lib/aspera/api/alee.rb +1 -1
  14. data/lib/aspera/api/aoc.rb +14 -12
  15. data/lib/aspera/api/ats.rb +1 -1
  16. data/lib/aspera/api/cos_node.rb +2 -2
  17. data/lib/aspera/api/faspex.rb +11 -7
  18. data/lib/aspera/api/httpgw.rb +38 -35
  19. data/lib/aspera/api/node.rb +39 -34
  20. data/lib/aspera/ascmd.rb +3 -1
  21. data/lib/aspera/ascp/installation.rb +63 -28
  22. data/lib/aspera/ascp/management.rb +1 -0
  23. data/lib/aspera/assert.rb +4 -0
  24. data/lib/aspera/cli/ascp_actions.rb +20 -41
  25. data/lib/aspera/cli/async_transfer_store.rb +12 -11
  26. data/lib/aspera/cli/bootstrapper.rb +14 -16
  27. data/lib/aspera/cli/command_line.rb +252 -0
  28. data/lib/aspera/cli/command_registry.rb +215 -37
  29. data/lib/aspera/cli/command_spec.rb +104 -15
  30. data/lib/aspera/cli/completion/ascli.bash +12 -0
  31. data/lib/aspera/cli/completion/ascli.fish +16 -0
  32. data/lib/aspera/cli/completion/ascli.zsh +19 -0
  33. data/lib/aspera/cli/context.rb +3 -0
  34. data/lib/aspera/cli/deprecation.rb +37 -0
  35. data/lib/aspera/cli/extended_value.rb +6 -3
  36. data/lib/aspera/cli/formatter.rb +94 -80
  37. data/lib/aspera/cli/gem_checker.rb +1 -1
  38. data/lib/aspera/cli/hints.rb +7 -6
  39. data/lib/aspera/cli/http.rb +22 -34
  40. data/lib/aspera/cli/info.rb +3 -0
  41. data/lib/aspera/cli/mcp_tool.rb +47 -83
  42. data/lib/aspera/cli/option_declarator.rb +33 -42
  43. data/lib/aspera/cli/option_registry.rb +69 -0
  44. data/lib/aspera/cli/option_types.rb +105 -0
  45. data/lib/aspera/cli/option_value.rb +278 -0
  46. data/lib/aspera/cli/options.schema.yaml +124 -15
  47. data/lib/aspera/cli/parser.rb +333 -862
  48. data/lib/aspera/cli/plugins/alee.rb +7 -4
  49. data/lib/aspera/cli/plugins/aoc.rb +545 -518
  50. data/lib/aspera/cli/plugins/ats.rb +59 -80
  51. data/lib/aspera/cli/plugins/base.rb +221 -265
  52. data/lib/aspera/cli/plugins/basic_auth.rb +2 -10
  53. data/lib/aspera/cli/plugins/config.rb +263 -184
  54. data/lib/aspera/cli/plugins/console.rb +103 -39
  55. data/lib/aspera/cli/plugins/cos.rb +6 -23
  56. data/lib/aspera/cli/plugins/factory.rb +3 -0
  57. data/lib/aspera/cli/plugins/faspex5.rb +204 -182
  58. data/lib/aspera/cli/plugins/faspio.rb +6 -11
  59. data/lib/aspera/cli/plugins/httpgw.rb +8 -11
  60. data/lib/aspera/cli/plugins/mcp.rb +20 -55
  61. data/lib/aspera/cli/plugins/node.rb +300 -327
  62. data/lib/aspera/cli/plugins/orchestrator.rb +152 -110
  63. data/lib/aspera/cli/plugins/preview.rb +96 -105
  64. data/lib/aspera/cli/plugins/server.rb +78 -53
  65. data/lib/aspera/cli/plugins/shares.rb +80 -131
  66. data/lib/aspera/cli/preset_actions.rb +44 -27
  67. data/lib/aspera/cli/preset_manager.rb +44 -19
  68. data/lib/aspera/cli/prompt.rb +36 -0
  69. data/lib/aspera/cli/result.rb +42 -36
  70. data/lib/aspera/cli/runner.rb +32 -59
  71. data/lib/aspera/cli/special_values.rb +5 -0
  72. data/lib/aspera/cli/sync_actions.rb +51 -46
  73. data/lib/aspera/cli/terminal_formatter.rb +9 -3
  74. data/lib/aspera/cli/transfer_actions.rb +14 -9
  75. data/lib/aspera/cli/transfer_agent.rb +34 -38
  76. data/lib/aspera/cli/transfer_progress.rb +290 -55
  77. data/lib/aspera/cli/vault_manager.rb +0 -17
  78. data/lib/aspera/cli/version.rb +1 -1
  79. data/lib/aspera/cli/wizard.rb +5 -3
  80. data/lib/aspera/coverage.rb +1 -1
  81. data/lib/aspera/environment.rb +35 -5
  82. data/lib/aspera/faspex_gw.rb +2 -1
  83. data/lib/aspera/faspex_postproc.rb +1 -0
  84. data/lib/aspera/graphql.rb +5 -5
  85. data/lib/aspera/json_rpc/client.rb +5 -5
  86. data/lib/aspera/keychain/encrypted_hash.rb +2 -2
  87. data/lib/aspera/keychain/factory.rb +2 -1
  88. data/lib/aspera/keychain/one_password_api.rb +1 -1
  89. data/lib/aspera/link_header.rb +2 -2
  90. data/lib/aspera/log.rb +47 -27
  91. data/lib/aspera/markdown.rb +2 -0
  92. data/lib/aspera/mime.rb +25 -0
  93. data/lib/aspera/node_emulator.rb +759 -0
  94. data/lib/aspera/oauth/base.rb +37 -26
  95. data/lib/aspera/oauth/factory.rb +7 -3
  96. data/lib/aspera/oauth/generic.rb +1 -1
  97. data/lib/aspera/oauth/json_credentials.rb +34 -0
  98. data/lib/aspera/oauth/jwt.rb +4 -5
  99. data/lib/aspera/oauth/web.rb +9 -8
  100. data/lib/aspera/oauth.rb +1 -0
  101. data/lib/aspera/persistency_folder.rb +1 -3
  102. data/lib/aspera/preview/file_types.rb +4 -4
  103. data/lib/aspera/preview/generator.rb +11 -1
  104. data/lib/aspera/preview/options.schema.yaml +119 -0
  105. data/lib/aspera/preview/terminal.rb +4 -3
  106. data/lib/aspera/preview/utils.rb +9 -6
  107. data/lib/aspera/products/connect.rb +1 -1
  108. data/lib/aspera/rainbow.rb +7 -0
  109. data/lib/aspera/rest/aspera_errors.rb +72 -0
  110. data/lib/aspera/rest/call_error.rb +27 -0
  111. data/lib/aspera/rest/client.rb +523 -0
  112. data/lib/aspera/rest/error_analyzer.rb +113 -0
  113. data/lib/aspera/rest/list.rb +149 -0
  114. data/lib/aspera/rest/parameters.rb +55 -0
  115. data/lib/aspera/rest/util.rb +176 -0
  116. data/lib/aspera/rest.rb +7 -621
  117. data/lib/aspera/schema/IBM Aspera Console-enhanced.yaml +1125 -0
  118. data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +0 -20
  119. data/lib/aspera/schema/IBM Aspera Orchestrator API-v1.yaml +1784 -0
  120. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +1230 -137
  121. data/lib/aspera/schema/IBM Aspera on Cloud Automation API-1.0.5-enhanced.yaml +2395 -0
  122. data/lib/aspera/schema/IBM_Aspera_Shares.yaml +14 -13
  123. data/lib/aspera/schema/documentation.rb +13 -3
  124. data/lib/aspera/schema/reader.rb +12 -18
  125. data/lib/aspera/schema/registry.rb +23 -1
  126. data/lib/aspera/schema/validator.rb +92 -0
  127. data/lib/aspera/secret_hider.rb +36 -25
  128. data/lib/aspera/string_ext.rb +15 -0
  129. data/lib/aspera/temp_file_manager.rb +6 -5
  130. data/lib/aspera/transfer/parameters.rb +2 -0
  131. data/lib/aspera/transfer/spec.rb +1 -0
  132. data/lib/aspera/uri_reader.rb +11 -11
  133. data/lib/aspera/web_auth/index.html +147 -0
  134. data/lib/aspera/web_auth/server.rb +81 -0
  135. data.tar.gz.sig +0 -0
  136. metadata +43 -9
  137. metadata.gz.sig +0 -0
  138. data/lib/aspera/colors.rb +0 -79
  139. data/lib/aspera/node_simulator.rb +0 -344
  140. data/lib/aspera/preview/options.rb +0 -45
  141. data/lib/aspera/rest_call_error.rb +0 -25
  142. data/lib/aspera/rest_error_analyzer.rb +0 -111
  143. data/lib/aspera/rest_errors_aspera.rb +0 -58
  144. data/lib/aspera/rest_list.rb +0 -136
  145. data/lib/aspera/web_auth.rb +0 -211
@@ -5,6 +5,7 @@
5
5
  require 'aspera/schema/registry'
6
6
  require 'aspera/cli/plugins/oauth'
7
7
  require 'aspera/cli/extended_value'
8
+ require 'aspera/cli/formatter'
8
9
  require 'aspera/transfer/result'
9
10
  require 'aspera/cli/special_values'
10
11
  require 'aspera/cli/wizard'
@@ -15,6 +16,10 @@ require 'aspera/nagios'
15
16
  require 'aspera/environment'
16
17
  require 'aspera/assert'
17
18
  require 'securerandom'
19
+ require 'digest'
20
+ require 'json'
21
+ require 'aspera/rainbow'
22
+ using Rainbow
18
23
 
19
24
  module Aspera
20
25
  module Cli
@@ -35,7 +40,7 @@ module Aspera
35
40
  urls.each do |base_url|
36
41
  # Faspex is always HTTPS
37
42
  next unless base_url.start_with?('https://')
38
- api = Rest.new(base_url: base_url, redirect_max: 1)
43
+ api = Rest::Client.new(base_url: base_url, redirect_max: 1)
39
44
  response = api.read(Api::Faspex::PATH_API_DETECT, ret: :resp)
40
45
  next unless response.code.start_with?('2') && response.body.strip.empty?
41
46
  # end is at -1, and subtract 1 for "/"
@@ -96,13 +101,6 @@ module Aspera
96
101
  option :shared_folder, description: 'Send package with files from shared folder'
97
102
  option :group_type, description: 'Type of shared box', allowed: %i[shared_inboxes workgroups], default: :shared_inboxes
98
103
 
99
- def initialize(**_)
100
- super
101
- options.parse_options!
102
- # [Aspera::Api::Faspex]
103
- @api_v5 = nil
104
- end
105
-
106
104
  # if recipient is just an email, then convert to expected API hash : name and type
107
105
  def normalize_recipients(parameters, type)
108
106
  type = type.to_s
@@ -117,7 +115,7 @@ module Aspera
117
115
  parameters[type].map! do |recipient_data|
118
116
  # If just a string, make a general lookup and build expected name/type hash
119
117
  if recipient_data.is_a?(String)
120
- matched = @api_v5.lookup_with_q('contacts', value: recipient_data, query: Rest.php_style({context: 'packages', type: recipient_types}))
118
+ matched = api_v5.lookup_with_q('contacts', value: recipient_data, query: Rest.php_style({context: 'packages', type: recipient_types}))
121
119
  recipient_data = {
122
120
  name: matched['name'],
123
121
  recipient_type: matched['type']
@@ -132,7 +130,7 @@ module Aspera
132
130
  def wait_package_status(id, status_list: Api::Faspex::PACKAGE_TERMINATED)
133
131
  total_sent = false
134
132
  loop do
135
- status = @api_v5.read("packages/#{id}/upload_details")
133
+ status = api_v5.read("packages/#{id}/upload_details")
136
134
  status['id'] = id
137
135
  # user asked to not follow
138
136
  return status if status_list.nil?
@@ -147,7 +145,7 @@ module Aspera
147
145
  end
148
146
  if status_list.include?(status['upload_status'])
149
147
  progress_bar&.event(:session_end, session_id: id)
150
- progress_bar&.event(:end)
148
+ progress_bar&.event(:end, info: !status['upload_status'].eql?('failed'))
151
149
  return status
152
150
  end
153
151
  sleep(1.0)
@@ -159,12 +157,12 @@ module Aspera
159
157
  def wait_for_job(job_id)
160
158
  result = nil
161
159
  loop do
162
- result = @api_v5.read("jobs/#{job_id}", {type: :formatted})
160
+ result = api_v5.read("jobs/#{job_id}", {type: :formatted})
163
161
  break unless Api::Faspex::JOB_RUNNING.include?(result['status'])
164
- RestParameters.instance.spinner_cb.call(result['status'])
162
+ Rest::Parameters.instance.spinner_cb.call(result['status'])
165
163
  sleep(0.5)
166
164
  end
167
- RestParameters.instance.spinner_cb.call(action: :success)
165
+ Rest::Parameters.instance.spinner_cb.call(action: :success)
168
166
  return result
169
167
  end
170
168
 
@@ -185,27 +183,39 @@ module Aspera
185
183
  when *Api::Faspex::API_LIST_MAILBOX_TYPES then "#{box}/packages"
186
184
  else
187
185
  group_type = options.get_option(:group_type)
188
- "#{group_type}/#{@api_v5.lookup_entity_by_field(entity: group_type, value: box)['id']}/packages"
186
+ "#{group_type}/#{api_v5.lookup_entity_by_field(entity: group_type, value: box)['id']}/packages"
189
187
  end
190
188
  # Merge default query with user-provided query: user values take precedence, but defaults are preserved
191
189
  user_query = query_read_delete(schema: Schema::Registry.query_params(Schema::Registry::FASPEX, 'packages'))
192
190
  merged_query = user_query.nil? ? query.dup : query.merge(user_query)
193
191
  # Extract `max` before the API call so callers can apply it after post-API filtering
194
- max_items = merged_query.delete(RestList::MAX_ITEMS)&.to_i
195
- list, total = @api_v5.list_entities_limit_offset_total_count(entity: entity, query: merged_query)
192
+ max_items = merged_query.delete(Rest::List::MAX_ITEMS)&.to_i
193
+ list, total = api_v5.list_entities_limit_offset_total_count(entity: entity, query: merged_query)
196
194
  return list.select(&filter), max_items, total
197
195
  end
198
196
 
199
197
  # Build query to get package recipients based on package info in case of shared inbox or workgroup recipient
200
198
  # @param package_id [String] the package id to get info from
201
199
  def recipient_query(package_id)
202
- package_info = @api_v5.read("packages/#{package_id}")
200
+ # With a public link, the recipient context is already encoded in the auth token: no extra query params needed
201
+ return {} if api_v5.pub_link_context
202
+ package_info = api_v5.read("packages/#{package_id}")
203
203
  base_query = {}
204
- base_query['recipient_workgroup_id'] = package_info['recipients'].first['id'] if WORKGROUP_TYPES.include?(package_info['recipients'].first['recipient_type'])
205
- base_query['recipient_user_id'] = package_info['recipients'].first['id'] if package_info['recipients'].first['recipient_type'].eql?('user')
204
+ first_recipient = package_info['recipients']&.first
205
+ if first_recipient
206
+ base_query['recipient_workgroup_id'] = first_recipient['id'] if WORKGROUP_TYPES.include?(first_recipient['recipient_type'])
207
+ base_query['recipient_user_id'] = first_recipient['id'] if first_recipient['recipient_type'].eql?('user')
208
+ end
206
209
  base_query
207
210
  end
208
211
 
212
+ # @return [String] user part of the persistency id of received packages:
213
+ # the username, or for a public link (no username), a digest of the link context (unique per link)
214
+ def persistency_user_id
215
+ return options.get_option(:username, mandatory: true) if api_v5.pub_link_context.nil?
216
+ Digest::SHA256.hexdigest(JSON.generate(api_v5.pub_link_context))[0, 16]
217
+ end
218
+
209
219
  def package_receive(package_ids)
210
220
  # prepare persistency if needed
211
221
  skip_ids_persistency = nil
@@ -217,7 +227,7 @@ module Aspera
217
227
  id: IdGenerator.from_list(
218
228
  'faspex_recv',
219
229
  options.get_option(:url, mandatory: true),
220
- options.get_option(:username, mandatory: true),
230
+ persistency_user_id,
221
231
  options.get_option(:box, mandatory: true)
222
232
  )
223
233
  )
@@ -233,7 +243,7 @@ module Aspera
233
243
  # TODO: if packages have same name, they will overwrite ?
234
244
  packages, max_items = list_packages_with_filter(query: {'status' => 'completed'}) # no filter: all completed packages
235
245
  Log.dump(:package_ids, level: :trace1) { packages.map { |p| p['id'] } }
236
- Log.dump(:skip_ids, skip_ids_persistency.data, level: :trace1)
246
+ Log.dump(:skip_ids, skip_ids_persistency&.data, level: :trace1)
237
247
  packages.reject! { |p| skip_ids_persistency.data.include?(p['id']) } if skip_ids_persistency
238
248
  # Apply `max` after once_only filtering so we get the N first not-yet-downloaded packages
239
249
  packages = packages[0, max_items] if max_items
@@ -242,13 +252,15 @@ module Aspera
242
252
  # a single id was provided, or a list of ids
243
253
  package_ids = [package_ids] unless package_ids.is_a?(Array)
244
254
  Aspera.assert_array_all(package_ids, String) { 'Package id(s)' }
245
- # packages = package_ids.map{|pkg_id|@api_v5.read("packages/#{pkg_id}")}
255
+ # packages = package_ids.map{|pkg_id|api_v5.read("packages/#{pkg_id}")}
246
256
  packages = package_ids.map { |pkg_id| {'id'=>pkg_id} }
257
+ # The package of a public link is received only once, like with `ALL`
258
+ packages.reject! { |p| skip_ids_persistency.data.include?(p['id']) } if skip_ids_persistency && api_v5.pub_link_context
247
259
  end
248
260
  result_transfer = []
249
261
  param_file_list = {}
250
262
  begin
251
- param_file_list['paths'] = transfer.ts_source_paths
263
+ param_file_list['paths'] = transfer.ts_source_paths.map { |p| {'path' => p['source']} }
252
264
  rescue Cli::MissingArgument
253
265
  # paths is optional
254
266
  end
@@ -257,12 +269,12 @@ module Aspera
257
269
  type: Api::Faspex.box_type(box),
258
270
  transfer_type: Api::Faspex::TRANSFER_CONNECT
259
271
  }
260
- # download_params[:recipient_workgroup_id] = @api_v5.lookup_entity_by_field(entity: options.get_option(:group_type), value: box)['id'] if !Api::Faspex::API_LIST_MAILBOX_TYPES.include?(box) && box != SpecialValues::ALL
272
+ # download_params[:recipient_workgroup_id] = api_v5.lookup_entity_by_field(entity: options.get_option(:group_type), value: box)['id'] if !Api::Faspex::API_LIST_MAILBOX_TYPES.include?(box) && box != SpecialValues::ALL
261
273
  packages.each do |package|
262
274
  pkg_id = package['id']
263
275
  formatter.display_status("Receiving package #{pkg_id}")
264
276
  # TODO: allow from sent as well ?
265
- transfer_spec = @api_v5.call(
277
+ transfer_spec = api_v5.call(
266
278
  operation: 'POST',
267
279
  subpath: "packages/#{pkg_id}/transfer_spec/download",
268
280
  query: download_params.merge(recipient_query(pkg_id)),
@@ -285,10 +297,10 @@ module Aspera
285
297
 
286
298
  def package_send(parameters)
287
299
  # autofill recipient for public url
288
- if @api_v5.pub_link_context&.key?('recipient_type') && !parameters.key?('recipients')
300
+ if api_v5.pub_link_context&.key?('recipient_type') && !parameters.key?('recipients')
289
301
  parameters['recipients'] = [{
290
- name: @api_v5.pub_link_context['name'],
291
- recipient_type: @api_v5.pub_link_context['recipient_type']
302
+ name: api_v5.pub_link_context['name'],
303
+ recipient_type: api_v5.pub_link_context['recipient_type']
292
304
  }]
293
305
  end
294
306
  PACKAGE_RECIPIENT_TYPES.each { |type| normalize_recipients(parameters, type) }
@@ -298,22 +310,23 @@ module Aspera
298
310
  transfer.user_transfer_spec.delete('content_protection')
299
311
  parameters['ear_enabled'] = true
300
312
  end
301
- package = @api_v5.create('packages', parameters)
313
+ package = api_v5.create('packages', parameters)
302
314
  shared_folder = options.get_option(:shared_folder)
303
315
  if shared_folder.nil?
304
316
  # send from local files
305
- transfer_spec = @api_v5.create(
317
+ transfer_spec = api_v5.create(
306
318
  "packages/#{package['id']}/transfer_spec/upload",
307
319
  {paths: transfer.source_list},
308
320
  query: {transfer_type: Api::Faspex::TRANSFER_CONNECT}
309
321
  )
310
322
  # well, we asked a TS for connect, but we actually want a generic one
311
323
  transfer_spec.delete('authentication')
312
- return Runner.result_transfer(transfer.start(transfer_spec))
324
+ Runner.result_transfer(transfer.start(transfer_spec))
325
+ return Result::SingleObject.new(package)
313
326
  else
314
327
  # send from remote shared folder
315
328
  if (m = Parser.percent_selector(shared_folder))
316
- shared_folder = @api_v5.lookup_entity_by_field(
329
+ shared_folder = api_v5.lookup_entity_by_field(
317
330
  entity: 'shared_folders',
318
331
  field: m[:field],
319
332
  value: m[:value]
@@ -321,7 +334,7 @@ module Aspera
321
334
  end
322
335
  transfer_request = {shared_folder_id: shared_folder, paths: transfer.source_list}
323
336
  # start remote transfer and get first status
324
- result = @api_v5.create("packages/#{package['id']}/remote_transfer", transfer_request)
337
+ result = api_v5.create("packages/#{package['id']}/remote_transfer", transfer_request)
325
338
  result['id'] = package['id']
326
339
  unless result['status'].eql?('completed')
327
340
  formatter.display_status("Package #{package['id']}")
@@ -341,7 +354,7 @@ module Aspera
341
354
  Aspera.assert_type(filters, Hash)
342
355
  filters['basenames'] ||= []
343
356
  Aspera.assert_type(filters, Hash) { 'filters' }
344
- max_items = query.delete(RestList::MAX_ITEMS)
357
+ max_items = query.delete(Rest::List::MAX_ITEMS)
345
358
  recursive = query.delete('recursive')
346
359
  use_paging = query.delete('paging') { true }
347
360
  if use_paging
@@ -356,7 +369,7 @@ module Aspera
356
369
  until folders_to_process.empty?
357
370
  path = folders_to_process.shift
358
371
  loop do
359
- data, http = @api_v5.call(
372
+ data, http = api_v5.call(
360
373
  operation: 'POST',
361
374
  subpath: browse_endpoint,
362
375
  query: query,
@@ -380,49 +393,49 @@ module Aspera
380
393
  break if data['item_count'].eql?(0)
381
394
  query['offset'] += data['item_count']
382
395
  end
383
- RestParameters.instance.spinner_cb.call(all_items.count)
396
+ Rest::Parameters.instance.spinner_cb.call(all_items.count)
384
397
  end
385
398
  query.delete('iteration_token')
386
399
  end
387
- RestParameters.instance.spinner_cb.call(action: :success)
400
+ Rest::Parameters.instance.spinner_cb.call(action: :success)
388
401
  return Result::ObjectList.new(all_items, total: total_count)
389
402
  end
390
403
 
391
404
  # Per-resource configuration for admin CRUD sub-trees.
392
- # Keys mirror entity_list/show/create/modify/delete kwargs; extra_commands lists additional leaf commands.
405
+ # Keys mirror entity_list/show/create/modify/delete kwargs; extra_commands declares additional leaf commands: {name => command attributes}.
393
406
  # @return [Hash{Symbol => Hash}]
394
407
  RESOURCE_CONFIG = {
395
408
  accounts: {
396
- display_fields: -> { Formatter.all_but('user_profile_data_attributes') },
397
- extra_commands: [:reset_password],
398
- instance_arg_commands: {reset_password: {arguments: [{name: :contact_id, type: :identifier, lookup: ->(field, value, **) { res_lookup_id(:accounts, field, value) }}]}},
399
- query_component: Schema::Registry::FASPEX,
400
- body_component: Schema::Registry::FASPEX
409
+ display_fields: Formatter.all_but('user_profile_data_attributes'),
410
+ extra_commands: {reset_password: {description: 'Send password reset email to account', arguments: [{name: :account_id, type: :identifier, lookup: ->(field, value, **) { res_lookup_id(:accounts, field, value) }}]}},
411
+ query_component: Schema::Registry::FASPEX,
412
+ body_component: Schema::Registry::FASPEX
401
413
  },
402
414
  alternate_addresses: {entity: 'configuration/alternate_addresses', query_component: Schema::Registry::FASPEX, body_component: Schema::Registry::FASPEX},
403
415
  contacts: {query_component: Schema::Registry::FASPEX},
404
416
  distribution_lists: {entity: 'account/distribution_lists', delete_style: 'ids', query_component: Schema::Registry::FASPEX, body_component: Schema::Registry::FASPEX},
405
417
  email_notifications: {id_as_arg: 'type', query_component: Schema::Registry::FASPEX},
406
418
  file_processing: {
407
- commands: %i[next modify],
419
+ commands: %i[modify],
420
+ extra_commands: {next: {description: 'List next files to process'}},
408
421
  body_component: Schema::Registry::FASPEX,
409
422
  is_singleton: true
410
423
  },
411
424
  jobs: {display_fields: %w[id job_name job_type status], query_component: Schema::Registry::FASPEX},
412
425
  metadata_profiles: {entity: 'configuration/metadata_profiles', items_key: 'profiles', query_component: Schema::Registry::FASPEX, body_component: Schema::Registry::FASPEX},
413
426
  nodes: {
414
- extra_commands: %i[browse],
415
- instance_arg_commands: {
427
+ extra_commands: {
416
428
  browse: {
417
- arguments: [{name: :node_id, type: :identifier, lookup: :lookup_node_id},
418
- {name: :folder_path, type: String, mandatory: false, default: '/'}]
429
+ description: 'Browse files of node',
430
+ arguments: [{name: :node_id, type: :identifier, lookup: :lookup_node_id},
431
+ {name: :folder_path, mandatory: false, default: '/'}]
419
432
  }
420
433
  },
421
- query_component: Schema::Registry::FASPEX,
422
- body_component: Schema::Registry::FASPEX
434
+ query_component: Schema::Registry::FASPEX,
435
+ body_component: Schema::Registry::FASPEX
423
436
  },
424
437
  oauth_clients: {
425
- display_fields: -> { Formatter.all_but('public_key') },
438
+ display_fields: Formatter.all_but('public_key'),
426
439
  api: -> { Api::Faspex.new(root: Api::Faspex::PATH_AUTH, **Oauth.kwargs_from_options(options)) },
427
440
  list_query: {'expand': true, 'no_api_path': true, 'client_types[]': 'public'},
428
441
  query_component: Schema::Registry::FASPEX,
@@ -446,7 +459,7 @@ module Aspera
446
459
  def res_exec_args(res_sym)
447
460
  cfg = RESOURCE_CONFIG.fetch(res_sym, {})
448
461
  {
449
- api: resource_config_value(cfg, :api) || @api_v5,
462
+ api: resource_config_value(cfg, :api) || api_v5,
450
463
  entity: resource_config_value(cfg, :entity) || res_sym.to_s,
451
464
  items_key: resource_config_value(cfg, :items_key),
452
465
  delete_style: resource_config_value(cfg, :delete_style),
@@ -461,7 +474,7 @@ module Aspera
461
474
 
462
475
  # admin > nodes — lookup node id by field/value
463
476
  def lookup_node_id(field, value, **)
464
- @api_v5.lookup_entity_by_field(entity: 'nodes', field: field, value: value)['id']
477
+ api_v5.lookup_entity_by_field(entity: 'nodes', field: field, value: value)['id']
465
478
  end
466
479
 
467
480
  # Lookup id for a RESOURCE_CONFIG resource by field/value.
@@ -470,7 +483,7 @@ module Aspera
470
483
  entity = resource_config_value(cfg, :entity) || res_sym.to_s
471
484
  items_key = resource_config_value(cfg, :items_key)
472
485
  res_id_query = resource_config_value(cfg, :res_id_query) || :default
473
- @api_v5.lookup_entity_by_field(entity: entity, value: value, field: field, items_key: items_key, query: res_id_query)['id']
486
+ api_v5.lookup_entity_by_field(entity: entity, value: value, field: field, items_key: items_key, query: res_id_query)['id']
474
487
  end
475
488
 
476
489
  # Dynamically define lookup methods for admin resources (used by crud_commands)
@@ -482,78 +495,86 @@ module Aspera
482
495
 
483
496
  # --- DSL ---
484
497
 
485
- # Commands that need @api_v5 carry setup: :setup_api_v5.
486
- # :health and :postprocessing work without authentication, so they have no setup.
498
+ # :health and :postprocessing work without authentication: they do not use api_v5.
487
499
  command :health, description: 'Check Faspex 5 health'
488
- command :version, description: 'Show Faspex 5 version', setup: :setup_api_v5, action: -> { Result::SingleObject.new(@api_v5.read('version')) }
489
- command :bearer_token, description: 'Show OAuth bearer token', setup: :setup_api_v5, action: -> { Result::Text.new(@api_v5.oauth.authorization) }
490
- command :packages, description: 'Manage packages', setup: :setup_api_v5
500
+ command :version, description: 'Show Faspex 5 version', action: ->(**) { Result::SingleObject.new(api_v5.read('version')) }
501
+ command :bearer_token, description: 'Show OAuth bearer token', action: ->(**) { Result::Text.new(api_v5.oauth.authorization) }
502
+ # Package id argument; with a public link to a package, setup_package_id provides it instead
503
+ PACKAGE_ID_ARG = [{name: :package_id, type: :identifier}].freeze
504
+ private_constant :PACKAGE_ID_ARG
505
+ command :packages, description: 'Manage packages'
491
506
  commands_under :packages do
492
507
  command :list, description: 'List packages',
493
508
  arguments: [{name: :filter, mandatory: false, default: nil, type: Proc}]
494
509
  command :send, description: 'Send a package', transfer_paths: :send,
495
- arguments: [{name: :data, type: Hash, schema: Schema::Registry.req_body(Schema::Registry::FASPEX, 'packages.post')}],
496
- action: ->(data:, **) { package_send(data) }
497
- command :show, description: 'Show a package', setup: :setup_package_id,
498
- action: ->(package_id:, **) { Result::SingleObject.new(@api_v5.read("packages/#{package_id}")) }
510
+ arguments: [{name: :package, type: Hash, schema: Schema::Registry.req_body(Schema::Registry::FASPEX, 'packages.post')}],
511
+ action: ->(package:, **) { package_send(package) }
512
+ command :show, description: 'Show a package', setup: :setup_package_id, arguments: PACKAGE_ID_ARG,
513
+ action: ->(package_id:, **) { Result::SingleObject.new(api_v5.read("packages/#{package_id}", query_read_delete)) }
499
514
  command :browse, description: 'Browse package files', setup: :setup_package_id,
500
- arguments: [{name: :folder_path, type: String, mandatory: false, default: '/'}],
515
+ arguments: PACKAGE_ID_ARG + [{name: :folder_path, mandatory: false, default: '/'}],
501
516
  action: ->(folder_path:, package_id:, **) { browse_folder("packages/#{package_id}/files/#{Api::Faspex.box_type(options.get_option(:box))}", recipient_query(package_id), folder_path: folder_path) }
502
517
  command :status, description: 'Wait for package status', setup: :setup_package_id,
503
- arguments: [{name: :status_list, type: Array, mandatory: false, default: nil}],
518
+ arguments: PACKAGE_ID_ARG + [{name: :status_list, type: Array, mandatory: false, default: nil}],
504
519
  action: ->(status_list:, package_id:, **) { Result::SingleObject.new(wait_package_status(package_id, status_list: status_list)) }
505
- command :delete, description: 'Delete packages', setup: :setup_package_id
506
- command :receive, description: 'Receive a package', setup: :setup_package_id, transfer_paths: :receive,
520
+ command :delete, description: 'Delete packages', setup: :setup_package_id, arguments: PACKAGE_ID_ARG
521
+ command :receive, description: 'Receive a package', setup: :setup_package_id, transfer_paths: :receive, arguments: PACKAGE_ID_ARG,
507
522
  action: ->(package_id:, **) { package_receive(package_id) }
508
- command(:file_processing, description: 'Show file processing status', setup: :setup_package_id, action: lambda do |package_id:, **|
509
- result, count = @api_v5.list_entities_limit_offset_total_count(entity: "packages/#{package_id}/file_statuses", items_key: 'files')
523
+ command(:file_processing, description: 'Show file processing status', setup: :setup_package_id, arguments: PACKAGE_ID_ARG, action: lambda do |package_id:, **|
524
+ result, count = api_v5.list_entities_limit_offset_total_count(entity: "packages/#{package_id}/file_statuses", items_key: 'files')
510
525
  Result::ObjectList.new(result, total: count)
511
526
  end)
512
527
  end
513
- command :admin, description: 'Administer Faspex 5', setup: :setup_api_v5
514
- command :user, description: 'Manage current user', setup: :setup_api_v5
515
- command :shared_folders, description: 'Browse shared folders', setup: :setup_api_v5
516
- command :gateway, description: 'Start Faspex 4 gateway emulation', setup: :setup_api_v5,
528
+ command :admin, description: 'Administer Faspex 5'
529
+ command :user, description: 'Manage current user'
530
+ command :shared_folders, description: 'Browse shared folders'
531
+ command :gateway, description: 'Start Faspex 4 gateway emulation',
517
532
  arguments: [{name: :parameters, type: Hash, mandatory: false, default: {}}]
518
533
  command :postprocessing, description: 'Start Faspex 4 post-processing server',
519
534
  arguments: [{name: :parameters, type: Hash, mandatory: false, default: {}}]
520
- command :invitations, description: 'Manage invitations', setup: :setup_api_v5
535
+ command :invitations, description: 'Manage invitations'
521
536
 
522
537
  commands_under :invitations do
523
538
  command :create, description: 'Create an invitation',
524
- arguments: [{name: :input_data, type: Hash, bulk: true}]
525
- command :resend, description: 'Resend an invitation',
526
- arguments: [{name: :invitation_id, type: :identifier}]
539
+ arguments: [{name: :invitation, type: Hash, bulk: true}]
540
+ command(
541
+ :resend, description: 'Resend an invitation',
542
+ arguments: [{name: :invitation_id, type: :identifier}],
543
+ action: lambda do |invitation_id:, **|
544
+ api_v5.create("invitations/#{invitation_id}/resend", nil)
545
+ Result::Status.new('Invitation resent')
546
+ end
547
+ )
527
548
  crud_commands entity: 'invitations',
528
- api: :@api_v5,
549
+ api: :api_v5,
529
550
  operations: Operations::ALL - %i[create],
530
551
  items_key: 'invitations',
531
552
  display_fields: %w[id public recipient_type recipient_name email_address]
532
553
  end
533
554
 
534
555
  commands_under :user do
535
- command :account, description: 'Show account information', action: -> { Result::SingleObject.new(@api_v5.read('account', query_read_delete)) }
556
+ command :account, description: 'Show account information', action: ->(**) { Result::SingleObject.new(api_v5.read('account', query_read_delete)) }
536
557
  command :profile, description: 'Manage user profile'
537
558
  end
538
559
 
539
560
  commands_under %i[user profile] do
540
- command :show, description: 'Show user profile', action: -> { Result::SingleObject.new(@api_v5.read('account/preferences')) }
561
+ command :show, description: 'Show user profile', action: ->(**) { Result::SingleObject.new(api_v5.read('account/preferences')) }
541
562
  command(
542
563
  :modify,
543
564
  description: 'Modify user profile',
544
- arguments: [{name: :properties, type: Hash}],
545
- action: lambda do |properties:, **|
546
- @api_v5.update('account/preferences', properties)
565
+ arguments: [{name: :profile, type: Hash}],
566
+ action: lambda do |profile:, **|
567
+ api_v5.update('account/preferences', profile)
547
568
  Result::Status.new('modified')
548
569
  end
549
570
  )
550
571
  end
551
572
 
552
573
  commands_under :shared_folders do
553
- command :list, description: 'List shared folders', action: -> { Result::ObjectList.new(@api_v5.read('shared_folders')['shared_folders']) }
574
+ command :list, description: 'List shared folders', action: ->(**) { Result::ObjectList.new(api_v5.read('shared_folders')['shared_folders']) }
554
575
  command :browse, description: 'Browse a shared folder',
555
576
  arguments: [{name: :shared_folder_id, type: :identifier, lookup: :lookup_shared_folder_id},
556
- {name: :folder_path, type: String, mandatory: false, default: '/'}]
577
+ {name: :folder_path, mandatory: false, default: '/'}]
557
578
  end
558
579
 
559
580
  # admin sub-tree: fixed commands + all ADMIN_RESOURCES with their sub-commands
@@ -561,21 +582,26 @@ module Aspera
561
582
  command :configuration, description: 'Manage Faspex 5 configuration'
562
583
  command :smtp, description: 'Manage SMTP configuration'
563
584
  command :events, description: 'List events'
564
- command :clean_deleted, description: 'Clean deleted packages',
565
- arguments: [{name: :input_data, type: Hash, mandatory: false, default: {}}]
585
+ command(
586
+ :clean_deleted, description: 'Clean deleted packages',
587
+ arguments: [{name: :parameters, type: Hash, mandatory: false, default: {}}],
588
+ action: lambda do |parameters: {}, **|
589
+ parameters = api_v5.read('configuration').slice('days_before_deleting_package_records') if parameters.empty?
590
+ Result::SingleObject.new(api_v5.create('internal/packages/clean_deleted', parameters))
591
+ end
592
+ )
566
593
  Api::Faspex::ADMIN_RESOURCES.each do |res|
567
594
  cfg = RESOURCE_CONFIG.fetch(res, {})
568
- extra = cfg[:extra_commands] || []
569
- ia_cmds = cfg[:instance_arg_commands] || {}
595
+ extra = cfg[:extra_commands] || {}
570
596
  is_singleton = cfg[:is_singleton] || false
571
597
  entity_path = cfg[:entity] || res.to_s
572
598
  crud_ops = ((cfg[:commands] || Operations::ALL) - %i[list]) & Operations::ALL
573
599
 
574
- command res, description: "Manage #{res.to_s.tr('_', ' ')}"
575
- commands_under(res) do
600
+ command res, description: "Manage #{entity_noun(res, singular: false)}"
601
+ commands_under res do
576
602
  # List is handled with Faspex 5 pagination (item_list_with_total)
577
603
  unless is_singleton
578
- list_kwargs = {description: 'List'}
604
+ list_kwargs = {description: operation_description(:list, entity_noun(entity_path))}
579
605
  list_kwargs[:query_schema] = Schema::Registry.query_params(cfg[:query_component], entity_path) if cfg[:query_component]
580
606
  command :list, **list_kwargs
581
607
  end
@@ -594,9 +620,8 @@ module Aspera
594
620
  )
595
621
 
596
622
  # Extra commands (e.g. browse, reset_password, next)
597
- extra.each do |c|
598
- ia = ia_cmds[c] || {}
599
- command c, description: c.to_s.tr('_', ' ').capitalize, **ia
623
+ extra.each do |name, attrs|
624
+ command name, **attrs
600
625
  end
601
626
  end
602
627
  end
@@ -612,19 +637,19 @@ module Aspera
612
637
  # entity: :sf_entity is resolved at runtime from ctx (injected by setup_admin_nodes_shared_folders).
613
638
  commands_under %i[admin nodes shared_folders] do
614
639
  crud_commands entity: :sf_entity,
615
- api: :@api_v5,
640
+ api: :api_v5,
616
641
  name: 'shared folder',
617
642
  lookup: :lookup_sf_id,
618
643
  items_key: 'shared_folders'
619
- command :user, description: 'Custom access users',
620
- arguments: [{name: :sf_id, type: :identifier, lookup: :lookup_sf_id}],
644
+ command :user, description: 'Manage custom access users',
645
+ arguments: [{name: :shared_folder_id, type: :identifier, lookup: :lookup_sf_id}],
621
646
  setup: :setup_admin_nodes_shared_folders_user
622
647
  end
623
648
 
624
649
  # admin > nodes > shared_folders > user sub-tree (custom access users)
625
650
  # entity: :user_path is resolved at runtime from ctx (injected by setup_admin_nodes_shared_folders_user).
626
651
  commands_under %i[admin nodes shared_folders user] do
627
- crud_commands entity: :user_path, api: :@api_v5, name: 'custom access user', lookup: :lookup_sf_user_id, items_key: 'users'
652
+ crud_commands entity: :user_path, api: :api_v5, name: 'custom access user', lookup: :lookup_sf_user_id, items_key: 'users'
628
653
  end
629
654
 
630
655
  # `members` and `saml_groups`
@@ -632,41 +657,48 @@ module Aspera
632
657
  CRUD_NO_LIST = %i[create modify delete show].freeze
633
658
 
634
659
  RES_SINGULAR = {shared_inboxes: :shared_inbox, workgroups: :workgroup}.freeze
635
- private_constant :RES_SINGULAR
660
+ # Access levels of members, per resource
661
+ MEMBER_ACCESS = {
662
+ shared_inboxes: %i[submit_only standard shared_inbox_admin].freeze,
663
+ workgroups: %i[standard workgroup_admin].freeze
664
+ }.freeze
665
+ private_constant :RES_SINGULAR, :MEMBER_ACCESS
636
666
 
637
667
  # admin > shared_inboxes|workgroups > members|saml_groups|invite_external_collaborator:
638
668
  # res_id consumed via arguments:(:identifier) + lookup:, builds res_instance_path for all children
639
669
  %i[shared_inboxes workgroups].each do |res|
640
- lookup_res_id = ->(field, value, **) { @api_v5.lookup_entity_by_field(entity: res.to_s, field: field, value: value, query: {'all': true})['id'] }
670
+ lookup_res_id = ->(field, value, **) { api_v5.lookup_entity_by_field(entity: res.to_s, field: field, value: value, query: {'all': true})['id'] }
641
671
  commands_under [:admin, res] do
642
- command :members, description: 'Members',
672
+ command :members, description: 'Manage members',
643
673
  arguments: [{name: :"#{RES_SINGULAR[res]}_id", type: :identifier, lookup: lookup_res_id}],
644
674
  setup: :"setup_admin_#{res}_instance"
645
- command :saml_groups, description: 'Saml groups',
675
+ command :saml_groups, description: 'Manage SAML groups',
646
676
  arguments: [{name: :"#{RES_SINGULAR[res]}_id", type: :identifier, lookup: lookup_res_id}],
647
677
  setup: :"setup_admin_#{res}_instance"
648
678
  command :invite_external_collaborator, description: 'Invite external collaborator',
649
679
  arguments: [{name: :"#{RES_SINGULAR[res]}_id", type: :identifier, lookup: lookup_res_id},
650
- {name: :input_data, type: Hash}]
680
+ {name: :collaborator, type: Hash}]
651
681
  end
652
682
 
653
683
  commands_under [:admin, res, :members] do
654
684
  CRUD_NO_SHOW.each do |c|
655
685
  args =
656
686
  if c.eql?(:create)
657
- {arguments: [{name: :users, bulk: true}, {name: :access, mandatory: false, default: :standard}]}
687
+ {arguments: [{name: :users, bulk: true}, {name: :access, allowed: MEMBER_ACCESS[res], mandatory: false, default: :standard}]}
688
+ elsif c.eql?(:modify)
689
+ {arguments: [{name: :member_id, type: :identifier, lookup: :"lookup_#{res}_members_id"}, {name: :member, type: Hash}]}
658
690
  elsif Operations::INSTANCE.include?(c)
659
691
  {arguments: [{name: :member_id, type: :identifier, lookup: :"lookup_#{res}_members_id"}]}
660
692
  else
661
693
  {}
662
694
  end
663
- command c, description: c.to_s.capitalize, **args
695
+ command c, description: operation_description(c, 'member'), **args
664
696
  end
665
697
  end
666
698
 
667
699
  commands_under [:admin, res, :saml_groups] do
668
700
  crud_commands entity: :saml_groups_path,
669
- api: :@api_v5,
701
+ api: :api_v5,
670
702
  name: 'SAML group',
671
703
  operations: CRUD_NO_SHOW,
672
704
  lookup: :"lookup_#{res}_saml_groups_id",
@@ -675,57 +707,51 @@ module Aspera
675
707
  end
676
708
 
677
709
  commands_under %i[admin configuration] do
678
- command :show, description: 'Show configuration', action: -> { Result::SingleObject.new(@api_v5.read('configuration')) }
710
+ command :show, description: 'Show configuration', action: ->(**) { Result::SingleObject.new(api_v5.read('configuration')) }
679
711
  command :modify, description: 'Modify configuration',
680
- arguments: [{name: :input_data, type: Hash}],
681
- action: ->(input_data:, **) { Result::SingleObject.new(@api_v5.update('configuration', input_data)) }
712
+ arguments: [{name: :configuration, type: Hash}],
713
+ action: ->(configuration:, **) { Result::SingleObject.new(api_v5.update('configuration', configuration)) }
682
714
  end
683
715
 
684
716
  commands_under %i[admin smtp] do
685
- command :show, description: 'Show SMTP configuration', action: -> { Result::SingleObject.new(@api_v5.read('configuration/smtp')) }
717
+ command :show, description: 'Show SMTP configuration', action: ->(**) { Result::SingleObject.new(api_v5.read('configuration/smtp')) }
686
718
  command :create, description: 'Create SMTP configuration',
687
- arguments: [{name: :input_data, type: Hash}],
688
- action: ->(input_data:, **) { Result::SingleObject.new(@api_v5.create('configuration/smtp', input_data)) }
719
+ arguments: [{name: :smtp, type: Hash}],
720
+ action: ->(smtp:, **) { Result::SingleObject.new(api_v5.create('configuration/smtp', smtp)) }
689
721
  command :modify, description: 'Modify SMTP configuration',
690
- arguments: [{name: :input_data, type: Hash}],
691
- action: ->(input_data:, **) { Result::SingleObject.new(@api_v5.update('configuration/smtp', input_data)) }
722
+ arguments: [{name: :smtp, type: Hash}],
723
+ action: ->(smtp:, **) { Result::SingleObject.new(api_v5.update('configuration/smtp', smtp)) }
692
724
  command(
693
725
  :delete, description: 'Delete SMTP configuration',
694
- action: lambda do
695
- @api_v5.delete('configuration/smtp')
726
+ action: lambda do |**|
727
+ api_v5.delete('configuration/smtp')
696
728
  Result::Status.new('SMTP configuration deleted')
697
729
  end
698
730
  )
699
731
  command :test, description: 'Test SMTP configuration',
700
- arguments: [{name: :test_data, type: nil}]
732
+ arguments: [{name: :recipient}]
701
733
  end
702
734
 
703
735
  commands_under %i[admin events] do
704
736
  command(
705
737
  :application, description: 'List application events',
706
- action: lambda do
707
- list, total = @api_v5.list_entities_limit_offset_total_count(entity: 'application_events', query: query_read_delete(schema: Schema::Registry.query_params(Schema::Registry::FASPEX, 'application_events')))
738
+ action: lambda do |**|
739
+ list, total = api_v5.list_entities_limit_offset_total_count(entity: 'application_events', query: query_read_delete(schema: Schema::Registry.query_params(Schema::Registry::FASPEX, 'application_events')))
708
740
  Result::ObjectList.new(list, total: total, fields: %w[event_type created_at application user.name])
709
741
  end
710
742
  )
711
743
  command(
712
744
  :webhook, description: 'List webhook events',
713
- action: lambda do
714
- list, total = @api_v5.list_entities_limit_offset_total_count(entity: 'all_webhooks_events', query: query_read_delete(schema: Schema::Registry.query_params(Schema::Registry::FASPEX, 'all_webhooks_events')), items_key: 'events')
745
+ action: lambda do |**|
746
+ list, total = api_v5.list_entities_limit_offset_total_count(entity: 'all_webhooks_events', query: query_read_delete(schema: Schema::Registry.query_params(Schema::Registry::FASPEX, 'all_webhooks_events')), items_key: 'events')
715
747
  Result::ObjectList.new(list, total: total)
716
748
  end
717
749
  )
718
750
  end
719
751
 
720
- # admin > clean_deleted handler (leaf, no sub-commands)
721
- define_action_method(%i[admin clean_deleted]) do |input_data: {}, **|
722
- input_data = @api_v5.read('configuration').slice('days_before_deleting_package_records') if input_data.empty?
723
- Result::SingleObject.new(@api_v5.create('internal/packages/clean_deleted', input_data))
724
- end
725
-
726
752
  # admin > <resource> > list
727
753
  Api::Faspex::ADMIN_RESOURCES.each do |res|
728
- define_action_method([:admin, res, :list]) do
754
+ define_action_method([:admin, res, :list]) do |**|
729
755
  args = res_exec_args(res)
730
756
  # Special case: email_notifications list returns a fixed value list
731
757
  next Result::ValueList.new(Api::Faspex::EMAIL_NOTIF_LIST, name: 'email_id') if res.eql?(:email_notifications)
@@ -740,9 +766,9 @@ module Aspera
740
766
  end
741
767
  end
742
768
 
743
- def action_admin_smtp_test(test_data:, **)
744
- test_data = {test_email_recipient: test_data} if test_data.is_a?(String)
745
- creation = @api_v5.create('configuration/smtp/test', test_data)
769
+ def action_admin_smtp_test(recipient:, **)
770
+ recipient = {test_email_recipient: recipient} if recipient.is_a?(String)
771
+ creation = api_v5.create('configuration/smtp/test', recipient)
746
772
  result = wait_for_job(creation['job_id'])
747
773
  begin
748
774
  result['serialized_args'] = JSON.parse(result['serialized_args'])
@@ -753,15 +779,15 @@ module Aspera
753
779
  end
754
780
 
755
781
  # admin > accounts > reset_password
756
- def action_admin_accounts_reset_password(contact_id:, **)
757
- @api_v5.create("accounts/#{contact_id}/reset_password", {})
782
+ def action_admin_accounts_reset_password(account_id:, **)
783
+ api_v5.create("accounts/#{account_id}/reset_password", {})
758
784
  Result::Status.new('password reset, user shall check email')
759
785
  end
760
786
 
761
787
  # admin > file_processing > next
762
- def action_admin_file_processing_next
788
+ def action_admin_file_processing_next(**)
763
789
  args = res_exec_args(:file_processing)
764
- result, count = @api_v5.list_entities_limit_offset_total_count(entity: args[:entity], operation: 'POST', items_key: 'files')
790
+ result, count = api_v5.list_entities_limit_offset_total_count(entity: args[:entity], operation: 'POST', items_key: 'files')
765
791
  Result::ObjectList.new(result, total: count)
766
792
  end
767
793
 
@@ -778,18 +804,18 @@ module Aspera
778
804
  # Lookup shared folder id by field/value within a node's shared_folders entity.
779
805
  # sf_entity is in ctx from setup_admin_nodes_shared_folders (Phase A of parent).
780
806
  def lookup_sf_id(field, value, sf_entity:, **)
781
- @api_v5.lookup_entity_by_field(entity: sf_entity, items_key: 'shared_folders', field: field, value: value)['id']
807
+ api_v5.lookup_entity_by_field(entity: sf_entity, items_key: 'shared_folders', field: field, value: value)['id']
782
808
  end
783
809
 
784
810
  # Lookup custom access user id by field/value within a shared folder's users entity.
785
811
  # user_path is in ctx from setup_admin_nodes_shared_folders_user (Phase A of parent).
786
812
  def lookup_sf_user_id(field, value, user_path:, **)
787
- @api_v5.lookup_entity_by_field(entity: user_path, items_key: 'users', field: field, value: value)['id']
813
+ api_v5.lookup_entity_by_field(entity: user_path, items_key: 'users', field: field, value: value)['id']
788
814
  end
789
815
 
790
- # admin > nodes > shared_folders > user — sf_id: already in ctx via arguments: on the :user command
791
- def setup_admin_nodes_shared_folders_user(sf_entity:, sf_id:, **)
792
- {user_path: "#{sf_entity}/#{sf_id}/custom_access_users"}
816
+ # admin > nodes > shared_folders > user — shared_folder_id: already in ctx via arguments: on the :user command
817
+ def setup_admin_nodes_shared_folders_user(sf_entity:, shared_folder_id:, **)
818
+ {user_path: "#{sf_entity}/#{shared_folder_id}/custom_access_users"}
793
819
  end
794
820
 
795
821
  # admin > shared_inboxes|workgroups — res_id: already in ctx via arguments: on the :members|:saml_groups command
@@ -810,7 +836,7 @@ module Aspera
810
836
  users = [users] unless users.is_a?(Array)
811
837
  users.map do |user|
812
838
  if (m = Parser.percent_selector(user))
813
- @api_v5.lookup_entity_by_field(entity: 'accounts', field: m[:field], value: m[:value], query: Rest.php_style({type: ACCOUNT_TYPES}))['id']
839
+ api_v5.lookup_entity_by_field(entity: 'accounts', field: m[:field], value: m[:value], query: Rest.php_style({type: ACCOUNT_TYPES}))['id']
814
840
  else
815
841
  user
816
842
  end
@@ -822,11 +848,13 @@ module Aspera
822
848
  %i[shared_inboxes workgroups].each do |res|
823
849
  define_method(:"lookup_#{res}_members_id") do |field, value, res_instance_path:, **|
824
850
  res_path = "#{res_instance_path}/members"
825
- @api_v5.lookup_entity_by_field(entity: res_path, field: field, value: value, query: Rest.php_style({type: %w[user]}))['user_id']
851
+ api_v5.lookup_entity_by_field(entity: res_path, field: field, value: value, query: Rest.php_style({type: %w[user]}))['user_id']
826
852
  end
827
- define_method(:"lookup_#{res}_saml_groups_id") do |field, value, res_instance_path:, **|
828
- res_path = "#{res_instance_path}/saml_groups"
829
- @api_v5.lookup_entity_by_field(entity: res_path, field: field, value: value, query: Rest.php_style({type: %w[user]}))['user_id']
853
+ # Like members (`user_id`), a SAML group of the resource is identified by `group_id`; its name is `group_name`
854
+ define_method(:"lookup_#{res}_saml_groups_id") do |field, value, saml_groups_path:, **|
855
+ field = 'group_name' if field.eql?('name')
856
+ query = field.eql?('group_name') ? {'q' => value} : {}
857
+ api_v5.lookup_entity_by_field(entity: saml_groups_path, field: field, value: value, items_key: 'groups', query: query)['group_id']
830
858
  end
831
859
  end
832
860
 
@@ -834,52 +862,52 @@ module Aspera
834
862
  # (saml_groups CRUD is handled by crud_commands)
835
863
  %i[shared_inboxes workgroups].each do |res|
836
864
  define_action_method([:admin, res, :members, :list]) do |res_instance_path:, **|
837
- entity_list(api: @api_v5, entity: "#{res_instance_path}/members", items_key: 'members')
865
+ entity_list(api: api_v5, entity: "#{res_instance_path}/members", items_key: 'members')
838
866
  end
839
867
 
840
- define_action_method([:admin, res, :members, :modify]) do |res_instance_path:, **kwargs|
841
- entity_modify(api: @api_v5, entity: "#{res_instance_path}/members", id: kwargs[:member_id])
868
+ define_action_method([:admin, res, :members, :modify]) do |res_instance_path:, member_id:, member:, **|
869
+ entity_modify(api: api_v5, entity: "#{res_instance_path}/members", id: member_id, data: member)
842
870
  end
843
871
 
844
872
  define_action_method([:admin, res, :members, :delete]) do |res_instance_path:, **kwargs|
845
- entity_delete(api: @api_v5, entity: "#{res_instance_path}/members", id: kwargs[:member_id])
873
+ entity_delete(api: api_v5, entity: "#{res_instance_path}/members", id: kwargs[:member_id])
846
874
  end
847
875
 
848
876
  define_action_method([:admin, res, :members, :create]) do |users:, access:, res_instance_path:, **|
849
877
  res_path = "#{res_instance_path}/members"
850
878
  resolved = resolve_member_user_ids(users)
851
- input_data = [{user: resolved.map { |u| {id: u, access: access} }}]
852
- entity_create(api: @api_v5, entity: res_path, input_data: input_data)
879
+ entity_create(api: api_v5, entity: res_path, data: {user: resolved.map { |u| {id: u, access: access} }})
853
880
  end
854
881
  end
855
882
 
856
883
  # admin > shared_inboxes|workgroups > invite_external_collaborator
857
884
  %i[shared_inboxes workgroups].each do |res|
858
- define_action_method([:admin, res, :invite_external_collaborator]) do |input_data:, **kwargs|
885
+ define_action_method([:admin, res, :invite_external_collaborator]) do |collaborator:, **kwargs|
859
886
  res_instance_path = "#{res}/#{kwargs[:"#{RES_SINGULAR[res]}_id"]}"
860
- result = @api_v5.create("#{res_instance_path}/external_collaborator", input_data)
887
+ result = api_v5.create("#{res_instance_path}/external_collaborator", collaborator)
861
888
  formatter.display_status(result['message'])
862
- Result::SingleObject.new(@api_v5.lookup_entity_by_field(entity: "#{res_instance_path}/members", items_key: 'members', value: input_data['email_address'], query: {}))
889
+ Result::SingleObject.new(api_v5.lookup_entity_by_field(entity: "#{res_instance_path}/members", items_key: 'members', value: collaborator['email_address'], query: {}))
863
890
  end
864
891
  end
865
892
 
866
- # --- root setup ---
893
+ # --- API ---
867
894
 
868
- # Build @api_v5 for all commands that need it (all except :health and :postprocessing).
869
- # @return [Hash] empty ctx (state stored in @api_v5)
870
- def setup_api_v5(**)
871
- return {} if @api_v5
895
+ # Faspex 5 API, built from CLI options on first use.
896
+ # @return [Api::Faspex]
897
+ def api_v5
898
+ return @api_v5 if @api_v5
872
899
  @api_v5 = Api::Faspex.new(**Oauth.kwargs_from_options(options))
873
900
  # in case user wants to use HTTPGW tell transfer agent how to get address
874
901
  transfer.httpgw_url_cb = lambda { @api_v5.read('account')['gateway_url'] }
875
- {}
902
+ @api_v5
876
903
  end
877
904
 
878
- # Setup for package sub-commands that need an id: resolves package_id from pub_link or argument.
879
- # @return [Hash] ctx key: package_id
905
+ # Setup for package sub-commands that need an id: with a public link to a package, the id comes from the link.
906
+ # Leaf setup runs before argument resolution: the declared package_id argument is then not read.
907
+ # @return [Hash] ctx key: package_id, or empty (package_id is read from the command line)
880
908
  def setup_package_id(**)
881
- package_id = @api_v5.pub_link_context&.key?('package_id') ? @api_v5.pub_link_context['package_id'] : options.instance_identifier
882
- {package_id: package_id}
909
+ return {} unless api_v5.pub_link_context&.key?('package_id')
910
+ {package_id: api_v5.pub_link_context['package_id']}
883
911
  end
884
912
 
885
913
  def action_packages_list(filter: nil, **)
@@ -895,7 +923,7 @@ module Aspera
895
923
  ids = package_id.is_a?(Array) ? package_id : [package_id]
896
924
  Aspera.assert_array_all(ids, String) { 'Package id(s)' }
897
925
  # API returns 204, empty on success
898
- @api_v5.call(
926
+ api_v5.call(
899
927
  operation: 'DELETE',
900
928
  subpath: 'packages',
901
929
  content_type: Mime::JSON,
@@ -907,10 +935,10 @@ module Aspera
907
935
 
908
936
  # --- handlers ---
909
937
 
910
- def action_health
938
+ def action_health(**)
911
939
  nagios = Nagios.new
912
940
  begin
913
- data, http = Rest.new(base_url: options.get_option(:url, mandatory: true))
941
+ data, http = Rest::Client.new(base_url: options.get_option(:url, mandatory: true))
914
942
  .read('health', ret: :both)
915
943
  data.each do |k, v|
916
944
  nagios.add_ok(k, v.to_s)
@@ -925,7 +953,7 @@ module Aspera
925
953
  # Lookup a shared folder id by field/value.
926
954
  # Called via lookup: :lookup_shared_folder_id on the shared_folders > browse command.
927
955
  def lookup_shared_folder_id(field, value, **)
928
- all = @api_v5.read('shared_folders')['shared_folders']
956
+ all = api_v5.read('shared_folders')['shared_folders']
929
957
  matches = all.select { |i| i[field].eql?(value) }
930
958
  Aspera.assert(!matches.empty?) { "no match for #{field} = #{value}" }
931
959
  Aspera.assert(matches.length == 1) { "multiple matches for #{field} = #{value}" }
@@ -933,7 +961,7 @@ module Aspera
933
961
  end
934
962
 
935
963
  def action_shared_folders_browse(folder_path:, shared_folder_id:, **)
936
- all_shared_folders = @api_v5.read('shared_folders')['shared_folders']
964
+ all_shared_folders = api_v5.read('shared_folders')['shared_folders']
937
965
  node = all_shared_folders.find { |i| i['id'].eql?(shared_folder_id) }
938
966
  Aspera.assert(!node.nil?) { "No such shared folder id #{shared_folder_id}" }
939
967
  browse_folder("nodes/#{node['node_id']}/shared_folders/#{shared_folder_id}/browse", {}, folder_path: folder_path)
@@ -941,15 +969,10 @@ module Aspera
941
969
 
942
970
  # invitations sub-handlers
943
971
 
944
- def action_invitations_resend(invitation_id:, **)
945
- @api_v5.create("invitations/#{invitation_id}/resend", nil)
946
- Result::Status.new('Invitation resent')
947
- end
948
-
949
- def action_invitations_create(input_data:, **)
950
- bulk_result(input_data, command: :create) do |params|
972
+ def action_invitations_create(invitation:, **)
973
+ bulk_result(invitation, command: :create) do |params|
951
974
  endpoint = params.key?('recipient_name') ? 'public_invitations' : 'invitations'
952
- @api_v5.create(endpoint, params)
975
+ api_v5.create(endpoint, params)
953
976
  end
954
977
  end
955
978
 
@@ -959,7 +982,7 @@ module Aspera
959
982
  uri = URI.parse(parameters.delete(:url) { WebServerSimple::DEFAULT_URL })
960
983
  server = WebServerSimple.new(uri, **parameters.slice(*WebServerSimple::PARAMS))
961
984
  Aspera.assert(parameters.except(*WebServerSimple::PARAMS).empty?) { "unexpected parameters: #{parameters.except(*WebServerSimple::PARAMS).keys}" }
962
- server.mount(uri.path, Faspex4GWServlet, @api_v5, nil)
985
+ server.mount(uri.path, Faspex4GWServlet, api_v5, nil)
963
986
  server.start
964
987
  Result::Status.new('Gateway terminated')
965
988
  end
@@ -974,12 +997,11 @@ module Aspera
974
997
  server.start
975
998
  Result::Status.new('Gateway terminated')
976
999
  end
977
- SHARED_INBOX_MEMBER_LEVELS = %i[submit_only standard shared_inbox_admin].freeze
978
1000
  ACCOUNT_TYPES = %w{local_user saml_user self_registered_user external_user}.freeze
979
1001
  WORKGROUP_TYPES = %w{workgroup shared_inbox}.freeze
980
1002
  CONTACT_TYPES = (WORKGROUP_TYPES + %w{distribution_list user external_user}).freeze
981
1003
  PACKAGE_RECIPIENT_TYPES = %i{recipients private_recipients notified_on_upload notified_on_download notified_on_receipt}
982
- private_constant :SHARED_INBOX_MEMBER_LEVELS, :ACCOUNT_TYPES, :CONTACT_TYPES, :PACKAGE_RECIPIENT_TYPES,
1004
+ private_constant :ACCOUNT_TYPES, :CONTACT_TYPES, :PACKAGE_RECIPIENT_TYPES,
983
1005
  :CRUD_NO_SHOW, :CRUD_NO_LIST
984
1006
  end
985
1007
  end