X-Git-Url: https://git.arvados.org/arvados.git/blobdiff_plain/27f5c1635d56c3f3cb6c5ef069c28db939eec2a1..1cbf8cd312dd019809b060d83999c677e94dbe7e:/apps/workbench/app/models/arvados_api_client.rb diff --git a/apps/workbench/app/models/arvados_api_client.rb b/apps/workbench/app/models/arvados_api_client.rb index c6d8720c92..47fcc4ce51 100644 --- a/apps/workbench/app/models/arvados_api_client.rb +++ b/apps/workbench/app/models/arvados_api_client.rb @@ -1,27 +1,108 @@ +# Copyright (C) The Arvados Authors. All rights reserved. +# +# SPDX-License-Identifier: AGPL-3.0 + require 'httpclient' require 'thread' class ArvadosApiClient - class NotLoggedInException < StandardError + class ApiError < StandardError + attr_reader :api_response, :api_response_s, :api_status, :request_url + + def initialize(request_url, errmsg) + @request_url = request_url + @api_response ||= {} + errors = @api_response[:errors] + if not errors.is_a?(Array) + @api_response[:errors] = [errors || errmsg] + end + super(errmsg) + end + end + + class NoApiResponseException < ApiError + def initialize(request_url, exception) + @api_response_s = exception.to_s + super(request_url, + "#{exception.class.to_s} error connecting to API server") + end + end + + class InvalidApiResponseException < ApiError + def initialize(request_url, api_response) + @api_status = api_response.status_code + @api_response_s = api_response.content + super(request_url, "Unparseable response from API server") + end + end + + class ApiErrorResponseException < ApiError + def initialize(request_url, api_response) + @api_status = api_response.status_code + @api_response_s = api_response.content + @api_response = Oj.strict_load(@api_response_s, :symbol_keys => true) + errors = @api_response[:errors] + if errors.respond_to?(:join) + errors = errors.join("\n\n") + else + errors = errors.to_s + end + super(request_url, "#{errors} [API: #{@api_status}]") + end + end + + class AccessForbiddenException < ApiErrorResponseException; end + class NotFoundException < ApiErrorResponseException; end + class NotLoggedInException < ApiErrorResponseException; end + + ERROR_CODE_CLASSES = { + 401 => NotLoggedInException, + 403 => AccessForbiddenException, + 404 => NotFoundException, + } + + @@profiling_enabled = Rails.configuration.Workbench.ProfilingEnabled + @@discovery = nil + + # An API client object suitable for handling API requests on behalf + # of the current thread. + def self.new_or_current + # If this thread doesn't have an API client yet, *or* this model + # has been reloaded since the existing client was created, create + # a new client. Otherwise, keep using the latest client created in + # the current thread. + unless Thread.current[:arvados_api_client].andand.class == self + Thread.current[:arvados_api_client] = new + end + Thread.current[:arvados_api_client] end - class InvalidApiResponseException < StandardError + + def initialize *args + @api_client = nil + @client_mtx = Mutex.new end - @@client_mtx = Mutex.new - @@api_client = nil - @@profiling_enabled = Rails.configuration.profiling_enabled + def api(resources_kind, action, data=nil, tokens={}, include_anon_token=true) - def api(resources_kind, action, data=nil) profile_checkpoint - @@client_mtx.synchronize do - if not @@api_client - @@api_client = HTTPClient.new - if Rails.configuration.arvados_insecure_https - @@api_client.ssl_config.verify_mode = OpenSSL::SSL::VERIFY_NONE + if not @api_client + @client_mtx.synchronize do + @api_client = HTTPClient.new + @api_client.ssl_config.timeout = Rails.configuration.Workbench.APIClientConnectTimeout + @api_client.connect_timeout = Rails.configuration.Workbench.APIClientConnectTimeout + @api_client.receive_timeout = Rails.configuration.Workbench.APIClientReceiveTimeout + if Rails.configuration.TLS.Insecure + @api_client.ssl_config.verify_mode = OpenSSL::SSL::VERIFY_NONE else # Use system CA certificates - @@api_client.ssl_config.add_trust_ca('/etc/ssl/certs') + ["/etc/ssl/certs/ca-certificates.crt", + "/etc/pki/tls/certs/ca-bundle.crt"] + .select { |ca_path| File.readable?(ca_path) } + .each { |ca_path| @api_client.ssl_config.add_trust_ca(ca_path) } + end + if Rails.configuration.Workbench.APIResponseCompression + @api_client.transparent_gzip_decompression = true end end end @@ -32,9 +113,13 @@ class ArvadosApiClient # Clean up /arvados/v1/../../discovery/v1 to /discovery/v1 url.sub! '/arvados/v1/../../', '/' + anon_tokens = [Rails.configuration.Users.AnonymousUserToken].select { |x| !x.empty? && include_anon_token } + query = { - 'api_token' => Thread.current[:arvados_api_token] || '', - 'reader_tokens' => (Thread.current[:reader_tokens] || []).to_json, + 'reader_tokens' => ((tokens[:reader_tokens] || + Thread.current[:reader_tokens] || + []) + + anon_tokens).to_json, } if !data.nil? data.each do |k,v| @@ -45,43 +130,56 @@ class ArvadosApiClient elsif v == false query[k] = 0 else - query[k] = JSON.dump(v) + query[k] = Oj.dump(v, mode: :compat) end end else query["_method"] = "GET" end + if @@profiling_enabled query["_profile"] = "true" end - header = {"Accept" => "application/json"} + headers = { + "Accept" => "application/json", + "Authorization" => "OAuth2 " + + (tokens[:arvados_api_token] || + Thread.current[:arvados_api_token] || + ''), + "X-Request-Id" => Thread.current[:request_id] || '', + } - profile_checkpoint { "Prepare request #{url} #{query[:uuid]} #{query[:where]} #{query[:filters]}" } - msg = @@api_client.post(url, - query, - header: header) + profile_checkpoint { "Prepare request #{query["_method"] or "POST"} #{url} #{query[:uuid]} #{query.inspect[0,256]}" } + msg = @client_mtx.synchronize do + begin + @api_client.post(url, query, headers) + rescue => exception + raise NoApiResponseException.new(url, exception) + end + end profile_checkpoint 'API transaction' - - if msg.status_code == 401 - raise NotLoggedInException.new + if @@profiling_enabled + if msg.headers['X-Runtime'] + Rails.logger.info "API server: #{msg.headers['X-Runtime']} runtime reported" + end + Rails.logger.info "Content-Encoding #{msg.headers['Content-Encoding'].inspect}, Content-Length #{msg.headers['Content-Length'].inspect}, actual content size #{msg.content.size}" end - json = msg.content - begin - resp = Oj.load(json, :symbol_keys => true) + resp = Oj.strict_load(msg.content, :symbol_keys => true) rescue Oj::ParseError - raise InvalidApiResponseException.new json + resp = nil end + if not resp.is_a? Hash - raise InvalidApiResponseException.new json - end - if msg.status_code != 200 - errors = resp[:errors] - errors = errors.join("\n\n") if errors.is_a? Array - raise "#{errors} [API: #{msg.status_code}]" + raise InvalidApiResponseException.new(url, msg) + elsif msg.status_code != 200 + error_class = ERROR_CODE_CLASSES.fetch(msg.status_code, + ApiErrorResponseException) + raise error_class.new(url, msg) end + if resp[:_profile] Rails.logger.info "API client: " \ "#{resp.delete(:_profile)[:request_time]} request_time" @@ -137,16 +235,13 @@ class ArvadosApiClient end def arvados_login_url(params={}) - if Rails.configuration.respond_to? :arvados_login_base - uri = Rails.configuration.arvados_login_base - else - uri = self.arvados_v1_base.sub(%r{/arvados/v\d+.*}, '/login') - end - if params.size > 0 - uri += '?' << params.collect { |k,v| - CGI.escape(k.to_s) + '=' + CGI.escape(v.to_s) - }.join('&') + uri = URI.parse(Rails.configuration.Services.Controller.ExternalURL.to_s) + if Rails.configuration.testing_override_login_url + uri = URI(Rails.configuration.testing_override_login_url) end + uri.path = "/login" + uri.query = URI.encode_www_form(params) + uri.to_s end def arvados_logout_url(params={}) @@ -154,11 +249,15 @@ class ArvadosApiClient end def arvados_v1_base - Rails.configuration.arvados_v1_base + # workaround Ruby 2.3 bug, can't duplicate URI objects + # https://github.com/httprb/http/issues/388 + u = URI.parse(Rails.configuration.Services.Controller.ExternalURL.to_s) + u.path = "/arvados/v1" + u.to_s end def discovery - @discovery ||= api '../../discovery/v1/apis/arvados/v1/rest', '' + @@discovery ||= api '../../discovery/v1/apis/arvados/v1/rest', '' end def kind_class(kind) @@ -169,6 +268,10 @@ class ArvadosApiClient resource_class.to_s.underscore end + def self.class_kind(resource_class) + resource_class.to_s.underscore + end + protected def profile_checkpoint label=nil return if !@@profiling_enabled