From c0448bfae33a638c083c13bb3d3b07124d2fc35d Mon Sep 17 00:00:00 2001
From: Aaron Bauman <aaron@messageagency.com>
Date: Fri, 30 Dec 2016 13:56:18 -0500
Subject: [PATCH] - Refactor salesforce push and push queue to accommodate
 async delete - Fix RestClient behavior for objectDelete: do not throw an
 exception when delete returns 404 - Prevent missing field mapping plugins
 from causing fatal errors - Normalize data for EntityNotFoundException

---
 .../salesforce_mapping.module                 |  15 +-
 .../src/Entity/SalesforceMapping.php          |  16 +-
 .../Form/SalesforceMappingFormCrudBase.php    |   4 +-
 .../salesforce_push/salesforce_push.module    | 156 +++++++++---------
 .../SalesforcePushQueueProcessor/Rest.php     |  63 +++++--
 modules/salesforce_push/src/PushQueue.php     |  57 +++++--
 src/EntityNotFoundException.php               |  20 ++-
 src/Rest/RestClient.php                       |  89 ++++++----
 8 files changed, 271 insertions(+), 149 deletions(-)

diff --git a/modules/salesforce_mapping/salesforce_mapping.module b/modules/salesforce_mapping/salesforce_mapping.module
index fb8d1312..e5702eaa 100644
--- a/modules/salesforce_mapping/salesforce_mapping.module
+++ b/modules/salesforce_mapping/salesforce_mapping.module
@@ -103,7 +103,7 @@ function salesforce_mapping_load($name) {
     ->getStorage('salesforce_mapping')
     ->load($name);
   if (empty($mapping)) {
-   throw new EntityNotFoundException("No mapping found for $name.");
+   throw new EntityNotFoundException($name, 'salesforce_mapping');
   }
   return $mapping;
 }
@@ -127,7 +127,11 @@ function salesforce_mapping_load_multiple($properties = []) {
     ->getStorage('salesforce_mapping')
     ->loadByProperties($properties);
   if (empty($mappings)) {
-   throw new EntityNotFoundException('No mappings found.');
+    $bt = debug_backtrace(FALSE);
+    foreach ($bt as &$e) {
+      unset($e['args']);
+    }
+    throw new EntityNotFoundException($properties, 'salesforce_mapping');
   }
   return $mappings;
 }
@@ -162,7 +166,7 @@ function salesforce_mapped_object_load_multiple($properties = []) {
     ->getStorage('salesforce_mapped_object')
     ->loadByProperties($properties);
   if (empty($mappings)) {
-    throw new EntityNotFoundException('No mapped objects found');
+    throw new EntityNotFoundException($properties, 'salesforce_mapped_object');
   }
   return $mappings;
 }
@@ -351,7 +355,7 @@ function salesforce_mapping_entity_update(EntityInterface $entity) {
   try {
     salesforce_mapping_load_by_drupal($entity->getEntityTypeId());
   }
-  catch (Exception $e) {
+  catch (\Exception $e) {
     return;
   }
 
@@ -359,9 +363,10 @@ function salesforce_mapping_entity_update(EntityInterface $entity) {
   try {
     $mapped_objects = salesforce_mapped_object_load_by_drupal($entity->getEntityTypeId(), $entity->id());
   }
-  catch (Exception $e) {
+  catch (\Exception $e) {
     return;
   }
+
   foreach ($mapped_objects as $mapped_object) {
     // Resaving the object should update the timestamp.
     // NB: we are purposefully not creating a new revision here.
diff --git a/modules/salesforce_mapping/src/Entity/SalesforceMapping.php b/modules/salesforce_mapping/src/Entity/SalesforceMapping.php
index 08f947f3..1464d02c 100644
--- a/modules/salesforce_mapping/src/Entity/SalesforceMapping.php
+++ b/modules/salesforce_mapping/src/Entity/SalesforceMapping.php
@@ -5,6 +5,7 @@ namespace Drupal\salesforce_mapping\Entity;
 use Drupal\Core\Config\Entity\ConfigEntityBase;
 use Drupal\Core\Entity\EntityInterface;
 use Drupal\salesforce\Exception;
+use Drupal\Component\Plugin\Exception\PluginNotFoundException;
 
 /**
  * Defines a Salesforce Mapping configuration entity class.
@@ -273,10 +274,17 @@ class SalesforceMapping extends ConfigEntityBase implements SalesforceMappingInt
     // @TODO #fieldMappingField
     $mappings = [];
     foreach ($this->field_mappings as $field) {
-      $mappings[] = $this->fieldManager->createInstance(
-         $field['drupal_field_type'],
-         $field
-       );
+      try {
+        $mappings[] = $this->fieldManager->createInstance(
+           $field['drupal_field_type'],
+           $field
+         );
+       }
+       catch (PluginNotFoundException $e) {
+         // Don't let a missing plugin kill our mapping.
+         watchdog_exception(__CLASS__, $e);
+         salesforce_set_message(t('Field plugin not found: %message The field will be removed from this mapping.', ['%message' => $e->getMessage()]), 'error');
+       }
     }
     return $mappings;
   }
diff --git a/modules/salesforce_mapping/src/Form/SalesforceMappingFormCrudBase.php b/modules/salesforce_mapping/src/Form/SalesforceMappingFormCrudBase.php
index 2af14f83..b7cdc971 100644
--- a/modules/salesforce_mapping/src/Form/SalesforceMappingFormCrudBase.php
+++ b/modules/salesforce_mapping/src/Form/SalesforceMappingFormCrudBase.php
@@ -263,7 +263,7 @@ abstract class SalesforceMappingFormCrudBase extends SalesforceMappingFormBase {
     if (!empty($entity_type) && empty($form_state->getValue('drupal_bundle')[$entity_type])) {
       $element = &$form['drupal_entity']['drupal_bundle'][$entity_type];
       // @TODO replace with Dependency Injection
-      \Drupal::formBuilder()->setError($element, $this->t('!name field is required.', ['!name' => $element['#title']]));
+      $form_state->setError($element, $this->t('%name field is required.', ['%name' => $element['#title']]));
     }
 
     // In case the form was submitted without javascript, we must validate the
@@ -272,7 +272,7 @@ abstract class SalesforceMappingFormCrudBase extends SalesforceMappingFormBase {
       $record_types = $this->get_salesforce_record_type_options($form_state->getValue('salesforce_object_type'), $form_state);
       if (count($record_types) > 1) {
         $element = &$form['salesforce_object']['salesforce_record_type'];
-        drupal_set_message($this->t('!name field is required for this Salesforce Object type.', ['!name' => $element['#title']]));
+        $form_state->setError($element, $this->t('%name field is required for this Salesforce Object type.', ['%name' => $element['#title']]));
         $form_state->setValue('rebuild', TRUE);
       }
     }
diff --git a/modules/salesforce_push/salesforce_push.module b/modules/salesforce_push/salesforce_push.module
index 69fd575b..0a56af7c 100644
--- a/modules/salesforce_push/salesforce_push.module
+++ b/modules/salesforce_push/salesforce_push.module
@@ -7,6 +7,7 @@
 
 use Drupal\Core\Form\FormStateInterface;
 use Drupal\Core\Entity\EntityInterface;
+use Drupal\salesforce\EntityNotFoundException;
 use Drupal\salesforce_mapping\Entity\MappedObject;
 use Drupal\salesforce_mapping\Entity\MappedObjectInterface;
 use Drupal\salesforce_mapping\Entity\SalesforceMapping;
@@ -58,7 +59,13 @@ function salesforce_push_salesforce_push_entity_allowed(EntityInterface $entity,
  *   to be a very long ways away. https://www.drupal.org/node/2551893
  */
 function salesforce_push_entity_crud(EntityInterface $entity, $op) {
-  $mappings = salesforce_push_load_push_mappings($entity->getEntityTypeId());
+  try {
+    $mappings = salesforce_push_load_push_mappings($entity->getEntityTypeId());
+  }
+  catch (EntityNotFoundException $e) {
+    return;
+  }
+
   foreach ($mappings as $mapping) {
     $mapped_objects = [];
     $mapped_object = FALSE;
@@ -69,17 +76,58 @@ function salesforce_push_entity_crud(EntityInterface $entity, $op) {
       }
     }
 
+    // Look for existing mapped object.
+    $props = [
+      'entity_id' => $entity->id(),
+      'entity_type_id' => $entity->getEntityTypeId(),
+      'salesforce_mapping' => $mapping->id(),
+    ];
+    $mapped_object = FALSE;
+    try {
+      $mapped_objects = salesforce_mapped_object_load_multiple($props);
+      // There should really only be one, but the return value is always an array
+      $mapped_object = current($mapped_objects);
+    }
+    catch (EntityNotFoundException $e) {
+      // No mappings found.
+      if ($op == SALESFORCE_MAPPING_SYNC_DRUPAL_DELETE) {
+        // If no existing mapping, and this is a delete, purge any entries from push queue and we're done.
+        \Drupal::service('queue.salesforce_push')
+          ->setName($mapping->id())
+          ->deleteItemByEntity($entity);
+        return;
+      }
+      $mapped_object = new MappedObject($props);
+    }
+
     if ($mapping->async) {
-      // Enqueue
-      salesforce_push_enqueue_async($entity, $mapping, $op);
+      // Enqueue async push if the mapping is configured to do so, and quit.
+      try {
+        salesforce_push_enqueue_async($entity, $mapping, $mapped_object, $op);
+      }
+      catch (\Exception $e) {
+        // @TODO log: failed to enqueue changes.
+      }
       return;
     }
 
     try {
-      $mapped_object = salesforce_push_sync_rest($entity, $mapping, $op);
+      // If this is a delete, destroy the SF object and we're done.
+      if ($op == SALESFORCE_MAPPING_SYNC_DRUPAL_DELETE) {
+        $mapped_object->pushDelete();
+      }
+      else {
+        // Push to SF. This also saves the mapped object.
+        $mapped_object->push();
+      }
     }
-    catch (Exception $e) {
-      // what do do here?
+    catch (RequestException $e) {
+      $mapped_object
+        ->set('last_sync_action', $op)
+        ->set('last_sync_status', FALSE)
+        ->set('revision_log_message', $e->getMessage())
+        ->save();
+      throw $e;
     }
   }
 }
@@ -92,84 +140,27 @@ function salesforce_push_entity_crud(EntityInterface $entity, $op) {
  *   (optional) filter by mapping drupal entity type.
  *
  * @return array
+ *
+ * @throws EntityNotFoundException if no push mappings found.
  */
 function salesforce_push_load_push_mappings($entity_type_id = NULL) {
   $push_mappings = [];
-  try {
-    $properties = empty($entity_type_id)
-      ? []
-      : ["drupal_entity_type" => $entity_type_id];
-    $mappings = salesforce_mapping_load_multiple($properties);
-    foreach ($mappings as $key => $mapping) {
-      if (!$mapping->doesPush()) {
-        continue;
-      }
-      $push_mappings[$key] = $mapping;
+  $properties = empty($entity_type_id)
+    ? []
+    : ["drupal_entity_type" => $entity_type_id];
+  $mappings = salesforce_mapping_load_multiple($properties);
+  foreach ($mappings as $key => $mapping) {
+    if (!$mapping->doesPush()) {
+      continue;
     }
+    $push_mappings[$key] = $mapping;
   }
-  catch (Exception $e) {
-    // No mappings found.
+  if (empty($push_mappings)) {
+    throw new EntityNotFoundException($properties, 'salesforce_mapping');
   }
   return $push_mappings;
 }
 
-/**
- * Worker function to do actual push to Salesforce.
- *
- * @param EntityInterface $entity
- * @param SalesforceMappingInterface $mapping
- * @param string $op
- *   one of push_create, push_update, push_delete
- * @return SF result
- *
- * @throws Exception if mapping object cannot be loaded or created
- * @throws RequestException if push operations fail
- */
-function salesforce_push_sync_rest(EntityInterface $entity, SalesforceMappingInterface $mapping, $op) {
-
-  // First, look for existing mapped object.
-  $props = [
-    'entity_id' => $entity->id(),
-    'entity_type_id' => $entity->getEntityTypeId(),
-    'salesforce_mapping' => $mapping->id(),
-  ];
-  try {
-    $mapped_objects = salesforce_mapped_object_load_multiple($props);
-    // There should really only be one, but the return value is always an array
-    $mapped_object = current($mapped_objects);
-  }
-  catch (Exception $e) {
-    // No mappings found, need to create a new one and push...
-    if ($op == SALESFORCE_MAPPING_SYNC_DRUPAL_DELETE) {
-      // Unless it's a delete. Then we're done.
-      throw $e;
-    }
-    $mapped_object = new MappedObject($props);
-  }
-
-  try {
-    // If this is a delete, destroy the SF object and we're done.
-    if ($op == SALESFORCE_MAPPING_SYNC_DRUPAL_DELETE) {
-      return $mapped_object->pushDelete();
-    }
-    else {
-      // Push to SF. This also saves the mapped object.
-      return $mapped_object->push();
-    }
-  }
-  catch (RequestException $e) {
-    $mapped_object
-      ->set('last_sync_action', $op)
-      ->set('last_sync_status', FALSE)
-      ->set('revision_log_message', $e->getMessage())
-      ->save();
-    throw $e;
-
-    // @TODO Can we pawn this off to an entity_save hook? implementations can look at last_sync_status to determine success vs failure.
-    // \Drupal::moduleHandler()->invokeAll('salesforce_push_failure', array($e->getMessage(), $vars));
-  }
-}
-
 /**
  * Worker function to insert a new queue item into the async push queue for the
  * given mapping.
@@ -177,16 +168,21 @@ function salesforce_push_sync_rest(EntityInterface $entity, SalesforceMappingInt
  * @param SalesforceMappingInterface $mapping
  * @param string $op
  */
-function salesforce_push_enqueue_async(EntityInterface $entity, SalesforceMappingInterface $mapping, $op) {
+function salesforce_push_enqueue_async(EntityInterface $entity, SalesforceMappingInterface $mapping, MappedObjectInterface $mapped_object = NULL, $op) {
   // Each mapping has its own queue, so that like entries can be easily grouped
   // for batching. Each queue item is a unique array of entity ids to be
   // pushed. The async queue worker loads the queue item and works through as
   // many entities as possible, up to the async limit for this mapping.
-  \Drupal::service('queue.salesforce_push')->createItem([
+  $props = [
     'name' => $mapping->id(),
     'entity_id' => $entity->id(),
     'op' => $op,
-  ]);
+  ];
+  if ($mapped_object) {
+    $props['mapped_object_id'] = $mapped_object->id();
+  }
+
+  \Drupal::service('queue.salesforce_push')->createItem($props);
 }
 
 /**
@@ -194,11 +190,11 @@ function salesforce_push_enqueue_async(EntityInterface $entity, SalesforceMappin
  */
 function salesforce_push_cron() {
   $queue = \Drupal::service('queue.salesforce_push');
+  $queue->garbageCollection();
   try {
     $queue->processQueues();
   }
-  catch (Exception $e) {
+  catch (\Exception $e) {
     watchdog_exception('Salesforce Push', $e);
   }
-  $queue->garbageCollection();
 }
diff --git a/modules/salesforce_push/src/Plugin/SalesforcePushQueueProcessor/Rest.php b/modules/salesforce_push/src/Plugin/SalesforcePushQueueProcessor/Rest.php
index d240d1a8..93b2007c 100644
--- a/modules/salesforce_push/src/Plugin/SalesforcePushQueueProcessor/Rest.php
+++ b/modules/salesforce_push/src/Plugin/SalesforcePushQueueProcessor/Rest.php
@@ -8,6 +8,7 @@ use Drupal\salesforce\EntityNotFoundException;
 use Drupal\salesforce\Rest\RestClient;
 use Drupal\salesforce_push\PushQueue;
 use Drupal\salesforce_push\PushQueueProcessorInterface;
+use Drupal\salesforce_mapping\Entity\MappedObject;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 
 /**
@@ -52,23 +53,57 @@ class Rest extends PluginBase implements PushQueueProcessorInterface {
   }
 
   protected function processItem(\stdClass $item) {
+    $mapped_object = \Drupal::entityTypeManager()
+      ->getStorage('salesforce_mapped_object')
+      ->load($item->mapped_object_id);
+
+    // Allow exceptions to bubble up for PushQueue to sort things out.
     $mapping = salesforce_mapping_load($item->name);
 
-    $entity = \Drupal::entityTypeManager()
-      ->getStorage($mapping->get('drupal_entity_type'))
-      ->load($item->entity_id);
-    if (!$entity) {
-      throw new EntityNotFoundException();
-    }
+    if (!$mapped_object) {
+      if ($item->op == SALESFORCE_MAPPING_SYNC_DRUPAL_DELETE) {
+        // If mapped object doesn't exist or fails to load for this delete, this item can be considered successfully processed.
+        return;
+      }
+      $mapped_object = new MappedObject([
+        'entity_id' => $item->entity_id,
+        'entity_type_id' => $mapping->drupal_entity_type,
+        'salesforce_mapping' => $mapping->id(),
+      ]);
+    } 
+   
+    // @TODO: the following is nearly identical to the end of salesforce_push_entity_crud(). Can we DRY it? Do we care?
+    try {
+      // If this is a delete, destroy the SF object and we're done.
+      if ($item->op == SALESFORCE_MAPPING_SYNC_DRUPAL_DELETE) {
+        $mapped_object->pushDelete();
+      }
+      else {
+        $entity = \Drupal::entityTypeManager()
+          ->getStorage($mapping->drupal_entity_type)
+          ->load($item->entity_id);
+        if (!$entity) {
+          // Bubble this up also
+          throw new EntityNotFoundException($item->entity_id, $mapping->drupal_entity_type);
+        }
 
-    salesforce_push_sync_rest($entity, $mapping, $item->op);
-    \Drupal::logger('Salesforce Push')->notice('Entity %type %id for salesforce mapping %mapping pushed successfully.',
-      [
-        '%type' => $mapping->get('drupal_entity_type'),
-        '%id' => $item->entity_id,
-        '%mapping' => $mapping->id(),
-      ]
-    );
+        // Push to SF. This also saves the mapped object.
+        $mapped_object
+          ->setDrupalEntity($entity)
+          ->push();
+      }
+    }
+    catch (\Exception $e) {
+      if (!$mapped_object->isNew()) {
+        // Only update existing mapped objects.
+        $mapped_object
+          ->set('last_sync_action', $item->op)
+          ->set('last_sync_status', FALSE)
+          ->set('revision_log_message', $e->getMessage())
+          ->save();
+      }
+      throw $e;
+    }
   }
 
 }
diff --git a/modules/salesforce_push/src/PushQueue.php b/modules/salesforce_push/src/PushQueue.php
index 2178045f..f5633823 100644
--- a/modules/salesforce_push/src/PushQueue.php
+++ b/modules/salesforce_push/src/PushQueue.php
@@ -10,6 +10,7 @@ use Drupal\Core\Queue\DatabaseQueue;
 use Drupal\Core\State\State;
 use Drupal\Core\Queue\SuspendQueueException;
 use Drupal\Core\Queue\RequeueException;
+use Drupal\salesforce\EntityNotFoundException;
 
 /**
  * Salesforce push queue.
@@ -88,15 +89,23 @@ class PushQueue extends DatabaseQueue {
     }
     $this->name = $data['name'];
     $time = time();
+    $fields = [
+      'name' => $this->name,
+      'entity_id' => $data['entity_id'],
+      'op' => $data['op'],
+      'updated' => $time,
+      'failures' => empty($data['failures'])
+        ? 0
+        : $data['failures'],
+      'mapped_object_id' => empty($data['mapped_object_id'])
+        ? 0
+        : $data['mapped_object_id'],
+    ];
+
     $query = $this->connection->merge(static::TABLE_NAME)
       ->key(array('name' => $this->name, 'entity_id' => $data['entity_id']))
-      ->fields(array(
-        'name' => $this->name,
-        'entity_id' => $data['entity_id'],
-        'op' => $data['op'],
-        'updated' => $time,
-      ));
-   
+      ->fields($fields);
+
     // Return Merge::STATUS_INSERT or Merge::STATUS_UPDATE
     $ret = $query->execute();
 
@@ -190,6 +199,12 @@ class PushQueue extends DatabaseQueue {
           'default' => 0,
           'description' => 'The entity id',
         ],
+        'mapped_object_id' => [
+          'type' => 'int',
+          'not null' => TRUE,
+          'default' => 0,
+          'description' => 'Foreign key for salesforce_mapped_object table.'
+        ],
         'op' => [
           'type' => 'varchar_ascii',
           'length' => 16,
@@ -238,7 +253,12 @@ class PushQueue extends DatabaseQueue {
    * Process Salesforce queues
    */
   public function processQueues() {
-    $mappings = salesforce_push_load_push_mappings();
+    try {
+      $mappings = salesforce_push_load_push_mappings();
+    }
+    catch (EntityNotFoundException $e) {
+      return $this;
+    }
     $i = 0;
 
     // @TODO push queue processor could be set globally, or per-mapping. Exposing some UI setting would probably be better than this:
@@ -270,7 +290,8 @@ class PushQueue extends DatabaseQueue {
         }
         catch (SuspendQueueException $e) {
           // Getting a SuspendQueue is more likely, e.g. because of a network
-          // or authorization error. Move on to the next mapping in this case.
+          // or authorization error. Release items and move on to the next
+          // mapping in this case.
           $this->releaseItems($items);
           watchdog_exception('Salesforce Push', $e);
 
@@ -307,7 +328,7 @@ class PushQueue extends DatabaseQueue {
     $mapping = salesforce_mapping_load($item->name);
 
     if ($e instanceof EntityNotFoundException) {
-      // If there was an exception loading the entity, we assume that this queue item is no longer relevant.
+      // If there was an exception loading any entities, we assume that this queue item is no longer relevant.
       \Drupal::logger('Salesforce Push')->error($e->getMessage() . 
         ' Exception while loading entity %type %id for salesforce mapping %mapping. Queue item deleted.',
         [
@@ -340,10 +361,10 @@ class PushQueue extends DatabaseQueue {
       ]
     );
 
+    // Failed items will remain in queue, but not be released. They'll be
+    // retried only when the current lease expires.
     // doCreateItem() doubles as "save" function.
-    // failed items will remain in queue in case fail params change or they need to be manually retried.
     $this->doCreateItem(get_object_vars($item));
-    $this->releaseItem($item);
   }
 
   /**
@@ -369,4 +390,16 @@ class PushQueue extends DatabaseQueue {
     }
   }
 
+  public function deleteItemByEntity(EntityInterface $entity) {
+    try {
+      $this->connection->delete(static::TABLE_NAME)
+        ->condition('entity_id', $entity->id())
+        ->condition('name', $this->name)
+        ->execute();
+    }
+    catch (\Exception $e) {
+      $this->catchException($e);
+    }    
+  }
+
 }
diff --git a/src/EntityNotFoundException.php b/src/EntityNotFoundException.php
index aba4d9ee..6383ffc2 100644
--- a/src/EntityNotFoundException.php
+++ b/src/EntityNotFoundException.php
@@ -6,6 +6,24 @@ namespace Drupal\salesforce;
  * EntityNotFoundException extends Drupal\salesforce\Exception
  * Thrown when a load operation returns no results.
  */
-class EntityNotFoundException extends Exception {
+class EntityNotFoundException extends \RuntimeException {
 
+  protected $entity_properties;
+
+  protected $entity_type_id;
+
+  public function __construct($entity_properties, $entity_type_id, Throwable $previous = NULL) {
+    parent::__construct(t('Entity not found. type: %type properties: %props', ['%type' => $entity_type_id, '%props' => var_export($entity_properties, TRUE)]), 0, $previous);
+    $this->entity_properties = $entity_properties;
+    $this->entity_type_id = $entity_type_id;
+  }
+
+  public function getEntityProperties() {
+    return $this->entity_properties;
+  }
+
+  public function getEntityTypeId() {
+    return $this->entity_type_id;
+  }
+  
 }
diff --git a/src/Rest/RestClient.php b/src/Rest/RestClient.php
index 01bf15cd..1ed44865 100644
--- a/src/Rest/RestClient.php
+++ b/src/Rest/RestClient.php
@@ -90,38 +90,33 @@ class RestClient {
     catch (RequestException $e) {
       // RequestException gets thrown for any response status but 2XX.
       $this->response = $e->getResponse();
+
+      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();
+            throw $e;
+          }
+          break;
+
+        default:
+          // Any exceptions besides 401 we bubble up to the caller.
+          throw $e;
+      }
     }
-    if (!is_object($this->response)) {
+
+    if (empty($this->response)
+    || !in_array($this->response->getStatusCode(), [200, 201, 204])) {
       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();
-          throw $e;
-        }
-        break;
-
-      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');
-        }
-    }
     if ($returnObject) {
       return $this->response;
     }
@@ -182,6 +177,27 @@ class RestClient {
     return $response;
   }
 
+  /**
+   * Extract normalized error information from a RequestException
+   *
+   * @param RequestException $e 
+   * @return array
+   *   Error array with keys:
+   *   * message
+   *   * errorCode
+   *   * fields
+   */
+  protected function getErrorData(RequestException $e) {
+    $response = $e->getResponse();
+    $response_body = $response->getBody()->getContents();
+    $data = Json::decode($response_body);
+    if (!empty($data[0])) {
+      $data = $data[0];
+    }
+    return $data;
+  }
+
+
   /**
    * Get the API end point for a given type of the API.
    *
@@ -641,20 +657,31 @@ class RestClient {
   }
 
   /**
-   * Delete a Salesforce object.
+   * Delete a Salesforce object. Note: if Object with given $id doesn't exist,
+   * objectDelete() will assume success unless $throw_exception is given.
    *
    * @param string $name
    *   Object type name, E.g., Contact, Account.
    * @param string $id
    *   Salesforce id of the object.
+   * @pararm bool $throw_exception
+   *   (optional) If TRUE, 404 response code will cause RequestException to be
+   *   thrown. Otherwise, hide those errors. Default is FALSE.
    *
    * @addtogroup salesforce_apicalls
    *
    * @return null
    *   Delete() doesn't return any data. Examine HTTP response or Exception.
    */
-  public function objectDelete($name, $id) {
-    $this->apiCall("sobjects/{$name}/{$id}", [], 'DELETE');
+  public function objectDelete($name, $id, $throw_exception = FALSE) {
+    try {
+      $this->apiCall("sobjects/{$name}/{$id}", [], 'DELETE');
+    }
+    catch (RequestException $e) {
+      if ($throw_exception || $e->getResponse()->getStatusCode() != 404) {
+        throw $e;
+      }
+    }
   }
 
   /**
-- 
GitLab