aspera-cli 4.27.1 → 4.27.3

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 (133) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +67 -1
  4. data/bin/ascli +2 -1
  5. data/docs/README.md +805 -747
  6. data/lib/aspera/agent/connect.rb +6 -4
  7. data/lib/aspera/agent/desktop.rb +2 -2
  8. data/lib/aspera/agent/direct.rb +3 -1
  9. data/lib/aspera/agent/node.rb +3 -3
  10. data/lib/aspera/api/alee.rb +1 -1
  11. data/lib/aspera/api/aoc.rb +14 -12
  12. data/lib/aspera/api/ats.rb +1 -1
  13. data/lib/aspera/api/cos_node.rb +2 -2
  14. data/lib/aspera/api/faspex.rb +9 -7
  15. data/lib/aspera/api/httpgw.rb +37 -33
  16. data/lib/aspera/api/node.rb +38 -33
  17. data/lib/aspera/ascmd.rb +3 -1
  18. data/lib/aspera/ascp/installation.rb +62 -27
  19. data/lib/aspera/ascp/management.rb +1 -0
  20. data/lib/aspera/assert.rb +4 -0
  21. data/lib/aspera/cli/ascp_actions.rb +20 -41
  22. data/lib/aspera/cli/async_transfer_store.rb +2 -2
  23. data/lib/aspera/cli/bootstrapper.rb +11 -15
  24. data/lib/aspera/cli/command_line.rb +252 -0
  25. data/lib/aspera/cli/command_registry.rb +149 -33
  26. data/lib/aspera/cli/command_spec.rb +103 -14
  27. data/lib/aspera/cli/completion/ascli.bash +12 -0
  28. data/lib/aspera/cli/completion/ascli.fish +16 -0
  29. data/lib/aspera/cli/completion/ascli.zsh +19 -0
  30. data/lib/aspera/cli/context.rb +3 -0
  31. data/lib/aspera/cli/deprecation.rb +37 -0
  32. data/lib/aspera/cli/extended_value.rb +2 -0
  33. data/lib/aspera/cli/formatter.rb +87 -75
  34. data/lib/aspera/cli/gem_checker.rb +1 -1
  35. data/lib/aspera/cli/hints.rb +7 -6
  36. data/lib/aspera/cli/http.rb +21 -21
  37. data/lib/aspera/cli/info.rb +3 -0
  38. data/lib/aspera/cli/mcp_tool.rb +47 -83
  39. data/lib/aspera/cli/option_declarator.rb +33 -42
  40. data/lib/aspera/cli/option_registry.rb +69 -0
  41. data/lib/aspera/cli/option_types.rb +103 -0
  42. data/lib/aspera/cli/option_value.rb +281 -0
  43. data/lib/aspera/cli/options.schema.yaml +38 -5
  44. data/lib/aspera/cli/parser.rb +307 -848
  45. data/lib/aspera/cli/plugins/alee.rb +7 -4
  46. data/lib/aspera/cli/plugins/aoc.rb +435 -380
  47. data/lib/aspera/cli/plugins/ats.rb +58 -73
  48. data/lib/aspera/cli/plugins/base.rb +190 -240
  49. data/lib/aspera/cli/plugins/basic_auth.rb +2 -10
  50. data/lib/aspera/cli/plugins/config.rb +244 -178
  51. data/lib/aspera/cli/plugins/console.rb +102 -38
  52. data/lib/aspera/cli/plugins/cos.rb +6 -23
  53. data/lib/aspera/cli/plugins/factory.rb +3 -0
  54. data/lib/aspera/cli/plugins/faspex5.rb +176 -173
  55. data/lib/aspera/cli/plugins/faspio.rb +5 -10
  56. data/lib/aspera/cli/plugins/httpgw.rb +8 -11
  57. data/lib/aspera/cli/plugins/mcp.rb +20 -55
  58. data/lib/aspera/cli/plugins/node.rb +277 -311
  59. data/lib/aspera/cli/plugins/orchestrator.rb +90 -77
  60. data/lib/aspera/cli/plugins/preview.rb +79 -90
  61. data/lib/aspera/cli/plugins/server.rb +76 -50
  62. data/lib/aspera/cli/plugins/shares.rb +68 -116
  63. data/lib/aspera/cli/preset_actions.rb +17 -10
  64. data/lib/aspera/cli/preset_manager.rb +12 -2
  65. data/lib/aspera/cli/prompt.rb +35 -0
  66. data/lib/aspera/cli/result.rb +13 -18
  67. data/lib/aspera/cli/runner.rb +31 -54
  68. data/lib/aspera/cli/special_values.rb +5 -0
  69. data/lib/aspera/cli/sync_actions.rb +41 -37
  70. data/lib/aspera/cli/terminal_formatter.rb +9 -3
  71. data/lib/aspera/cli/transfer_actions.rb +0 -6
  72. data/lib/aspera/cli/transfer_agent.rb +29 -35
  73. data/lib/aspera/cli/vault_manager.rb +0 -17
  74. data/lib/aspera/cli/version.rb +1 -1
  75. data/lib/aspera/cli/wizard.rb +4 -2
  76. data/lib/aspera/command_line_builder.rb +1 -0
  77. data/lib/aspera/coverage.rb +1 -0
  78. data/lib/aspera/environment.rb +7 -1
  79. data/lib/aspera/faspex_gw.rb +2 -1
  80. data/lib/aspera/faspex_postproc.rb +1 -0
  81. data/lib/aspera/graphql.rb +5 -5
  82. data/lib/aspera/json_rpc/client.rb +5 -5
  83. data/lib/aspera/keychain/encrypted_hash.rb +1 -1
  84. data/lib/aspera/keychain/one_password_api.rb +1 -1
  85. data/lib/aspera/link_header.rb +2 -2
  86. data/lib/aspera/log.rb +22 -25
  87. data/lib/aspera/markdown.rb +2 -0
  88. data/lib/aspera/mime.rb +25 -0
  89. data/lib/aspera/node_simulator.rb +1 -0
  90. data/lib/aspera/oauth/base.rb +35 -25
  91. data/lib/aspera/oauth/factory.rb +1 -0
  92. data/lib/aspera/oauth/generic.rb +1 -1
  93. data/lib/aspera/oauth/jwt.rb +1 -1
  94. data/lib/aspera/oauth/web.rb +9 -8
  95. data/lib/aspera/preview/file_types.rb +4 -4
  96. data/lib/aspera/preview/generator.rb +7 -0
  97. data/lib/aspera/preview/options.rb +4 -4
  98. data/lib/aspera/preview/terminal.rb +4 -3
  99. data/lib/aspera/preview/utils.rb +9 -6
  100. data/lib/aspera/products/connect.rb +1 -1
  101. data/lib/aspera/rainbow.rb +7 -0
  102. data/lib/aspera/rest/aspera_errors.rb +60 -0
  103. data/lib/aspera/rest/call_error.rb +27 -0
  104. data/lib/aspera/rest/client.rb +514 -0
  105. data/lib/aspera/rest/error_analyzer.rb +113 -0
  106. data/lib/aspera/rest/list.rb +143 -0
  107. data/lib/aspera/rest/parameters.rb +55 -0
  108. data/lib/aspera/rest/util.rb +176 -0
  109. data/lib/aspera/rest.rb +7 -621
  110. data/lib/aspera/schema/IBM Aspera Console-enhanced.yaml +1125 -0
  111. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +39 -0
  112. data/lib/aspera/schema/IBM Aspera on Cloud Automation API-1.0.5-enhanced.yaml +2395 -0
  113. data/lib/aspera/schema/documentation.rb +13 -3
  114. data/lib/aspera/schema/registry.rb +18 -1
  115. data/lib/aspera/schema/validator.rb +92 -0
  116. data/lib/aspera/secret_hider.rb +36 -25
  117. data/lib/aspera/string_ext.rb +15 -0
  118. data/lib/aspera/temp_file_manager.rb +6 -5
  119. data/lib/aspera/transfer/parameters.rb +2 -0
  120. data/lib/aspera/transfer/spec.rb +1 -0
  121. data/lib/aspera/transfer/spec.schema.yaml +1 -0
  122. data/lib/aspera/uri_reader.rb +11 -11
  123. data/lib/aspera/web_auth/index.html +147 -0
  124. data/lib/aspera/web_auth/server.rb +81 -0
  125. data.tar.gz.sig +0 -0
  126. metadata +39 -7
  127. metadata.gz.sig +0 -0
  128. data/lib/aspera/colors.rb +0 -79
  129. data/lib/aspera/rest_call_error.rb +0 -25
  130. data/lib/aspera/rest_error_analyzer.rb +0 -111
  131. data/lib/aspera/rest_errors_aspera.rb +0 -58
  132. data/lib/aspera/rest_list.rb +0 -136
  133. data/lib/aspera/web_auth.rb +0 -211
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/rest/call_error'
4
+ require 'aspera/log'
5
+ require 'singleton'
6
+ require 'net/http'
7
+
8
+ module Aspera
9
+ module Rest
10
+ # analyze error codes returned by REST calls and raise ruby exception
11
+ #
12
+ # @!method self.instance
13
+ # Returns the singleton instance of ErrorAnalyzer
14
+ # @return [ErrorAnalyzer] the singleton instance
15
+ class ErrorAnalyzer
16
+ include Singleton
17
+
18
+ attr_accessor :log_file
19
+
20
+ # the singleton object is registered with application specific handlers
21
+ def initialize
22
+ # list of handlers
23
+ @error_handlers = []
24
+ @log_file = nil
25
+ add_handler('Type Generic') do |type, context|
26
+ if !context[:response].code.start_with?('2')
27
+ # add generic information
28
+ ErrorAnalyzer.add_error(context, type, "#{context[:request]['host']} #{context[:response].code} #{context[:response].message}")
29
+ end
30
+ end
31
+ end
32
+
33
+ # Use this method to analyze a EST result and raise an exception
34
+ # Analyzes REST call response and raises a CallError exception
35
+ # if HTTP result code is not 2XX
36
+ # @param req [Net::HTTPRequest] the HTTP request
37
+ # @param data [Object] the parsed response body
38
+ # @param http [Net::HTTPResponse] the raw HTTP response
39
+ def raise_on_error(req, data, http)
40
+ Log.log.debug { "raise_on_error #{req.method} #{req.path} #{http.code}" }
41
+ context = {
42
+ messages: [],
43
+ request: req,
44
+ response: http,
45
+ data: data
46
+ }
47
+ # multiple error messages can be found
48
+ # analyze errors from provided handlers
49
+ # note that there can be an error even if code is 2XX
50
+ @error_handlers.each do |handler|
51
+ handler[:block].call(handler[:name], context)
52
+ rescue StandardError => e
53
+ Log.log.error { "ERROR in handler:\n#{e.message}\n#{e.backtrace}" }
54
+ end
55
+ raise CallError, context unless context[:messages].empty?
56
+ end
57
+
58
+ # Add a new error handler (done at application initialization)
59
+ # @param name [String] name of error handler (for logs)
60
+ # @param block [Proc] processing of response: takes two parameters: `name`, `context`
61
+ # name is the one provided here
62
+ # context is built in method raise_on_error
63
+ def add_handler(name, &block)
64
+ @error_handlers.unshift({name: name, block: block})
65
+ nil
66
+ end
67
+
68
+ # Add a simple error handler
69
+ # Check that key exists and is string under specified path (hash)
70
+ # Adds other keys as secondary information
71
+ # @param name [String] name of error handler (for logs)
72
+ # @param always [Boolean] if true, always add error message, even if response code is 2XX
73
+ # @param path [Array] path to error message in response
74
+ def add_simple_handler(name:, always: false, path:)
75
+ path.freeze
76
+ add_handler(name) do |type, context|
77
+ if context[:data].is_a?(Hash) && (!context[:response].code.start_with?('2') || always)
78
+ # Log.log.debug{"simple_handler: #{type} #{path} #{path.last}"}
79
+ # dig and find hash containing error message
80
+ error_struct = path.length.eql?(1) ? context[:data] : context[:data].dig(*path[0..-2])
81
+ # Log.log.debug{"found: #{error_struct.class} #{error_struct}"}
82
+ if error_struct.is_a?(Hash) && error_struct[path.last].is_a?(String)
83
+ ErrorAnalyzer.add_error(context, type, error_struct[path.last])
84
+ error_struct.each do |k, v|
85
+ next if k.eql?(path.last)
86
+ ErrorAnalyzer.add_error(context, "#{type}(sub)", "#{k}: #{v}") if [String, Integer].include?(v.class)
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+
93
+ class << self
94
+ # Used by handler to add an error description to list of errors
95
+ # For logging and tracing : collect error descriptions (create file to activate)
96
+ # @param context [Hash] the result context, provided to handler
97
+ # @param type [String] type of exception, for logging purpose
98
+ # @param message [String] one error message to add to list
99
+ def add_error(context, type, message)
100
+ context[:messages].push(message)
101
+ Log.log.trace1 { "Found error: #{type}: #{message}" }
102
+ log_file = instance.log_file
103
+ # log error for further analysis (file must exist to activate)
104
+ return if log_file.nil? || !File.exist?(log_file)
105
+ File.open(log_file, 'a+') do |f|
106
+ f.write("\n=#{type}=====\n#{context[:request].method} #{context[:request].path}\n#{context[:response].code}\n" \
107
+ "#{JSON.generate(context[:data])}\n#{context[:messages].join("\n")}")
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/rest/parameters'
4
+ require 'aspera/mime'
5
+ require 'aspera/assert'
6
+ require 'aspera/log'
7
+
8
+ module Aspera
9
+ module Rest
10
+ # List and lookup methods for Rest::Client
11
+ # To be included in classes inheriting Rest::Client that require those methods.
12
+ module List
13
+ # `max`: special query parameter: max number of items for list command
14
+ MAX_ITEMS = 'max'
15
+ # `pmax`: special query parameter: max number of pages for list command
16
+ MAX_PAGES = 'pmax'
17
+
18
+ # Query entity by general search (read with parameter `q`)
19
+ #
20
+ # @param entity [String] Path of entity in API
21
+ # @param value [String] Value of field of searched entity
22
+ # @param field [String] Field of searched entity
23
+ # @param query [Hash] Optional additional search query parameters
24
+ # @return [Hash] A single entity matching the search, or an exception if not found or multiple found
25
+ def lookup_with_q(entity, value:, field: 'name', query: {})
26
+ Aspera.assert_type(query, Hash) { 'query' }
27
+ Aspera.assert_type(field, String) { 'field' }
28
+ # returns entities matching the query (it matches against several fields in case insensitive way)
29
+ # We don't do paging, as anyway, we look for only one match
30
+ matching_items = read(entity, query.merge({'q' => value}))
31
+ # API style: {totalcount:, ...} cspell: disable-line: TODO: is that total_count ?
32
+ # @type [Array<Hash{String => String}>]
33
+ matching_items = matching_items[entity] if matching_items.is_a?(Hash)
34
+ Aspera.assert_type(matching_items, Array)
35
+ case matching_items.length
36
+ when 1 then return matching_items.first
37
+ when 0 then raise EntityNotFound, %Q{No such #{entity}: "#{value}"}
38
+ else
39
+ # multiple case insensitive partial matches, try case insensitive full match
40
+ # (AoC does not allow 2 entities with same case insensitive name, except for some, e.g. packages)
41
+ value_matches = matching_items.select { |i| i[field].casecmp?(value) }
42
+ case value_matches.length
43
+ when 1 then return value_matches.first
44
+ when 0 then raise Error, "#{entity}: Multiple case insensitive partial match for: \"#{value}\" in #{matching_items.map { |i| i[field] }.join(', ')} but no case insensitive full match. Please be more specific or give exact #{field}."
45
+ else raise Error, "#{entity}: Multiple entities with #{field}: \"#{value}\". Please use the identifier instead."
46
+ end
47
+ end
48
+ end
49
+
50
+ # Get a (full or partial) list of all entities of a given type.
51
+ # Using query: `offset` + `limit`
52
+ # And response `total_count`
53
+ # @param entity [String,Symbol] API endpoint of entity to list
54
+ # @param items_key [String] Key in the result to get the list of items (Default: same as `entity`)
55
+ # @param query [Hash,nil] Additional query parameters
56
+ # @return [Array<(Array<Hash>, Integer)>] items, total_count
57
+ def list_entities_limit_offset_total_count(
58
+ entity:,
59
+ operation: 'GET',
60
+ items_key: nil,
61
+ query: nil
62
+ )
63
+ entity = entity.to_s if entity.is_a?(Symbol)
64
+ items_key = entity.split('/').last if items_key.nil?
65
+ query = {} if query.nil?
66
+ Aspera.assert_type(entity, String)
67
+ Aspera.assert_type(items_key, String)
68
+ Aspera.assert_type(query, Hash)
69
+ Log.log.debug { "list_entities t=#{entity} k=#{items_key}" }
70
+ Log.dump(:query, query)
71
+ result = []
72
+ offset = 0
73
+ max_items = query.delete(MAX_ITEMS)
74
+ remain_pages = query.delete(MAX_PAGES)
75
+ # Merge default parameters, by default 100 per page
76
+ query = {'limit'=> PER_PAGE_DEFAULT}.merge(query)
77
+ total_count = nil
78
+ call_args = {
79
+ operation: operation,
80
+ subpath: entity,
81
+ query: query,
82
+ headers: {'Accept' => Mime::JSON}
83
+ }
84
+ loop do
85
+ query['offset'] = offset
86
+ page_result = call(**call_args)
87
+ Aspera.assert_type(page_result[items_key], Array)
88
+ result.concat(page_result[items_key])
89
+ # Reach the limit set by user ?
90
+ if !max_items.nil? && (result.length >= max_items)
91
+ result = result.slice(0, max_items)
92
+ break
93
+ end
94
+ total_count ||= page_result['total_count']
95
+ break if result.length >= total_count
96
+ remain_pages -= 1 unless remain_pages.nil?
97
+ break if remain_pages == 0
98
+ offset += page_result[items_key].length
99
+ Parameters.instance.spinner_cb.call("#{result.length} / #{total_count || '?'}")
100
+ end
101
+ Parameters.instance.spinner_cb.call(action: :success)
102
+ return result, total_count
103
+ end
104
+
105
+ # Lookup an entity id from its name.
106
+ # Uses query `q` if `query` is `:default` and `field` is `name`.
107
+ # @param entity [String] Type of entity to lookup, by default it is the path, and it is also the field name in result
108
+ # @param value [String] Value to lookup
109
+ # @param field [String] Field to match, by default it is `'name'`
110
+ # @param items_key [String] Key in the result to get the list of items (override entity)
111
+ # @param query [Hash] Additional query parameters (Default: `:default`)
112
+ def lookup_entity_by_field(entity:, value:, field: 'name', items_key: nil, query: :default)
113
+ if query.eql?(:default)
114
+ Aspera.assert_values(field, ['name']) { 'Default query field' }
115
+ query = {'q'=> value}
116
+ end
117
+ lookup_entity_generic(entity: entity, field: field, value: value) { list_entities_limit_offset_total_count(entity: entity, items_key: items_key, query: query).first }
118
+ end
119
+
120
+ # Lookup entity by field and value.
121
+ # Extracts a single result from the list returned by the block.
122
+ #
123
+ # @param entity [String] Type of entity to lookup (path, and by default it is also the field name in result)
124
+ # @param value [String] Value to match against the field.
125
+ # @param field [String] Field to match in the hashes (defaults to 'name').
126
+ # @yield [] A mandatory block that returns an Array of Hashes.
127
+ # @return [Hash] The unique matching object.
128
+ # @raise [Cli::BadIdentifier] If 0 or >1 matches are found.
129
+ def lookup_entity_generic(entity:, value:, field: 'name')
130
+ Aspera.assert(block_given?, 'block required for lookup_entity_generic')
131
+ found = yield
132
+ Aspera.assert_array_all(found, Hash)
133
+ found = found.select { |i| i[field].eql?(value) }
134
+ return found.first if found.length.eql?(1)
135
+ raise Cli::BadIdentifier.new(entity, value, field: field, count: found.length)
136
+ end
137
+
138
+ PER_PAGE_DEFAULT = 1000
139
+ private_constant :PER_PAGE_DEFAULT
140
+ module_function :lookup_entity_generic
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'singleton'
4
+
5
+ module Aspera
6
+ module Rest
7
+ # Global settings for Rest::Client
8
+ # For example to remove certificate verification globally:
9
+ # `Parameters.instance.session_cb = lambda{|http|http.verify_mode=OpenSSL::SSL::VERIFY_NONE}`
10
+ #
11
+ # @!method self.instance
12
+ # Returns the singleton instance of Parameters
13
+ # @return [Parameters] the singleton instance
14
+ class Parameters
15
+ include Singleton
16
+
17
+ # @return [String] HTTP request header: `User-Agent`
18
+ attr_accessor :user_agent
19
+ # @return [String] Suffix of file being downloaded, removed when download is complete
20
+ attr_accessor :download_partial_suffix
21
+ # @return [Boolean] Retry on any error (network or HTTP)
22
+ attr_accessor :retry_on_error
23
+ # @return [Boolean] Retry on connection timeout
24
+ attr_accessor :retry_on_timeout
25
+ # @return [Boolean] Retry on HTTP code 503 (service unavailable)
26
+ attr_accessor :retry_on_unavailable
27
+ # @return [Integer] Maximum number of retries on error (first call not included)
28
+ attr_accessor :retry_max
29
+ # @return [Integer] Seconds to wait before retry
30
+ attr_accessor :retry_sleep
31
+ # @return [Proc, nil] Called on new HTTP session, with the `Net::HTTP` as argument, e.g. to set timeouts or certificate verification
32
+ attr_accessor :session_cb
33
+ # @return [Object, nil] Progress bar, receives `event` calls during download
34
+ attr_accessor :progress_bar
35
+ # @return [Proc, nil] Called with `(title = nil, action: :spin)` to display progress of long operations
36
+ attr_accessor :spinner_cb
37
+
38
+ private
39
+
40
+ # Set default values
41
+ def initialize
42
+ @user_agent = 'RubyAsperaRest'
43
+ @download_partial_suffix = '.http_partial'
44
+ @retry_on_error = false
45
+ @retry_on_timeout = true
46
+ @retry_on_unavailable = true
47
+ @retry_max = 1
48
+ @retry_sleep = 4
49
+ @session_cb = nil
50
+ @progress_bar = nil
51
+ @spinner_cb = nil
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/rest/parameters'
4
+ require 'aspera/log'
5
+ require 'aspera/assert'
6
+ require 'net/http'
7
+ require 'net/https'
8
+ require 'uri'
9
+ require 'base64'
10
+
11
+ module Aspera
12
+ # HTTP REST client and helpers
13
+ module Rest
14
+ class << self
15
+ # Build a Basic authentication header value
16
+ # @param user [String] Username
17
+ # @param pass [String] Password
18
+ # @return [String] Basic auth token
19
+ def basic_authorization(user, pass) = "Basic #{Base64.strict_encode64("#{user}:#{pass}")}"
20
+
21
+ # Indicate that the given Hash query uses php style for array parameters
22
+ # @param query [Hash] A key can have Array value and result will use PHP format: a[]=1&a[]=2
23
+ # @return [Hash] The query parameters.
24
+ def php_style(query)
25
+ Aspera.assert_type(query, Hash) { 'query' }
26
+ query[:x_array_php_style] = true
27
+ query
28
+ end
29
+
30
+ # Build URI from URL and parameters and check it is `http` or `https`.
31
+ # Check if php style is specified.
32
+ # `nil` values in query result in key without value, e.g. `?a`, while empty string values result in `?a=`.
33
+ # @param url [String] The URL without query.
34
+ # @param query [Hash,Array,String] The query parameters.
35
+ # @return [URI] The built URI.
36
+ def build_uri(url, query)
37
+ uri = URI.parse(url)
38
+ Aspera.assert_values(uri.scheme, %w[http https]) { 'URI scheme' }
39
+ return uri if query.nil? || query.respond_to?(:empty?) && query.empty?
40
+ Log.dump(:query, query)
41
+ uri.query =
42
+ case query
43
+ when String
44
+ query
45
+ when Hash
46
+ URI.encode_www_form(h_to_query_array(query))
47
+ when Array
48
+ Aspera.assert(query.all? { |i| i.is_a?(Array) && i.length.eql?(2) }, 'Query must be array of arrays of 2 elements')
49
+ URI.encode_www_form(query) # remove nil values
50
+ else Aspera.error_unexpected_value(query.class) { 'query type' }
51
+ end.gsub('%5B%5D=', '[]=')
52
+ # [] is allowed in url parameters
53
+ uri
54
+ end
55
+
56
+ # Support array for query parameter, there is no standard.
57
+ # Either p=1&p=2 (default)
58
+ # or p[]=1&p[]=2 (if `:x_array_php_style` is set to true in query)
59
+ # @param query [Hash] HTTP query as hash
60
+ # @return [Array<Array>] Array of [key, value] pairs suitable for URI.encode_www_form
61
+ def h_to_query_array(query)
62
+ Aspera.assert_type(query, Hash)
63
+ suffix = query[:x_array_php_style] ? '[]' : nil
64
+ query.each_with_object([]) do |(k, v), query_array|
65
+ next if k.eql?(:x_array_php_style)
66
+ case v
67
+ when Array
68
+ v.each do |e|
69
+ query_array.push(["#{k}#{suffix}", e])
70
+ end
71
+ else
72
+ query_array.push([k, v])
73
+ end
74
+ end
75
+ end
76
+
77
+ # Decode query string as Hash
78
+ # if parameter is only once, then it's a scalar
79
+ # if a parameter is several, then it's array
80
+ # if parameter has [] then it's an array, and [] is removed
81
+ # Support arrays in query string, e.g. PHP's way is p[]=1&p[]=2
82
+ # @param query [String] query string as in URI.query
83
+ # @return [Hash] decoded query
84
+ def query_to_h(query)
85
+ URI.decode_www_form(query).each_with_object({}) do |(key, value), h|
86
+ if key.end_with?('[]')
87
+ key = key[..-3]
88
+ h[key] = [] unless h.key?(key)
89
+ end
90
+ if h.key?(key)
91
+ h[key] = [h[key]] if !h[key].is_a?(Array)
92
+ h[key].push(value)
93
+ else
94
+ h[key] = value
95
+ end
96
+ end
97
+ end
98
+
99
+ # Start a HTTP/S session, also used for web sockets
100
+ # @param base_url [String] Base url of HTTP/S session
101
+ # @return [Net::HTTP] A started HTTP session
102
+ def start_http_session(base_url)
103
+ uri = URI.parse(base_url)
104
+ Aspera.assert_values(uri.scheme, %w[http https]) { 'URI scheme' }
105
+ # This honors http_proxy env var
106
+ http_session = Net::HTTP.new(uri.host, uri.port)
107
+ http_session.use_ssl = uri.scheme.eql?('https')
108
+ # Set http options in callback, such as timeout and cert. verification
109
+ Parameters.instance.session_cb&.call(http_session)
110
+ # Manually start session for keep alive (if supported by server, else, session is closed every time)
111
+ http_session.start
112
+ return http_session
113
+ end
114
+
115
+ # get Net::HTTP underlying socket i/o
116
+ # little hack, handy because HTTP debug, proxy, etc... will be available
117
+ # used implement web sockets after `start_http_session`
118
+ # @param http_session [Net::HTTP] the session object
119
+ # @return [Net::BufferedIO] The underlying socket i/o
120
+ def io_http_session(http_session)
121
+ Aspera.assert_type(http_session, Net::HTTP)
122
+ # Net::BufferedIO in net/protocol.rb
123
+ result = http_session.instance_variable_get(:@socket)
124
+ Aspera.assert(!result.nil?) { "no socket for #{http_session}" }
125
+ return result
126
+ end
127
+
128
+ # Get certificate chain of remote server
129
+ # @param url [String] URL of server
130
+ # @param as_string [Boolean] `true` to return PEM string, `false` for certificate objects
131
+ # @return [String, Array<OpenSSL::X509::Certificate>] Certificates of remote server
132
+ def remote_certificate_chain(url, as_string: true)
133
+ result = []
134
+ # initiate a session to retrieve remote certificate
135
+ http_session = Rest.start_http_session(url)
136
+ begin
137
+ # retrieve underlying openssl socket
138
+ result = Rest.io_http_session(http_session).io.peer_cert_chain
139
+ rescue
140
+ result = http_session.peer_cert
141
+ ensure
142
+ http_session.finish
143
+ end
144
+ result = result.map(&:to_pem).join("\n") if as_string
145
+ return result
146
+ end
147
+
148
+ # Parses an HTTP Content-Type header string into its media type and parameters
149
+ # according to RFC 9110 and RFC 6838.
150
+ # TODO: use gem: content_type
151
+ #
152
+ # @param header [String] The Content-Type header string, e.g., "application/json; charset=utf-8"
153
+ # @return [Hash] A hash with :type and :parameters keys.
154
+ # Example:
155
+ # {
156
+ # type: "application/json",
157
+ # parameters: {
158
+ # charset: "utf-8",
159
+ # version: "1.0"
160
+ # }
161
+ # }
162
+ def parse_header(header)
163
+ parts = header.split(';').map(&:strip)
164
+ media_type = parts.shift.downcase
165
+ parameters = parts.filter_map do |param|
166
+ key, value = param.split('=', 2)
167
+ next unless key && value
168
+ key = key.strip.downcase.to_sym
169
+ value = value.strip.gsub(/\A"|"\z/, '')
170
+ [key, value]
171
+ end.to_h
172
+ {type: media_type, parameters: parameters}
173
+ end
174
+ end
175
+ end
176
+ end