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