Étendre Entity Share avec des plugins

Dans ce post, nous allons voir comment nous pouvons étendre Entity Share à travers l'implémentation de plugins. Nous illustrerons cela par la création d'un plugin permettant de bypasser les champs lors de la synchronisation.

 

Entity Share, c'est quoi ?

Entity Share est un module contrib Drupal qui permet de synchroniser des entités de même type entre plusieurs Drupal. Il utilise JSON:API afin d'effectuer la synchronisation.

Entity Share se découpe en 2 sous modules principaux :

  • Entity Share Server : Ce module permet d'exposer à des client Entity Share des listes d'entités par type (avec filtrage et tri possible) à travers ce qu'on appel des canaux.
  • Entity Share Client : Ce module permet de synchroniser des entités depuis un serveur Entity Share.
    Pour fonctionner il est constitué de 2 configuration :
    • Site web distant (Remote website) : Il s'agit du lien vers le site Drupal qui est le serveur Entity Share. On peut aussi configurer une méthode d'authentification.
    • Configuration d'import (Import config) : Il s'agit comme son nom l'indique de la configuration d'import. C'est à dire comment ces données vont être analysées et traitées durant l'import (permet de déterminer si on doit ou non synchroniser une entité par exemple). C'est justement à ce niveau là qu'on va intervenir afin d'étendre Entity Share à travers des plugins, aussi appelé Processor dans la configuration.

Avec ces 2 composants, 2 Drupal peuvent partager des entités (à partir du moment où elles sont du même type).
Il est à noter qu'un client peut être en même serveur et vice versa.

Vous trouverez le module ici : https://www.drupal.org/project/entity_share
Et pour plus d'informations, vous pouvez lire cette autre billet : https://www.iosan.fr/blog/partager-du-contenu-entre-plusieurs-drupal-cest-possible-grace-entity-share 

 

C'est quoi les Processors Entity Share ?

Lorsqu'on synchronise des entités avec Entity Share, nous faisons appel à des Processors (Plugins). C'est dans la configuration d'import que l'on défini quel processor est actif, sa hiérarchie dans l’exécution ainsi que son paramétrage (s'il est paramétrable).

  • Activation des processor :
    Il s'agit simplement de la liste de l'ensemble des processors, dans laquelle on coche les processors qui doivent s'exécuter.

    Image
    Entity Share Client > Import Config > Activate Processors
  • Ordre/Priorisation d’exécution : 
    Elles se découpe en 2 parties : 
    - A quelle étape le processor va s'exécuter
    - Sa priorisation au sein d'une étape

    Image
    Ordre/Priorisation de l'exécution des processors Entity Share
  • Paramétrage :
    Il s'agit d'une partie composée de plusieurs onglets. Chaque onglet correspond à la configuration d'un processor paramétrable.
     

    Image
    Section de la configuration des processors Entity Share


     

Pour faire simple un processor est un plugin qui est appelé à un moment donné lors de l'import. Ce plugin lorsqu'il est exécuté vient influencer sur la synchronisation. On peut notamment avoir des plugins qui viennent déterminer si une entité peut être synchronisée, en avoir d'autres qui vont aller récupérer les entités référence par l'entité qu'on synchronise, on pourrait aussi en avoir qui vienne déclencher ou faire des action une fois l'entité synchronisé.

 

Comment créer un Processor (plugin) custom ?

Dans certains cas, les processors fournis par Entity Share ne sont pas suffisant. On peut donc être amené à vouloir en ajouter.
Pas d'inquiétude, ajouter un processor est finalement quelque chose de simple !

Voici la structure d'un processor :

<?php

declare(strict_types = 1);

namespace Drupal\your_custom_module\Plugin\EntityShareClient\Processor;

use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\PluginFormInterface;
use Drupal\entity_share_client\ImportProcessor\ImportProcessorPluginBase;
use Drupal\entity_share_client\RuntimeImportContext;

/**
 * Plugin processor description.
 *
 * @ImportProcessor(
 *   id = "your_processor_id",
 *   label = @Translation("Your processor label"),
 *   description = @Translation("A short description of doing your processor"),
 *   stages = {
 *     "prepare_entity_data" = -100,
 *     "is_entity_importable" = -100,
 *     "prepare_importable_entity_data" = -100,
 *     "process_entity" = -100,
 *     "post_entity_save" = -100,
 *   },
 *   locked = false,
 * )
 */
class YourProcessor extends ImportProcessorPluginBase implements PluginFormInterface {

  /**
   * {@inheritdoc}
   */
  public function defaultConfiguration() {
    return [
        // Here your default config.
      ] + parent::defaultConfiguration();
  }

  /**
   * {@inheritdoc}
   */
  public function buildConfigurationForm(array $form, FormStateInterface $form_state)
  {
    // Here your config form.
  }

  /**
   * Method called on STAGE_PREPARE_ENTITY_DATA.
   *
   * If the plugin reacts to this stage.
   *
   * @param \Drupal\entity_share_client\RuntimeImportContext $runtime_import_context
   *   The import context.
   * @param array $entity_json_data
   *   The entity JSON data.
   */
  public function prepareEntityData(RuntimeImportContext $runtime_import_context, array &$entity_json_data) {
    // Add and use this method only if your processor must be executed during
    // "Prepare entity data". If you use it, you must add "prepare_entity_data"
    // in the 'stages' part in the class annotation (@ImportProcessor).
  }

  /**
   * Method called on STAGE_IS_ENTITY_IMPORTABLE.
   *
   * If the plugin reacts to this stage.
   *
   * @param \Drupal\entity_share_client\RuntimeImportContext $runtime_import_context
   *   The import context.
   * @param array $entity_json_data
   *   The entity JSON data.
   *
   * @return bool
   *   TRUE if the entity is importable. FALSE otherwise.
   */
  public function isEntityImportable(RuntimeImportContext $runtime_import_context, array $entity_json_data) {
    // Add and use this method only if your processor must be executed during
    // "Is entity importable". If you use it, you must add "is_entity_importable"
    // in the 'stages' part in the class annotation (@ImportProcessor).
  }

  /**
   * Method called on STAGE_PREPARE_IMPORTABLE_ENTITY_DATA.
   *
   * If the plugin reacts to this stage.
   *
   * @param \Drupal\entity_share_client\RuntimeImportContext $runtime_import_context
   *   The import context.
   * @param array $entity_json_data
   *   The entity JSON data.
   */
  public function prepareImportableEntityData(RuntimeImportContext $runtime_import_context, array &$entity_json_data) {
    // Add and use this method only if your processor must be executed during
    // "Prepare importable entity data". If you use it, you must add
    // "prepare_importable_entity_data" in the 'stages' part in the class
    // annotation (@ImportProcessor).
  }

  /**
   * Method called on STAGE_PROCESS_ENTITY.
   *
   * If the plugin reacts to this stage.
   *
   * @param \Drupal\entity_share_client\RuntimeImportContext $runtime_import_context
   *   The import context.
   * @param \Drupal\Core\Entity\ContentEntityInterface $processed_entity
   *   The entity being processed.
   * @param array $entity_json_data
   *   The entity JSON data.
   */
  public function processEntity(RuntimeImportContext $runtime_import_context, ContentEntityInterface $processed_entity, array $entity_json_data) {
    // Add and use this method only if your processor must be executed during
    // "Process entity". If you use it, you must add "process_entity" in
    // the 'stages' part in the class annotation (@ImportProcessor).
  }

  /**
   * Method called on STAGE_POST_ENTITY_SAVE.
   *
   * If the plugin reacts to this stage.
   *
   * @param \Drupal\entity_share_client\RuntimeImportContext $runtime_import_context
   *   The import context.
   * @param \Drupal\Core\Entity\ContentEntityInterface $processed_entity
   *   The entity being processed.
   */
  public function postEntitySave(RuntimeImportContext $runtime_import_context, ContentEntityInterface $processed_entity) {
    // Add and use this method only if your processor must be executed during
    // "Post entity save". If you use it, you must add "post_entity_save"
    // in the 'stages' part in the class annotation (@ImportProcessor).
  }
  
}

 

  • Dans l'annotation "@ImportProcessor" :
    • "stages" nous donne le poids par défaut lors de l'exécution des processors pour une étape : '[processing_stages_id] = [default_weight]'
    • "locked" nous permet de définir si on peut activer ou désactiver le processor (si false alors le processor est bloqué en activé)
  • On implémente PluginFormInterface et ajoute les méthodes defaultConfiguration et buildConfigurationForm uniquement si on veut rendre notre processor configurable.
  • Dans le code ci-dessus, nous avons remis les PHPDoc, mais il faut préférer l'utilisation de "{@inheritdoc}"

 

Exemple d'usage : Bypass Fields

Dans notre cas, nous avons eu besoin de synchroniser des entités de même type entre 2 Drupal. Cependant, notre Drupal client, ne disposait pas de l'ensemble des champs présent sur notre Drupal serveur. Hors, Entity Share ne sait pas nativement gérer ce cas, et retournera une erreur lors de la synchronisation s'il se présente.

Nous avons donc identifié le besoin de mettre en place un processor qui nous permettrai d'à la fois bypasser les champs manquants.
Pour se faire, nous devons intervenir au moment ou les données de entités sont préparée pour la synchronisation. Cela correspond à l'étape "prepare_entity_data".

Nous avons donc créé un nouveau plugin avec la structure suivante :

<?php

declare(strict_types = 1);

namespace Drupal\entity_share_bypass_fields\Plugin\EntityShareClient\Processor;

use Drupal\Core\Entity\FieldableEntityInterface;
use Drupal\entity_share_client\ImportProcessor\ImportProcessorPluginBase;
use Drupal\entity_share_client\RuntimeImportContext;

/**
 * Processor to bypass fields.
 *
 * @ImportProcessor(
 *   id = "entity_share_bypass_fields",
 *   label = @Translation("Bypass fields"),
 *   description = @Translation("Allow you to bypass missing fields and specific fields while synchronized entities.    "),
 *   stages = {
 *     "prepare_entity_data" = -100,
 *   },
 *   locked = false,
 * )
 */
class BypassFieldsProcessor extends ImportProcessorPluginBase {

  /**
   * {@inheritdoc}
   */
  public function prepareEntityData(RuntimeImportContext $runtime_import_context, array &$entity_json_data) {

  }

}

Une fois la structure implémentée, il ne nous reste plus qu'à en implémenter la méthode prepareEntityData qui devra donc retirer de la synchronisation tous les champs manquants.
Pour faire ça, nous avons besoin de faire un peu d'injection de dépendance. Pourquoi ? Car nous avons besoin d'accéder aux champs existants pour une entité. Nous en profiterons pour ajouter un logger à notre processor.

Pour injecter notre dépendance, il suffit simplement d'ajouter la méthode "create()". Nous nous retrouvons donc avec le code suivant :

//...
use Symfony\Component\DependencyInjection\ContainerInterface;

//...
class BypassFieldsProcessor extends ImportProcessorPluginBase {

  /**
   * The entity type manager.
   *
   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
   */
  protected $entityTypeManager;

  /**
   * Logger.
   *
   * @var \Psr\Log\LoggerInterface
   */
  protected $logger;

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
    $instance = parent::create($container, $configuration, $plugin_id, $plugin_definition);
    $instance->entityTypeManager = $container->get('entity_type.manager');
    $instance->logger = $container->get('logger.channel.entity_share_client');
    return $instance;
  }

  //...

}

 

A ce stade nous avons :

  • Notre Processor de déclaré.
  • Nos prérequis pour implémenter la méthode qui sera exécuté lors de la préparation des données de l'entité.

Il ne nous reste plus qu'à implémenter notre méthode afin de bypasser les champs manquants. Ce qui nous donne le code suivant pour notre processor : 

<?php

declare(strict_types = 1);

namespace Drupal\entity_share_bypass_fields\Plugin\EntityShareClient\Processor;

use Drupal\Core\Entity\FieldableEntityInterface;
use Drupal\entity_share_client\ImportProcessor\ImportProcessorPluginBase;
use Drupal\entity_share_client\RuntimeImportContext;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Processor to bypass fields.
 *
 * @ImportProcessor(
 *   id = "entity_share_bypass_fields",
 *   label = @Translation("Bypass fields"),
 *   description = @Translation("Allow you to bypass missing fields and specific fields while synchronized entities.    "),
 *   stages = {
 *     "prepare_entity_data" = -100,
 *   },
 *   locked = false,
 * )
 */
class BypassFieldsProcessor extends ImportProcessorPluginBase {

  /**
   * The entity type manager.
   *
   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
   */
  protected $entityTypeManager;

  /**
   * Logger.
   *
   * @var \Psr\Log\LoggerInterface
   */
  protected $logger;

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
    $instance = parent::create($container, $configuration, $plugin_id, $plugin_definition);
    $instance->entityTypeManager = $container->get('entity_type.manager');
    $instance->logger = $container->get('logger.channel.entity_share_client');
    return $instance;
  }

  /**
   * {@inheritdoc}
   */
  public function prepareEntityData(RuntimeImportContext $runtime_import_context, array &$entity_json_data) {
    // Entity Share response contains entity type and entity bundle under the
    // ID 'type' ($entity_json_data['type']) with the following format :
    // [entity_type]--[entity_bundle].
    $entityType = explode('--', $entity_json_data['type']);
    $entityUuid = $entity_json_data['id'];

    try {
      $entities = $this->entityTypeManager->getStorage($entityType[0])->loadByProperties(['uuid' => $entityUuid]);
      if (count($entities) > 1) {
        $this->logger->error('BypassFieldProcessor : There is more than 1 entity while loading uuid ' . $entityUuid . '.');
        return;
      }
      
      // We're loading by uuid so we must have only one entity returned by the
      // loadByProperties()
      $entity = reset($entities);
      if (empty($entity)) {
        $this->logger->error('BypassFieldProcessor : No entity found with uuid ' . $entityUuid . '.');
        return;
      }

      if (!($entity instanceof FieldableEntityInterface)) {
        $this->logger->error('BypassFieldProcessor : Entity found with uuid ' . $entityUuid . '. But it\'s not a FieldableEntityInterface instance.');
        return;
      }

      $entityAttributes = $entity_json_data['attributes'];
      foreach ($entityAttributes as $attributeId => $attribute) {
        if (str_starts_with($attributeId, 'field_') && !($entity->hasField($attributeId))) {
          unset($entity_json_data['attributes'][$attributeId]);
        }
      }
    } catch (\Exception $e) {
      $this->logger->critical('BypassFieldProcessor failed with error: ' . $e->getMessage());
    }
  }

}

 

C'est à ce moment, que nous nous sommes fait la remarque suivante : "Si nous sommes capable de bypasser les champs manquants. Pourquoi ne pas rendre configurable notre processor afin de pouvoir lui passer une liste de champs supplémentaire à bypasser ?"

Et c'est ce que nous avons fait en ajoutant le code suivant :

<?php

declare(strict_types = 1);

namespace Drupal\entity_share_bypass_fields\Plugin\EntityShareClient\Processor;

//...
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\PluginFormInterface;
//...

//...
class BypassFieldsProcessor extends ImportProcessorPluginBase implements PluginFormInterface {

  //...

  /**
   * {@inheritdoc}
   */
  public function defaultConfiguration() {
    return [
      'manual_bypassed_fields' => '',
    ] + parent::defaultConfiguration();
  }

  /**
   * {@inheritdoc}
   */
  public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
    $form['manual_bypassed_fields'] = [
      '#type' => 'textarea',
      '#title' => $this->t('Bypass fields manually'),
      '#description' => $this->t('Allow you to bypass some specific fields manually. Format : fields must be comma separated (example: "field_one,field_two,field_three")'),
      '#default_value' => $this->configuration['manual_bypassed_fields'],
    ];

    return $form;
  }

  /**
   * {@inheritdoc}
   */
  public function prepareEntityData(RuntimeImportContext $runtime_import_context, array &$entity_json_data) {
    // Get as array the list of manually bypassed field.
    $manuallyExcludedFields = explode(',', $this->configuration['manual_bypassed_fields']);

    //...

    try {
      //...

      $entityAttributes = $entity_json_data['attributes'];
      foreach ($entityAttributes as $attributeId => $attribute) {
        if (in_array($attributeId, $manuallyExcludedFields)) {
          unset($entity_json_data['attributes'][$attributeId]);
          continue;
        }

        //...
      }
    } catch (\Exception $e) {
      //...
    }
  }

}

 

Et voilà, notre processor pour bypasser des champs est nait !

Vous pouvez retrouver ce processor ici : https://www.drupal.org/project/entity_share_bypass_fields

 

A travers cette exemple, on se rend compte, qu'il n'est pas compliqué d'étendre Entity Share.

Du THEMING au Drupalcamp Paris

Du THEMING au Drupalcamp Paris

Session DrupalCamp Paris 2019 - Les bonnes pratiques sous Drupal 8

Session DrupalCamp Paris 2019 - Les bonnes pratiques sous Drupal 8

Meetup AFUP du 24 janvier 2019 - Découverte de Drupal et ses bonnes pratiques

Meetup AFUP du 24 janvier 2019 - Découverte de Drupal et ses bonnes pratiques