Drupal 9: Adding Custom Plugins To The Session Inspector Module

Since starting the Session Inspector module I have been busy adding features to improve how the module functions.

One of these additions is the module now being able to retrieve and store the browser that the user is current using to access the session (also known as user agent string). This just uses a hook_user_login() hook and stores the browser using the standard session storage system built into Drupal. This browser string is then pulled out of the session metadata and presented to the user in the session inspector interface.

Whilst this provides the user with more information, the one big downside is that when accessing the session inspector page the user sees a browser string that looks something like this.

Mozilla/5.0 (Macintosh; Intel Mac OS X 12_2_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.109 Safari/537.36

For most of users this really isn't helpful or useful in any way. This is from me visiting a testing site using Safari, but I can also see references to Chrome and Mozilla, including a reference to the type of computer I'm using.

After seeing this I decided to implement a feature I talked about in my last article where I said that plugins could be used to allow the formatting of browser strings without developers needing to create custom themes or alter any code. As it turns out there are lots of packages that can process browser strings into something useful for the user. What I didn't want to do is hard code one of these packages into the module since that ties the module to be a dependency of the package. To my mind, separate modules containing plugins for different formatting options makes sense and allows for changes in the future if another package is needed.

This article will go through how I created the browser plugin type so that administrators could then change the format of the browser string to something more user friendly.

There are a few things we need to set up before anything works, but let us begin with annotations.

Annotations

Annotations are used to let Drupal know some information about your plugins and allows you to set some sensible defaults for different properties. They are essentially structured comments and the contents of those comments must be configured to let Drupal know what type of thing is being looked at.

If you have ever created a block or an entity then you'll have seen annotations. If you search the Drupal code base for the word "@Annotation" then you can see plenty of examples.

For our browser format plugin we want to define a machine name and a human readable name as a minimum. More attributes could be added to this in the future, but right now this is enough to allow the functionality we need.

The attribute for a basic browser format plugin would look something like this.

/**
 * @BrowserFormat(
 *   id = "basic",
 *   name = @Translation("Basic browser format")
 * )
 */

In order to tell Drupal about this annotation we need to make an annotation class. This class must extend the \Drupal\Component\Annotation\Plugin class and simply defines a number of properties that the plugin will have.

In our case we have defined an ID and a human readable name of the plugin.

<?php

namespace Drupal\session_inspector\Annotation;

use Drupal\Component\Annotation\Plugin;

/**
 * Defines a browser format plugin.
 *
 * Plugin Namespace: Plugin\BrowserFormat.
 *
 * @see plugin_api
 *
 * @Annotation
 */
class BrowserFormat extends Plugin {

  /**
   * The plugin ID.
   *
   * @var string
   */
  public $id;

  /**
   * The human-readable name of the browser format plugin.
   *
   * @var \Drupal\Core\Annotation\Translation
   *
   * @ingroup plugin_translatable
   */
  public $name;

}

This doesn't do anything on its own, but is used to allow quick definition of plugins through annotations.

As a side note, the settings we define in this annotation class don't need to match the options in the annotation itself. It is possible to add custom attributes to the annotation, all of which get picked up by Drupal. Annotation classes are extremely useful and should be used as it provides a way of setting defaults and ensuring that certain annotation parameters are correct. These classes should always be the first step when creating custom plugins as it allows you to plan out your plugin implementation.

Plugin Interface

Every browser format plugin that is created must contain a method called formatBrowser(), which is where the actual formatting will be done. This method will be added to the controller later so it is essential that every browser format plugin have this method or the code will break.

The best way to ensure that a class contains a method is by using a PHP interface, this is also used by Drupal to ensure that any plugins created are the correct 'type' and so is required when setting up plugins.

The interface for the browser format plugin consists of two methods. A formatBrowser() method will accept a string (i.e. the user agent string) and return a nicely formatted string.

<?php

namespace Drupal\session_inspector\Plugin;

use Drupal\Component\Plugin\PluginInspectionInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;

/**
 * Defines the interface for browser format plugins.
 *
 * @package Drupal\session_inspector\Plugin
 */
interface BrowserFormatInterface extends PluginInspectionInterface, ContainerFactoryPluginInterface {

  /**
   * Format the browser string into something useful.
   *
   * @param string $browser
   *   The browser string.
   *
   * @return string
   *   The formatted browser string.
   */
  public function formatBrowser(string $browser):string;

}

With the annotations telling Drupal what our plugins will be called and the interface telling Drupal what methods will exist we are now ready to start building our plugins. First though, we need to create a plugin manager to manage our custom plugins.

Plugin Manager

In order to organise your custom plugins we need to have a service that can find and collate information about custom plugins on your system. This service can also be used to create plugin objects and saves writing a lot of code so it's a really good idea to have.

As this is a service we must first create a reference to it in our session_inspector.services.yml file. We also tell Drupal that this is part of the plugin manager system by using the parent keyword, which points to an abstract service called default_plugin_manager. An abstract service is quite like an abstract class in that you can't create it directly, but you can extend it to use the same dependencies as the parent.

  plugin.manager.session_inspector.browser_format:
    class: Drupal\session_inspector\Plugin\BrowserFormatManager
    parent: default_plugin_manager

The BrowserFormatManager class must extend the DefaultPluginManager class. The class doesn't actually need to do a lot at the moment, but the core of the class is passing the needed parameters to the parent constructor.

<?php

namespace Drupal\session_inspector\Plugin;

use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Plugin\DefaultPluginManager;
use Drupal\Component\Plugin\FallbackPluginManagerInterface;

/**
 * A plugin manager class for browser format plugins.
 *
 * @package Drupal\session_inspector\Plugin
 */
class BrowserFormatManager extends DefaultPluginManager implements FallbackPluginManagerInterface {

  /**
   * Constructs a BrowserFormatManager object.
   *
   * @param \Traversable $namespaces
   *   An object that implements \Traversable which contains the root paths
   *   keyed by the corresponding namespace to look for plugin implementations.
   * @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
   *   Cache backend instance to use.
   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
   *   The module handler to invoke the alter hook with.
   */
  public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler) {
    parent::__construct(
      'Plugin/BrowserFormat',
      $namespaces,
      $module_handler,
      'Drupal\session_inspector\Plugin\BrowserFormatInterface',
      'Drupal\session_inspector\Annotation\BrowserFormat'
    );
    $this->alterInfo('browser_format_info');
    $this->setCacheBackend($cache_backend, 'browser_format_info_plugins');
  }

  /**
   * {@inheritdoc}
   */
  public function getFallbackPluginId($plugin_id, array $configuration = []) {
    return 'basic';
  }

}

There are a few parameters being passed to the __construct method here, so let's break them down a little.

  • 'Plugin/BrowserFormat' is the name of the directory to find plugins of this type. This means that if anyone wants to define a BrowserFormat plugin then it must be in this directory structure inside the module src directory.
  • $namespaces is an injected dependency that is a traversable object of all of the discovered namespaces in the project. This is essentially a list of directories that the manager can look at to discover plugins.
  • $module_handler is another injected service that allows access to the core module_handler service. This service allows control of modules in the system, but also allows us to create our own alter hooks to that plugin information can be altered by third party modules.
  • 'Drupal\session_inspector\Plugin\BrowserFormatInterface' is the plugin interface we defined.
  • 'Drupal\session_inspector\Annotation\BrowserFormat' is the plugin annotation we defined.

Our class also implements the FallbackPluginManagerInterface, which defines a method called getFallbackPluginId() that returns the ID of a default plugin that can be used to prevent errors if an assigned custom plugin doesn't exist for some reason. We will just add a placeholder of "basic" for now, but we will be building that plugin later.

The plugin manager class will come in useful when we start to use our custom plugins, and can already be used to find existing browser format plugins on the site.

Adding A Basic Plugin

With all that in place we can now build a basic plugin that will ensure that the plugin functionality works on the site with this module installed. This is our fallback plugin, so if anything goes wrong with third party plugins then this basic version will be used instead. We defined the name of this plugin when creating the plugin manager class in the previous step, so it must have the ID of "basic".

The actual implementation of a browser plugin is pretty simple. We need to define only three methods and add our @BrowserFormat annotation to the class doc block comment.

The methods we need to create in the plugin are:

  • create() - This method is defined by the ContainerFactoryPluginInterface interface and is used to generate an instance of the plugin. This needs to accept three parameters, as indicated by the interface.
  • formatBrowser($browser) - We defined this method in our BrowserFormatInterface and it needs to accept a string and return a string. What happens inside the method in order to convert that string into something useful it down to the plugin implementation.

Our basic plugin only needs to return the string that was passed to it, which means that there isn't a lot of code involved here.

<?php

namespace Drupal\session_inspector\Plugin\BrowserFormat;

use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\Core\Plugin\PluginBase;
use Drupal\session_inspector\Plugin\BrowserFormatInterface;

/**
 * Provides basic formatting for browser details.
 *
 * @BrowserFormat(
 *   id = "basic",
 *   name = @Translation("Basic browser format")
 * )
 */
class BasicBrowserFormat extends PluginBase implements BrowserFormatInterface {

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
    return new static(
      $configuration,
      $plugin_id,
      $plugin_definition
    );
  }

  /**
   * {@inheritdoc}
   */
  public function formatBrowser(string $browser):string {
    return $browser;
  }

}

We place this class in the directory at src/Plugins/BrowserFormat so that the plugin manager can automatically pick it up.

Configuring The Module

What we have created so far will work, but we have no way of controlling which plugin will be used. To do this we need to add a configuration form to the module.

There is quite a bit of boiler plate code for the form, most of which is just involved in setting things up, so I'll just include the important bits here. The source code for the session inspector module already contains all the code so you can find the complete class there if you want to look more deeply.

Within the buildForm() method we use the injected BrowserFormatManager object to get a list of the definitions available using the getDefinitions() method. This method returns an array that contains all plugins of the type BrowserFormat. We just need to loop through this array and create a set of options for a select element.

We also use the current configuration settings of the session inspector module to pull in what the currently configured plugin is and apply this to the select element. As this form class extends the ConfigFormBase form we have ready access to the configuration manager so there is no need to inject it.

    $config = $this->config('session_inspector.settings');

    // Set up the browser format plugin options.
    $browserFormatPluginDefinitions = $this->browserFormatManager->getDefinitions();

    $browserFormatOptions = [];
    foreach ($browserFormatPluginDefinitions as $pluginId => $pluginDefinition) {
      /** @var \Drupal\Core\StringTranslation\TranslatableMarkup $pluginDefinition */
      $pluginDefinitionName = $pluginDefinition['name'];
      $browserFormatOptions[$pluginId] = $pluginDefinitionName->render();
    }

    $form['browser_format'] = [
      '#type' => 'select',
      '#title' => $this->t('Browser format'),
      '#default_value' => $config->get('browser_format') ?? 'basic',
      '#options' => $browserFormatOptions,
    ];

Saving the form is pretty simple and just involves pulling the information out of the browser format select element and saving it to the configuration system.

  public function submitForm(array &$form, FormStateInterface $form_state) {
    $this->config('session_inspector.settings')
      ->set('browser_format', $form_state->getValue('browser_format'))
      ->save();

    parent::submitForm($form, $form_state);
  }

After saving the form we now have configuration for the module.

Creating The Default Configuration And Configuration Schema 

Because we have created new configuration options we also need to tell Drupal about these settings. This requires us to create two files.

The default configuration settings for the module are pretty simple as we just need to inform Drupal what the default value for the session inspector are. In this case we create a file at config/install/session_inspector.settings.yml and set the browser_format value to be 'basic'.

browser_format: 'basic'

Slightly more complicated is the configuration schema for our module configuration, although there are plenty of examples of documentation on how to create this.

What we need to do is create a file at config/schema/session_inspector.schema.yml and add a few options that describe what our configuration looks like. For the moment we only have the browser_settings configuration item (which will always be a string) so that makes the schema document pretty simple.

session_inspector.settings:
  type: config_object
  label: 'Session Inspector settings.'
  mapping:
    browser_format:
      type: string
      label: 'The browser format plugin.'

If we wanted to add more options to the plugin, or different plugins, then we would need to expand this to include those new options.

Using The Browser Format Plugin

We are now ready to start using the browser format plugin. Quite a bit of code has been created to get us to this point, but the actual use of the plugin is very simple.

Within our session inspector controller we just need to inject the configuration manager service and the browser format manager. Using these together we ask the browser format manager to create an instance of the plugin defined in the configuration. Using this created plugin object we can pass in the session browser information and receive an output. 

$browserFormat = $config->get('browser_format');
$browserFormatter = $this->browserFormatManager->createInstance($browserFormat);
$formatted = $browserFormatter->formatBrowser($session->browser);

This output is then used to render the page out so that the user now sees the formatted browser string, rather than the standard user agent string.

Testing Plugins

Now that we have a new plugin we absolutely have to test it. But how do we test custom Drupal plugin types? By creating a testing module that implements the plugin.

Within the testing directory we create a new module that will implement the browser format plugin and we can configure Drupal to use this plugin during the testing run.

To start with we need a info.yml file.

name: 'Session Inspector Plugins Test'
description: 'Functionality to assist session inspector plugins testing.'
type: module
hidden: true
core_version_requirement: ^8.8 || ^9
dependencies:
  - session_inspector
package: Testing

Note that we have set the "hidden" parameter to "true" in order to hide this testing module from the normal operation of the site. This means that site administrators can't turn on the module, which is a good thing as the testing plugins won't produce any meaningful output.

Next, we implement a custom browser format plugin, but rather than do anything useful we just return a random string (in this case a UUID). This is useful as we can use this string to ensure that the configuration works correctly and that the plugin manager is calling the correct plugin to produce the output.

<?php

namespace Drupal\session_inspector_plugins_test\Plugin\BrowserFormat;

use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\Core\Plugin\PluginBase;
use Drupal\session_inspector\Plugin\BrowserFormatInterface;

/**
 * Provides a testing formatting for browser details.
 *
 * @BrowserFormat(
 *   id = "testing",
 *   name = @Translation("Testing browser format")
 * )
 */
class TestingBrowserFormat extends PluginBase implements BrowserFormatInterface {

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
    return new static(
      $configuration,
      $plugin_id,
      $plugin_definition
    );
  }

  /**
   * {@inheritdoc}
   */
  public function formatBrowser(string $browser):string {
    // Deliberately return a unique string to prove the plugin is active.
    return '7f7090b5-2440-47cb-9cb0-e8b4e0e676eb';
  }

}

The test itself just needs to follow through some actions in order to configure the correct plugin and then ensure that the sessions page contains the random string from the testing browser format plugin.

Here is the test class in full. I have added comments to most of it to show what each part is doing.

<?php

namespace Drupal\Tests\session_inspector\Functional;

use Drupal\Tests\BrowserTestBase;

/**
 * Test the functionality of the session inspector module configuration form.
 *
 * @group session_inspector
 */
class SessionInspectorConfigTest extends BrowserTestBase {

  /**
   * Modules to enable.
   *
   * @var array
   */
  protected static $modules = [
    'session_inspector',
    'session_inspector_plugins_test',
  ];

  /**
   * The theme to install as the default for testing.
   *
   * @var string
   */
  public $defaultTheme = 'stark';

  /**
   * Test that a user can inspect and delete their own sessions.
   */
  public function testAdminUserCanEditConfig() {
    $permissions = [
      'administer session inspector configuration',
      'inspect own user sessions',
    ];
    $user = $this->createUser($permissions);
    $this->drupalLogin($user);

    $this->drupalGet('admin/config/people/session_inspector');

    // Update the configuration of the module.
    $input = [];
    $input['browser_format'] = 'testing';

    $this->submitForm($input, 'Save configuration');

    // Ensure that the configuration options are now set.
    $this->assertEquals('testing', $this->container->get('config.factory')->get('session_inspector.settings')->get('browser_format'));

    // Visit the sessions page.
    $this->drupalGet('user/' . $user->id() . '/sessions');

    // Browser plugin is being used.
    $this->assertSession()->responseContains('7f7090b5-2440-47cb-9cb0-e8b4e0e676eb');
  }

}

This test passes correctly and we now have confidence that the browser format plugin system works correctly.

If you want to see all this in action then I have already added the browser format plugin to the Session Inspector module source code. I have also added another plugin type for formatting the hostname, which works in a similar way to the browser formatting plugin.

Let me know if this article and the Session Inspector module are useful to you. The module is currently in an alpha release, but the only tasks before a full release are to tidy up some of the code and add more descriptions to different output screens.

More in this series

Add new comment

The content of this field is kept private and will not be shown publicly.
CAPTCHA
17 + 3 =
Solve this simple math problem and enter the result. E.g. for 1+3, enter 4.
This question is for testing whether or not you are a human visitor and to prevent automated spam submissions.