Newer
Older
namespace Drupal\salesforce\Rest;
use Drupal\Component\Serialization\Json;

Aaron Bauman
committed
use Drupal\Component\Utility\Unicode;
use Drupal\Component\Utility\UrlHelper;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Routing\UrlGeneratorInterface;

Aaron Bauman
committed
use Drupal\Core\State\StateInterface;
use Drupal\salesforce\SFID;
use Drupal\salesforce\SObject;

Aaron Bauman
committed
use Drupal\salesforce\SelectQuery;
use Drupal\salesforce\SelectQueryResult;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\RequestException;

Aaron Bauman
committed
use GuzzleHttp\Psr7\Response;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Objects, properties, and methods to communicate with the Salesforce REST API.
*/
class RestClient {
public $response;
protected $httpClient;
protected $configFactory;
protected $urlGenerator;
private $config;
private $configEditable;

Aaron Bauman
committed
private $state;
const CACHE_LIFETIME = 300;
/**
* Constructor which initializes the consumer.
* @param \Drupal\Core\Http\Client $http_client
* @param \Guzzle\Http\ClientInterface $http_client

Aaron Bauman
committed
public function __construct(ClientInterface $http_client, ConfigFactoryInterface $config_factory, UrlGeneratorInterface $url_generator, StateInterface $state) {
$this->configFactory = $config_factory;
$this->httpClient = $http_client;
$this->urlGenerator = $url_generator;
$this->config = $this->configFactory->get('salesforce.settings');
$this->configEditable = $this->configFactory->getEditable('salesforce.settings');

Aaron Bauman
committed
$this->state = $state;
return $this;
}
/**
* Determine if this SF instance is fully configured.
*
* @TODO: Consider making a test API call.
*/
public function isAuthorized() {
return $this->getConsumerKey() && $this->getConsumerSecret() && $this->getRefreshToken();
}
/**
* Make a call to the Salesforce REST API.
*
* @param string $path
* Path to resource.
* @param array $params
* Parameters to provide.
* @param string $method
* Method to initiate the call, such as GET or POST. Defaults to GET.
* @param bool $returnObject
* If true, return a Drupal\salesforce\Rest\RestResponse;
* Otherwise, return json-decoded response body only.
* Defaults to FALSE for backwards compatibility.
*
* @return mixed
*
public function apiCall($path, array $params = [], $method = 'GET', $returnObject = FALSE) {
if (!$this->getAccessToken()) {
$this->refreshToken();
}
try {
$this->response = new RestResponse($this->apiHttpRequest($path, $params, $method));
}
catch (RequestException $e) {
// RequestException gets thrown for any response status but 2XX.
$this->response = $e->getResponse();
}
if (!is_object($this->response)) {
throw new Exception('Unknown error occurred during API call');
}
switch ($this->response->getStatusCode()) {
case 401:
// The session ID or OAuth token used has expired or is invalid: refresh
// token. If refreshToken() throws an exception, or if apiHttpRequest()
// throws anything but a RequestException, let it bubble up.
$this->refreshToken();
try {
$this->response = new RestResponse($this->apiHttpRequest($path, $params, $method));
}
catch (RequestException $e) {
$this->response = $e->getResponse();
case 200:
case 201:
case 204:
// All clear.
break;
default:
// We have problem and no specific Salesforce error provided.
if (empty($this->response)) {
throw new Exception('Unknown error occurred during API call');
return $this->response;
}
else {
return $this->response->data;
}
}
/**
* Private helper to issue an SF API request.
*
* @param string $path
* Path to resource.
* @param array $params
* Parameters to provide.
* @param string $method
* Method to initiate the call, such as GET or POST. Defaults to GET.
*

Aaron Bauman
committed
protected function apiHttpRequest($path, array $params, $method) {
if (!$this->getAccessToken()) {
throw new Exception('Missing OAuth Token');
}
$url = $this->getApiEndPoint() . $path;

Aaron Bauman
committed
$headers = [
'Authorization' => 'OAuth ' . $this->getAccessToken(),
'Content-type' => 'application/json',

Aaron Bauman
committed
];
$data = NULL;
if (!empty($params)) {
// @TODO: convert this into Dependency Injection
}
return $this->httpRequest($url, $data, $headers, $method);
}
/**
* Make the HTTP request. Wrapper around drupal_http_request().
*
* @param string $url
* Path to make request from.
* @param array $data
* The request body.
* @param array $headers
* Request headers to send as name => value.
* @param string $method
* Method to initiate the call, such as GET or POST. Defaults to GET.
*
* @throws RequestException
*

Aaron Bauman
committed
protected function httpRequest($url, $data = NULL, array $headers = [], $method = 'GET') {
// Build the request, including path and headers. Internal use.
$response = $this->httpClient->$method($url, ['headers' => $headers, 'body' => $data]);
return $response;
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
}
/**
* Get the API end point for a given type of the API.
*
* @param string $api_type
* E.g., rest, partner, enterprise.
*
* @return string
* Complete URL endpoint for API access.
*/
public function getApiEndPoint($api_type = 'rest') {
$url = &drupal_static(__FUNCTION__ . $api_type);
if (!isset($url)) {
$identity = $this->getIdentity();
if (is_string($identity)) {
$url = $identity;
}
elseif (isset($identity['urls'][$api_type])) {
$url = $identity['urls'][$api_type];
}
$url = str_replace('{version}', $this->config->get('rest_api_version.version'), $url);
}
return $url;
}
public function getConsumerKey() {

Aaron Bauman
committed
return $this->state->get('salesforce.consumer_key');
public function setConsumerKey($value) {

Aaron Bauman
committed
return $this->state->set('salesforce.consumer_key', $value);
public function getConsumerSecret() {

Aaron Bauman
committed
return $this->state->get('salesforce.consumer_secret');
public function setConsumerSecret($value) {

Aaron Bauman
committed
return $this->state->set('salesforce.consumer_secret', $value);
}

Aaron Bauman
committed
public function getLoginUrl() {
$login_url = $this->state->get('salesforce.login_url');
return empty($login_url) ? 'https://login.salesforce.com' : $login_url;

Aaron Bauman
committed

Aaron Bauman
committed
public function setLoginUrl($value) {
return $this->state->set('salesforce.login_url', $value);
}
/**
* Get the SF instance URL. Useful for linking to objects.
*/
public function getInstanceUrl() {

Aaron Bauman
committed
return $this->state->get('salesforce.instance_url');
}
/**
* Set the SF instanc URL.
*
* @param string $url
* URL to set.
*/
protected function setInstanceUrl($url) {

Aaron Bauman
committed
$this->state->set('salesforce.instance_url', $url);
return $this;
}
/**
* Get the access token.
*/
public function getAccessToken() {

Aaron Bauman
committed
$access_token = $this->state->get('salesforce.access_token');
return isset($access_token) && Unicode::strlen($access_token) !== 0 ? $access_token : FALSE;
}
/**
* Set the access token.
*
* @param string $token
* Access token from Salesforce.
*/

Aaron Bauman
committed
public function setAccessToken($token) {
$this->state->set('salesforce.access_token', $token);
return $this;
}
/**
* Get refresh token.
*/
protected function getRefreshToken() {

Aaron Bauman
committed
return $this->state->get('salesforce.refresh_token');
}
/**
* Set refresh token.
*
* @param string $token
* Refresh token from Salesforce.
*/
protected function setRefreshToken($token) {

Aaron Bauman
committed
$this->state->set('salesforce.refresh_token', $token);
return $this;

Aaron Bauman
committed
* Refresh access token based on the refresh token.
* @throws Exception
*/
protected function refreshToken() {
$refresh_token = $this->getRefreshToken();
if (empty($refresh_token)) {
throw new Exception(t('There is no refresh token.'));

Aaron Bauman
committed
$data = UrlHelper::buildQuery([
'grant_type' => 'refresh_token',
'refresh_token' => urldecode($refresh_token),
'client_id' => $this->getConsumerKey(),
'client_secret' => $this->getConsumerSecret(),

Aaron Bauman
committed
]);

Aaron Bauman
committed
$url = $this->getAuthTokenUrl();
$headers = [
// This is an undocumented requirement on Salesforce's end.
'Content-Type' => 'application/x-www-form-urlencoded',

Aaron Bauman
committed
];
$response = $this->httpRequest($url, $data, $headers, 'POST');

Aaron Bauman
committed
$this->handleAuthResponse($response);
return $this;

Aaron Bauman
committed
}
/**
* Helper callback for OAuth handshake, and refreshToken()
*
* @param GuzzleHttp\Psr7\Response $response
* Response object from refreshToken or authToken endpoints.

Aaron Bauman
committed
*
* @see SalesforceController::oauthCallback()
* @see self::refreshToken()
*/
public function handleAuthResponse(Response $response) {
if ($response->getStatusCode() != 200) {
throw new Exception($response->getReasonPhrase(), $response->getStatusCode());
$data = (new RestResponse($response))->data;
$this
->setAccessToken($data['access_token'])
->initializeIdentity($data['id'])
->setInstanceUrl($data['instance_url']);
// Do not overwrite an existing refresh token with an empty value.
if (!empty($data['refresh_token'])) {
$this->setRefreshToken($data['refresh_token']);
}
return $this;

Aaron Bauman
committed
}
/**
* Retrieve and store the Salesforce identity given an ID url.
*
* @param string $id
* Identity URL.
*
* @throws Exception

Aaron Bauman
committed
public function initializeIdentity($id) {
$headers = [
'Authorization' => 'OAuth ' . $this->getAccessToken(),
'Content-type' => 'application/json',

Aaron Bauman
committed
];
$response = $this->httpRequest($id, NULL, $headers);

Aaron Bauman
committed
if ($response->getStatusCode() != 200) {
throw new Exception(t('Unable to access identity service.'), $response->getStatusCode());
$data = (new RestResponse($response))->data;

Aaron Bauman
committed
$this->setIdentity($data);
return $this;

Aaron Bauman
committed
}

Aaron Bauman
committed
protected function setIdentity(array $data) {
$this->state->set('salesforce.identity', $data);
return $this;
}
/**
* Return the Salesforce identity, which is stored in a variable.
*
* @return array
* Returns FALSE is no identity has been stored.
*/
public function getIdentity() {

Aaron Bauman
committed
return $this->state->get('salesforce.identity');

Aaron Bauman
committed
* Helper to build the redirect URL for OAUTH workflow.
*
* @return string
* Redirect URL.
*
* @see Drupal\salesforce\Controller\SalesforceController

Aaron Bauman
committed
public function getAuthCallbackUrl() {
return \Drupal::url('salesforce.oauth_callback', [], [
'absolute' => TRUE,
'https' => TRUE,
]);

Aaron Bauman
committed
* Get Salesforce oauth login endpoint. (OAuth step 1)

Aaron Bauman
committed
* @return string
* REST OAuth Login URL.

Aaron Bauman
committed
public function getAuthEndpointUrl() {
return $this->getLoginUrl() . '/services/oauth2/authorize';

Aaron Bauman
committed
* Get Salesforce oauth token endpoint. (OAuth step 2)
*
* @return string

Aaron Bauman
committed
* REST OAuth Token URL.

Aaron Bauman
committed
public function getAuthTokenUrl() {
return $this->getLoginUrl() . '/services/oauth2/token';
}
/**
* @defgroup salesforce_apicalls Wrapper calls around core apiCall()
*/
/**
* Available objects and their metadata for your organization's data.
*
* @param array $conditions
* Associative array of filters to apply to the returned objects. Filters
* are applied after the list is returned from Salesforce.
* @param bool $reset
* Whether to reset the cache and retrieve a fresh version from Salesforce.
*
* @return array
* Available objects and metadata.
*
* @addtogroup salesforce_apicalls
*/

Aaron Bauman
committed
public function objects(array $conditions = ['updateable' => TRUE], $reset = FALSE) {
$cache = \Drupal::cache()->get('salesforce:objects');
// Force the recreation of the cache when it is older than 5 minutes.
if ($cache && REQUEST_TIME < ($cache->created + self::CACHE_LIFETIME) && !$reset) {
$result = $cache->data;
}
else {
$result = $this->apiCall('sobjects');

Aaron Bauman
committed
\Drupal::cache()->set('salesforce:objects', $result, 0, ['salesforce']);
}
if (!empty($conditions)) {
foreach ($result['sobjects'] as $key => $object) {
foreach ($conditions as $condition => $value) {
if (!$object[$condition] == $value) {
unset($result['sobjects'][$key]);
}
}
}
}
return $result['sobjects'];
}
/**
* Use SOQL to get objects based on query string.
*
* @param SalesforceSelectQuery $query
* The constructed SOQL query.
*
* @return SelectQueryResult
*
* @addtogroup salesforce_apicalls
*/

Aaron Bauman
committed
public function query(SelectQuery $query) {
// $this->moduleHander->alter('salesforce_query', $query);
// Casting $query as a string calls SalesforceSelectQuery::__toString().
return new SelectQueryResult($this->apiCall('query?q=' . (string) $query));
}
/**
* Retreieve all the metadata for an object.
*
* @param string $name
* Object type name, E.g., Contact, Account, etc.
* @param bool $reset
* Whether to reset the cache and retrieve a fresh version from Salesforce.
*
* @return RestResponse_Describe
*
* @addtogroup salesforce_apicalls
*/
public function objectDescribe($name, $reset = FALSE) {
if (empty($name)) {
throw new Exception('No name provided to describe');

Aaron Bauman
committed
$cache = \Drupal::cache()->get('salesforce:object:' . $name);
// Force the recreation of the cache when it is older than 5 minutes.
if ($cache && REQUEST_TIME < ($cache->created + self::CACHE_LIFETIME) && !$reset) {
return $cache->data;
}
else {
$response = new RestResponse_Describe($this->apiCall("sobjects/{$name}/describe", [], 'GET', TRUE));
\Drupal::cache()->set('salesforce:object:' . $name, $response, 0, ['salesforce']);
return $response;
}
}
/**
* Create a new object of the given type.
*
* @param string $name
* Object type name, E.g., Contact, Account, etc.
* @param array $params
* Values of the fields to set for the object.
*
* @return Drupal\salesforce\SFID
*
* @addtogroup salesforce_apicalls
*/

Aaron Bauman
committed
public function objectCreate($name, array $params) {
$response = $this->apiCall("sobjects/{$name}", $params, 'POST', TRUE);
$data = $response->data;
return new SFID($data['id']);
}
/**
* Create new records or update existing records.
*
* The new records or updated records are based on the value of the specified
* field. If the value is not unique, REST API returns a 300 response with

Aaron Bauman
committed
* the list of matching records and throws an Exception.
*
* @param string $name
* Object type name, E.g., Contact, Account.
* @param string $key
* The field to check if this record should be created or updated.
* @param string $value
* The value for this record of the field specified for $key.
* @param array $params
* Values of the fields to set for the object.
*
* @return Drupal\salesforce\SFID or NULL
*
* @addtogroup salesforce_apicalls
*/

Aaron Bauman
committed
public function objectUpsert($name, $key, $value, array $params) {
// If key is set, remove from $params to avoid UPSERT errors.
if (isset($params[$key])) {
unset($params[$key]);
}

Aaron Bauman
committed
$response = $this->apiCall("sobjects/{$name}/{$key}/{$value}", $params, 'PATCH', TRUE);

Aaron Bauman
committed
// On update, upsert method returns an empty body. Retreive object id, so that we can return a consistent response.
if ($this->response->getStatusCode() == 204) {
// We need a way to allow callers to distinguish updates and inserts. To
// that end, cache the original response and reset it after fetching the
// ID.
$this->original_response = $this->response;

Aaron Bauman
committed
$sf_object = $this->objectReadbyExternalId($name, $key, $value);
return $sf_object->id();

Aaron Bauman
committed
}
$data = $response->data;
return new SFID($data['id']);
}
/**
* Update an existing object.
*
* @param string $name
* Object type name, E.g., Contact, Account.
* @param string $id
* Salesforce id of the object.
* @param array $params
* Values of the fields to set for the object.
*
* @return null
* Update() doesn't return any data. Examine HTTP response or Exception.
*
* @addtogroup salesforce_apicalls
*/

Aaron Bauman
committed
public function objectUpdate($name, $id, array $params) {
$this->apiCall("sobjects/{$name}/{$id}", $params, 'PATCH');
}
/**
* Return a full loaded Salesforce object.
*
* @param string $name
* Object type name, E.g., Contact, Account.
* @param string $id
* Salesforce id of the object.
*
* @return SObject
* Object of the requested Salesforce object.
*
* @addtogroup salesforce_apicalls
*/
public function objectRead($name, $id) {
return new SObject($this->apiCall("sobjects/{$name}/{$id}"));

Aaron Bauman
committed
* Return a full loaded Salesforce object from External ID.
*
* @param string $name

Aaron Bauman
committed
* Object type name, E.g., Contact, Account.
* @param string $field
* Salesforce external id field name.
* @param string $value
* Value of external id.
* @return SObject

Aaron Bauman
committed
* Object of the requested Salesforce object.
*
* @addtogroup salesforce_apicalls
*/

Aaron Bauman
committed
public function objectReadbyExternalId($name, $field, $value) {
return new SObject($this->apiCall("sobjects/{$name}/{$field}/{$value}"));
}
/**
* Delete a Salesforce object.
*
* @param string $name
* Object type name, E.g., Contact, Account.
* @param string $id
* Salesforce id of the object.
*
* @addtogroup salesforce_apicalls
*
* @return null
* Delete() doesn't return any data. Examine HTTP response or Exception.
*/
public function objectDelete($name, $id) {

Aaron Bauman
committed
$this->apiCall("sobjects/{$name}/{$id}", [], 'DELETE');
/**
* Retrieves the list of individual objects that have been deleted within the
* given timespan for a specified object type.
*
* @param string $type
* Object type name, E.g., Contact, Account.
* @param string $startDate
* Start date to check for deleted objects (in ISO 8601 format).
* @param string $endDate
* End date to check for deleted objects (in ISO 8601 format).
* @return GetDeletedResult
*/
public function getDeleted($type, $startDate, $endDate) {
return $this->apiCall("sobjects/{$type}/deleted/?start={$startDate}&end={$endDate}");
}
/**
* Return a list of available resources for the configured API version.
*
* @return Drupal\salesforce\Rest\RestResponse_Resources
*
* @addtogroup salesforce_apicalls
*/
public function listResources() {
return new RestResponse_Resources($this->apiCall('', [], 'GET', TRUE));
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
}
/**
* Return a list of SFIDs for the given object, which have been created or
* updated in the given timeframe.
*
* @param string $name
* Object type name, E.g., Contact, Account.
*
* @param int $start
* unix timestamp for older timeframe for updates.
* Defaults to "-29 days" if empty.
*
* @param int $end
* unix timestamp for end of timeframe for updates.
* Defaults to now if empty
*
* @return array
* return array has 2 indexes:
* "ids": a list of SFIDs of those records which have been created or
* updated in the given timeframe.
* "latestDateCovered": ISO 8601 format timestamp (UTC) of the last date
* covered in the request.
*
* @see https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_getupdated.htm
*
* @addtogroup salesforce_apicalls
*/
public function getUpdated($name, $start = null, $end = null) {
if (empty($start)) {
$start = strtotime('-29 days');
$start = urlencode(gmdate(DATE_ATOM, $start));
if (empty($end)) {
$end = time();
}
$end = urlencode(gmdate(DATE_ATOM, $end));
return $this->apiCall("sobjects/{$name}/updated/?start=$start&end=$end");
/**
* Given a DeveloperName and SObject Name, return the SFID of the
* corresponding RecordType. DeveloperName doesn't change between Salesforce
* environments, so it's safer to rely on compared to SFID.
*
* @param string $name
* Object type name, E.g., Contact, Account.
*
* @param string $devname
* RecordType DeveloperName, e.g. Donation, Membership, etc.
*
* @return SFID
* The Salesforce ID of the given Record Type, or null.
*
* @throws Exception if record type not found
*/
public function getRecordTypeIdByDeveloperName($name, $devname, $reset = FALSE) {
$cache = \Drupal::cache()->get('salesforce:record_types');
// Force the recreation of the cache when it is older than 5 minutes.
if ($cache && REQUEST_TIME < ($cache->created + self::CACHE_LIFETIME) && !$reset) {
$record_types = $cache->data;
}
else {
$query = new SalesforceSelectQuery('RecordType');
$query->fields = array('Id', 'Name', 'DeveloperName', 'SobjectType');
$result = $this->query($query);
$record_types = array();
foreach ($result['records'] as $rt) {
$record_types[$rt['SobjectType']][$rt['DeveloperName']] = $rt;
}
\Drupal::cache()->set('salesforce:record_types', $record_types, 0, ['salesforce']);
}
if (empty($record_types[$name][$devname]['Id'])) {
throw new Exception("No record type $devname for $name");
}
return new SFID($record_types[$name][$devname]['Id']);
}
/**
* Utility function to determine object type for given SFID
*
* @param SFID $id
* @return string
* @throws Exception if SFID doesn't match any object type
*/
public static function getObjectTypeName(SFID $id) {
$prefix = substr((string)$id, 0, 3);
$describe = $this->objects();
foreach ($describe as $object) {
if ($prefix == $object['keyPrefix']) {
return $object['name'];
}
}
throw new Exception('No matching object type');
}