aspera-cli 4.26.2 → 4.27.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 (136) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +281 -21
  4. data/CONTRIBUTING.md +98 -7
  5. data/bin/ascli +3 -3
  6. data/docs/README.md +10757 -0
  7. data/docs/test-mcp-with-ai.md +202 -0
  8. data/lib/aspera/agent/base.rb +20 -6
  9. data/lib/aspera/agent/connect.rb +53 -16
  10. data/lib/aspera/agent/desktop.rb +52 -17
  11. data/lib/aspera/agent/direct.rb +26 -24
  12. data/lib/aspera/agent/httpgw.rb +1 -1
  13. data/lib/aspera/agent/node.rb +32 -0
  14. data/lib/aspera/agent/transferd.rb +39 -3
  15. data/lib/aspera/api/alee.rb +1 -1
  16. data/lib/aspera/api/aoc.rb +14 -16
  17. data/lib/aspera/api/cos_node.rb +1 -1
  18. data/lib/aspera/api/faspex.rb +15 -14
  19. data/lib/aspera/api/httpgw.rb +4 -4
  20. data/lib/aspera/api/node.rb +26 -23
  21. data/lib/aspera/api/queries/bss_subscription_account.graphql +18 -0
  22. data/lib/aspera/api/queries/bss_subscription_usage.graphql +18 -0
  23. data/lib/aspera/ascmd.rb +21 -19
  24. data/lib/aspera/ascp/installation.rb +37 -38
  25. data/lib/aspera/ascp/management.rb +2 -3
  26. data/lib/aspera/cli/ascp_actions.rb +155 -0
  27. data/lib/aspera/cli/async_transfer_store.rb +81 -0
  28. data/lib/aspera/cli/bootstrapper.rb +197 -0
  29. data/lib/aspera/cli/command_registry.rb +145 -0
  30. data/lib/aspera/cli/command_spec.rb +138 -0
  31. data/lib/aspera/cli/context.rb +16 -3
  32. data/lib/aspera/cli/error.rb +16 -0
  33. data/lib/aspera/cli/extended_value.rb +2 -1
  34. data/lib/aspera/cli/formatter.rb +41 -27
  35. data/lib/aspera/cli/gem_checker.rb +65 -0
  36. data/lib/aspera/cli/hints.rb +2 -2
  37. data/lib/aspera/cli/http.rb +70 -19
  38. data/lib/aspera/cli/info.rb +2 -0
  39. data/lib/aspera/cli/mailer.rb +97 -0
  40. data/lib/aspera/cli/mcp_tool.rb +198 -0
  41. data/lib/aspera/cli/options.schema.yaml +364 -7
  42. data/lib/aspera/cli/{manager.rb → parser.rb} +334 -142
  43. data/lib/aspera/cli/plugins/alee.rb +20 -22
  44. data/lib/aspera/cli/plugins/aoc.rb +1166 -853
  45. data/lib/aspera/cli/plugins/ats.rb +200 -161
  46. data/lib/aspera/cli/plugins/base.rb +457 -100
  47. data/lib/aspera/cli/plugins/basic_auth.rb +14 -4
  48. data/lib/aspera/cli/plugins/config.rb +428 -754
  49. data/lib/aspera/cli/plugins/console.rb +106 -64
  50. data/lib/aspera/cli/plugins/cos.rb +44 -32
  51. data/lib/aspera/cli/plugins/factory.rb +4 -4
  52. data/lib/aspera/cli/plugins/faspex.rb +294 -257
  53. data/lib/aspera/cli/plugins/faspex5.rb +585 -357
  54. data/lib/aspera/cli/plugins/faspio.rb +49 -51
  55. data/lib/aspera/cli/plugins/httpgw.rb +18 -25
  56. data/lib/aspera/cli/plugins/mcp.rb +279 -0
  57. data/lib/aspera/cli/plugins/node.rb +998 -794
  58. data/lib/aspera/cli/plugins/oauth.rb +7 -10
  59. data/lib/aspera/cli/plugins/orchestrator.rb +111 -134
  60. data/lib/aspera/cli/plugins/preview.rb +180 -139
  61. data/lib/aspera/cli/plugins/server.rb +124 -79
  62. data/lib/aspera/cli/plugins/shares.rb +301 -107
  63. data/lib/aspera/cli/preset_actions.rb +139 -0
  64. data/lib/aspera/cli/preset_manager.rb +13 -12
  65. data/lib/aspera/cli/result.rb +61 -11
  66. data/lib/aspera/cli/runner.rb +275 -141
  67. data/lib/aspera/cli/secret_finder.rb +40 -0
  68. data/lib/aspera/cli/special_values.rb +1 -0
  69. data/lib/aspera/cli/sync_actions.rb +83 -59
  70. data/lib/aspera/cli/terminal_formatter.rb +1 -1
  71. data/lib/aspera/cli/transfer_actions.rb +83 -0
  72. data/lib/aspera/cli/transfer_agent.rb +116 -51
  73. data/lib/aspera/cli/transfer_progress.rb +3 -3
  74. data/lib/aspera/cli/vault_manager.rb +57 -0
  75. data/lib/aspera/cli/version.rb +1 -1
  76. data/lib/aspera/cli/wizard.rb +21 -20
  77. data/lib/aspera/command_line_builder.rb +22 -22
  78. data/lib/aspera/data_repository.rb +2 -1
  79. data/lib/aspera/dot_container.rb +4 -4
  80. data/lib/aspera/environment.rb +18 -11
  81. data/lib/aspera/exec_spec.rb +13 -0
  82. data/lib/aspera/faspex_gw.rb +1 -1
  83. data/lib/aspera/faspex_postproc.rb +4 -3
  84. data/lib/aspera/graphql.rb +35 -0
  85. data/lib/aspera/hash_ext.rb +6 -0
  86. data/lib/aspera/json_rpc/client.rb +62 -0
  87. data/lib/aspera/json_rpc/version.rb +7 -0
  88. data/lib/aspera/keychain/encrypted_hash.rb +1 -1
  89. data/lib/aspera/keychain/factory.rb +3 -3
  90. data/lib/aspera/keychain/macos_security.rb +3 -3
  91. data/lib/aspera/link_header.rb +82 -0
  92. data/lib/aspera/log.rb +17 -3
  93. data/lib/aspera/markdown.rb +85 -2
  94. data/lib/aspera/node_simulator.rb +3 -1
  95. data/lib/aspera/oauth/base.rb +5 -5
  96. data/lib/aspera/oauth/boot.rb +2 -2
  97. data/lib/aspera/oauth/factory.rb +7 -5
  98. data/lib/aspera/oauth/jwt.rb +4 -4
  99. data/lib/aspera/oauth/web.rb +2 -2
  100. data/lib/aspera/persistency_action_once.rb +8 -8
  101. data/lib/aspera/persistency_folder.rb +10 -3
  102. data/lib/aspera/preview/file_types.rb +3 -3
  103. data/lib/aspera/preview/generator.rb +3 -3
  104. data/lib/aspera/preview/utils.rb +1 -1
  105. data/lib/aspera/products/connect.rb +1 -1
  106. data/lib/aspera/products/other.rb +1 -1
  107. data/lib/aspera/products/transferd.rb +1 -1
  108. data/lib/aspera/proxy_auto_config.rb +7 -6
  109. data/lib/aspera/rest.rb +10 -36
  110. data/lib/aspera/rest_error_analyzer.rb +3 -3
  111. data/lib/aspera/rest_errors_aspera.rb +0 -10
  112. data/lib/aspera/rest_list.rb +3 -3
  113. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +1853 -510
  114. data/lib/aspera/schema/documentation.rb +57 -26
  115. data/lib/aspera/schema/reader.rb +59 -10
  116. data/lib/aspera/schema/registry.rb +39 -6
  117. data/lib/aspera/secret_hider.rb +7 -2
  118. data/lib/aspera/ssl.rb +4 -4
  119. data/lib/aspera/sync/conf.schema.yaml +2 -2
  120. data/lib/aspera/sync/operations.rb +10 -13
  121. data/lib/aspera/temp_file_manager.rb +1 -1
  122. data/lib/aspera/timer_limiter.rb +1 -1
  123. data/lib/aspera/transfer/faux_file.rb +24 -11
  124. data/lib/aspera/transfer/parameters.rb +23 -21
  125. data/lib/aspera/transfer/result.rb +74 -0
  126. data/lib/aspera/transfer/resumer.rb +6 -6
  127. data/lib/aspera/transfer/spec.rb +18 -1
  128. data/lib/aspera/transfer/spec.schema.yaml +12 -3
  129. data/lib/aspera/uri_reader.rb +2 -2
  130. data/lib/aspera/web_auth.rb +2 -2
  131. data/lib/aspera/web_server_simple.rb +8 -5
  132. data/lib/aspera/yaml.rb +2 -1
  133. data.tar.gz.sig +0 -0
  134. metadata +25 -3
  135. metadata.gz.sig +0 -0
  136. data/lib/aspera/json_rpc.rb +0 -52
@@ -4,6 +4,7 @@
4
4
 
5
5
  require 'aspera/cli/plugins/oauth'
6
6
  require 'aspera/cli/extended_value'
7
+ require 'aspera/transfer/result'
7
8
  require 'aspera/cli/special_values'
8
9
  require 'aspera/cli/wizard'
9
10
  require 'aspera/api/faspex'
@@ -18,11 +19,11 @@ module Aspera
18
19
  module Cli
19
20
  module Plugins
20
21
  class Faspex5 < Oauth
21
- class << self
22
- def application_name
23
- 'Faspex'
24
- end
22
+ application_name 'Faspex v5'
23
+ # Commands that may carry a query_schema annotation
24
+ QUERY_SCHEMA_COMMANDS = %i[list delete].freeze
25
25
 
26
+ class << self
26
27
  # @return [Hash,NilClass]
27
28
  def detect(address_or_url)
28
29
  # add scheme if missing
@@ -92,11 +93,12 @@ module Aspera
92
93
  }
93
94
  end
94
95
 
96
+ option :box, description: "Package inbox, either shared inbox name or one of: #{Api::Faspex::API_LIST_MAILBOX_TYPES.join(', ')} or #{SpecialValues::ALL}", default: 'inbox_all'
97
+ option :shared_folder, description: 'Send package with files from shared folder'
98
+ option :group_type, description: 'Type of shared box', allowed: %i[shared_inboxes workgroups], default: :shared_inboxes
99
+
95
100
  def initialize(**_)
96
101
  super
97
- options.declare(:box, "Package inbox, either shared inbox name or one of: #{Api::Faspex::API_LIST_MAILBOX_TYPES.join(', ')} or #{SpecialValues::ALL}", default: 'inbox_all')
98
- options.declare(:shared_folder, 'Send package with files from shared folder')
99
- options.declare(:group_type, 'Type of shared box', allowed: %i[shared_inboxes workgroups], default: :shared_inboxes)
100
102
  options.parse_options!
101
103
  # [Aspera::Api::Faspex]
102
104
  @api_v5 = nil
@@ -136,24 +138,24 @@ module Aspera
136
138
  # user asked to not follow
137
139
  return status if status_list.nil?
138
140
  if status['upload_status'].eql?('submitted')
139
- config.progress_bar&.event(:sessions_init, session_id: nil, info: status['upload_status'])
141
+ progress_bar&.event(:sessions_init, session_id: nil, info: status['upload_status'])
140
142
  elsif !total_sent
141
- config.progress_bar&.event(:session_start, session_id: id)
142
- config.progress_bar&.event(:session_size, session_id: id, info: status['bytes_total'].to_i)
143
+ progress_bar&.event(:session_start, session_id: id)
144
+ progress_bar&.event(:session_size, session_id: id, info: status['bytes_total'].to_i)
143
145
  total_sent = true
144
146
  else
145
- config.progress_bar&.event(:transfer, session_id: id, info: status['bytes_written'].to_i)
147
+ progress_bar&.event(:transfer, session_id: id, info: status['bytes_written'].to_i)
146
148
  end
147
149
  if status_list.include?(status['upload_status'])
148
- config.progress_bar&.event(:session_end, session_id: id)
149
- config.progress_bar&.event(:end)
150
+ progress_bar&.event(:session_end, session_id: id)
151
+ progress_bar&.event(:end)
150
152
  return status
151
153
  end
152
154
  sleep(1.0)
153
155
  end
154
156
  end
155
157
 
156
- # @param [String] job identifier
158
+ # @param job_id [String] job identifier
157
159
  # @return [Hash] result of API call for job status
158
160
  def wait_for_job(job_id)
159
161
  result = nil
@@ -261,7 +263,7 @@ module Aspera
261
263
  statuses = transfer.start(transfer_spec)
262
264
  result_transfer.push({'package' => pkg_id, Runner::STATUS_FIELD => statuses})
263
265
  # skip only if all sessions completed
264
- if TransferAgent.session_status(statuses).eql?(:success) && skip_ids_persistency
266
+ if statuses.is_a?(Transfer::Result::Success) && skip_ids_persistency
265
267
  skip_ids_persistency.data.push(pkg_id)
266
268
  skip_ids_persistency.save
267
269
  end
@@ -269,8 +271,7 @@ module Aspera
269
271
  return Runner.result_transfer_multiple(result_transfer)
270
272
  end
271
273
 
272
- def package_send
273
- parameters = value_create_modify(command: :send, schema: Schema::Registry.req_body(Schema::Registry::FASPEX, 'packages.post'))
274
+ def package_send(parameters)
274
275
  # autofill recipient for public url
275
276
  if @api_v5.pub_link_context&.key?('recipient_type') && !parameters.key?('recipients')
276
277
  parameters['recipients'] = [{
@@ -299,7 +300,7 @@ module Aspera
299
300
  return Runner.result_transfer(transfer.start(transfer_spec))
300
301
  else
301
302
  # send from remote shared folder
302
- if (m = Manager.percent_selector(shared_folder))
303
+ if (m = Parser.percent_selector(shared_folder))
303
304
  shared_folder = @api_v5.lookup_entity_by_field(
304
305
  entity: 'shared_folders',
305
306
  field: m[:field],
@@ -320,8 +321,9 @@ module Aspera
320
321
 
321
322
  # Browse a folder
322
323
  # @param browse_endpoint [String] the endpoint to browse
323
- def browse_folder(browse_endpoint, base_query = {})
324
- folders_to_process = [options.get_next_argument('folder path', default: '/')]
324
+ # @param folder_path [String] starting path (default: '/')
325
+ def browse_folder(browse_endpoint, base_query = {}, folder_path: '/')
326
+ folders_to_process = [folder_path]
325
327
  query = base_query.merge(query_read_delete(default: {}))
326
328
  filters = query.delete('filters'){{}}
327
329
  Aspera.assert_type(filters, Hash)
@@ -374,374 +376,600 @@ module Aspera
374
376
  return Result::ObjectList.new(all_items, total: total_count)
375
377
  end
376
378
 
377
- def package_action
378
- command = options.get_next_command(%i[show browse status delete receive send list file_processing])
379
- package_id =
380
- unless %i[send list].include?(command)
381
- @api_v5.pub_link_context&.key?('package_id') ? @api_v5.pub_link_context['package_id'] : options.instance_identifier
382
- end
383
- package_instance = "packages/#{package_id}"
384
- case command
385
- when :show
386
- return Result::SingleObject.new(@api_v5.read(package_instance))
387
- when :browse
388
- return browse_folder("#{package_instance}/files/#{Api::Faspex.box_type(options.get_option(:box))}", recipient_query(package_id))
389
- when :status
390
- status_list = options.get_next_argument('list of states, or nothing', mandatory: false, validation: Array)
391
- status = wait_package_status(package_id, status_list: status_list)
392
- return Result::SingleObject.new(status)
393
- when :delete
394
- ids = package_id
395
- ids = [ids] unless ids.is_a?(Array)
396
- Aspera.assert_array_all(ids, String){'Package id(s)'}
397
- # API returns 204, empty on success
398
- @api_v5.call(
399
- operation: 'DELETE',
400
- subpath: 'packages',
401
- content_type: Mime::JSON,
402
- body: {ids: ids},
403
- headers: {'Accept' => Mime::JSON}
404
- )
405
- return Result::Status.new('Package(s) deleted')
406
- when :receive
407
- return package_receive(package_id)
408
- when :send
409
- return package_send
410
- when :list
411
- list, total = list_packages_with_filter
412
- fields = %w[id title status sender.name recipients.0.name release_date total_bytes total_files]
413
- fields.delete('recipients.0.name') if %w[inbox inbox_history].include?(options.get_option(:box))
414
- fields.delete('sender.name') if %w[outbox outbox_history].include?(options.get_option(:box))
415
- return Result::ObjectList.new(list, total: total, fields: fields)
416
- when :file_processing
417
- result, count = @api_v5.list_entities_limit_offset_total_count(
418
- entity: "#{package_instance}/file_statuses",
419
- items_key: 'files'
420
- )
421
- return Result::ObjectList.new(result, total: count)
422
- end
379
+ # Per-resource configuration for admin CRUD sub-trees.
380
+ # Keys mirror entity_execute kwargs; extra_commands lists additional leaf commands.
381
+ # @return [Hash{Symbol => Hash}]
382
+ RESOURCE_CONFIG = {
383
+ accounts: {
384
+ display_fields: ->{Formatter.all_but('user_profile_data_attributes')},
385
+ extra_commands: [:reset_password],
386
+ instance_arg_commands: {reset_password: {arguments: [{name: :contact_id, type: :identifier, lookup: :lookup_accounts_id}]}},
387
+ query_component: Schema::Registry::FASPEX
388
+ },
389
+ alternate_addresses: {entity: 'configuration/alternate_addresses', query_component: Schema::Registry::FASPEX},
390
+ contacts: {query_component: Schema::Registry::FASPEX},
391
+ distribution_lists: {entity: 'account/distribution_lists', delete_style: 'ids', query_component: Schema::Registry::FASPEX},
392
+ email_notifications: {id_as_arg: 'type', query_component: Schema::Registry::FASPEX},
393
+ file_processing: {
394
+ commands: %i[next modify],
395
+ schema: ->{Schema::Registry.req_body(Schema::Registry::FASPEX, 'file_processing.put')},
396
+ is_singleton: true
397
+ },
398
+ jobs: {display_fields: %w[id job_name job_type status], query_component: Schema::Registry::FASPEX},
399
+ metadata_profiles: {entity: 'configuration/metadata_profiles', items_key: 'profiles', query_component: Schema::Registry::FASPEX},
400
+ nodes: {
401
+ extra_commands: %i[browse],
402
+ instance_arg_commands: {
403
+ browse: {
404
+ arguments: [{name: :node_id, type: :identifier, lookup: :lookup_node_id},
405
+ {name: :folder_path, type: String, mandatory: false, default: '/'}]
406
+ }
407
+ },
408
+ query_component: Schema::Registry::FASPEX
409
+ },
410
+ oauth_clients: {
411
+ display_fields: ->{Formatter.all_but('public_key')},
412
+ api: ->{Api::Faspex.new(root: Api::Faspex::PATH_AUTH, **Oauth.kwargs_from_options(options))},
413
+ list_query: {'expand': true, 'no_api_path': true, 'client_types[]': 'public'},
414
+ query_component: Schema::Registry::FASPEX
415
+ },
416
+ registrations: {query_component: Schema::Registry::FASPEX},
417
+ saml_configs: {query_component: Schema::Registry::FASPEX},
418
+ shared_inboxes: {res_id_query: {'all': true}, query_component: Schema::Registry::FASPEX},
419
+ webhooks: {query_component: Schema::Registry::FASPEX},
420
+ workgroups: {res_id_query: {'all': true}, query_component: Schema::Registry::FASPEX}
421
+ }.freeze
422
+ private_constant :RESOURCE_CONFIG
423
+
424
+ # Resolve a RESOURCE_CONFIG value that may be a Proc (evaluated in instance context).
425
+ def resource_config_value(cfg, key)
426
+ v = cfg[key]
427
+ v.is_a?(Proc) ? instance_exec(&v) : v
423
428
  end
424
429
 
425
- def execute_resource(res_sym)
426
- # Arguments for Plugin::Base::entity_execute
427
- exec_args = {
428
- api: @api_v5,
429
- entity: res_sym.to_s
430
- }
431
- res_id_query = :default
432
- available_commands = Operations::ALL
433
- case res_sym
434
- when :metadata_profiles
435
- exec_args[:entity] = 'configuration/metadata_profiles'
436
- exec_args[:items_key] = 'profiles'
437
- when :alternate_addresses
438
- exec_args[:entity] = 'configuration/alternate_addresses'
439
- when :distribution_lists
440
- exec_args[:entity] = 'account/distribution_lists'
441
- exec_args[:delete_style] = 'ids'
442
- when :email_notifications
443
- exec_args.delete(:items_key)
444
- exec_args[:id_as_arg] = 'type'
445
- when :accounts
446
- exec_args[:display_fields] = Formatter.all_but('user_profile_data_attributes')
447
- available_commands += [:reset_password]
448
- when :oauth_clients
449
- exec_args[:display_fields] = Formatter.all_but('public_key')
450
- exec_args[:api] = Api::Faspex.new(root: Api::Faspex::PATH_AUTH, **Oauth.kwargs_from_options(options))
451
- exec_args[:list_query] = {'expand': true, 'no_api_path': true, 'client_types[]': 'public'}
452
- when :shared_inboxes, :workgroups
453
- available_commands += %i[members saml_groups invite_external_collaborator]
454
- res_id_query = {'all': true}
455
- when :nodes
456
- available_commands += %i[shared_folders browse]
457
- when :jobs
458
- exec_args[:display_fields] = %w[id job_name job_type status]
459
- when :file_processing
460
- available_commands = %i[next modify]
461
- # schema for modify
462
- exec_args[:schema] = Schema::Registry.req_body(Schema::Registry::FASPEX, 'file_processing.put')
463
- exec_args[:is_singleton] = true
430
+ # Build exec_args hash for entity_execute from RESOURCE_CONFIG for the given resource.
431
+ def res_exec_args(res_sym)
432
+ cfg = RESOURCE_CONFIG.fetch(res_sym, {})
433
+ {
434
+ api: resource_config_value(cfg, :api) || @api_v5,
435
+ entity: resource_config_value(cfg, :entity) || res_sym.to_s,
436
+ items_key: resource_config_value(cfg, :items_key),
437
+ delete_style: resource_config_value(cfg, :delete_style),
438
+ id_as_arg: resource_config_value(cfg, :id_as_arg) || false,
439
+ display_fields: resource_config_value(cfg, :display_fields),
440
+ list_query: resource_config_value(cfg, :list_query),
441
+ is_singleton: resource_config_value(cfg, :is_singleton) || false,
442
+ schema: resource_config_value(cfg, :schema),
443
+ query_component: resource_config_value(cfg, :query_component)
444
+ }.compact
445
+ end
446
+
447
+ # Lookup methods for arguments: + lookup: on specific nodes
448
+
449
+ # admin > nodes — lookup node id by field/value
450
+ def lookup_node_id(field, value, **)
451
+ @api_v5.lookup_entity_by_field(entity: 'nodes', field: field, value: value)['id']
452
+ end
453
+
454
+ # admin > shared_inboxes — lookup id by field/value
455
+ def lookup_shared_inboxes_id(field, value, **)
456
+ @api_v5.lookup_entity_by_field(entity: 'shared_inboxes', field: field, value: value, query: {'all': true})['id']
457
+ end
458
+
459
+ # admin > workgroups — lookup id by field/value
460
+ def lookup_workgroups_id(field, value, **)
461
+ @api_v5.lookup_entity_by_field(entity: 'workgroups', field: field, value: value, query: {'all': true})['id']
462
+ end
463
+
464
+ # admin > accounts — lookup id by field/value (used for reset_password arguments:)
465
+ def lookup_accounts_id(field, value, **)
466
+ res_lookup_id(:accounts, field, value)
467
+ end
468
+
469
+ # Lookup id for a RESOURCE_CONFIG resource by field/value.
470
+ def res_lookup_id(res_sym, field, value)
471
+ cfg = RESOURCE_CONFIG.fetch(res_sym, {})
472
+ entity = resource_config_value(cfg, :entity) || res_sym.to_s
473
+ items_key = resource_config_value(cfg, :items_key)
474
+ res_id_query = resource_config_value(cfg, :res_id_query) || :default
475
+ @api_v5.lookup_entity_by_field(entity: entity, value: value, field: field, items_key: items_key, query: res_id_query)['id']
476
+ end
477
+
478
+ # --- DSL ---
479
+
480
+ # Commands that need @api_v5 carry setup: :setup_api_v5.
481
+ # :health and :postprocessing work without authentication, so they have no setup.
482
+ command :health, description: 'Check Faspex 5 health'
483
+ command :version, description: 'Show Faspex 5 version', setup: :setup_api_v5, action: ->{Result::SingleObject.new(@api_v5.read('version'))}
484
+ command :bearer_token, description: 'Show OAuth bearer token', setup: :setup_api_v5, action: ->{Result::Text.new(@api_v5.oauth.authorization)}
485
+ command :packages, description: 'Manage packages', setup: :setup_api_v5
486
+ commands_under(:packages) do
487
+ command :list, description: 'List packages'
488
+ command :send, description: 'Send a package', transfer_paths: :send,
489
+ arguments: [{name: :data, type: Hash, schema: Schema::Registry.req_body(Schema::Registry::FASPEX, 'packages.post')}],
490
+ action: ->(data:, **){package_send(data)}
491
+ command :show, description: 'Show a package', setup: :setup_package_id,
492
+ action: ->(package_id:, **){Result::SingleObject.new(@api_v5.read("packages/#{package_id}"))}
493
+ command :browse, description: 'Browse package files', setup: :setup_package_id,
494
+ arguments: [{name: :folder_path, type: String, mandatory: false, default: '/'}],
495
+ 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)}
496
+ command(
497
+ :status, description: 'Wait for package status', setup: :setup_package_id,
498
+ arguments: [{name: :status_list, type: Array, mandatory: false, default: nil}],
499
+ action: ->(status_list:, package_id:, **){Result::SingleObject.new(wait_package_status(package_id, status_list: status_list))}
500
+ )
501
+ command :delete, description: 'Delete package(s)', setup: :setup_package_id
502
+ command :receive, description: 'Receive a package', setup: :setup_package_id, transfer_paths: :receive,
503
+ action: ->(package_id:, **){package_receive(package_id)}
504
+ command(:file_processing, description: 'Show file processing status', setup: :setup_package_id, action: lambda do |package_id:, **|
505
+ result, count = @api_v5.list_entities_limit_offset_total_count(entity: "packages/#{package_id}/file_statuses", items_key: 'files')
506
+ Result::ObjectList.new(result, total: count)
507
+ end)
508
+ end
509
+ command :admin, description: 'Administer Faspex 5', setup: :setup_api_v5
510
+ command :user, description: 'Manage current user', setup: :setup_api_v5
511
+ command :shared_folders, description: 'Browse shared folders', setup: :setup_api_v5
512
+ command :gateway, description: 'Start Faspex 4 gateway emulation', setup: :setup_api_v5,
513
+ arguments: [{name: :parameters, type: Hash, mandatory: false, default: {}}]
514
+ command :postprocessing, description: 'Start Faspex 4 post-processing server',
515
+ arguments: [{name: :parameters, type: Hash, mandatory: false, default: {}}]
516
+ command :invitations, description: 'Manage invitations', setup: :setup_api_v5
517
+
518
+ commands_under(:invitations) do
519
+ command :create, description: 'Create an invitation',
520
+ arguments: [{name: :input_data, type: Hash, bulk: true}]
521
+ command :resend, description: 'Resend an invitation',
522
+ arguments: [{name: :invitation_id, type: :identifier}]
523
+ Operations::ALL.reject{ |op| op == :create}.each do |op|
524
+ command(op, description: "#{op.capitalize} invitation(s)")
464
525
  end
465
- res_command = options.get_next_command(available_commands)
466
- return Result::ValueList.new(Api::Faspex::EMAIL_NOTIF_LIST, name: 'email_id') if res_command.eql?(:list) && res_sym.eql?(:email_notifications)
467
- case res_command
468
- when :create, :modify, :delete, :show
469
- return entity_execute(command: res_command, **exec_args) do |field, value|
470
- @api_v5.lookup_entity_by_field(entity: exec_args[:entity], value: value, field: field, items_key: exec_args[:items_key], query: res_id_query)['id']
471
- end
472
- when :list
473
- data, total = exec_args[:api].list_entities_limit_offset_total_count(entity: exec_args[:entity], items_key: exec_args[:items_key], query: query_read_delete(default: exec_args[:list_query]))
474
- return Result::ObjectList.new(data, total: total, fields: exec_args[:display_fields])
475
- when :shared_folders
476
- # nodes
477
- node_id = options.instance_identifier do |field, value|
478
- @api_v5.lookup_entity_by_field(entity: 'nodes', field: field, value: value)['id']
526
+ end
527
+
528
+ commands_under(:user) do
529
+ command :account, description: 'Show account information', action: ->{Result::SingleObject.new(@api_v5.read('account', query_read_delete))}
530
+ command :profile, description: 'Manage user profile'
531
+ end
532
+
533
+ commands_under(%i[user profile]) do
534
+ command :show, description: 'Show user profile', action: ->{Result::SingleObject.new(@api_v5.read('account/preferences'))}
535
+ command(
536
+ :modify, description: 'Modify user profile',
537
+ arguments: [{name: :properties, type: Hash}],
538
+ action: lambda do |properties:, **|
539
+ @api_v5.update('account/preferences', properties)
540
+ Result::Status.new('modified')
479
541
  end
480
- shared_folder_entity = "nodes/#{node_id}/shared_folders"
481
- sh_command = options.get_next_command(Operations::ALL + [:user])
482
- case sh_command
483
- when *Operations::ALL
484
- return entity_execute(
485
- api: @api_v5,
486
- entity: shared_folder_entity,
487
- command: sh_command
488
- ) do |field, value|
489
- @api_v5.lookup_entity_by_field(entity: shared_folder_entity, field: field, value: value)['id']
490
- end
491
- when :user
492
- sh_id = options.instance_identifier do |field, value|
493
- @api_v5.lookup_entity_by_field(entity: shared_folder_entity, field: field, value: value)['id']
494
- end
495
- user_path = "#{shared_folder_entity}/#{sh_id}/custom_access_users"
496
- return entity_execute(api: @api_v5, entity: user_path, items_key: 'users') do |field, value|
497
- @api_v5.lookup_entity_by_field(entity: user_path, items_key: 'users', field: field, value: value)['id']
498
- end
542
+ )
543
+ end
499
544
 
545
+ commands_under(:shared_folders) do
546
+ command :list, description: 'List shared folders', action: ->{Result::ObjectList.new(@api_v5.read('shared_folders')['shared_folders'])}
547
+ command :browse, description: 'Browse a shared folder',
548
+ arguments: [{name: :shared_folder_id, type: :identifier, lookup: :lookup_shared_folder_id},
549
+ {name: :folder_path, type: String, mandatory: false, default: '/'}]
550
+ end
551
+
552
+ # admin sub-tree: fixed commands + all ADMIN_RESOURCES with their sub-commands
553
+ commands_under(:admin) do
554
+ command :configuration, description: 'Manage Faspex 5 configuration'
555
+ command :smtp, description: 'Manage SMTP configuration'
556
+ command :events, description: 'List events'
557
+ command :clean_deleted, description: 'Clean deleted packages',
558
+ arguments: [{name: :input_data, type: Hash, mandatory: false, default: {}}]
559
+ Api::Faspex::ADMIN_RESOURCES.each do |res|
560
+ cfg = RESOURCE_CONFIG.fetch(res, {})
561
+ extra = cfg[:extra_commands] || []
562
+ cmds = cfg[:commands] || (Operations::ALL + extra)
563
+ ia_cmds = cfg[:instance_arg_commands] || {}
564
+ is_singleton = cfg[:is_singleton] || false
565
+ schema_val = cfg[:schema]
566
+ command(res, description: "Manage #{res.to_s.tr('_', ' ')}")
567
+ commands_under([:admin, res]) do
568
+ cmds.each do |c|
569
+ ia = ia_cmds[c] || {}
570
+ extra_args =
571
+ if !is_singleton && c.eql?(:create)
572
+ [{name: :input_data, type: Hash, bulk: true, schema: schema_val}]
573
+ elsif c.eql?(:modify)
574
+ [{name: :input_data, type: Hash, schema: schema_val}]
575
+ else
576
+ []
577
+ end
578
+ # Merge arguments: from ia (e.g. identifier spec) with extra_args for this operation
579
+ merged_args = Array(ia[:arguments]) + extra_args
580
+ spec_kwargs = merged_args.empty? ? ia.except(:arguments) : ia.except(:arguments).merge(arguments: merged_args)
581
+ # Attach query_schema (full path) to :list/:delete CommandSpec so --help shows the tip.
582
+ # cfg[:query_component] is always a String constant so direct read is safe here (no Proc).
583
+ if QUERY_SCHEMA_COMMANDS.include?(c) && cfg[:query_component]
584
+ entity_path = cfg[:entity] || res.to_s
585
+ spec_kwargs = spec_kwargs.merge(
586
+ query_schema: Schema::Registry.query_params(cfg[:query_component], entity_path)
587
+ )
588
+ end
589
+ command(c, description: c.to_s.tr('_', ' ').capitalize, **spec_kwargs)
590
+ end
500
591
  end
501
- when :browse
502
- # nodes
503
- node_id = options.instance_identifier do |field, value|
504
- @api_v5.lookup_entity_by_field(entity: 'nodes', value: value, field: field)['id']
592
+ end
593
+ end
594
+
595
+ commands_under(%i[admin nodes]) do
596
+ command :shared_folders, description: 'Manage shared folders',
597
+ arguments: [{name: :node_id, type: :identifier, lookup: :lookup_node_id}],
598
+ setup: :setup_admin_nodes_shared_folders
599
+ end
600
+
601
+ # admin > nodes > shared_folders sub-tree
602
+ commands_under(%i[admin nodes shared_folders]) do
603
+ Operations::ALL.each{ |c| command(c, description: c.to_s.tr('_', ' ').capitalize)}
604
+ command :user, description: 'Custom access users',
605
+ arguments: [{name: :sf_id, type: :identifier, lookup: :lookup_sf_id}],
606
+ setup: :setup_admin_nodes_shared_folders_user
607
+ end
608
+
609
+ # admin > nodes > shared_folders > user sub-tree (custom access users)
610
+ commands_under(%i[admin nodes shared_folders user]) do
611
+ Operations::ALL.each{ |c| command(c, description: c.to_s.tr('_', ' ').capitalize)}
612
+ end
613
+
614
+ MEMBER_SUBS = %i[members saml_groups].freeze
615
+ CRUD_NO_SHOW = %i[create list modify delete].freeze
616
+ CRUD_NO_LIST = %i[create modify delete show].freeze
617
+
618
+ # admin > shared_inboxes|workgroups > members|saml_groups|invite_external_collaborator:
619
+ # res_id consumed via arguments:(:identifier) + lookup:, builds res_instance_path for all children
620
+ %i[shared_inboxes workgroups].each do |res|
621
+ MEMBER_SUBS.each do |sub|
622
+ commands_under([:admin, res]) do
623
+ command sub, description: sub.to_s.tr('_', ' ').capitalize,
624
+ arguments: [{name: :res_id, type: :identifier, lookup: :"lookup_#{res}_id"}],
625
+ setup: :"setup_admin_#{res}_instance"
505
626
  end
506
- return browse_folder("nodes/#{node_id}/browse")
507
- when :invite_external_collaborator
508
- # :shared_inboxes, :workgroups
509
- shared_inbox_id = options.instance_identifier{ |field, value| @api_v5.lookup_entity_by_field(entity: res_sym.to_s, field: field, value: value, query: res_id_query)['id']}
510
- creation_payload = value_create_modify(command: res_command)
511
- result = @api_v5.create("#{res_sym}/#{shared_inbox_id}/external_collaborator", creation_payload)
512
- formatter.display_status(result['message'])
513
- result = @api_v5.lookup_entity_by_field(
514
- entity: "#{res_sym}/#{shared_inbox_id}/members",
515
- items_key: 'members',
516
- value: creation_payload['email_address'],
517
- query: {}
518
- )
519
- return Result::SingleObject.new(result)
520
- when :members, :saml_groups
521
- # res_command := :shared_inboxes, :workgroups
522
- res_id = options.instance_identifier{ |field, value| @api_v5.lookup_entity_by_field(entity: res_sym.to_s, field: field, value: value, query: res_id_query)['id']}
523
- res_path = "#{res_sym}/#{res_id}/#{res_command}"
524
- list_key = res_command.to_s
525
- list_key = 'groups' if res_command.eql?(:saml_groups)
526
- sub_command = options.get_next_command(%i[create list modify delete])
527
- if sub_command.eql?(:create) && res_command.eql?(:members)
528
- # first arg is one user name or list of users
529
- users = options.get_next_argument('user id, %name:, or Array')
530
- users = [users] unless users.is_a?(Array)
531
- users = users.map do |user|
532
- if (m = Manager.percent_selector(user))
533
- @api_v5.lookup_entity_by_field(
534
- entity: 'accounts',
535
- field: m[:field],
536
- value: m[:value],
537
- query: Rest.php_style({type: ACCOUNT_TYPES})
538
- )['id']
627
+ commands_under([:admin, res, sub]) do
628
+ CRUD_NO_SHOW.each do |c|
629
+ args = if c.eql?(:create) && sub.eql?(:members)
630
+ {arguments: [{name: :users, bulk: true}, {name: :access, mandatory: false, default: :standard}]}
539
631
  else
540
- # it's the user id (not member id...)
541
- user
632
+ {}
542
633
  end
634
+ command(c, description: c.to_s.capitalize, **args)
543
635
  end
544
- access = options.get_next_argument('level', mandatory: false, accept_list: SHARED_INBOX_MEMBER_LEVELS, default: :standard)
545
- options.unshift_next_argument({user: users.map{ |u| {id: u, access: access}}})
546
636
  end
547
- # TODO: test SAML group
548
- return entity_execute(
549
- api: @api_v5,
550
- entity: res_path,
551
- command: sub_command,
552
- items_key: list_key
553
- ) do |field, value|
554
- @api_v5.lookup_entity_by_field(
555
- entity: res_path,
556
- field: field,
557
- value: value,
558
- query: Rest.php_style({type: %w[user]})
559
- )['user_id']
560
- end
561
- when :reset_password
562
- # :accounts
563
- contact_id = options.instance_identifier{ |field, value| @api_v5.lookup_entity_by_field(entity: 'accounts', field: field, value: value, query: res_id_query)['id']}
564
- @api_v5.create("accounts/#{contact_id}/reset_password", {})
565
- return Result::Status.new('password reset, user shall check email')
566
- when :next
567
- result, count = @api_v5.list_entities_limit_offset_total_count(
568
- entity: exec_args[:entity],
569
- operation: 'POST',
570
- items_key: 'files'
571
- )
572
- return Result::ObjectList.new(result, total: count)
573
637
  end
574
- Aspera.error_unreachable_line
575
- end
576
-
577
- def execute_admin
578
- command = options.get_next_command(%i[configuration smtp events clean_deleted].concat(Api::Faspex::ADMIN_RESOURCES).freeze)
579
- case command
580
- when *Api::Faspex::ADMIN_RESOURCES
581
- return execute_resource(command)
582
- when :clean_deleted
583
- delete_data = value_create_modify(command: command, default: {})
584
- delete_data = @api_v5.read('configuration').slice('days_before_deleting_package_records') if delete_data.empty?
585
- res = @api_v5.create('internal/packages/clean_deleted', delete_data)
586
- return Result::SingleObject.new(res)
587
- when :events
588
- event_type = options.get_next_command(%i[application webhook])
589
- case event_type
590
- when :application
591
- list, total = @api_v5.list_entities_limit_offset_total_count(entity: 'application_events', query: query_read_delete)
592
- return Result::ObjectList.new(list, total: total, fields: %w[event_type created_at application user.name])
593
- when :webhook
594
- list, total = @api_v5.list_entities_limit_offset_total_count(
595
- entity: 'all_webhooks_events',
596
- query: query_read_delete,
597
- items_key: 'events'
638
+ commands_under([:admin, res]) do
639
+ command :invite_external_collaborator, description: 'Invite external collaborator',
640
+ arguments: [{name: :res_id, type: :identifier, lookup: :"lookup_#{res}_id"},
641
+ {name: :input_data, type: Hash}]
642
+ end
643
+ end
644
+
645
+ commands_under(%i[admin configuration]) do
646
+ command :show, description: 'Show configuration', action: ->{Result::SingleObject.new(@api_v5.read('configuration'))}
647
+ command(
648
+ :modify, description: 'Modify configuration',
649
+ arguments: [{name: :input_data, type: Hash}],
650
+ action: ->(input_data:, **){Result::SingleObject.new(@api_v5.update('configuration', input_data))}
651
+ )
652
+ end
653
+
654
+ commands_under(%i[admin smtp]) do
655
+ command :show, description: 'Show SMTP configuration', action: ->{Result::SingleObject.new(@api_v5.read('configuration/smtp'))}
656
+ command(
657
+ :create, description: 'Create SMTP configuration',
658
+ arguments: [{name: :input_data, type: Hash}],
659
+ action: ->(input_data:, **){Result::SingleObject.new(@api_v5.create('configuration/smtp', input_data))}
660
+ )
661
+ command(
662
+ :modify, description: 'Modify SMTP configuration',
663
+ arguments: [{name: :input_data, type: Hash}],
664
+ action: ->(input_data:, **){Result::SingleObject.new(@api_v5.update('configuration/smtp', input_data))}
665
+ )
666
+ command(:delete, description: 'Delete SMTP configuration', action: lambda do
667
+ @api_v5.delete('configuration/smtp')
668
+ Result::Status.new('SMTP configuration deleted')
669
+ end)
670
+ command :test, description: 'Test SMTP configuration',
671
+ arguments: [{name: :test_data, type: nil}]
672
+ end
673
+
674
+ commands_under(%i[admin events]) do
675
+ command(:application, description: 'List application events', action: lambda do
676
+ list, total = @api_v5.list_entities_limit_offset_total_count(entity: 'application_events', query: query_read_delete)
677
+ Result::ObjectList.new(list, total: total, fields: %w[event_type created_at application user.name])
678
+ end)
679
+ command(:webhook, description: 'List webhook events', action: lambda do
680
+ list, total = @api_v5.list_entities_limit_offset_total_count(entity: 'all_webhooks_events', query: query_read_delete, items_key: 'events')
681
+ Result::ObjectList.new(list, total: total)
682
+ end)
683
+ end
684
+
685
+ # admin > clean_deleted handler (leaf, no sub-commands)
686
+ define_action_method(%i[admin clean_deleted]) do |input_data: {}, **|
687
+ input_data = @api_v5.read('configuration').slice('days_before_deleting_package_records') if input_data.empty?
688
+ Result::SingleObject.new(@api_v5.create('internal/packages/clean_deleted', input_data))
689
+ end
690
+
691
+ # admin > <resource> > list
692
+ Api::Faspex::ADMIN_RESOURCES.each do |res|
693
+ define_action_method([:admin, res, :list]) do
694
+ args = res_exec_args(res)
695
+ # Special case: email_notifications list returns a fixed value list
696
+ next Result::ValueList.new(Api::Faspex::EMAIL_NOTIF_LIST, name: 'email_id') if res.eql?(:email_notifications)
697
+ data, total = args[:api].list_entities_limit_offset_total_count(
698
+ entity: args[:entity], items_key: args[:items_key],
699
+ query: query_read_delete(
700
+ default: args[:list_query],
701
+ schema: args[:query_component] ? Schema::Registry.query_params(args[:query_component], args[:entity]) : nil
598
702
  )
599
- return Result::ObjectList.new(list, total: total)
600
- end
601
- when :configuration
602
- conf_path = 'configuration'
603
- conf_cmd = options.get_next_command(%i[show modify])
604
- case conf_cmd
605
- when :show
606
- return Result::SingleObject.new(@api_v5.read(conf_path))
607
- when :modify
608
- return Result::SingleObject.new(@api_v5.update(conf_path, value_create_modify(command: conf_cmd)))
609
- end
610
- when :smtp
611
- # only one SMTP config
612
- smtp_path = 'configuration/smtp'
613
- smtp_cmd = options.get_next_command(%i[show create modify delete test])
614
- case smtp_cmd
615
- when :show
616
- return Result::SingleObject.new(@api_v5.read(smtp_path))
617
- when :create
618
- return Result::SingleObject.new(@api_v5.create(smtp_path, value_create_modify(command: smtp_cmd)))
619
- when :modify
620
- return Result::SingleObject.new(@api_v5.update(smtp_path, value_create_modify(command: smtp_cmd)))
621
- when :delete
622
- @api_v5.delete(smtp_path)
623
- return Result::Status.new('SMTP configuration deleted')
624
- when :test
625
- test_data = options.get_next_argument('Email or test data, see API')
626
- test_data = {test_email_recipient: test_data} if test_data.is_a?(String)
627
- creation = @api_v5.create(File.join(smtp_path, 'test'), test_data)
628
- result = wait_for_job(creation['job_id'])
629
- result['serialized_args'] = JSON.parse(result['serialized_args']) rescue result['serialized_args']
630
- return Result::SingleObject.new(result)
703
+ )
704
+ Result::ObjectList.new(data, total: total, fields: args[:display_fields])
705
+ end
706
+ end
707
+
708
+ # admin > <resource> > create / modify / delete / show
709
+ Api::Faspex::ADMIN_RESOURCES.each do |res|
710
+ CRUD_NO_LIST.each do |op|
711
+ define_action_method([:admin, res, op]) do |input_data: nil, res_id: nil, **|
712
+ args = res_exec_args(res)
713
+ is_bulk = options.get_option(:bulk)
714
+ items = input_data
715
+ items = [items] if items && !items.is_a?(Array)
716
+ entity_execute(command: op, input_data: items, res_id: res_id, is_bulk: is_bulk, bfail: options.get_option(:bfail), **args){ |f, v| res_lookup_id(res, f, v)}
631
717
  end
632
718
  end
633
719
  end
634
720
 
635
- ACTIONS = %i[health version user bearer_token packages shared_folders admin gateway postprocessing invitations].freeze
721
+ def action_admin_smtp_test(test_data:, **)
722
+ test_data = {test_email_recipient: test_data} if test_data.is_a?(String)
723
+ creation = @api_v5.create('configuration/smtp/test', test_data)
724
+ result = wait_for_job(creation['job_id'])
725
+ begin
726
+ result['serialized_args'] = JSON.parse(result['serialized_args'])
727
+ rescue JSON::ParserError
728
+ # keep as string if not valid JSON
729
+ end
730
+ Result::SingleObject.new(result)
731
+ end
732
+
733
+ # admin > accounts > reset_password
734
+ def action_admin_accounts_reset_password(contact_id:, **)
735
+ @api_v5.create("accounts/#{contact_id}/reset_password", {})
736
+ Result::Status.new('password reset, user shall check email')
737
+ end
636
738
 
637
- def execute_action
638
- command = options.get_next_command(ACTIONS)
639
- unless %i{postprocessing health}.include?(command)
640
- # create an API object with the same options, but with a different subpath
641
- @api_v5 = Api::Faspex.new(**Oauth.kwargs_from_options(options))
642
- # in case user wants to use HTTPGW tell transfer agent how to get address
643
- transfer.httpgw_url_cb = lambda{@api_v5.read('account')['gateway_url']}
739
+ # admin > file_processing > next
740
+ def action_admin_file_processing_next
741
+ args = res_exec_args(:file_processing)
742
+ result, count = @api_v5.list_entities_limit_offset_total_count(entity: args[:entity], operation: 'POST', items_key: 'files')
743
+ Result::ObjectList.new(result, total: count)
744
+ end
745
+
746
+ # admin > nodes > browse
747
+ def action_admin_nodes_browse(folder_path:, node_id:, **)
748
+ browse_folder("nodes/#{node_id}/browse", {}, folder_path: folder_path)
749
+ end
750
+
751
+ # admin > nodes > shared_folders — node_id: already in ctx via arguments: on the :shared_folders command
752
+ def setup_admin_nodes_shared_folders(node_id:, **)
753
+ {sf_entity: "nodes/#{node_id}/shared_folders"}
754
+ end
755
+
756
+ # Lookup shared folder id by field/value within a node's shared_folders entity.
757
+ # Used as lookup: on the :user command under admin > nodes > shared_folders.
758
+ # sf_entity is available in ctx because setup_admin_nodes_shared_folders ran first (Phase A of parent).
759
+ def lookup_sf_id(field, value, sf_entity:, **)
760
+ @api_v5.lookup_entity_by_field(entity: sf_entity, items_key: 'shared_folders', field: field, value: value)['id']
761
+ end
762
+
763
+ # admin > nodes > shared_folders > create/modify/delete/show/list
764
+ Operations::ALL.each do |op|
765
+ define_action_method([:admin, :nodes, :shared_folders, op]) do |sf_entity:, **|
766
+ entity_execute(api: @api_v5, entity: sf_entity, items_key: 'shared_folders', command: op){ |f, v| @api_v5.lookup_entity_by_field(entity: sf_entity, items_key: 'shared_folders', field: f, value: v)['id']}
644
767
  end
645
- case command
646
- when :version
647
- return Result::SingleObject.new(@api_v5.read('version'))
648
- when :health
649
- nagios = Nagios.new
650
- begin
651
- data, http = Rest.new(base_url: options.get_option(:url, mandatory: true))
652
- .read('health', ret: :both)
653
- data.each do |k, v|
654
- nagios.add_ok(k, v.to_s)
655
- end
656
- nagios.add_ok('version', http['X-IBM-Aspera']) if http['X-IBM-Aspera']
657
- rescue StandardError => e
658
- nagios.add_critical('core', e.to_s)
768
+ end
769
+
770
+ # admin > nodes > shared_folders > user — sf_id: already in ctx via arguments: on the :user command
771
+ def setup_admin_nodes_shared_folders_user(sf_entity:, sf_id:, **)
772
+ {user_path: "#{sf_entity}/#{sf_id}/custom_access_users"}
773
+ end
774
+
775
+ # admin > nodes > shared_folders > user > create/modify/delete/show/list (custom access users)
776
+ Operations::ALL.each do |op|
777
+ define_action_method([:admin, :nodes, :shared_folders, :user, op]) do |user_path:, **|
778
+ entity_execute(api: @api_v5, entity: user_path, items_key: 'users', command: op){ |f, v| @api_v5.lookup_entity_by_field(entity: user_path, items_key: 'users', field: f, value: v)['id']}
779
+ end
780
+ end
781
+
782
+ # admin > shared_inboxes|workgroups — res_id: already in ctx via arguments: on the :members|:saml_groups command
783
+ %i[shared_inboxes workgroups].each do |res|
784
+ define_method(:"setup_admin_#{res}_instance") do |res_id:, **|
785
+ {res_instance_path: "#{res}/#{res_id}"}
786
+ end
787
+ end
788
+
789
+ # Resolve user ids for shared_inbox/workgroup members/create, handling percent selectors.
790
+ # @param users [Array] raw user ids or percent-selector strings
791
+ # @return [Array<String>] resolved user ids
792
+ def resolve_member_user_ids(users)
793
+ users = [users] unless users.is_a?(Array)
794
+ users.map do |user|
795
+ if (m = Parser.percent_selector(user))
796
+ @api_v5.lookup_entity_by_field(entity: 'accounts', field: m[:field], value: m[:value], query: Rest.php_style({type: ACCOUNT_TYPES}))['id']
797
+ else
798
+ user
659
799
  end
660
- Result::ObjectList.new(nagios.status_list)
661
- when :user
662
- case options.get_next_command(%i[account profile])
663
- when :account
664
- return Result::SingleObject.new(@api_v5.read('account', query_read_delete))
665
- when :profile
666
- case options.get_next_command(%i[show modify])
667
- when :show
668
- return Result::SingleObject.new(@api_v5.read('account/preferences'))
669
- when :modify
670
- @api_v5.update('account/preferences', options.get_next_argument('modified parameters', validation: Hash))
671
- return Result::Status.new('modified')
800
+ end
801
+ end
802
+
803
+ # admin > shared_inboxes|workgroups > members|saml_groups > create/list/modify/delete
804
+ %i[shared_inboxes workgroups].each do |res|
805
+ MEMBER_SUBS.each do |sub|
806
+ CRUD_NO_SHOW.each do |op|
807
+ if op.eql?(:create) && sub.eql?(:members)
808
+ define_action_method([:admin, res, sub, op]) do |users:, access:, res_instance_path:, **|
809
+ res_path = "#{res_instance_path}/#{sub}"
810
+ list_key = sub.eql?(:saml_groups) ? 'groups' : sub.to_s
811
+ resolved = resolve_member_user_ids(users)
812
+ input_data = [{user: resolved.map{ |u| {id: u, access: access}}}]
813
+ entity_execute(api: @api_v5, entity: res_path, command: op, input_data: input_data, items_key: list_key) do |f, v|
814
+ @api_v5.lookup_entity_by_field(entity: res_path, field: f, value: v, query: Rest.php_style({type: %w[user]}))['user_id']
815
+ end
816
+ end
817
+ else
818
+ define_action_method([:admin, res, sub, op]) do |res_instance_path:, **|
819
+ res_path = "#{res_instance_path}/#{sub}"
820
+ list_key = sub.eql?(:saml_groups) ? 'groups' : sub.to_s
821
+ entity_execute(api: @api_v5, entity: res_path, command: op, items_key: list_key) do |f, v|
822
+ @api_v5.lookup_entity_by_field(entity: res_path, field: f, value: v, query: Rest.php_style({type: %w[user]}))['user_id']
823
+ end
824
+ end
672
825
  end
673
826
  end
674
- when :bearer_token
675
- return Result::Text.new(@api_v5.oauth.authorization)
676
- when :packages
677
- return package_action
678
- when :shared_folders
679
- all_shared_folders = @api_v5.read('shared_folders')['shared_folders']
680
- case options.get_next_command(%i[list browse])
681
- when :list
682
- return Result::ObjectList.new(all_shared_folders)
683
- when :browse
684
- shared_folder_id = options.instance_identifier do |field, value|
685
- matches = all_shared_folders.select{ |i| i[field].eql?(value)}
686
- raise "no match for #{field} = #{value}" if matches.empty?
687
- raise "multiple matches for #{field} = #{value}" if matches.length > 1
688
- matches.first['id']
689
- end
690
- node = all_shared_folders.find{ |i| i['id'].eql?(shared_folder_id)}
691
- raise "No such shared folder id #{shared_folder_id}" if node.nil?
692
- return browse_folder("nodes/#{node['node_id']}/shared_folders/#{shared_folder_id}/browse")
827
+ end
828
+ end
829
+
830
+ # admin > shared_inboxes|workgroups > invite_external_collaborator
831
+ %i[shared_inboxes workgroups].each do |res|
832
+ define_action_method([:admin, res, :invite_external_collaborator]) do |input_data:, res_id:, **|
833
+ res_instance_path = "#{res}/#{res_id}"
834
+ result = @api_v5.create("#{res_instance_path}/external_collaborator", input_data)
835
+ formatter.display_status(result['message'])
836
+ Result::SingleObject.new(@api_v5.lookup_entity_by_field(entity: "#{res_instance_path}/members", items_key: 'members', value: input_data['email_address'], query: {}))
837
+ end
838
+ end
839
+
840
+ # --- root setup ---
841
+
842
+ # Build @api_v5 for all commands that need it (all except :health and :postprocessing).
843
+ # @return [Hash] empty ctx (state stored in @api_v5)
844
+ def setup_api_v5(**)
845
+ return {} if @api_v5
846
+ @api_v5 = Api::Faspex.new(**Oauth.kwargs_from_options(options))
847
+ # in case user wants to use HTTPGW tell transfer agent how to get address
848
+ transfer.httpgw_url_cb = lambda{@api_v5.read('account')['gateway_url']}
849
+ {}
850
+ end
851
+
852
+ # Setup for package sub-commands that need an id: resolves package_id from pub_link or argument.
853
+ # @return [Hash] ctx key: package_id
854
+ def setup_package_id(**)
855
+ package_id = @api_v5.pub_link_context&.key?('package_id') ? @api_v5.pub_link_context['package_id'] : options.instance_identifier
856
+ {package_id: package_id}
857
+ end
858
+
859
+ def action_packages_list
860
+ list, total = list_packages_with_filter
861
+ fields = %w[id title status sender.name recipients.0.name release_date total_bytes total_files]
862
+ fields.delete('recipients.0.name') if %w[inbox inbox_history].include?(options.get_option(:box))
863
+ fields.delete('sender.name') if %w[outbox outbox_history].include?(options.get_option(:box))
864
+ Result::ObjectList.new(list, total: total, fields: fields)
865
+ end
866
+
867
+ def action_packages_delete(package_id:, **)
868
+ ids = package_id.is_a?(Array) ? package_id : [package_id]
869
+ Aspera.assert_array_all(ids, String){'Package id(s)'}
870
+ # API returns 204, empty on success
871
+ @api_v5.call(
872
+ operation: 'DELETE',
873
+ subpath: 'packages',
874
+ content_type: Mime::JSON,
875
+ body: {ids: ids},
876
+ headers: {'Accept' => Mime::JSON}
877
+ )
878
+ Result::Status.new('Package(s) deleted')
879
+ end
880
+
881
+ # --- handlers ---
882
+
883
+ def action_health
884
+ nagios = Nagios.new
885
+ begin
886
+ data, http = Rest.new(base_url: options.get_option(:url, mandatory: true))
887
+ .read('health', ret: :both)
888
+ data.each do |k, v|
889
+ nagios.add_ok(k, v.to_s)
693
890
  end
694
- when :admin
695
- return execute_admin
696
- when :invitations
697
- invitation_endpoint = 'invitations'
698
- invitation_command = options.get_next_command(%i[resend].concat(Operations::ALL))
699
- case invitation_command
700
- when :create
701
- return do_bulk_operation(command: invitation_command, descr: 'data') do |params|
702
- invitation_endpoint = params.key?('recipient_name') ? 'public_invitations' : 'invitations'
703
- @api_v5.create(invitation_endpoint, params)
704
- end
705
- when :resend
706
- @api_v5.create("#{invitation_endpoint}/#{options.instance_identifier}/resend", nil)
707
- return Result::Status.new('Invitation resent')
708
- else
709
- return entity_execute(
710
- api: @api_v5,
711
- entity: invitation_endpoint,
712
- command: invitation_command,
713
- items_key: invitation_endpoint,
714
- display_fields: %w[id public recipient_type recipient_name email_address]
715
- ) do |field, value|
716
- @api_v5.lookup_entity_by_field(entity: invitation_endpoint, field: field, value: value, query: {})['id']
717
- end
891
+ nagios.add_ok('version', http['X-IBM-Aspera']) if http['X-IBM-Aspera']
892
+ rescue StandardError => e
893
+ nagios.add_critical('core', e.to_s)
894
+ end
895
+ Result::ObjectList.new(nagios.status_list)
896
+ end
897
+
898
+ # Lookup a shared folder id by field/value.
899
+ # Called via lookup: :lookup_shared_folder_id on the shared_folders > browse command.
900
+ def lookup_shared_folder_id(field, value, **)
901
+ all = @api_v5.read('shared_folders')['shared_folders']
902
+ matches = all.select{ |i| i[field].eql?(value)}
903
+ Aspera.assert(!matches.empty?){"no match for #{field} = #{value}"}
904
+ Aspera.assert(matches.length == 1){"multiple matches for #{field} = #{value}"}
905
+ matches.first['id']
906
+ end
907
+
908
+ def action_shared_folders_browse(folder_path:, shared_folder_id:, **)
909
+ all_shared_folders = @api_v5.read('shared_folders')['shared_folders']
910
+ node = all_shared_folders.find{ |i| i['id'].eql?(shared_folder_id)}
911
+ Aspera.assert(!node.nil?){"No such shared folder id #{shared_folder_id}"}
912
+ browse_folder("nodes/#{node['node_id']}/shared_folders/#{shared_folder_id}/browse", {}, folder_path: folder_path)
913
+ end
914
+
915
+ # invitations sub-handlers
916
+
917
+ def action_invitations_resend(invitation_id:, **)
918
+ @api_v5.create("invitations/#{invitation_id}/resend", nil)
919
+ Result::Status.new('Invitation resent')
920
+ end
921
+
922
+ def action_invitations_create(input_data:, **)
923
+ is_bulk = options.get_option(:bulk)
924
+ Result.bulk(input_data, is_bulk: is_bulk, command: :create, bfail: options.get_option(:bfail)) do |params|
925
+ endpoint = params.key?('recipient_name') ? 'public_invitations' : 'invitations'
926
+ @api_v5.create(endpoint, params)
927
+ end
928
+ end
929
+
930
+ # CRUD handlers for invitations (list, show, modify, delete)
931
+ Operations::ALL.reject{ |op| op == :create}.each do |op|
932
+ define_action_method([:invitations, op]) do
933
+ entity_execute(
934
+ api: @api_v5,
935
+ entity: 'invitations',
936
+ command: op,
937
+ items_key: 'invitations',
938
+ display_fields: %w[id public recipient_type recipient_name email_address]
939
+ ) do |field, value|
940
+ @api_v5.lookup_entity_by_field(entity: 'invitations', field: field, value: value, query: {})['id']
718
941
  end
719
- when :gateway
720
- require 'aspera/faspex_gw'
721
- parameters = value_create_modify(command: command, default: {}).symbolize_keys
722
- uri = URI.parse(parameters.delete(:url){WebServerSimple::DEFAULT_URL})
723
- server = WebServerSimple.new(uri, **parameters.slice(*WebServerSimple::PARAMS))
724
- Aspera.assert(parameters.except(*WebServerSimple::PARAMS).empty?){"unexpected parameters: #{parameters.except(*WebServerSimple::PARAMS).keys}"}
725
- server.mount(uri.path, Faspex4GWServlet, @api_v5, nil)
726
- server.start
727
- return Result::Status.new('Gateway terminated')
728
- when :postprocessing
729
- require 'aspera/faspex_postproc' # cspell:disable-line
730
- parameters = value_create_modify(command: command, default: {}).symbolize_keys
731
- uri = URI.parse(parameters.delete(:url){WebServerSimple::DEFAULT_URL})
732
- parameters[:root] = uri.path
733
- server = WebServerSimple.new(uri, **parameters.slice(*WebServerSimple::PARAMS))
734
- server.mount(uri.path, Faspex4PostProcServlet, parameters.except(*WebServerSimple::PARAMS))
735
- server.start
736
- return Result::Status.new('Gateway terminated')
737
942
  end
738
943
  end
944
+
945
+ def action_gateway(parameters: {}, **)
946
+ require 'aspera/faspex_gw'
947
+ parameters = parameters.symbolize_keys
948
+ uri = URI.parse(parameters.delete(:url){WebServerSimple::DEFAULT_URL})
949
+ server = WebServerSimple.new(uri, **parameters.slice(*WebServerSimple::PARAMS))
950
+ Aspera.assert(parameters.except(*WebServerSimple::PARAMS).empty?){"unexpected parameters: #{parameters.except(*WebServerSimple::PARAMS).keys}"}
951
+ server.mount(uri.path, Faspex4GWServlet, @api_v5, nil)
952
+ server.start
953
+ Result::Status.new('Gateway terminated')
954
+ end
955
+
956
+ def action_postprocessing(parameters: {}, **)
957
+ require 'aspera/faspex_postproc' # cspell:disable-line
958
+ parameters = parameters.symbolize_keys
959
+ uri = URI.parse(parameters.delete(:url){WebServerSimple::DEFAULT_URL})
960
+ parameters[:root] = uri.path
961
+ server = WebServerSimple.new(uri, **parameters.slice(*WebServerSimple::PARAMS))
962
+ server.mount(uri.path, Faspex4PostProcServlet, parameters.except(*WebServerSimple::PARAMS))
963
+ server.start
964
+ Result::Status.new('Gateway terminated')
965
+ end
739
966
  SHARED_INBOX_MEMBER_LEVELS = %i[submit_only standard shared_inbox_admin].freeze
740
967
  ACCOUNT_TYPES = %w{local_user saml_user self_registered_user external_user}.freeze
741
968
  WORKGROUP_TYPES = %w{workgroup shared_inbox}.freeze
742
969
  CONTACT_TYPES = (WORKGROUP_TYPES + %w{distribution_list user external_user}).freeze
743
970
  PACKAGE_RECIPIENT_TYPES = %i{recipients private_recipients notified_on_upload notified_on_download notified_on_receipt}
744
- private_constant :SHARED_INBOX_MEMBER_LEVELS, :ACCOUNT_TYPES, :CONTACT_TYPES, :PACKAGE_RECIPIENT_TYPES
971
+ private_constant :SHARED_INBOX_MEMBER_LEVELS, :ACCOUNT_TYPES, :CONTACT_TYPES, :PACKAGE_RECIPIENT_TYPES,
972
+ :MEMBER_SUBS, :CRUD_NO_SHOW, :CRUD_NO_LIST
745
973
  end
746
974
  end
747
975
  end