diff --git a/config/install/salesforce.settings.yml b/config/install/salesforce.settings.yml index 6ed932c9529b8e084c435ece97617ae95ae91712..87347f3b1fa964fb08ab16bb8bb4239299cc44c2 100644 --- a/config/install/salesforce.settings.yml +++ b/config/install/salesforce.settings.yml @@ -6,3 +6,4 @@ use_latest: true global_push_limit: 100000 pull_max_queue_size: 100000 show_all_objects: false +standalone: false diff --git a/config/schema/salesforce.schema.yml b/config/schema/salesforce.schema.yml index 53afd14331427e8a8ab7b617b4f74a81a67a40de..54b319853d115b015fb1440ae8e0d5c5d155a349 100644 --- a/config/schema/salesforce.schema.yml +++ b/config/schema/salesforce.schema.yml @@ -2,6 +2,12 @@ salesforce.settings: type: config_object label: 'Salesforce Settings' mapping: + standalone: + type: boolean + label: 'Provide standalone push queue processing endpoint.' + show_all_objects: + type: boolean + label: 'Show all Salesforce objects in mapping UI, including system and non-writeable tables' use_latest: type: boolean label: 'Use latest REST API Version by default' diff --git a/modules/salesforce_example/salesforce_example-apex_endpoint.php b/modules/salesforce_example/salesforce_example-apex_endpoint.php new file mode 100644 index 0000000000000000000000000000000000000000..1c6eda6abe3fda6129e4b8a639ef7f713cdbe923 --- /dev/null +++ b/modules/salesforce_example/salesforce_example-apex_endpoint.php @@ -0,0 +1,64 @@ +<?php + +// This line for "security" purposes: +exit; + +// Include the exception class: +use Drupal\salesforce\Rest\RestException; + +// Your api path should NOT include a domain, but should include any query +// string params. The leading slash, "/", on your path tells apiCall() that +// this is a custom endpoint: +$path = '/services/apexrest/MyEndpoint?getParam1=getValue1&getParam2=getValue2'; + +// Create your POST body appropriately, if necessary. +// This must be an array, which will be json-encoded before POSTing +$payload = ['postParam1' => 'postValue1', 'postParam2' => 'postValue2', ... ]; + +$returnObject = FALSE; +// Uncomment the following line to get Drupal\salesforce\Rest\RestResponse object instead of json-decoded value: +// $returnObject = TRUE; + +// Instantiate the client so we can reference the response later if necessary: +/** @var Drupal\salesforce\Rest\RestClient **/ +$client = \Drupal::service('salesforce.client'); + +$method = 'POST'; + +try { + // apiCall() method will pre-pend the appropriate instance URL, send request + // with OAuth headers, and will automatically retry ONCE if SF responds with + // status code 401. + // $response_data is json-decoded response body. + // (or RestResponse if $returnObject is TRUE). + /** @var mixed array | Drupal\salesforce\Rest\RestResponse **/ + $response_data = $client->apiCall($path, $payload, $method, $returnObject); +} +catch (RestException $e) { + // RestException will be raised if: + // - SF responds with 300+ status code, or if Response + // - SF response body is not valid JSON + // - SF response body is empty + // - SF response contains an 'error' element + // - SF response contains an 'errorCode' element + + /** @var Psr\Http\Message\ResponseInterface **/ + $response = $e->getResponse(); + + // Convenience wrapper for $response->getBody()->getContents() + /** @var string **/ + $responseBody = $e->getResponseBody(); + + /** @var int **/ + $statusCode = $response->getStatusCode(); + + // insert exception handling here. + // ... +} +catch (\Exception $e) { + // Another exception may be thrown, e.g. for a network error, missing OAuth credentials, invalid params, etc. + // see GuzzleHttp\Client +} + +``` + diff --git a/modules/salesforce_mapping/config/schema/salesforce_mapping.schema.yml b/modules/salesforce_mapping/config/schema/salesforce_mapping.schema.yml index f78a584ff8371f35b576170622f03923d029ecbd..2130f12705a282ed11782f4ee975bfff2bc1b151 100644 --- a/modules/salesforce_mapping/config/schema/salesforce_mapping.schema.yml +++ b/modules/salesforce_mapping/config/schema/salesforce_mapping.schema.yml @@ -28,6 +28,9 @@ salesforce_mapping.salesforce_mapping.*: async: type: boolean label: 'Push async' + push_standalone: + type: boolean + label: 'Standalone push queue processing' pull_trigger_date: type: string label: 'Pull Trigger Date Field' diff --git a/modules/salesforce_mapping/src/Entity/SalesforceMapping.php b/modules/salesforce_mapping/src/Entity/SalesforceMapping.php index 0316b8180b301b7a3529af39aa584ab5f03688f9..71957c550cd949e2cae86bc6b2d0fb0628a8655d 100644 --- a/modules/salesforce_mapping/src/Entity/SalesforceMapping.php +++ b/modules/salesforce_mapping/src/Entity/SalesforceMapping.php @@ -48,6 +48,7 @@ use Drupal\salesforce_mapping\MappingConstants; * "type", * "key", * "async", + * "push_standalone", * "pull_trigger_date", * "pull_where_clause", * "sync_triggers", @@ -130,6 +131,13 @@ class SalesforceMapping extends ConfigEntityBase implements SalesforceMappingInt */ protected $async = FALSE; + /** + * Whether a standalone push endpoint is enabled for this mapping. + * + * @var bool + */ + protected $push_standalone = FALSE; + /** * The Salesforce field to use for determining whether or not to pull. * @@ -398,6 +406,13 @@ class SalesforceMapping extends ConfigEntityBase implements SalesforceMappingInt return $this->pull_trigger_date; } + /** + * {@inheritdoc} + */ + public function doesPushStandalone() { + return $this->push_standalone; + } + /** * {@inheritdoc} */ diff --git a/modules/salesforce_mapping/src/Entity/SalesforceMappingInterface.php b/modules/salesforce_mapping/src/Entity/SalesforceMappingInterface.php index da8b242b920297aba5075576e9ea1316502090f5..65e49c75f1ecbf16833db4f6e92d99477c18ec13 100644 --- a/modules/salesforce_mapping/src/Entity/SalesforceMappingInterface.php +++ b/modules/salesforce_mapping/src/Entity/SalesforceMappingInterface.php @@ -76,6 +76,12 @@ interface SalesforceMappingInterface extends ConfigEntityInterface { */ public function getPullTriggerDate(); + /** + * Return TRUE if this mapping is set to process push queue via a standalone + * endpoint instead of during cron. + */ + public function doesPushStandalone(); + /** * Checks mappings for any push operation positive * diff --git a/modules/salesforce_mapping/src/Form/SalesforceMappingFormCrudBase.php b/modules/salesforce_mapping/src/Form/SalesforceMappingFormCrudBase.php index 720df0fe88b9300f6e8dbeb654db7cd1aee60311..cedae00157a8e45652c948f60f90d8e221dbc200 100644 --- a/modules/salesforce_mapping/src/Form/SalesforceMappingFormCrudBase.php +++ b/modules/salesforce_mapping/src/Form/SalesforceMappingFormCrudBase.php @@ -153,6 +153,9 @@ abstract class SalesforceMappingFormCrudBase extends SalesforceMappingFormBase { salesforce_push and salesforce_pull modules.' ), ]; + if (empty($trigger_options)) { + $form['sync_triggers']['#description'] += ' ' . t('<em>No trigger options are available when Salesforce Push and Pull modules are disabled. Enable one or both modules to allow Push or Pull processing.'); + } foreach ($trigger_options as $option => $label) { $form['sync_triggers'][$option] = [ @@ -162,30 +165,39 @@ abstract class SalesforceMappingFormCrudBase extends SalesforceMappingFormBase { ]; } - // @TODO should push and pull settings get moved into push and pull modules? - $form['pull'] = [ - '#title' => t('Pull Settings'), - '#type' => 'details', - '#description' => t(''), - '#open' => TRUE, - '#tree' => FALSE, - '#states' => [ - 'visible' => [ - ':input[name^="sync_triggers[pull"]' => array('checked' => TRUE), + if ($this->moduleHandler->moduleExists('salesforce_pull')) { + // @TODO should push and pull settings get moved into push and pull modules? + $form['pull'] = [ + '#title' => t('Pull Settings'), + '#type' => 'details', + '#description' => t(''), + '#open' => TRUE, + '#tree' => FALSE, + '#states' => [ + 'visible' => [ + ':input[name^="sync_triggers[pull"]' => array('checked' => TRUE), + ] ] - ] - ]; + ]; - if (!$mapping->isNew()) { - // This doesn't work until after mapping gets saved. - // @TODO figure out best way to alert admins about this, or AJAX-ify it. - $form['pull']['pull_trigger_date'] = [ - '#type' => 'select', - '#title' => t('Date field to trigger pull'), - '#description' => t('Poll Salesforce for updated records based on the given date field. Defaults to "Last Modified Date".'), - '#required' => $mapping->salesforce_object_type, - '#default_value' => $mapping->pull_trigger_date, - '#options' => $this->get_pull_trigger_options($salesforce_object_type), + if (!$mapping->isNew()) { + // This doesn't work until after mapping gets saved. + // @TODO figure out best way to alert admins about this, or AJAX-ify it. + $form['pull']['pull_trigger_date'] = [ + '#type' => 'select', + '#title' => t('Date field to trigger pull'), + '#description' => t('Poll Salesforce for updated records based on the given date field. Defaults to "Last Modified Date".'), + '#required' => $mapping->salesforce_object_type, + '#default_value' => $mapping->pull_trigger_date, + '#options' => $this->get_pull_trigger_options($salesforce_object_type), + ]; + } + + $form['pull']['pull_where_clause'] = [ + '#title' => t('Pull query SOQL "Where" clause'), + '#type' => 'textarea', + '#description' => t('Add a "where" SOQL condition clause to limit records pulled from Salesforce. e.g. Email != \'\' AND RecordType.DevelopName = \'ExampleRecordType\''), + '#default_value' => $mapping->pull_where_clause, ]; } @@ -203,57 +215,92 @@ abstract class SalesforceMappingFormCrudBase extends SalesforceMappingFormBase { '#description' => t('Enter a frequency, in seconds, for how often this mapping should be used to pull data to Drupal. Enter 0 to pull as often as possible. FYI: 1 hour = 3600; 1 day = 86400. <em>NOTE: pull frequency is shared per-Salesforce Object. The setting is exposed here for convenience.</em>'), ]; - $form['push'] = [ - '#title' => t('Push Settings'), - '#type' => 'details', - '#description' => t('The asynchronous push queue is always enabled in Drupal 8: real-time push fails are queued for async push. Alternatively, you can choose to disable real-time push and use async-only.'), - '#open' => TRUE, - '#tree' => FALSE, - '#states' => [ - 'visible' => [ - ':input[name^="sync_triggers[push"]' => array('checked' => TRUE), + if ($this->moduleHandler->moduleExists('salesforce_push')) { + $form['push'] = [ + '#title' => t('Push Settings'), + '#type' => 'details', + '#description' => t('The asynchronous push queue is always enabled in Drupal 8: real-time push fails are queued for async push. Alternatively, you can choose to disable real-time push and use async-only.'), + '#open' => TRUE, + '#tree' => FALSE, + '#states' => [ + 'visible' => [ + ':input[name^="sync_triggers[push"]' => array('checked' => TRUE), + ] ] - ] - ]; + ]; - $form['push']['async'] = [ - '#title' => t('Disable real-time push'), - '#type' => 'checkbox', - '#description' => t('When real-time push is disabled, enqueue changes and push to Salesforce asynchronously during cron. When disabled, push changes immediately upon entity CRUD, and only enqueue failures for async push.'), - '#default_value' => $mapping->async, - ]; + $form['push']['async'] = [ + '#title' => t('Disable real-time push'), + '#type' => 'checkbox', + '#description' => t('When real-time push is disabled, enqueue changes and push to Salesforce asynchronously during cron. When disabled, push changes immediately upon entity CRUD, and only enqueue failures for async push.'), + '#default_value' => $mapping->async, + ]; - $form['push']['push_frequency'] = [ - '#title' => t('Push Frequency'), - '#type' => 'number', - '#default_value' => $mapping->push_frequency, - '#description' => t('Enter a frequency, in seconds, for how often this mapping should be used to push data to Salesforce. Enter 0 to push as often as possible. FYI: 1 hour = 3600; 1 day = 86400.'), - '#min' => 0, - ]; + $form['push']['push_frequency'] = [ + '#title' => t('Push Frequency'), + '#type' => 'number', + '#default_value' => $mapping->push_frequency, + '#description' => t('Enter a frequency, in seconds, for how often this mapping should be used to push data to Salesforce. Enter 0 to push as often as possible. FYI: 1 hour = 3600; 1 day = 86400.'), + '#min' => 0, + ]; - $form['push']['push_limit'] = [ - '#title' => t('Push Limit'), - '#type' => 'number', - '#default_value' => $mapping->push_limit, - '#description' => t('Enter the maximum number of records to be pushed to Salesforce during a single queue batch. Enter 0 to process as many records as possible, subject to the global push queue limit.'), - '#min' => 0, - ]; + $form['push']['push_limit'] = [ + '#title' => t('Push Limit'), + '#type' => 'number', + '#default_value' => $mapping->push_limit, + '#description' => t('Enter the maximum number of records to be pushed to Salesforce during a single queue batch. Enter 0 to process as many records as possible, subject to the global push queue limit.'), + '#min' => 0, + ]; - $form['push']['push_retries'] = [ - '#title' => t('Push Retries'), - '#type' => 'number', - '#default_value' => $mapping->push_retries, - '#description' => t('Enter the maximum number of attempts to push a record to Salesforce before it\'s considered failed. Enter 0 for no limit.'), - '#min' => 0, - ]; + $form['push']['push_retries'] = [ + '#title' => t('Push Retries'), + '#type' => 'number', + '#default_value' => $mapping->push_retries, + '#description' => t('Enter the maximum number of attempts to push a record to Salesforce before it\'s considered failed. Enter 0 for no limit.'), + '#min' => 0, + ]; - $form['push']['weight'] = [ - '#title' => t('Weight'), - '#type' => 'select', - '#options' => array_combine(range(-50,50), range(-50,50)), - '#description' => t('Not yet in use. During cron, mapping weight determines in which order items will be pushed. Lesser weight items will be pushed before greater weight items.'), - '#default_value' => $mapping->weight, - ]; + $form['push']['weight'] = [ + '#title' => t('Weight'), + '#type' => 'select', + '#options' => array_combine(range(-50,50), range(-50,50)), + '#description' => t('Not yet in use. During cron, mapping weight determines in which order items will be pushed. Lesser weight items will be pushed before greater weight items.'), + '#default_value' => $mapping->weight, + ]; + + $standalone_url = Url::fromRoute( + 'salesforce_push.endpoint.salesforce_mapping', + [ + 'salesforce_mapping' => $mapping->id(), + 'key' => \Drupal::state()->get('system.cron_key') + ], + ['absolute' => TRUE]) + ->toString(); + + $form['push']['push_standalone'] = [ + '#title' => t('Enable standalone push queue processing'), + '#type' => 'checkbox', + '#description' => t('Check this box to disable cron push processing for this mapping, and allow standalone processing via this URL: <a href="@url">@url</a>', ['@url' => $standalone_url]), + '#default_value' => $mapping->push_standalone, + ]; + + // If global standalone is enabled, then we force this mapping's + // standalone property to true. + if ($this->config('salesforce.settings')->get('standalone')) { + $settings_url = Url::fromRoute('salesforce.global_settings'); + $form['push']['push_standalone']['#default_value'] = TRUE; + $form['push']['push_standalone']['#disabled'] = TRUE; + $form['push']['push_standalone']['#description'] .= ' ' . t('See also <a href="@url">global standalone processing settings</a>.', ['@url' => $settings_url]); + } + + $form['push']['weight'] = [ + '#title' => t('Weight'), + '#type' => 'select', + '#options' => array_combine(range(-50,50), range(-50,50)), + '#description' => t('Not yet in use. During cron, mapping weight determines in which order items will be pushed. Lesser weight items will be pushed before greater weight items.'), + '#default_value' => $mapping->weight, + ]; + } $form['meta'] = [ '#type' => 'details', @@ -398,14 +445,22 @@ abstract class SalesforceMappingFormCrudBase extends SalesforceMappingFormBase { * label as the value. */ protected function get_sync_trigger_options() { - return [ - MappingConstants::SALESFORCE_MAPPING_SYNC_DRUPAL_CREATE => t('Drupal entity create (push)'), - MappingConstants::SALESFORCE_MAPPING_SYNC_DRUPAL_UPDATE => t('Drupal entity update (push)'), - MappingConstants::SALESFORCE_MAPPING_SYNC_DRUPAL_DELETE => t('Drupal entity delete (push)'), - MappingConstants::SALESFORCE_MAPPING_SYNC_SF_CREATE => t('Salesforce object create (pull)'), - MappingConstants::SALESFORCE_MAPPING_SYNC_SF_UPDATE => t('Salesforce object update (pull)'), - MappingConstants::SALESFORCE_MAPPING_SYNC_SF_DELETE => t('Salesforce object delete (pull)'), - ]; + $options = []; + if ($this->moduleHandler->moduleExists('salesforce_push')) { + $options += [ + MappingConstants::SALESFORCE_MAPPING_SYNC_DRUPAL_CREATE => t('Drupal entity create (push)'), + MappingConstants::SALESFORCE_MAPPING_SYNC_DRUPAL_UPDATE => t('Drupal entity update (push)'), + MappingConstants::SALESFORCE_MAPPING_SYNC_DRUPAL_DELETE => t('Drupal entity delete (push)'), + ]; + } + if ($this->moduleHandler->moduleExists('salesforce_pull')) { + $options += [ + MappingConstants::SALESFORCE_MAPPING_SYNC_SF_CREATE => t('Salesforce object create (pull)'), + MappingConstants::SALESFORCE_MAPPING_SYNC_SF_UPDATE => t('Salesforce object update (pull)'), + MappingConstants::SALESFORCE_MAPPING_SYNC_SF_DELETE => t('Salesforce object delete (pull)'), + ]; + } + return $options; } /** diff --git a/modules/salesforce_mapping/src/SalesforceMappingStorage.php b/modules/salesforce_mapping/src/SalesforceMappingStorage.php index 5f90ad4c2f68d89f3c3b5a8e9128b715071fb70b..cc9570017d232f7bdc45b0a5ca707d1823d59f75 100644 --- a/modules/salesforce_mapping/src/SalesforceMappingStorage.php +++ b/modules/salesforce_mapping/src/SalesforceMappingStorage.php @@ -82,12 +82,26 @@ class SalesforceMappingStorage extends ConfigEntityStorage { * @return array */ public function loadPushMappings($entity_type_id = NULL) { - $push_mappings = []; $properties = empty($entity_type_id) ? [] : ["drupal_entity_type" => $entity_type_id]; - $mappings = $this->loadByProperties($properties); + return $this->loadPushMappingsByProperties($properties); + } + + /** + * Return an array of SalesforceMapping entities who are push-enabled. + * + * @param string $entity_type_id + * + * @return array + */ + public function loadCronPushMappings() { + $properties["push_standalone"] = FALSE; + return $this->loadPushMappingsByProperties($properties); + } + protected function loadPushMappingsByProperties($properties) { + $mappings = $this->loadByProperties($properties); foreach ($mappings as $key => $mapping) { if (!$mapping->doesPush()) { continue; @@ -97,7 +111,7 @@ class SalesforceMappingStorage extends ConfigEntityStorage { if (empty($push_mappings)) { return []; } - return $push_mappings; + return $push_mappings; } /** diff --git a/modules/salesforce_pull/src/QueueHandler.php b/modules/salesforce_pull/src/QueueHandler.php index cad96dc43cb8fa1cc741be56ce5aca9a7e949285..37e61e5ac88c9d05cbcab78c05e9facda238567b 100644 --- a/modules/salesforce_pull/src/QueueHandler.php +++ b/modules/salesforce_pull/src/QueueHandler.php @@ -99,10 +99,14 @@ class QueueHandler { // Iterate over each field mapping to determine our query parameters. foreach ($this->mappings as $mapping) { + if (!$mapping->doesPull()) { + continue; + } if ($mapping->getNextPullTime() > $this->time->getRequestTime()) { // Skip this mapping, based on pull frequency. continue; } + $results = $this->doSfoQuery($mapping); if ($results) { $this->enqueueAllResults($mapping, $results); @@ -125,7 +129,6 @@ class QueueHandler { */ protected function doSfoQuery(SalesforceMappingInterface $mapping) { // @TODO figure out the new way to build the query. - // Execute query. try { $soql = $mapping->getPullQuery(); @@ -151,7 +154,16 @@ class QueueHandler { */ public function enqueueAllResults(SalesforceMappingInterface $mapping, SelectQueryResult $results) { while (!$this->enqueueResultSet($mapping, $results)) { - $results = $this->sfapi->queryMore($results); + try { + $results = $this->sfapi->queryMore($results); + } + catch (\Exception $e) { + $message = '%type: @message in %function (line %line of %file).'; + $args = Error::decodeException($e); + $this->eventDispatcher->dispatch(SalesforceEvents::ERROR, new SalesforceErrorEvent($e, $message, $args)); + // @TODO do we really want to eat this exception here? + return; + } } } diff --git a/modules/salesforce_push/salesforce_push.module b/modules/salesforce_push/salesforce_push.module index 7d66b394fa9b6e2e8a1f84904474c6e76a16b715..db4a8193920cd3842b420bec5c20be4deeed44f5 100644 --- a/modules/salesforce_push/salesforce_push.module +++ b/modules/salesforce_push/salesforce_push.module @@ -210,8 +210,19 @@ function salesforce_push_enqueue_async(EntityInterface $entity, SalesforceMappin function salesforce_push_cron() { $queue = \Drupal::service('queue.salesforce_push'); $queue->garbageCollection(); + if (\Drupal::config('salesforce.settings')->get('standalone')) { + // If global standalone processing is enabled, stop here. + return; + } try { - $queue->processQueues(); + // Process mappings only for those which are not marked standalone. + $mappings = \Drupal::service('entity.manager') + ->getStorage('salesforce_mapping') + ->loadCronPushMappings(); + if (empty($mappings)) { + return; + } + $queue->processQueues($mappings); } catch (\Exception $e) { \Drupal::service('event_dispatcher')->dispatch(SalesforceEvents::ERROR, new SalesforceErrorEvent($e)); diff --git a/modules/salesforce_push/salesforce_push.routing.yml b/modules/salesforce_push/salesforce_push.routing.yml new file mode 100644 index 0000000000000000000000000000000000000000..7b43dc6c186399da0ad0b12e8a81a0607dce889a --- /dev/null +++ b/modules/salesforce_push/salesforce_push.routing.yml @@ -0,0 +1,17 @@ +salesforce_push.endpoint: + path: '/salesforce_push/endpoint/{key}' + defaults: + _controller: '\Drupal\salesforce_push\PushController::endpoint' + options: + no_cache: TRUE + requirements: + _access_system_cron: 'TRUE' + +salesforce_push.endpoint.salesforce_mapping: + path: '/salesforce_push/{salesforce_mapping}/endpoint/{key}' + defaults: + _controller: '\Drupal\salesforce_push\PushController::mappingEndpoint' + options: + no_cache: TRUE + requirements: + _access_system_cron: 'TRUE' diff --git a/modules/salesforce_push/src/PushController.php b/modules/salesforce_push/src/PushController.php new file mode 100644 index 0000000000000000000000000000000000000000..950e249787cf86b041a4eb939f507270e732c425 --- /dev/null +++ b/modules/salesforce_push/src/PushController.php @@ -0,0 +1,58 @@ +<?php + +namespace Drupal\salesforce_push; + +use Drupal\Core\Controller\ControllerBase; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\HttpFoundation\Response; +use Drupal\Core\Entity\EntityManagerInterface; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; + +class PushController extends ControllerBase { + + protected $pushQueue; + protected $mappingStorage; + + public function __construct(PushQueue $pushQueue, EntityManagerInterface $entity_manager) { + $this->pushQueue = $pushQueue; + $this->mappingStorage = $entity_manager->getStorage('salesforce_mapping'); + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('queue.salesforce_push'), + $container->get('entity.manager') + ); + } + + /** + * Page callback to process the entire push queue. + */ + public function endpoint() { + // "Access Denied" if standalone global config not enabled. + if (!$this->config('salesforce.settings')->get('standalone')) { + throw new AccessDeniedHttpException(); + } + $this->pushQueue->processQueues(); + return new Response('', 204); + } + + /** + * Page callback to process push queue for a given mapping. + */ + public function mappingEndpoint($salesforce_mapping) { + $mapping = $this->mappingStorage->load($salesforce_mapping); + // If standalone for this mapping is disabled, and global standalone is + // disabled, then "Access Denied" for this mapping. + if (!$mapping->doesPushStandalone() + && !\Drupal::config('salesforce.settings')->get('standalone')) { + throw new AccessDeniedHttpException(); + } + $this->pushQueue->processQueue($mapping); + return new Response('', 204); + } + +} \ No newline at end of file diff --git a/modules/salesforce_push/src/PushQueue.php b/modules/salesforce_push/src/PushQueue.php index 0b7edb2d58c861c5808209e088a5f6208dd0e797..191d769a2e177c2a1226df991352742060bfb348 100644 --- a/modules/salesforce_push/src/PushQueue.php +++ b/modules/salesforce_push/src/PushQueue.php @@ -17,6 +17,7 @@ use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Drupal\salesforce\Event\SalesforceEvents; use Drupal\Component\Datetime\TimeInterface; use Symfony\Component\DependencyInjection\ContainerInterface; +use Drupal\salesforce_mapping\Entity\SalesforceMappingInterface; /** * Salesforce push queue. @@ -295,79 +296,97 @@ class PushQueue extends DatabaseQueue { /** * Process Salesforce queues. */ - public function processQueues() { - $mappings = $this - ->mapping_storage - ->loadPushMappings(); + public function processQueues($mappings = []) { + if (empty($mappings)) { + $mappings = $this + ->mapping_storage + ->loadPushMappings(); + } if (empty($mappings)) { return $this; } + $i = 0; + foreach ($mappings as $mapping) { + $i += $this->processQueue($mapping); + if ($i >= $this->global_limit) { + break; + } + } + return $this; + } - // @TODO push queue processor could be set globally, or per-mapping. Exposing some UI setting would probably be better than this: - $plugin_name = $this->state->get('salesforce.push_queue_processor', static::DEFAULT_QUEUE_PROCESSOR); + /** + * Given a salesforce mapping, process all its push queue entries. + * + * @param SalesforceMapping $mapping + * + * @return int + * The number of items procesed, or -1 if there was any error, And also + * dispatches a SalesforceEvents::ERROR event. + */ + public function processQueue(SalesforceMappingInterface $mapping) { + static $queue_processor = FALSE; + // Check mapping frequency before proceeding. + if ($mapping->getNextPushTime() > $this->time->getRequestTime()) { + return; + } - $queue_processor = $this->queueManager->createInstance($plugin_name); + if (!$queue_processor) { + // @TODO push queue processor could be set globally, or per-mapping. Exposing some UI setting would probably be better than this: + $plugin_name = $this->state->get('salesforce.push_queue_processor', static::DEFAULT_QUEUE_PROCESSOR); + $queue_processor = $this->queueManager->createInstance($plugin_name); + } - foreach ($mappings as $mapping) { - $j = 0; - // Check mapping frequency before proceeding. - if ($mapping->getNextPushTime() > $this->time->getRequestTime()) { - continue; - } + $i = 0; + // Set the queue name, which is the mapping id. + $this->setName($mapping->id()); - // Set the queue name, which is the mapping id. - $this->setName($mapping->id()); - - // Iterate through items in this queue (mapping) until we run out or hit - // the mapping limit, then move to the next queue. If we hit the global - // limit, return immediately. - while (TRUE) { - // Claim as many items as we can from this queue and advance our counter. If this queue is empty, move to the next mapping. - $items = $this->claimItems($mapping->push_limit, $mapping->push_retries); - if (empty($items)) { - $mapping->setLastPushTime($this->time->getRequestTime()); - continue 2; - } + // Iterate through items in this queue (mapping) until we run out or hit + // the mapping limit, then move to the next queue. If we hit the global + // limit, return immediately. + while (TRUE) { + // Claim as many items as we can from this queue and advance our counter. If this queue is empty, move to the next mapping. + $items = $this->claimItems($mapping->push_limit, $mapping->push_retries); + if (empty($items)) { + $mapping->setLastPushTime($this->time->getRequestTime()); + return $i; + } - // Hand them to the queue processor. - try { - $queue_processor->process($items); - } - catch (RequeueException $e) { - // Getting a Requeue here is weird for a group of items, but we'll - // deal with it. - $this->releaseItems($items); - $this->eventDispatcher->dispatch(SalesforceEvents::ERROR, new SalesforceErrorEvent($e)); - } - catch (SuspendQueueException $e) { - // Getting a SuspendQueue is more likely, e.g. because of a network - // or authorization error. Release items and move on to the next - // mapping in this case. - $this->releaseItems($items); - $this->eventDispatcher->dispatch(SalesforceEvents::ERROR, new SalesforceErrorEvent($e)); - continue 2; - } - catch (\Exception $e) { - // In case of any other kind of exception, log it and leave the item - // in the queue to be processed again later. - // @TODO: this is how Cron.php queue works, but I don't really understand why it doesn't get re-queued. - $this->eventDispatcher->dispatch(SalesforceEvents::ERROR, new SalesforceErrorEvent($e)); - } - finally { - // If we've reached our limit, we're done. Otherwise, continue to next items. - $i += count($items); - $j += count($items); - if ($i >= $this->global_limit) { - return $this; - } - } - if ($mapping_limit && $j > $mapping_limit) { - continue 2; + // Hand them to the queue processor. + try { + $queue_processor->process($items); + } + catch (RequeueException $e) { + // Getting a Requeue here is weird for a group of items, but we'll + // deal with it. + $this->releaseItems($items); + $this->eventDispatcher->dispatch(SalesforceEvents::ERROR, new SalesforceErrorEvent($e)); + continue; + } + catch (SuspendQueueException $e) { + // Getting a SuspendQueue is more likely, e.g. because of a network + // or authorization error. Release items and move on to the next + // mapping in this case. + $this->releaseItems($items); + $this->eventDispatcher->dispatch(SalesforceEvents::ERROR, new SalesforceErrorEvent($e)); + return $i; + } + catch (\Exception $e) { + // In case of any other kind of exception, log it and leave the item + // in the queue to be processed again later. + // @TODO: this is how Cron.php queue works, but I don't really understand why it doesn't get re-queued. + $this->eventDispatcher->dispatch(SalesforceEvents::ERROR, new SalesforceErrorEvent($e)); + } + finally { + // If we've reached our limit, we're done. Otherwise, continue to next items. + $i += count($items); + if ($i >= $this->global_limit) { + return $i; } } } - return $this; + return $i; } /** diff --git a/salesforce.drush.inc b/salesforce.drush.inc index 2e47a42d8ede2637e8bc86a07a7bf76592aa8c99..fc606bcf0409743e9044e54608130f9185df1714 100644 --- a/salesforce.drush.inc +++ b/salesforce.drush.inc @@ -145,6 +145,20 @@ raw: Display the complete, raw describe response." ], ]; + $items['sf-push-queue'] = [ + 'description' => 'Process push queues (as though during cron) for one or all Salesforce Mappings.', + 'aliases' => ['sfpushq', 'sfpm'], + 'arguments' => [ + 'name' => [ + 'description' => 'Machine name of the Salesforce Mapping for which to process push queue. If omitted, process all queues.', + ], + ], + 'examples' => [ + 'drush sfpushq' => 'Process all push queue items', + 'drush sfpushq foo' => 'Process push queue items for mapping "foo"', + ], + ]; + return $items; } @@ -624,3 +638,18 @@ function _salesforce_drush_get_pull_query(SalesforceMappingInterface $mapping) { $soql->conditions = $new_conditions; return $soql; } + +function drush_salesforce_sf_push_queue($name = NULL) { + $queue = \Drupal::service('queue.salesforce_push'); + if ($name !== NULL) { + if (!($mapping = _salesforce_drush_get_mapping($name))) { + return; + } + // Process one mapping queue + $queue->processQueue($mapping); + } + else { + // Process all queues + $queue->processQueues(); + } +} \ No newline at end of file diff --git a/src/Form/SettingsForm.php b/src/Form/SettingsForm.php index 9e8ca868b3480390d25c0112336348f573b9f38f..f66c53a3eada3d2f155c00f85f70e171715f80be 100644 --- a/src/Form/SettingsForm.php +++ b/src/Form/SettingsForm.php @@ -13,6 +13,7 @@ use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Drupal\salesforce\Event\SalesforceEvents; use Drupal\salesforce\Event\SalesforceErrorEvent; +use Drupal\Core\Url; /** * Creates authorization form for Salesforce. @@ -136,6 +137,28 @@ class SettingsForm extends ConfigFormBase { '#default_value' => $config->get('show_all_objects'), ]; + $form['standalone'] = [ + '#title' => $this->t('Standalone Push Processing'), + '#description' => $this->t('Enable standalone push processing, and do not process push mappings during cron. Note: when enabled, you must set up your own service to query this endpoint.'), + '#type' => 'checkbox', + '#default_value' => $config->get('standalone'), + ]; + + $standalone_url = Url::fromRoute( + 'salesforce_push.endpoint', + ['key' => \Drupal::state()->get('system.cron_key')], + ['absolute' => TRUE]); + $form['standalone_url'] = [ + '#type' => 'item', + '#title' => $this->t('Standalone URL'), + '#markup' => $this->t('<a href="@url">@url</a>', ['@url' => $standalone_url->toString()]), + '#states' => [ + 'visible' => [ + ':input#edit-standalone' => ['checked' => TRUE], + ], + ], + ]; + $form = parent::buildForm($form, $form_state); $form['creds']['actions'] = $form['actions']; unset($form['actions']); @@ -147,35 +170,17 @@ class SettingsForm extends ConfigFormBase { * {@inheritdoc} */ public function submitForm(array &$form, FormStateInterface $form_state) { - $values = $form_state->getValues(); - $this->sf_client->setConsumerKey($values['consumer_key']); - $this->sf_client->setConsumerSecret($values['consumer_secret']); - $this->sf_client->setLoginUrl($values['login_url']); - - try { - $path = $this->sf_client->getAuthEndpointUrl(); - $query = [ - 'redirect_uri' => $this->sf_client->getAuthCallbackUrl(), - 'response_type' => 'code', - 'client_id' => $values['consumer_key'], - ]; - - // Send the user along to the Salesforce OAuth login form. If successful, - // the user will be redirected to {redirect_uri} to complete the OAuth - // handshake. - $form_state->setResponse(new TrustedRedirectResponse($path . '?' . http_build_query($query), 302)); - } - catch (RequestException $e) { - drupal_set_message(t("Error during authorization: %message", ['%message' => $e->getMessage()]), 'error'); - $this->eventDispatcher->dispatch(SalesforceEvents::ERROR, new SalesforceErrorEvent($e)); - } - - $this->sf_client->setApiVersion($form_state->getValue('use_latest'), $form_state->getValue('rest_api_version')); - $config = $this->config('salesforce.settings'); $config->set('show_all_objects', $form_state->getValue('show_all_objects')); + $config->set('standalone', $form_state->getValue('standalone')); + $use_latest = $form_state->getValue('use_latest'); + $config->set('use_latest', $use_latest); + if (!$use_latest) { + $versions = $this->sf_client->getVersions(); + $version = $versions[$form_state->getValue('rest_api_version')]; + $config->set('rest_api_version', $version); + } $config->save(); - parent::submitForm($form, $form_state); } diff --git a/src/Rest/RestClient.php b/src/Rest/RestClient.php index a7b6d477aa4132faa74354562afbd7770dc4607d..b3c510aa9cd82635e7f9a8b1666bedbd057ed158 100644 --- a/src/Rest/RestClient.php +++ b/src/Rest/RestClient.php @@ -109,8 +109,6 @@ class RestClient implements RestClientInterface { /** * 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(); @@ -124,8 +122,15 @@ class RestClient implements RestClientInterface { $this->refreshToken(); } + if (strpos($path, '/') === 0) { + $url = $this->getInstanceUrl() . $path; + } + else { + $url = $this->getApiEndPoint() . $path; + } + try { - $this->response = new RestResponse($this->apiHttpRequest($path, $params, $method)); + $this->response = new RestResponse($this->apiHttpRequest($url, $params, $method)); } catch (RequestException $e) { // RequestException gets thrown for any response status but 2XX. @@ -143,7 +148,7 @@ class RestClient implements RestClientInterface { // throws anything but a RequestException, let it bubble up. $this->refreshToken(); try { - $this->response = new RestResponse($this->apiHttpRequest($path, $params, $method)); + $this->response = new RestResponse($this->apiHttpRequest($url, $params, $method)); } catch (RequestException $e) { $this->response = $e->getResponse(); @@ -167,8 +172,9 @@ class RestClient implements RestClientInterface { /** * Private helper to issue an SF API request. * - * @param string $path - * Path to resource. + * @param string $url + * Fully-qualified URL to resource. + * * @param array $params * Parameters to provide. * @param string $method @@ -177,11 +183,10 @@ class RestClient implements RestClientInterface { * @return GuzzleHttp\Psr7\Response * Response object. */ - protected function apiHttpRequest($path, array $params, $method) { + protected function apiHttpRequest($url, array $params, $method) { if (!$this->getAccessToken()) { throw new \Exception('Missing OAuth Token'); } - $url = $this->getApiEndPoint() . $path; $headers = [ 'Authorization' => 'OAuth ' . $this->getAccessToken(), @@ -395,11 +400,9 @@ class RestClient implements RestClientInterface { } /** - * Refresh access token based on the refresh token. - * - * @throws Exception + * {@inheritdoc} */ - protected function refreshToken() { + public function refreshToken() { $refresh_token = $this->getRefreshToken(); if (empty($refresh_token)) { throw new \Exception(t('There is no refresh token.')); diff --git a/src/Rest/RestClientInterface.php b/src/Rest/RestClientInterface.php index 8b151ed8310d78e67240beca1f13c89272dc5d4c..758b4f54640b1f59a48f42e90ff9893ca4258e89 100644 --- a/src/Rest/RestClientInterface.php +++ b/src/Rest/RestClientInterface.php @@ -24,6 +24,14 @@ interface RestClientInterface { * * @param string $path * Path to resource. + * + * If $path begins with a slash, the resource will be considered absolute, + * and only the instance URL will be pre-pended. This can be used, for + * example, to issue an API call to a custom Apex Rest endpoint. + * + * If $path does not begin with a slash, the resource will be considered + * relative and the Rest API Endpoint will be pre-pended. + * * @param array $params * Parameters to provide. * @param string $method @@ -99,6 +107,13 @@ interface RestClientInterface { */ public function setAccessToken($token); + /** + * Refresh access token based on the refresh token. + * + * @throws Exception + */ + public function refreshToken(); + /** * Helper callback for OAuth handshake, and refreshToken() *