class RightApi::Client

Constants

API_VERSION
AUTH_PARAMS

permitted parameters for initializing

CLIENT_VERSION
DEFAULT_API_URL
DEFAULT_MAX_ATTEMPTS
DEFAULT_OPEN_TIMEOUT
DEFAULT_SSL_VERSION
DEFAULT_TIMEOUT
OAUTH_ENDPOINT
ROOT_INSTANCE_RESOURCE
ROOT_RESOURCE
VERSION

Attributes

access_token[R]

@return [String] OAuth 2.0 access token, if present

access_token_expires_at[R]

@return [Time] expiry timestamp for OAuth 2.0 access token

account_id[RW]
api_url[RW]

@return [String] Base API url, e.g. us-3.rightscale.com

cookies[R]

@return [Hash] collection of API cookies @deprecated please use OAuth 2.0 refresh tokens instead of password-based authentication

enable_retry[R]

@return [Boolean] whether to retry idempotent requests that fail

instance_token[R]

@return [String] instance API token as included in user-data

last_request[R]

@return [Hash] debug information about the last request and response

max_attempts[R]

@return [Integer] number of times to retry idempotent requests (iff enable_retry == true)

open_timeout[R]

@return [Integer] number of seconds to wait for socket open

refresh_token[R]

@return [String] OAuth 2.0 refresh token if provided

timeout[R]

@return [Integer] number of seconds to wait for API response

Public Class Methods

new(args) click to toggle source

Instantiate a new Client, then login if necessary.

# File lib/right_api_client/client.rb, line 118
def initialize(args)
  raise 'This API client is only compatible with Ruby 1.8.7 and upwards.' if (RUBY_VERSION < '1.8.7')

  @api_url, @api_version = DEFAULT_API_URL, API_VERSION
  @open_timeout, @timeout, @max_attempts = DEFAULT_OPEN_TIMEOUT, DEFAULT_TIMEOUT, DEFAULT_MAX_ATTEMPTS
  @ssl_version = DEFAULT_SSL_VERSION
  @enable_retry = false

  # Initializing all instance variables from hash
  args.each { |key,value|
    instance_variable_set("@#{key}", value) if AUTH_PARAMS.include?(key.to_s)
  } if args.is_a? Hash

  raise 'This API client is only compatible with the RightScale API 1.5 and upwards.' if (Float(@api_version) < 1.5)

  # If rl10 parameter was passed true, read secrets file to set @local_token, and @api_url
  if @rl10
    case RbConfig::CONFIG['host_os']
    when /mswin|mingw|cygwin/
      local_secret_file = File.join(ENV['ProgramData'] || 'C:/ProgramData', 'RightScale/RightLink/secret')
    else
      local_secret_file = '/var/run/rightlink/secret'
    end
    local_auth_info = Hash[File.readlines(local_secret_file).map{ |line| line.chomp.split('=', 2) }]
    @local_token = local_auth_info['RS_RLL_SECRET']
    @api_url = "http://localhost:#{local_auth_info['RS_RLL_PORT']}"
  end

  # allow a custom resource-style REST client (for special logging, etc.)
  @rest_client_class ||= ::RestClient::Resource
  @rest_client = @rest_client_class.new(@api_url, :open_timeout => @open_timeout, :timeout => @timeout, :ssl_version => @ssl_version)
  @last_request = {}

  # There are five options for login:
  #  - user email/password (using plaintext or base64-obfuscated password)
  #  - user OAuth refresh token
  #  - instance API token
  #  - existing user-supplied cookies
  #  - existing user-supplied OAuth access token
  #
  # The latter two options are not really login; they imply that the user logged in out of band.
  # See config/login.yml.example for more info.
  login() if need_login?

  timestamp_cookies

  # Add the top level links for instance_facing_calls
  if @instance_token || @local_token
    resource_type, path, data = self.do_get(ROOT_INSTANCE_RESOURCE)
    instance_href = get_href_from_links(data['links'])
    cloud_href = instance_href.split('/instances')[0]

    define_instance_method(:get_instance) do |*params|
      type, instance_path, instance_data = self.do_get(ROOT_INSTANCE_RESOURCE)
      RightApi::ResourceDetail.new(self, type, instance_path, instance_data)
    end

    Helper::INSTANCE_FACING_RESOURCES.each do |meth|
      define_instance_method(meth) do |*args|
        obj_path = cloud_href + '/' + meth.to_s
        # Following are special cases that need to over-ride the obj_path
        obj_path = '/api/backups'                if meth == :backups
        obj_path = instance_href + '/live/tasks' if meth == :live_tasks
        obj_path = '/api/tags'                   if meth == :tags
        if has_id(*args)
          obj_path = add_id_and_params_to_path(obj_path, *args)
          RightApi::Resource.process(self, get_singular(meth), obj_path)
        else
          RightApi::Resources.new(self, obj_path, meth.to_s)
        end
      end
    end
  else
    # Session is the root resource that has links to all the base resources
    define_instance_method(:session) do |*params|
      RightApi::Resources.new(self, ROOT_RESOURCE, 'session')
    end
    # Allow the base resources to be accessed directly
    get_associated_resources(self, session.index.links, nil)
  end
end

Public Instance Methods

inspect()
Alias for: to_s
log(file) click to toggle source

Log HTTP calls to file (file can be STDOUT as well)

# File lib/right_api_client/client.rb, line 208
def log(file)
  RestClient.log = file
end
resource(path, params={}) click to toggle source

Given a path returns a RightApiClient::Resource instance.

# File lib/right_api_client/client.rb, line 214
def resource(path, params={})
  r = Resource.process_detailed(self, *do_get(path, params))

  # note that process_detailed will make a best-effort to return an already
  # detailed resource or array of detailed resources but there may still be
  # legacy cases where #show is still needed. calling #show on an already
  # detailed resource is a no-op.
  r.respond_to?(:show) ? r.show : r
end
resources(type, path) click to toggle source

Seems resource tends to expand (call index) on Resources instances, so this is a workaround.

# File lib/right_api_client/client.rb, line 227
def resources(type, path)
  Resources.new(self, path, type)
end
to_s() click to toggle source
# File lib/right_api_client/client.rb, line 200
def to_s
  api_host = URI.parse(api_url).host.split('.').first rescue 'unknown'
  "#<RightApi::Client host=#{api_host} account=#{@account_id}>"
end
Also aliased as: inspect

Protected Instance Methods

do_delete(path, params={}) click to toggle source

Generic delete

# File lib/right_api_client/client.rb, line 473
def do_delete(path, params={})
  login if need_login?

  # Resource id is a special param as it needs to be added to the path
  path = add_id_and_params_to_path(path, params)

  req, res, resource_type, body = nil

  begin
    retry_request do
      @rest_client[path].delete(headers) do |response, request, result|
        req, res = request, response
        update_cookies(response)
        update_last_request(request, response)

        case response.code
        when 200, 204
          nil
        when 301, 302
          update_api_url(response)
          do_delete(path, params)
        when 404
          raise UnknownRouteError.new(request, response)
        else
          raise ApiError.new(request, response)
        end
      end
    end
  rescue => e
    raise wrap(e, :delete, path, params, req, res)
  end
end
do_get(path, params={}) click to toggle source

Generic get params are NOT read only

# File lib/right_api_client/client.rb, line 351
def do_get(path, params={})
  login if need_login?

  # Resource id is a special param as it needs to be added to the path
  path = add_id_and_params_to_path(path, params)

  req, res, resource_type, body = nil

  begin
    retry_request(true) do
      # Return content type so the resulting resource object knows what kind of resource it is.
      resource_type, body = @rest_client[path].get(headers) do |response, request, result, &block|
        req, res = request, response
        update_cookies(response)
        update_last_request(request, response)

        case response.code
        when 200
          # Get the resource_type from the content_type, the resource_type
          # will be used later to add relevant methods to relevant resources
          type = if result.content_type.index('rightscale')
            get_resource_type(result.content_type)
          elsif result.content_type.index('text/plain')
            'text'
          else
            ''
          end

          # work around getting ASCII-8BIT from some resources like audit entry detail
          charset = get_charset(response.headers)
          if charset && response.body.encoding != charset
            response.body.force_encoding(charset)
          end

          # raise an error if the API is misbehaving and returning an empty response when it shouldn't
          if type != 'text' && response.body.empty?
            raise EmptyBodyError.new(request, response)
          end

          [type, response.body]
        when 301, 302
          update_api_url(response)
          response.follow_redirection(request, result, &block)
        when 404
          raise UnknownRouteError.new(request, response)
        else
          raise ApiError.new(request, response)
        end
      end
    end
  rescue => e
    raise wrap(e, :get, path, params, req, res)
  end

  data = if resource_type == 'text'
    { 'text' => body }
  else
    JSON.parse(body, :allow_nan => true)
  end

  [resource_type, path, data]
end
do_post(path, params={}) click to toggle source

Generic post

# File lib/right_api_client/client.rb, line 415
def do_post(path, params={})
  login if need_login?

  params = fix_array_of_hashes(params)

  req, res, resource_type, body = nil

  begin
    retry_request do
      @rest_client[path].post(params, headers) do |response, request, result|
        req, res = request, response
        update_cookies(response)
        update_last_request(request, response)

        case response.code
        when 201, 202
          # Create and return the resource
          href = response.headers[:location]
          relative_href = href.split(@api_url)[-1]
          # Return the resource that was just created
          # Determine the resource_type from the href (eg. api/clouds/id).
          # This is based on the assumption that we can determine the resource_type without doing a do_get
          resource_type = get_singular(relative_href.split('/')[-2])
          RightApi::Resource.process(self, resource_type, relative_href)
        when 204
          nil
        when 200..299
          # This is needed for the tags Resource -- which returns a 200 and has a content type
          # therefore, ResourceDetail objects needs to be returned
          if response.code == 200 && response.headers[:content_type].index('rightscale')
            # raise an error if the API is misbehaving and returning an empty response when it shouldn't
            raise EmptyBodyError.new(request, response) if response.body.empty?
            resource_type = get_resource_type(response.headers[:content_type])
            data = JSON.parse(response, :allow_nan => true)
            # Resource_tag is returned after querying tags.by_resource or tags.by_tags.
            # You cannot do a show on a resource_tag, but that is basically what we want to do
            data.map { |obj|
              RightApi::ResourceDetail.new(self, resource_type, path, obj)
            }
          else
            response.return!(request, result)
          end
        when 301, 302
          update_api_url(response)
          do_post(path, params)
        when 404
          raise UnknownRouteError.new(request, response)
        else
          raise ApiError.new(request, response)
        end
      end
    end
  rescue ApiError => e
    raise wrap(e, :post, path, params, req, res)
  end
end
do_put(path, params={}) click to toggle source

Generic put

# File lib/right_api_client/client.rb, line 507
def do_put(path, params={})
  login if need_login?

  params = fix_array_of_hashes(params)

  req, res, resource_type, body = nil

  # Altering headers to set Content-Type to text/plain when updating rightscript content
  put_headers = path =~ %r(^/api/right_scripts/.+/source$) ? headers.merge('Content-Type' => 'text/plain') : headers

  begin
    retry_request do
      @rest_client[path].put(params, put_headers) do |response, request, result|
        req, res = request, response
        update_cookies(response)
        update_last_request(request, response)

        case response.code
        when 204
          nil
        when 301, 302
          update_api_url(response)
          do_put(path, params)
        when 404
          raise UnknownRouteError.new(request, response)
        else
          raise ApiError.new(request, response)
        end
      end
    end
  rescue => e
    raise wrap(e, :put, path, params, req, res)
  end
end
get_charset(headers) click to toggle source

@param [Hash{Symbol => String}] headers the HTTP headers

# File lib/right_api_client/client.rb, line 594
def get_charset(headers)
  charset = headers[:content_type].split(';').map(&:strip).detect { |item| item =~ /^charset=/i }
  if charset
    Encoding.find(charset.gsub(/^charset=/i, ''))
  end
end
get_resource_type(content_type) click to toggle source

@param [String] content_type an HTTP Content-Type header @return [String] the resource_type associated with content_type

# File lib/right_api_client/client.rb, line 589
def get_resource_type(content_type)
  content_type.scan(/\.rightscale\.(.*)\+json/)[0][0]
end
headers() click to toggle source

Returns the request headers

# File lib/right_api_client/client.rb, line 321
def headers
  h = {
    'X-Api-Version' => @api_version,
    :accept => :json,
  }

  if @account_id
    h['X-Account'] = @account_id
  end

  if @access_token
    h['Authorization'] = "Bearer #{@access_token}"
  elsif @cookies
    h[:cookies] = @cookies
  end

  if @local_token
    h['X-RLL-Secret'] = @local_token
  end

  h
end
login() click to toggle source
# File lib/right_api_client/client.rb, line 271
def login
  account_href = "/api/accounts/#{@account_id}"

  params, path =
  if @refresh_token
    [ {'grant_type' => 'refresh_token',
       'refresh_token'=>@refresh_token},
      OAUTH_ENDPOINT ]
  elsif @instance_token
      [ { 'instance_token' => @instance_token,
          'account_href' => account_href },
        ROOT_INSTANCE_RESOURCE ]
  elsif @password_base64
    [ { 'email' => @email,
        'password' => Base64.decode64(@password_base64),
        'account_href' => account_href },
      ROOT_RESOURCE ]
  else
    [ { 'email' => @email,
        'password' => @password,
        'account_href' => account_href },
      ROOT_RESOURCE ]
  end

  response = nil
  attempts = 0
  begin
    response = @rest_client[path].extend(PostOverride).post(params, 'X-Api-Version' => @api_version) do |response, request, result, &block|
      if [301, 302, 307].include?(response.code)
        update_api_url(response)
        response = @rest_client[path].extend(PostOverride).post(params, 'X-Api-Version' => @api_version)
      else
        response.return!(request, result)
      end
    end
  rescue Errno::ECONNRESET, RestClient::RequestTimeout, OpenSSL::SSL::SSLError, RestClient::ServerBrokeConnection
    raise unless @enable_retry
    raise if attempts >= @max_attempts
    attempts += 1
    retry
  end

  if path == OAUTH_ENDPOINT
    update_access_token(response)
  else
    update_cookies(response)
  end
end
need_login?() click to toggle source

Determine whether the client should login based on known state of cookies/tokens and their expiration timestamps.

If the method returns true, then the client MUST login based on known state.

If the method returns false, login MAY still be required; we simply cannot determine with confidence that login is required. This can happen in the following cases:

- cookie jar has cookies, but they are expired, corrupted or unrelated to auth
- #initialize method received an access_token but no access_token_expires_at

@return [Boolean] true if re-login is known to be required

# File lib/right_api_client/client.rb, line 553
def need_login?
  # @local_token is the key to use the local proxy.  Connecting using this key
  # and the local proxy does not require login.
  if @local_token
    false
  elsif @access_token
    # If our access token is expired and we know it...
    @access_token_expires_at && @access_token_expires_at - Time.now < 900
  elsif @cookies
    # Or if we have a cookie jar and it's empty
    @cookies.respond_to?(:empty?) && @cookies.empty?
  else
    # Or if we have neither cookies nor an access token (because how else can a man auth?)
    true
  end
end
re_login?(e) click to toggle source

Determine whether an exception can be fixed by logging in again.

@param e [ApiError] the exception to check

@return [Boolean] true if re-login is appropriate

# File lib/right_api_client/client.rb, line 576
def re_login?(e)
  auth_error =
    (e.response_code == 403 && e.message =~ %r(.*cookie is expired or invalid)) ||
    e.response_code == 401

  renewable_creds =
    (@instance_token || (@email && (@password || @password_base64)) || @refresh_token)

  auth_error && renewable_creds
end
retry_request(is_read_only = false) { || ... } click to toggle source

Users shouldn't need to call the following methods directly

# File lib/right_api_client/client.rb, line 234
def retry_request(is_read_only = false)
  attempts = 0
  begin
    yield
  rescue OpenSSL::SSL::SSLError => e
    raise e unless @enable_retry
    # These errors pertain to the SSL handshake.  Since no data has been
    # exchanged its always safe to retry
    raise e if attempts >= @max_attempts
    attempts += 1
    retry
  rescue Errno::ECONNRESET, RestClient::ServerBrokeConnection, RestClient::RequestTimeout => e
    raise e unless @enable_retry
    #   Packetloss related.
    #   There are two timeouts on the ssl negotiation and data read with different
    #   times. Unfortunately the standard timeout class is used for both and the
    #   exceptions are caught and reraised so you can't distinguish between them.
    #   Unfortunate since ssl negotiation timeouts should always be retryable
    #   whereas data may not.
    if is_read_only
      raise e if attempts >= @max_attempts
      attempts += 1
      retry
    else
      raise e
    end
  rescue ApiError => e
    if re_login?(e)
      # Session is expired or invalid
      login()
      retry
    else
      raise e
    end
  end
end
timestamp_cookies() click to toggle source

Makes sure the @cookies have a timestamp.

# File lib/right_api_client/client.rb, line 603
def timestamp_cookies
  return unless @cookies

  class << @cookies; attr_accessor :timestamp; end
  @cookies.timestamp = Time.now
end
update_access_token(response) click to toggle source

Sets the @access_token and @access_token_expires_at

# File lib/right_api_client/client.rb, line 612
def update_access_token(response)
  h = JSON.load(response)
  @access_token = String(h['access_token'])
  @access_token_expires_at = Time.at(Time.now.to_i + Integer(h['expires_in']))
end
update_cookies(response) click to toggle source

Sets the @cookies (and timestamp it).

# File lib/right_api_client/client.rb, line 620
def update_cookies(response)
  return unless response.cookies

  (@cookies ||= {}).merge!(response.cookies)
  timestamp_cookies
end
update_last_request(request, response) click to toggle source
# File lib/right_api_client/client.rb, line 344
def update_last_request(request, response)
  @last_request[:request]  = request
  @last_request[:response] = response
end
wrap(error, method, path, params, request, response) click to toggle source

Adds details (path, params) to an error. Returns the error.

# File lib/right_api_client/client.rb, line 650
def wrap(error, method, path, params, request, response)

  class << error; attr_accessor :_details; end
  error._details = ErrorDetails.new(method, path, params, request, response)

  error
end

Private Instance Methods

update_api_url(response) click to toggle source
# File lib/right_api_client/client.rb, line 660
def update_api_url(response)
  # Update the rest client url if we are redirected to another endpoint
  uri = URI.parse(response.headers[:location])
  @api_url = "#{uri.scheme}://#{uri.host}"

  # note that the legacy code did not use the proper timeout values upon
  # redirect (i.e. always set :timeout => -1) but that seems like an
  # oversight; always use configured timeout values regardless of redirect.
  @rest_client = @rest_client_class.new(
    @api_url, :open_timeout => @open_timeout, :timeout => @timeout, :ssl_version => @ssl_version)
end