Skip to content
Snippets Groups Projects
Commit 333a85ce authored by Aaron Bauman's avatar Aaron Bauman
Browse files

Merge branch '8.x-3.x' into field-mapping-ui

* 8.x-3.x:
  by aaronbauman - - Introduce RestException and RestResponse for RestClient interactions - Add revisioning for Mapped Object - Re-introduce last_sync_status and last_sync_action
  by aaronbauman - adds revisioning to mapping objects by implementing RevisionableContentEntityBase interface

Conflicts:
	modules/salesforce_mapping/src/Entity/MappedObject.php
	modules/salesforce_mapping/src/Entity/SalesforceMapping.php
parents f322c928 739e19a0
No related branches found
No related tags found
No related merge requests found
......@@ -24,6 +24,8 @@ const SALESFORCE_MAPPING_SYNC_SF_CREATE = 'pull_create';
const SALESFORCE_MAPPING_SYNC_SF_UPDATE = 'pull_update';
const SALESFORCE_MAPPING_SYNC_SF_DELETE = 'pull_delete';
const SALESFORCE_MAPPING_TRIGGER_MAX_LENGTH = 16;
/**
* Field mapping direction constants.
*/
......@@ -46,6 +48,11 @@ const SALESFORCE_MAPPING_ARRAY_DELIMITER =';';
*/
const SALESFORCE_MAPPING_NAME_LENGTH = 128;
const SALESFORCE_MAPPING_STATUS_SUCCESS = 1;
const SALESFORCE_MAPPING_STATUS_ERROR = 0;
/**
* Implements hook_entity_type_alter().
*/
......
......@@ -7,13 +7,14 @@
namespace Drupal\salesforce_mapping\Entity;
use Drupal\Core\Entity\ContentEntityBase;
use Drupal\Core\Entity\RevisionableContentEntityBase;
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\Core\Entity\EntityChangedTrait;
use Drupal\Core\Entity\EntityChangedInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Language\LanguageInterface;
use Drupal\salesforce_mapping\Entity\MappedObjectInterface;
use Drupal\user\UserInterface;
/**
* Defines a Salesforce Mapped Object entity class. Mapped Objects are content
......@@ -35,15 +36,17 @@ use Drupal\salesforce_mapping\Entity\MappedObjectInterface;
* "access" = "Drupal\salesforce_mapping\MappedObjectAccessControlHandler",
* },
* base_table = "salesforce_mapped_object",
* revision_table = "salesforce_mapped_object_revision",
* admin_permission = "administer salesforce mapping",
* entity_keys = {
* "id" = "id",
* "entity_id" = "entity_id",
* "salesforce_id" = "salesforce_id"
* "salesforce_id" = "salesforce_id",
* "revision" = "revision_id"
* }
* )
*/
class MappedObject extends ContentEntityBase implements MappedObjectInterface {
class MappedObject extends RevisionableContentEntityBase implements MappedObjectInterface {
use EntityChangedTrait;
......@@ -76,21 +79,12 @@ class MappedObject extends ContentEntityBase implements MappedObjectInterface {
* {@inheritdoc}
*/
public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
// @TODO Do we really have to define this, and hook_schema, and entity_keys?
// so much redundancy.
$i = 0;
$fields = [];
$fields['id'] = BaseFieldDefinition::create('integer')
->setLabel(t('Salesforce Mapping Object ID'))
->setDescription(t('Primary Key: Unique salesforce_mapped_object entity ID.'))
->setReadOnly(TRUE)
->setSetting('unsigned', TRUE);
// We can't use an entity reference, which requires a single entity type. We need to accommodate a reference to any entity type, as specified by entity_type_id
$fields['entity_id'] = BaseFieldDefinition::create('integer')
->setLabel(t('Entity ID'))
->setDescription(t('Reference to the mapped Drupal entity.'))
->setRequired(TRUE)
->setRevisionable(TRUE)
->setDisplayOptions('form', [
'type' => 'hidden',
])
......@@ -102,7 +96,8 @@ class MappedObject extends ContentEntityBase implements MappedObjectInterface {
$fields['entity_type_id'] = BaseFieldDefinition::create('string')
->setLabel(t('Entity type'))
->setDescription(t('The entity type to which this comment is attached.'))
->setDescription(t('The entity type to which this mapped object is attached.'))
->setRevisionable(TRUE)
->setSetting('is_ascii', TRUE)
->setSetting('max_length', EntityTypeInterface::ID_MAX_LENGTH)
->setDisplayOptions('form', [
......@@ -117,6 +112,7 @@ class MappedObject extends ContentEntityBase implements MappedObjectInterface {
$fields['salesforce_mapping'] = BaseFieldDefinition::create('entity_reference')
->setLabel(t('Salesforce mapping'))
->setDescription(t('Salesforce mapping used to push/pull this mapped object'))
->setRevisionable(TRUE)
->setSetting('target_type', 'salesforce_mapping')
->setSetting('handler', 'default')
->setRequired(TRUE)
......@@ -138,6 +134,7 @@ class MappedObject extends ContentEntityBase implements MappedObjectInterface {
$fields['salesforce_id'] = BaseFieldDefinition::create('string')
->setLabel(t('Salesforce ID'))
->setDescription(t('Reference to the mapped Salesforce object (SObject)'))
->setRevisionable(TRUE)
->setTranslatable(FALSE)
->setSetting('is_ascii', TRUE)
->setSetting('max_length', MappedObjectInterface::SFID_MAX_LENGTH)
......@@ -154,9 +151,9 @@ class MappedObject extends ContentEntityBase implements MappedObjectInterface {
$fields['created'] = BaseFieldDefinition::create('created')
->setLabel(t('Authored on'))
->setDescription(t('The time that the object mapping was created.'))
->setReadOnly(TRUE)
->setRevisionable(TRUE)
->setDisplayOptions('view', [
'label' => 'hidden',
'label' => 'above',
'type' => 'timestamp',
'weight' => $i++,
]);
......@@ -171,10 +168,10 @@ class MappedObject extends ContentEntityBase implements MappedObjectInterface {
'weight' => $i++,
]);
$fields['entity_updated'] = BaseFieldDefinition::create('timestamp')
->setLabel(t('Drupal Entity Updated'))
->setDescription(t('The Unix timestamp when the mapped Drupal entity was last updated.'))
->setRevisionable(TRUE)
->setDefaultValue(0)
->setDisplayOptions('view', [
'label' => 'above',
......@@ -182,15 +179,22 @@ class MappedObject extends ContentEntityBase implements MappedObjectInterface {
'weight' => $i++,
]);
$fields['last_sync'] = BaseFieldDefinition::create('timestamp')
->setLabel(t('Last Sync'))
->setDescription(t('The Unix timestamp when the record was last synced with Salesforce.'))
->setDefaultValue(0)
->setDisplayOptions('view', [
'label' => 'above',
'type' => 'string',
'weight' => $i++,
]);
$fields['last_sync_status'] = BaseFieldDefinition::create('boolean')
->setLabel(t('Status of most recent sync'))
->setDescription(t('Indicates whether most recent sync was successful or not.'))
->setRevisionable(TRUE);
$fields['last_sync_action'] = BaseFieldDefinition::create('string')
->setLabel(t('Action of most recent sync'))
->setDescription(t('Indicates acion which triggered most recent sync for this mapped object'))
->setSetting('is_ascii', TRUE)
->setSetting('max_length', SALESFORCE_MAPPING_TRIGGER_MAX_LENGTH)
->setRevisionable(TRUE);
// @see ContentEntityBase::baseFieldDefinitions
// and RevisionLogEntityTrait::revisionLogBaseFieldDefinitions
$fields += parent::baseFieldDefinitions($entity_type);
return $fields;
}
......@@ -221,7 +225,7 @@ class MappedObject extends ContentEntityBase implements MappedObjectInterface {
}
public function push() {
// @TODO need error handling, logging, and hook invocations within this function, where we can provide full context. At the very least, we need to make sure to include $params in some kind of exception if we're not going to handle it inside this function.
// @TODO need error handling, logging, and hook invocations within this function, where we can provide full context, or short of that clear documentation on how callers should handle errors and exceptions. At the very least, we need to make sure to include $params in some kind of exception if we're not going to handle it inside this function.
// @TODO better way to handle push/pull:
$client = \Drupal::service('salesforce.client');
......@@ -272,14 +276,14 @@ class MappedObject extends ContentEntityBase implements MappedObjectInterface {
// @TODO: where to get entity updated timestamp?
$this->set('entity_updated', $drupal_entity->getChangedTime());
}
// dpm($result);
// @TODO restore last_sync_action, last_sync_status, last_sync_message
// @TODO: catch EntityStorageException ? Others ?
$this
->set('salesforce_id', $result['id'])
->set('last_sync', REQUEST_TIME)
// ->set('last_sync_action', $action)
// ->set('last_sync_status', 'success')
->set('last_sync_action', 'push_' . $action)
->set('last_sync_status', TRUE)
// ->set('last_sync_message', '')
->save();
......@@ -289,13 +293,13 @@ class MappedObject extends ContentEntityBase implements MappedObjectInterface {
public function pushDelete() {
$client = \Drupal::service('salesforce.client');
$mapping = $this->salesforce_mapping->entity;
$client->objectDelete($mapping->getSalesforceObjectType(), $this->sfid());
$result = $client->objectDelete($mapping->getSalesforceObjectType(), $this->sfid());
$this
->set('last_sync', REQUEST_TIME)
// ->set('last_sync_action', 'delete')
// ->set('last_sync_status', 'success')
// ->set('last_sync_message', '')
->set('last_sync_action', 'push_delete')
->set('last_sync_status', TRUE)
->save();
return $result;
}
public function pull(array $sf_object = NULL, EntityInterface $drupal_entity = NULL) {
......
......@@ -8,8 +8,9 @@
namespace Drupal\salesforce_mapping\Entity;
use Drupal\Core\Entity\EntityChangedInterface;
use Drupal\Core\Entity\RevisionLogInterface;
interface MappedObjectInterface extends EntityChangedInterface {
interface MappedObjectInterface extends EntityChangedInterface, RevisionLogInterface {
// Placeholder interface.
// @TODO figure out what to abstract out of MappedObject
......
......@@ -81,7 +81,6 @@ abstract class PullBase extends QueueWorkerBase {
$mapped_object->pull($sf_object, $entity);
// Update mapping object.
$mapped_object->last_sync = REQUEST_TIME;
$mapped_object->entity_update = REQUEST_TIME;
\Drupal::logger('Salesforce Pull')->notice(
'Updated entity %label associated with Salesforce Object ID: %sfid',
......
......@@ -8,6 +8,7 @@
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\salesforce_mapping\Entity;
use GuzzleHttp\Exception\RequestException;
/**
* Implements hook_entity_insert().
......@@ -42,25 +43,18 @@ function salesforce_push_salesforce_push_entity_allowed(EntityInterface $entity,
/**
* Push entities to Salesforce.
*
* @param object $entity
* @param EntityInterface $entity
* The entity object.
* @param int $op
* @param string $op
* The trigger being responded to.
* One of push_create, push_update, push_delete.
* @TODO
* at some point all these hook_entity_* implementations will go away. We'll
* create an event subscriber class to respond to entity events and delegate
* actions to the appropriate Push procedures.
* actions to the appropriate Push procedures. Unfortunately this point seems
* to be a very long ways away. https://www.drupal.org/node/2551893
*/
function salesforce_push_entity_crud(EntityInterface $entity, $op) {
// @TODO decide whether this hook is worth moving to Events framework, and how. Should subscribers throw an exception to prevent entity sync? Return false, like so? Something else entirely?
foreach (\Drupal::moduleHandler()->invokeAll('salesforce_push_entity_allowed', array($entity, $op)) as $value) {
if ($value === FALSE) {
return;
}
}
try {
$mappings = salesforce_mapping_load_by_drupal($entity->getEntityTypeId());
}
......@@ -75,60 +69,83 @@ function salesforce_push_entity_crud(EntityInterface $entity, $op) {
if (!$mapping->checkTriggers([$op])) {
continue;
}
try {
$props = [
'entity_id' => $entity->id(),
'entity_type_id' => $entity->getEntityTypeId(),
'salesforce_mapping' => $mapping->id(),
];
$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);
if ($op == SALESFORCE_MAPPING_SYNC_DRUPAL_DELETE) {
// If this is a delete, destroy the mapped object and we're done.
$mapped_object->pushDelete();
continue;
// @TODO decide whether this hook is worth moving to Events framework, and how. Should subscribers throw an exception to prevent entity sync? Return false, like so? Something else entirely?
foreach (\Drupal::moduleHandler()->invokeAll('salesforce_push_entity_allowed', array($entity, $op, $mapping)) as $value) {
if ($value === FALSE) {
continue 2;
}
}
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.
continue;
}
$mapped_object = new MappedObject([
'entity_id' => [LanguageInterface::LANGCODE_DEFAULT => $entity->id()],
'entity_type_id' => [LanguageInterface::LANGCODE_DEFAULT => $entity->getEntityTypeId()],
'salesforce_mapping' => [LanguageInterface::LANGCODE_DEFAULT => $mapping->id()],
]);
}
// @TODO batch vs. real-time logic goes here.
try {
// Push to SF. This also saves the mapped object.
$result = $mapped_object->push();
// Do something with the result?
$mapped_object = salesforce_push_sync_rest($entity, $mapping, $op);
}
catch (Exception $e) {
// @TODO As long as push is handled in MappedObject, this hook invocation should go there. We're only handling this exception so that we don't block the entity CRUD operation.
continue;
// what do do here?
}
}
}
// $vars = [
// 'entity' => $entity,
// 'mapping' => $mapping,
// 'params' => $params,
// 'exception' => $e,
// 'op' => $op,
// ];
//
// // @TODO This should definitely be an event. See SalesforceEvents::PUSH_FAIL
// \Drupal::moduleHandler()->invokeAll('salesforce_push_failure', array($e->getMessage(), $vars));
/**
* 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([
'entity_id' => [LanguageInterface::LANGCODE_DEFAULT => $entity->id()],
'entity_type_id' => [LanguageInterface::LANGCODE_DEFAULT => $entity->getEntityTypeId()],
'salesforce_mapping' => [LanguageInterface::LANGCODE_DEFAULT => $mapping->id()],
]);
}
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('last_sync_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));
}
}
/**
......
......@@ -71,9 +71,8 @@ class RestClient {
* Method to initiate the call, such as GET or POST. Defaults to GET.
*
* @return mixed
* The requested response.
*
* @throws Exception
* @throws GuzzleHttp\Exception\RequestException
*/
public function apiCall($path, array $params = [], $method = 'GET') {
if (!$this->getAccessToken()) {
......@@ -81,13 +80,12 @@ class RestClient {
}
try {
$this->response = $this->apiHttpRequest($path, $params, $method);
$this->response = new RestResponse($this->apiHttpRequest($path, $params, $method));
}
catch (RequestException $e) {
// A RequestException gets thrown if the response has any error status.
$this->response = $e->getResponse();
// RequestException gets thrown for any response status but 2XX
$this->response = new RestResponse($e->getResponse());
}
if (!is_object($this->response)) {
throw new Exception('Unknown error occurred during API call');
}
......@@ -102,8 +100,8 @@ class RestClient {
$this->response = $this->apiHttpRequest($path, $params, $method);
}
catch (RequestException $e) {
$this->response = $e->getResponse();
throw new Exception($this->response->getReasonPhrase(), $this->response->getStatusCode());
$this->response = new RestResponse($e->getResponse());
throw $e;
}
break;
case 200:
......@@ -118,15 +116,7 @@ class RestClient {
throw new Exception('Unknown error occurred during API call');
}
}
// Parse a json response, if body is not empty. Sometimes an empty body is valid, e.g. for upsert.
$data = '';
$response_body = $this->response->getBody()->getContents();
if (!empty($response_body)) {
$this->response->getBody()->rewind();
$data = $this->handleJsonResponse($this->response);
}
return $data;
return $this->response->data();
}
/**
......@@ -139,8 +129,7 @@ class RestClient {
* @param string $method
* Method to initiate the call, such as GET or POST. Defaults to GET.
*
* @return object
* The requested data.
* @return GuzzleHttp\Psr7\Response
*/
protected function apiHttpRequest($path, array $params, $method) {
if (!$this->getAccessToken()) {
......@@ -174,13 +163,12 @@ class RestClient {
*
* @throws RequestException
*
* @return object
* Salesforce response object.
* @return GuzzleHttp\Psr7\Response
*/
protected function httpRequest($url, $data = NULL, array $headers = [], $method = 'GET') {
// Build the request, including path and headers. Internal use.
$request = $this->httpClient->$method($url, ['headers' => $headers, 'body' => $data]);
return $request;
$response = $this->httpClient->$method($url, ['headers' => $headers, 'body' => $data]);
return $response;
}
/**
......@@ -330,7 +318,7 @@ class RestClient {
throw new Exception($response->getReasonPhrase(), $response->getStatusCode());
}
$data = $this->handleJsonResponse($response);
$data = (new RestResponse($response))->data();
$this
->setAccessToken($data['access_token'])
......@@ -344,39 +332,6 @@ class RestClient {
return $this;
}
/**
* Helper function to eliminate repetitive json parsing.
*
* @param Response $response
* @return array
* @throws Drupal\salesforce\Exception
*/
private function handleJsonResponse($response) {
// Allow any exceptions here to bubble up:
$data = Json::decode($response->getBody()->getContents());
if (empty($data)) {
throw new Exception('Invalid response ' . print_r($response->getBody()->getContents(), 1));
}
if (isset($data['error'])) {
throw new Exception($data['error_description'], $data['error']);
}
if (!empty($data[0]) && count($data) == 1) {
$data = $data[0];
}
if (isset($data['error'])) {
throw new Exception($data['error_description'], $data['error']);
}
if (!empty($data['errorCode'])) {
throw new Exception($data['message'], $this->response->getStatusCode());
}
return $data;
}
/**
* Retrieve and store the Salesforce identity given an ID url.
*
......@@ -395,8 +350,8 @@ class RestClient {
if ($response->getStatusCode() != 200) {
throw new Exception(t('Unable to access identity service.'), $response->getStatusCode());
}
$data = (new RestResponse($response))->data();
$data = $this->handleJsonResponse($response);
$this->setIdentity($data);
return $this;
}
......@@ -477,7 +432,7 @@ class RestClient {
$result = $cache->data;
}
else {
$result = $this->apiCall('sobjects');
$response = $this->apiCall('sobjects');
\Drupal::cache()->set('salesforce:objects', $result, 0, ['salesforce']);
}
......
<?php
/**
* @file
* Contains \Drupal\salesforce\Exception.
*/
namespace Drupal\salesforce;
use Symfony\Component\Serializer\Exception\Exception as SymfonyException;
class RestException extends \RuntimeException implements SymfonyException {
protected $response;
function __construct(RestResponse $response, $message = "", $code = 0, Throwable $previous = NULL) {
parent::__construct($message, $code, $previous);
$this->response = $response;
}
}
<?php
namespace Drupal\salesforce;
use GuzzleHttp\Psr7\Response;
use Drupal\Component\Serialization\Json;
class RestResponse extends Response {
protected $response;
protected $data;
/**
* {@inheritdoc}
* @throws RestException if body cannot be json-decoded
*/
function __construct(Response $response) {
$this->response = $response;
parent::__construct($response->getStatusCode(), $response->getHeaders(), $response->getBody(), $response->getProtocolVersion(), $response->getReasonPhrase());
$this->handleJsonResponse();
}
/**
* Get the orignal response
*
* @return GuzzleHttp\Psr7\Response;
*/
public function response() {
return $this->response;
}
/**
* Get the json-decoded data object from the response body
*
* @return stdObject
*/
public function data() {
return $this->data;
}
/**
* Helper function to eliminate repetitive json parsing.
*
* @return $this
* @throws Drupal\salesforce\RestException
*/
private function handleJsonResponse() {
$this->data = '';
$response_body = $this->getBody()->getContents();
if (empty($response_body)) {
return;
}
// Allow any exceptions here to bubble up:
try {
$data = Json::decode($response_body);
}
catch (UnexpectedValueException $e) {
throw new RestException($this, $e->getMessage(), $e->getCode(), $e);
}
if (empty($data)) {
throw new RestException($this, t('Invalid response'));
}
if (!empty($data['error'])) {
throw new RestException($this, $data['error']);
}
if (!empty($data[0]) && count($data) == 1) {
$data = $data[0];
}
if (!empty($data['error'])) {
throw new RestException($this, $data['error']);
}
if (!empty($data['errorCode'])) {
throw new RestException($this, $data['errorCode']);
}
$this->data = $data;
return $this;
}
}
\ No newline at end of file
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment