oEmbed in Drupal 8

computer with code

Drupal added oEmbed support to the Media module in Drupal 8.6.0. See the Drupal.org change record for more details. While having oEmbed support in the Drupal core is great, we discovered some limitations that, as of writing this blog post, haven’t been resolved.

What is oEmbed?

According to oembed.com:

oEmbed is a format for allowing an embedded representation of a URL on third party sites. The simple API allows a website to display embedded content (such as photos or videos) when a user posts a link to that resource, without having to parse the resource directly.

oembed.com

What does that mean? To put it simply, it allows a website's content to be embedded into another page. You have likely seen many examples without even realizing. Twitter, Instagram, YouTube, Vimeo, and far too many others to list, all support the oEmbed standard.

What’s wrong with the Drupal 8 implementation?

As of writing this post, Drupal does not support the ability to extend or alter some of the functionality. This is where the problem lies. Drupal only supports oEmbed providers listed in the oEmbed providers database at https://oembed.com/providers.json. While that sounds adequate, what if you want to add a custom provider? Or a provider not listed in providers JSON? There is no hook or plugin type to do so.

The other issue that I encountered is that not all oEmbed providers adhere to the specification. Height and/or width parameters are provided as a null value instead of a value in pixels. Drupal requires both a height and width value. If an oEmbed provider returns a null value for the height or width an error will be thrown: "The dimensions must be numbers greater than zero." There is no way to get around this or provide a default value.

What did I need?

Twitter supports oEmbed for Tweets, Timelines and Moments. The first obstacle is that the oEmbed providers database does not include support for Timelines or Moments. I needed to add Twitter Moments to the list of providers in order for them to be supported by the Drupal Media module.

The second obstacle is that Twitter is providing a null value for the height and/or width of the resources. For Timelines, the height and width are both null. For Moments, the width is defined, but the height is null.

Based on the above, in its current state Drupal does not, and can not support both Twitter Timelines and Moments. Fortunately, existing services can be altered.

Altering the core services

To alter/extend a service you need to create a class that extends the ServiceProviderBase class that implements the alter() method. In order for the service alteration to be discovered automatically, the class must be in the top-level namespace of your module. The name of the class must be a camel case version of your module's machine name directly followed by ServiceProvider. For example, in my case, our module's name is savas_labs. The class namespace will be Drupal\savas_labs and the class name will be SavasLabsServiceProvider. For more information about altering services and service decoration, see the documentation on Drupal.org.

Knowing that I'm able to alter services, the two services that I had to make changes to are:

  1. Service: media.oembed.provider_repository
    Class: \Drupal\media\OEmbed\ProviderRepository
    This service is responsible for fetching the list of providers from oembed.org. We need to invoke an alter hook after the providers have been fetched.
  2. Service: media.oembed.resource_fetcher
    Class: \Drupal\media\OEmbed\ResourceFetcher
    This service is responsible for fetching and caching the oEmbed resources. We need to be able to alter the resource to add a static value for the height.
<?php

namespace Drupal\savas_labs;

use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\Core\DependencyInjection\ServiceProviderBase;
use Symfony\Component\DependencyInjection\Reference;

/**
 * Class SavasLabsServiceProvider.
 *
 * @package Drupal\savas_labs
 */
class SavasLabsServiceProvider extends ServiceProviderBase {

  /**
   * {@inheritdoc}
   */
  public function alter(ContainerBuilder $container) {
    // Modify the provider repository so we can alter the list of providers.
    $definition = $container->getDefinition('media.oembed.provider_repository');
    $definition->setClass('Drupal\savas_labs\OEmbed\SavasLabsProviderRepository')
      ->addArgument(new Reference('module_handler'));

    // Modify the resource fetcher so we can alter the oembed data.
    $definition = $container->getDefinition('media.oembed.resource_fetcher');
    $definition->setClass('Drupal\savas_labs\OEmbed\SavasLabsResourceFetcher')
      ->addArgument(new Reference('module_handler'));
  }

}

The above example replaces the \Drupal\media\OEmbed\ProviderRepository class with \Drupal\savas_labs\OEmbed\SavasLabsProviderRepository and the \Drupal\media\OEmbed\ResourceFetcher class with \Drupal\savas_labs\OEmbed\SavasLabsResourceFetcher.

We’re also adding a new argument to each service. The module_handler will allow us to invoke the necessarily alter hooks.

The SavasLabsProviderRepository class will extend the \Drupal\media\OEmbed\ProviderRepository class. Be sure to update the class constructor (adding the module_handler service as a function parameter and setting the variable). Next, I needed to alter the providers after they had been fetched, but before they were instantiated as \Drupal\media\OEmbed\Provider classes and cached. Unfortunately, the best way to do so is to completely override the ProviderRepository::getAll() method. I copied the entire method and pasted it in the new SavasLabsProviderRepository class. I added:

// Allow other modules to alter the list of providers.
$providers = $this->alterProviders($providers);

Directly after the following code.


$providers = Json::decode((string) $response->getBody());

if (!is_array($providers) || empty($providers)) {
  throw new ProviderException('Remote oEmbed providers database returned invalid or empty list.');
}

The SavasLabsProviderRepository::alterProviders() method is below.

/**
 * Allow modules to alter the list of providers.
 *
 * @param array $providers
 *   An array of providers.
 *
 * @return array
 *   The updated array of providers.
 */
protected function alterProviders(array $providers) {
  $keyed_providers = [];

  foreach ($providers as $provider) {
    $name = (string) $provider['provider_name'];
    $keyed_providers[$name] = $provider;
  }

  $this->moduleHandler->alter('savas_labs_oembed_providers', $keyed_providers);

  return $keyed_providers;

I then implemented this new hook (hook_savas_labs_oembed_providers_alter) in our savas_labs module:


/**
 * Implements hook_savas_labs_oembed_providers_alter().
 */
function savas_labs_savas_labs_oembed_providers_alter(&$providers) {
  foreach ($providers['Twitter']['endpoints'] as &$endpoint) {
    if ('https://publish.twitter.com/oembed' == $endpoint['url']) {
      $endpoint['schemes'][] = 'https://twitter.com/i/moments/*';
      $endpoint['schemes'][] = 'https://*.twitter.com/i/moments/*';
    }
  }

The above code adds support for Twitter Moments by adding the URLs to the supported URL schemes for Twitter.

Now to fix the next issue: the null value for the resource height. As I did with the SavasLabsProviderRepository class, the SavasLabsResourceFetcher class extended the \Drupal\media\OEmbed\ResourceFetcher class. The class constructor was updated (adding the module_handler service) here as well. In this case, I need to update the resource after it has been fetched, but before it’s cached. Again, the best way to do so was to override the ResourceFetcher::fetchResource() method in the SavasLabsResourceFetcher class. I added:

// Allow other modules to alter the data.
$provider = $data['provider_name'] ? $this->providers->get($data['provider_name']) : NULL;
$this->moduleHandler->alter('savas_labs_oembed_resource_data', $data, $provider);

Just before:

$this->cacheSet($cache_id, $data);

One thing to note with this new hook (hook_savas_labs_oembed_resource_data_alter) is that a variable is passed for the provider \Drupal\media\OEmbed\Provider $provider if we can match it. That way in our module I can check to see if the provider is Twitter before modifying the height. I then implemented the hook in our module:


/**
 * Implements hook_savas_labs_oembed_resource_data_alter().
 */
function savas_labs_savas_labs_oembed_resource_data_alter(&$data, Provider $provider = NULL) {
  if ($provider && 'Twitter' == $provider->getName()) {
    if (empty($data['height'])) {
      $data['height'] = 440;
    }
  }
}

Our use case is quite simple. Check to see if the provider is Twitter. If yes and there is no height, set it to 440px. More complex requirements could certainly be added here.

That’s it! With those two new hooks (hook_savas_labs_oembed_providers_alter and hook_savas_labs_oembed_resource_data_alter) I was able to solve our problems.

Looking forward

Both of these issues have been discussed within the Drupal community and there are efforts to add similar functionality to the Drupal core. I’ve linked the relevant Drupal issues below.

Provide hook_oembed_providers_alter()
https://www.drupal.org/project/drupal/issues/3008119

The oembed Resource value object should be more permissive for NULL dimensions
https://www.drupal.org/project/drupal/issues/3071682