lib/restforce/client.rb



module Restforce
  class Client
    # Public: Creates a new client instance
    #
    # options - A hash of options to be passed in (default: {}).
    #           :username       - The String username to use (required for password authentication).
    #           :password       - The String password to use (required for password authentication).
    #           :security_token - The String security token to use 
    #                             (required for password authentication).
    #
    #           :oauth_token    - The String oauth access token to authenticate api
    #                             calls (required unless password
    #                             authentication is used).
    #           :refresh_token  - The String refresh token to obtain fresh
    #                             oauth access tokens (required if oauth
    #                             authentication is used).
    #           :instance_url   - The String base url for all api requests
    #                             (required if oauth authentication is used).
    #
    #           :client_id      - The oauth client id to use. Needed for both
    #                             password and oauth authentication
    #           :client_secret  - The oauth client secret to use.
    #
    #           :host           - The String hostname to use during
    #                             authentication requests (default: 'login.salesforce.com').
    #
    #           :api_version    - The String REST api version to use (default: '24.0')
    #
    # Examples
    #
    #   # Initialize a new client using password authentication:
    #   Restforce::Client.new :username => 'user',
    #     :password => 'pass',
    #     :security_token => 'security token',
    #     :client_id => 'client id',
    #     :client_secret => 'client secret'
    #   # => #<Restforce::Client:0x007f934aa2dc28 @options={ ... }>
    #
    #   # Initialize a new client using oauth authentication:
    #   Restforce::Client.new :oauth_token => 'access token',
    #     :refresh_token => 'refresh token',
    #     :instance_url => 'https://na1.salesforce.com',
    #     :client_id => 'client id',
    #     :client_secret => 'client secret'
    #   # => #<Restforce::Client:0x007f934aaaa0e8 @options={ ... }>
    #
    #   # Initialize a new client with using any authentication middleware:
    #   Restforce::Client.new :oauth_token => 'access token',
    #     :instance_url => 'https://na1.salesforce.com'
    #   # => #<Restforce::Client:0x007f934aab9980 @options={ ... }>
    def initialize(options = {})
      raise 'Please specify a hash of options' unless options.is_a?(Hash)
      @options = {}.tap do |options|
        [:username, :password, :security_token, :client_id, :client_secret, :host,
         :api_version, :oauth_token, :refresh_token, :instance_url, :cache, :authentication_retries].each do |option|
          options[option] = Restforce.configuration.send option
        end
      end
      @options.merge!(options)
    end

    # Public: Get the names of all sobjects on the org.
    #
    # Examples
    #
    #   # get the names of all sobjects on the org
    #   client.list_sobjects
    #   # => ['Account', 'Lead', ... ]
    #
    # Returns an Array of String names for each SObject.
    def list_sobjects
      describe.collect { |sobject| sobject['name'] }
    end
    
    # Public: Returns a detailed describe result for the specified sobject
    #
    # sobject - Stringish name of the sobject (default: nil).
    #
    # Examples
    #
    #   # get the global describe for all sobjects
    #   client.describe
    #   # => { ... }
    #
    #   # get the describe for the Account object
    #   client.describe('Account')
    #   # => { ... }
    #
    # Returns the Hash representation of the describe call.
    def describe(sobject=nil)
      if sobject
        response = api_get "sobjects/#{sobject.to_s}/describe"
        response.body
      else
        response = api_get 'sobjects'
        response.body['sobjects']
      end
    end

    # Public: Get the current organization's Id.
    #
    # Examples
    #
    #   client.org_id
    #   # => '00Dx0000000BV7z'
    #
    # Returns the String organization Id
    def org_id
      query('select id from Organization').first['Id']
    end
    
    # Public: Executs a SOQL query and returns the result.
    #
    # soql - A SOQL expression.
    #
    # Examples
    #
    #   # Find the names of all Accounts
    #   client.query('select Name from Account').map(&:Name)
    #   # => ['Foo Bar Inc.', 'Whizbang Corp']
    #
    # Returns a Restforce::Collection if Restforce.configuration.mashify is true.
    # Returns an Array of Hash for each record in the result if Restforce.configuration.mashify is false.
    def query(soql)
      response = api_get 'query', q: soql
      mashify? ? response.body : response.body['records']
    end
    
    # Public: Perform a SOSL search
    #
    # sosl - A SOSL expression.
    #
    # Examples
    #
    #   # Find all occurrences of 'bar'
    #   client.search('FIND {bar}')
    #   # => #<Restforce::Collection >
    #
    #   # Find accounts match the term 'genepoint' and return the Name field
    #   client.search('FIND {genepoint} RETURNING Account (Name)').map(&:Name)
    #   # => ['GenePoint']
    #
    # Returns a Restforce::Collection if Restforce.configuration.mashify is true.
    # Returns an Array of Hash for each record in the result if Restforce.configuration.mashify is false.
    def search(sosl)
      response = api_get 'search', q: sosl
      response.body
    end
    
    # Public: Insert a new record.
    #
    # Examples
    #
    #   # Add a new account
    #   client.create('Account', Name: 'Foobar Inc.')
    #   # => '0016000000MRatd'
    #
    # Returns the String Id of the newly created sobject. Returns false if
    # something bad happens
    def create(sobject, attrs)
      create!(sobject, attrs)
    rescue *exceptions
      false
    end
    alias_method :insert, :create

    # See .create
    #
    # Returns the String Id of the newly created sobject. Raises an error if
    # something bad happens.
    def create!(sobject, attrs)
      response = api_post "sobjects/#{sobject}", attrs
      response.body['id']
    end
    alias_method :insert!, :create!

    # Public: Update a record.
    #
    # Examples
    #
    #   # Update the Account with Id '0016000000MRatd'
    #   client.update('Account', Id: '0016000000MRatd', Name: 'Whizbang Corp')
    #
    # Returns true if the sobject was successfully updated, false otherwise.
    def update(sobject, attrs)
      update!(sobject, attrs)
    rescue *exceptions
      false
    end

    # See .update
    #
    # Returns true if the sobject was successfully updated, raises an error
    # otherwise.
    def update!(sobject, attrs)
      id = attrs.has_key?(:Id) ? attrs.delete(:Id) : attrs.delete('Id')
      raise 'Id field missing.' unless id
      api_patch "sobjects/#{sobject}/#{id}", attrs
      true
    end

    # Public: Update or Create a record based on an external ID
    #
    # sobject - The name of the sobject to created.
    # field   - The name of the external Id field to match against.
    # attrs   - Hash of attributes for the record.
    #
    # Examples
    #
    #   # Update the record with external ID of 12
    #   client.upsert('Account', 'External__c', External__c: 12, Name: 'Foobar')
    #
    # Returns true if the record was found and updated.
    # Returns the Id of the newly created record if the record was created.
    # Returns false if something bad happens.
    def upsert(sobject, field, attrs)
      upsert!(sobject, field, attrs)
    rescue *exceptions
      false
    end

    # See .upsert
    #
    # Returns true if the record was found and updated.
    # Returns the Id of the newly created record if the record was created.
    # Raises an error if something bad happens.
    def upsert!(sobject, field, attrs)
      external_id = attrs.has_key?(field.to_sym) ? attrs.delete(field.to_sym) : attrs.delete(field.to_s)
      response = api_patch "sobjects/#{sobject}/#{field.to_s}/#{external_id}", attrs
      (response.body && response.body['id']) ? response.body['id'] : true
    end

    # Public: Delete a record.
    #
    # Examples
    #
    #   # Delete the Account with Id '0016000000MRatd'
    #   client.delete('Account', '0016000000MRatd')
    #
    # Returns true if the sobject was successfully deleted, false otherwise.
    def destroy(sobject, id)
      destroy!(sobject, id)
    rescue *exceptions
      false
    end

    # See .destroy
    #
    # Returns true of the sobject was successfully deleted, raises an error
    # otherwise.
    def destroy!(sobject, id)
      api_delete "sobjects/#{sobject}/#{id}"
      true
    end

    # Public: Runs the block with caching disabled.
    #
    # block - A query/describe/etc.
    #
    # Returns the result of the block
    def without_caching(&block)
      @options[:perform_caching] = false
      block.call
    ensure
      @options.delete(:perform_caching)
    end

    # Public: Subscribe to a PushTopic
    #
    # channel - The name of the PushTopic channel to subscribe to.
    # block   - A block to run when a new message is received.
    #
    # Returns a Faye::Subscription
    def subscribe(channel, &block)
      faye.subscribe "/topic/#{channel}", &block
    end

    # Public: Force an authentication
    def authenticate!
      raise 'No authentication middleware present' unless authentication_middleware
      middleware = authentication_middleware.new nil, self, @options
      middleware.authenticate!
    end

    # Public: Decodes a signed request received from Force.com Canvas.
    #
    # message - The POST message containing the signed request from Salesforce.
    #
    # Returns the Hash context if the message is valid.
    def decode_signed_request(message)
      raise 'client_secret not set' unless @options[:client_secret]
      encryped_secret, payload = message.split('.')
      digest = OpenSSL::Digest::Digest.new('sha256')
      signature = Base64.encode64(OpenSSL::HMAC.hexdigest(digest, @options[:client_secret], payload))
      JSON.parse(Base64.decode64(payload)) if encryped_secret == signature
    end

    # Public: Helper methods for performing arbitrary actions against the API using
    # various HTTP verbs.
    #
    # Examples
    #
    #   # Perform a get request
    #   client.get '/services/data/v24.0/sobjects'
    #   client.api_get 'sobjects'
    #
    #   # Perform a post request
    #   client.post '/services/data/v24.0/sobjects/Account', { ... }
    #   client.api_post 'sobjects/Account', { ... }
    #
    #   # Perform a put request
    #   client.put '/services/data/v24.0/sobjects/Account/001D000000INjVe', { ... }
    #   client.api_put 'sobjects/Account/001D000000INjVe', { ... }
    #
    #   # Perform a delete request
    #   client.delete '/services/data/v24.0/sobjects/Account/001D000000INjVe'
    #   client.api_delete 'sobjects/Account/001D000000INjVe'
    #
    # Returns the Faraday::Response.
    [:get, :post, :put, :delete, :patch].each do |method|
      define_method method do |*args|
        retries = @options[:authentication_retries]
        begin
          connection.send(method, *args)
        rescue Restforce::UnauthorizedError
          if retries > 0
            retries -= 1
            connection.url_prefix = @options[:instance_url]
            retry
          end
          raise
        end
      end

      define_method :"api_#{method}" do |*args|
        args[0] = api_path(args[0])
        send(method, *args)
      end
    end

  private

    # Internal: Returns a path to an api endpoint
    #
    # Examples
    #
    #   api_path('sobjects')
    #   # => '/services/data/v24.0/sobjects'
    def api_path(path)
      "/services/data/v#{@options[:api_version]}/#{path}"
    end

    # Internal: Internal faraday connection where all requests go through
    def connection
      @connection ||= Faraday.new(@options[:instance_url]) do |builder|
        builder.use      Restforce::Middleware::Mashify, self, @options
        builder.use      Restforce::Middleware::Multipart
        builder.request  :json
        builder.use      authentication_middleware, self, @options if authentication_middleware
        builder.use      Restforce::Middleware::Authorization, self, @options
        builder.use      Restforce::Middleware::InstanceURL, self, @options
        builder.response :json
        builder.use      Restforce::Middleware::Caching, cache, @options if cache
        builder.use      FaradayMiddleware::FollowRedirects
        builder.use      Restforce::Middleware::RaiseError
        builder.use      Restforce::Middleware::Logger, Restforce.configuration.logger, @options if Restforce.log?
        builder.adapter  Faraday.default_adapter
      end
      @connection
    end

    # Internal: Determines what middleware will be used based on the options provided
    def authentication_middleware
      if username_password?
        Restforce::Middleware::Authentication::Password
      elsif oauth_refresh?
        Restforce::Middleware::Authentication::Token
      end
    end

    # Internal: Returns true if username/password (autonomous) flow should be used for
    # authentication.
    def username_password?
      @options[:username] &&
        @options[:password] &&
        @options[:security_token] &&
        @options[:client_id] &&
        @options[:client_secret]
    end

    # Internal: Returns true if oauth token refresh flow should be used for
    # authentication.
    def oauth_refresh?
      @options[:refresh_token] &&
        @options[:client_id] &&
        @options[:client_secret]
    end

    # Internal: Cache to use for the caching middleware
    def cache
      @options[:cache]
    end

    # Internal: Returns true if the middlware stack includes the
    # Restforce::Middleware::Mashify middleware.
    def mashify?
      connection.builder.handlers.find { |handler| handler == Restforce::Middleware::Mashify }
    end

    # Internal: Errors that should be rescued from in non-bang methods
    def exceptions
      [Faraday::Error::ClientError]
    end

    # Internal: Faye client to use for subscribing to PushTopics
    def faye
      raise 'Instance URL missing. Call .authenticate! first.' unless @options[:instance_url]
      @faye ||= Faye::Client.new("#{@options[:instance_url]}/cometd/#{@options[:api_version]}").tap do |client|
        raise 'OAuth token missing. Call .authenticate! first.' unless @options[:oauth_token]
        client.set_header 'Authorization', "OAuth #{@options[:oauth_token]}"
        client.bind 'transport:down' do
          Restforce.log "[COMETD DOWN]"
        end
        client.bind 'transport:up' do
          Restforce.log "[COMETD UP]"
        end
      end
    end
  end
end