Class: Aspera::Rest
- Inherits:
-
Object
- Object
- Aspera::Rest
- Defined in:
- lib/aspera/rest.rb
Overview
a simple class to make HTTP calls, equivalent to rest-client rest call errors are raised as exception RestCallError and error are analyzed in RestErrorAnalyzer
Constant Summary collapse
- ENTITY_NOT_FOUND =
error message when entity not found
'No such'- @@global =
global settings also valid for any subclass
{ # rubocop:disable Style/ClassVars debug: false, # true if https ignore certificate user_agent: 'Ruby', download_partial_suffix: '.http_partial', # a lambda which takes the Net::HTTP as arg, use this to change parameters session_cb: nil, proxy_user: nil, proxy_pass: nil }
Instance Attribute Summary collapse
-
#params ⇒ Object
readonly
Returns the value of attribute params.
Class Method Summary collapse
-
.array_params(values) ⇒ Object
used to build a parameter list prefixed with “[]”.
- .basic_creds(user, pass) ⇒ Object
-
.build_uri(url, params = nil) ⇒ Object
build URI from URL and parameters and check it is http or https.
- .start_http_session(base_url) ⇒ Object
Instance Method Summary collapse
- #build_request(call_data) ⇒ Object
-
#call(call_data) ⇒ Object
HTTP/S REST call call_data has keys: :auth :operation :subpath :headers :json_params :url_params :www_body_params :text_body_params :save_to_file (filepath) default: nil :return_error (bool) default: nil :redirect_max (int) default: 0 :not_auth_codes (array) codes that trigger a refresh/regeneration of bearer token —- authentication (:auth) : :type (:none, :basic, :oauth2, :url) :username [:basic] :password [:basic] :url_creds [:url] a hash :* [:oauth2] see Oauth class.
- #cancel(subpath) ⇒ Object
- #create(subpath, params, encoding = :json_params) ⇒ Object
- #delete(subpath, args = nil) ⇒ Object
-
#initialize(a_rest_params) ⇒ Rest
constructor
A new instance of Rest.
-
#lookup_by_name(subpath, search_name, options = {}) ⇒ Object
Query by name and returns a single result, else it throws an exception (no or multiple results).
- #oauth ⇒ Object
- #oauth_token(force_refresh: false) ⇒ Object
- #read(subpath, args = nil) ⇒ Object
- #update(subpath, params) ⇒ Object
Constructor Details
#initialize(a_rest_params) ⇒ Rest
Returns a new instance of Rest.
130 131 132 133 134 135 136 137 138 139 140 141 142 143 |
# File 'lib/aspera/rest.rb', line 130 def initialize(a_rest_params) raise 'ERROR: expecting Hash' unless a_rest_params.is_a?(Hash) raise 'ERROR: expecting base_url' unless a_rest_params[:base_url].is_a?(String) @params = a_rest_params.clone Log.dump('REST params', @params) # base url without trailing slashes (note: string may be frozen) @params[:base_url] = @params[:base_url].gsub(%r{/+$}, '') @http_session = nil # default is no auth @params[:auth] ||= {type: :none} @params[:not_auth_codes] ||= ['401'] @oauth = nil Log.dump('REST params(2)', @params) end |
Instance Attribute Details
#params ⇒ Object (readonly)
Returns the value of attribute params.
119 120 121 |
# File 'lib/aspera/rest.rb', line 119 def params @params end |
Class Method Details
.array_params(values) ⇒ Object
used to build a parameter list prefixed with “[]”
60 61 62 |
# File 'lib/aspera/rest.rb', line 60 def array_params(values) return [ARRAY_PARAMS].concat(values) end |
.basic_creds(user, pass) ⇒ Object
56 |
# File 'lib/aspera/rest.rb', line 56 def basic_creds(user, pass); return "Basic #{Base64.strict_encode64("#{user}:#{pass}")}"; end |
.build_uri(url, params = nil) ⇒ Object
build URI from URL and parameters and check it is http or https
65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 |
# File 'lib/aspera/rest.rb', line 65 def build_uri(url, params=nil) uri = URI.parse(url) raise "REST endpoint shall be http/s not #{uri.scheme}" unless %w[http https].include?(uri.scheme) if !params.nil? # support array url params, there is no standard. Either p[]=1&p[]=2, or p=1&p=2 if params.is_a?(Hash) orig = params params = [] orig.each do |k, v| case v when Array suffix = v.first.eql?(ARRAY_PARAMS) ? v.shift : '' v.each do |e| params.push([k.to_s + suffix, e]) end else params.push([k, v]) end end end # CGI.unescape to transform back %5D into [] uri.query = CGI.unescape(URI.encode_www_form(params)) end return uri end |
.start_http_session(base_url) ⇒ Object
91 92 93 94 95 96 97 98 99 100 101 102 103 104 |
# File 'lib/aspera/rest.rb', line 91 def start_http_session(base_url) uri = build_uri(base_url) # this honors http_proxy env var http_session = Net::HTTP.new(uri.host, uri.port) http_session.proxy_user = proxy_user http_session.proxy_pass = proxy_pass http_session.use_ssl = uri.scheme.eql?('https') http_session.set_debug_output($stdout) if debug # set http options in callback, such as timeout and cert. verification session_cb&.call(http_session) # manually start session for keep alive (if supported by server, else, session is closed every time) http_session.start return http_session end |
Instance Method Details
#build_request(call_data) ⇒ Object
150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 |
# File 'lib/aspera/rest.rb', line 150 def build_request(call_data) # TODO: shall we percent encode subpath (spaces) test with access key delete with space in id # URI.escape() uri = self.class.build_uri("#{call_data[:base_url]}#{['', '/'].include?(call_data[:subpath]) ? '' : '/'}#{call_data[:subpath]}", call_data[:url_params]) Log.log.debug{"URI=#{uri}"} begin # instantiate request object based on string name req = Net::HTTP.const_get(call_data[:operation].capitalize).new(uri) rescue NameError raise "unsupported operation : #{call_data[:operation]}" end if call_data.key?(:json_params) && !call_data[:json_params].nil? req.body = JSON.generate(call_data[:json_params]) Log.dump('body JSON data', call_data[:json_params]) req['Content-Type'] = 'application/json' # call_data[:headers]['Accept']='application/json' end if call_data.key?(:www_body_params) req.body = URI.encode_www_form(call_data[:www_body_params]) Log.log.debug{"body www data=#{req.body.chomp}"} req['Content-Type'] = 'application/x-www-form-urlencoded' end if call_data.key?(:text_body_params) req.body = call_data[:text_body_params] Log.log.debug{"body data=#{req.body.chomp}"} end # set headers if call_data.key?(:headers) call_data[:headers].each_key do |key| req[key] = call_data[:headers][key] end end # :type = :basic req.basic_auth(call_data[:auth][:username], call_data[:auth][:password]) if call_data[:auth][:type].eql?(:basic) return req end |
#call(call_data) ⇒ Object
HTTP/S REST call call_data has keys: :auth :operation :subpath :headers :json_params :url_params :www_body_params :text_body_params :save_to_file (filepath) default: nil :return_error (bool) default: nil :redirect_max (int) default: 0 :not_auth_codes (array) codes that trigger a refresh/regeneration of bearer token
authentication (:auth) : :type (:none, :basic, :oauth2, :url) :username [:basic] :password [:basic] :url_creds [:url] a hash :* [:oauth2] see Oauth class
208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 |
# File 'lib/aspera/rest.rb', line 208 def call(call_data) raise "Hash call parameter is required (#{call_data.class})" unless call_data.is_a?(Hash) call_data[:subpath] = '' if call_data[:subpath].nil? Log.log.debug{"accessing #{call_data[:subpath]}".red.bold.bg_green} call_data[:headers] ||= {} call_data[:headers]['User-Agent'] ||= self.class.user_agent # defaults from @params are overridden by call data call_data = @params.deep_merge(call_data) case call_data[:auth][:type] when :none # no auth when :basic Log.log.debug('using Basic auth') # done in build_req when :oauth2 call_data[:headers]['Authorization'] = oauth_token unless call_data[:headers].key?('Authorization') when :url call_data[:url_params] ||= {} call_data[:auth][:url_creds].each do |key, value| call_data[:url_params][key] = value end else raise "unsupported auth type: [#{call_data[:auth][:type]}]" end req = build_request(call_data) Log.log.debug{"call_data = #{call_data}"} result = {http: nil} # start a block to be able to retry the actual HTTP request begin # we try the call, and will retry only if oauth, as we can, first with refresh, and then re-auth if refresh is bad oauth_tries ||= 2 # initialize with number of initial retries allowed, nil gives zero tries_remain_redirect = call_data[:redirect_max].to_i if tries_remain_redirect.nil? Log.log.debug("send request (retries=#{tries_remain_redirect})") # make http request (pipelined) http_session.request(req) do |response| result[:http] = response if !call_data[:save_to_file].nil? && result[:http].code.to_s.start_with?('2') total_size = result[:http]['Content-Length'].to_i progress = ProgressBar.create( format: '%a %B %p%% %r KB/sec %e', rate_scale: lambda{|rate|rate / 1024}, title: 'progress', total: total_size) Log.log.debug('before write file') target_file = call_data[:save_to_file] # override user's path to path in header if !response['Content-Disposition'].nil? && (m = response['Content-Disposition'].match(/filename="([^"]+)"/)) target_file = File.join(File.dirname(target_file), m[1]) end # download with temp filename target_file_tmp = "#{target_file}#{self.class.download_partial_suffix}" Log.log.debug{"saving to: #{target_file}"} File.open(target_file_tmp, 'wb') do |file| result[:http].read_body do |fragment| file.write(fragment) new_process = progress.progress + fragment.length new_process = total_size if new_process > total_size progress.progress = new_process end end # rename at the end File.rename(target_file_tmp, target_file) progress = nil end # save_to_file end # sometimes there is a UTF8 char (e.g. (c) ) result[:http].body.force_encoding('UTF-8') if result[:http].body.is_a?(String) Log.log.debug{"result: body=#{result[:http].body}"} result_mime = (result[:http]['Content-Type'] || 'text/plain').split(';').first result[:data] = case result_mime when 'application/json', 'application/vnd.api+json' JSON.parse(result[:http].body) rescue nil else # when 'text/plain' result[:http].body end Log.dump("result: parsed: #{result_mime}", result[:data]) Log.log.debug{"result: code=#{result[:http].code}"} RestErrorAnalyzer.instance.raise_on_error(req, result) rescue RestCallError => e # not authorized: oauth token expired if call_data[:not_auth_codes].include?(result[:http].code.to_s) && call_data[:auth][:type].eql?(:oauth2) begin # try to use refresh token req['Authorization'] = oauth_token(force_refresh: true) rescue RestCallError => e_tok e = e_tok Log.log.error('refresh failed'.bg_red) # regenerate a brand new token req['Authorization'] = oauth_token(force_refresh: true) end Log.log.debug{"using new token=#{call_data[:headers]['Authorization']}"} retry unless (oauth_tries -= 1).zero? end # if oauth # redirect ? (any code beginning with 3) if tries_remain_redirect.positive? && e.response.is_a?(Net::HTTPRedirection) tries_remain_redirect -= 1 current_uri = URI.parse(call_data[:base_url]) new_url = e.response['location'] new_url = "#{current_uri.scheme}:#{new_url}" unless new_url.start_with?('http') Log.log.info{"URL is moved: #{new_url}"} redirection_uri = URI.parse(new_url) call_data[:base_url] = new_url call_data[:subpath] = '' if current_uri.host.eql?(redirection_uri.host) && current_uri.port.eql?(redirection_uri.port) req = build_request(call_data) retry else # change host Log.log.info{"Redirect changes host: #{current_uri.host} -> #{redirection_uri.host}"} return self.class.new(call_data).call(call_data) end end # raise exception if could not retry and not return error in result raise e unless call_data[:return_error] end # begin request Log.log.debug{"result=#{result}"} return result end |
#cancel(subpath) ⇒ Object
348 349 350 |
# File 'lib/aspera/rest.rb', line 348 def cancel(subpath) return call({operation: 'CANCEL', subpath: subpath, headers: {'Accept' => 'application/json'}}) end |
#create(subpath, params, encoding = :json_params) ⇒ Object
332 333 334 |
# File 'lib/aspera/rest.rb', line 332 def create(subpath, params, encoding=:json_params) return call({operation: 'POST', subpath: subpath, headers: {'Accept' => 'application/json'}, encoding => params}) end |
#delete(subpath, args = nil) ⇒ Object
344 345 346 |
# File 'lib/aspera/rest.rb', line 344 def delete(subpath, args=nil) return call({operation: 'DELETE', subpath: subpath, headers: {'Accept' => 'application/json'}, url_params: args}) end |
#lookup_by_name(subpath, search_name, options = {}) ⇒ Object
Query by name and returns a single result, else it throws an exception (no or multiple results)
356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 |
# File 'lib/aspera/rest.rb', line 356 def lookup_by_name(subpath, search_name, ={}) # returns entities whose name contains value (case insensitive) matching_items = read(subpath, .merge({'q' => CGI.escape(search_name)}))[:data] # API style: {totalcount:, ...} cspell: disable-line # TODO: not generic enough ? move somewhere ? inheritance ? matching_items = matching_items[subpath] if matching_items.is_a?(Hash) raise "Internal error: expecting array, have #{matching_items.class}" unless matching_items.is_a?(Array) case matching_items.length when 1 then return matching_items.first when 0 then raise %Q{#{ENTITY_NOT_FOUND} #{subpath}: "#{search_name}"} else # multiple case insensitive partial matches, try case insensitive full match # (anyway AoC does not allow creation of 2 entities with same case insensitive name) name_matches = matching_items.select{|i|i['name'].casecmp?(search_name)} case name_matches.length when 1 then return name_matches.first when 0 then raise %Q(#{subpath}: multiple case insensitive partial match for: "#{search_name}": #{matching_items.map{|i|i['name']}} but no case insensitive full match. Please be more specific or give exact name.) # rubocop:disable Layout/LineLength else raise "Two entities cannot have the same case insensitive name: #{name_matches.map{|i|i['name']}}" end end end |
#oauth ⇒ Object
121 122 123 124 125 126 127 |
# File 'lib/aspera/rest.rb', line 121 def oauth if @oauth.nil? raise 'ERROR: no OAuth defined' unless @params[:auth][:type].eql?(:oauth2) @oauth = Oauth.new(@params[:auth]) end return @oauth end |
#oauth_token(force_refresh: false) ⇒ Object
145 146 147 148 |
# File 'lib/aspera/rest.rb', line 145 def oauth_token(force_refresh: false) raise "ERROR: expecting boolean, have #{force_refresh}" unless [true, false].include?(force_refresh) return oauth.(use_refresh_token: force_refresh) end |
#read(subpath, args = nil) ⇒ Object
336 337 338 |
# File 'lib/aspera/rest.rb', line 336 def read(subpath, args=nil) return call({operation: 'GET', subpath: subpath, headers: {'Accept' => 'application/json'}, url_params: args}) end |
#update(subpath, params) ⇒ Object
340 341 342 |
# File 'lib/aspera/rest.rb', line 340 def update(subpath, params) return call({operation: 'PUT', subpath: subpath, headers: {'Accept' => 'application/json'}, json_params: params}) end |