Our own stream wrapper

At the beginning of this chapter, we briefly talked about stream wrappers and what they are used for. We saw that Drupal comes with four mainstream wrappers that map to the various types of file storage it needs. Now it's time to see how we can create our own. And the main reason why we would want to implement one is to expose resources at a specific location to PHP's native filesystem functions.

In this example, we will create a very simple stream wrapper that can basically only read the data from the resource. Just to keep things simple. And the data resource will be the product images hosted remotely (the ones we are importing via the JSON Importer). So there will be some rework there to use the new stream wrapper instead of the absolute URLs. Moreover, we will also learn how to use the site-wide settings service by which we can have environment-specific configurations set in the settings.php file and then read by our code.

The native way of registering a stream wrapper in PHP is by using the stream_wrapper_register() function. However, in Drupal 8, we have an abstraction layer on top of that in the form of services. So a stream wrapper is a simple tagged service, albeit with many potential methods. Let's see its definition, which we add to the products.services.yml file:

products.images_stream_wrapper: 
  class: Drupal\products\StreamWrapper\ProductsStreamWrapper 
  tags: 
    - { name: stream_wrapper, scheme: products }  

Nothing too complicated. The service is tagged with stream_wrapper and we use the scheme key to indicate the scheme of the wrapper. So the URIs will be in this format:

products://target  

One important thing to note about stream wrapper services is that we cannot pass dependencies to them. The reason is that they are not instantiated the normal way (by the container) but arbitrarily by PHP whenever some of its methods need to be called. So if we need to use some services, we'll have to use the static way of loading them.

The stream wrapper service class needs to implement StreamWrapperInterface which comes with a lot of methods. There are many possible filesystem interactions that PHP can do and these methods need to account for them all. However, we will only be focusing on a few specific ones that have to do with reading data. After all, our resources are remote and we don't even have a clue how to make changes to them over there. So for the rest of the methods, we will be returning FALSE to indicate that the operation cannot be performed.

Let's see this big class then:

namespace Drupal\products\StreamWrapper; 
 
use Drupal\Component\Utility\UrlHelper; 
use Drupal\Core\StreamWrapper\StreamWrapperInterface; 
use Drupal\Core\StringTranslation\StringTranslationTrait; 
 
/** 
 * Stream wrapper for the remote product image paths used by the JSON Importer. 
 */ 
class ProductsStreamWrapper implements StreamWrapperInterface { 
 
  use StringTranslationTrait; 
 
  /** 
   * The Stream URI 
   * 
   * @var string 
   */ 
  protected $uri; 
 
  /** 
   * @var \Drupal\Core\Site\Settings 
   */ 
  protected $settings; 
 
  /** 
   * Resource handle 
   * 
   * @var resource 
   */ 
  protected $handle; 
 
  /** 
   * ProductsStreamWrapper constructor. 
   */ 
  public function __construct() { 
    // Dependency injection does not work with stream wrappers. 
    $this->settings = \Drupal::service('settings'); 
  } 
 
  /** 
   * {@inheritdoc} 
   */ 
  public function getName() { 
    return $this->t('Product images stream wrapper'); 
  } 
 
  /** 
   * {@inheritdoc} 
   */ 
  public function getDescription() { 
    return $this->t('Stream wrapper for the remote location where product images can be found by the JSON Importer.'); 
  } 
 
  /** 
   * {@inheritdoc} 
   */ 
  public static function getType() { 
    return StreamWrapperInterface::HIDDEN; 
  } 
 
  /** 
   * {@inheritdoc} 
   */ 
  public function setUri($uri) { 
    $this->uri = $uri; 
  } 
 
  /** 
   * {@inheritdoc} 
   */ 
  public function getUri() { 
    return $this->uri; 
  } 
 
  /** 
   * Helper method that returns the local writable target of the resource within the stream. 
   * 
   * @param null $uri 
   * 
   * @return string 
   */ 
  public function getTarget($uri = NULL) { 
    if (!isset($uri)) { 
      $uri = $this->uri; 
    } 
 
    list($scheme, $target) = explode('://', $uri, 2); 
    return trim($target, '\/'); 
  } 
 
  /** 
   * {@inheritdoc} 
   */ 
  public function getExternalUrl() { 
    $path = str_replace('\\', '/', $this->getTarget()); 
    return $this->settings->get('product_images_path') . '/' . UrlHelper::encodePath($path); 
  } 
 
  /** 
   * {@inheritdoc} 
   */ 
  public function realpath() { 
    return $this->getTarget(); 
  } 
 
  /** 
   * {@inheritdoc} 
   */ 
  public function stream_open($path, $mode, $options, &$opened_path) { 
    $allowed_modes = array('r', 'rb'); 
    if (!in_array($mode, $allowed_modes)) { 
      return FALSE; 
    } 
    $this->uri = $path; 
    $url = $this->getExternalUrl(); 
    $this->handle = ($options && STREAM_REPORT_ERRORS) ? fopen($url, $mode) : @fopen($url, $mode); 
    return (bool) $this->handle; 
  } 
 
  /** 
   * {@inheritdoc} 
   */ 
  public function dir_closedir() { 
    return FALSE; 
  } 
 
  /** 
   * {@inheritdoc} 
   */ 
  public function dir_opendir($path, $options) { 
    return FALSE; 
  } 
 
  /** 
   * {@inheritdoc} 
   */ 
  public function dir_readdir() { 
    return FALSE; 
  } 
 
  /** 
   * {@inheritdoc} 
   */ 
  public function dir_rewinddir() { 
    return FALSE; 
  } 
 
  /** 
   * {@inheritdoc} 
   */ 
  public function mkdir($path, $mode, $options) { 
    return FALSE; 
  } 
 
  /** 
   * {@inheritdoc} 
   */ 
  public function rename($path_from, $path_to) { 
    return FALSE; 
  } 
 
  /** 
   * {@inheritdoc} 
   */ 
  public function rmdir($path, $options) { 
    return FALSE; 
  } 
 
  /** 
   * {@inheritdoc} 
   */ 
  public function stream_cast($cast_as) { 
    return FALSE; 
  } 
 
  /** 
   * {@inheritdoc} 
   */ 
  public function stream_close() { 
    return fclose($this->handle); 
  } 
 
  /** 
   * {@inheritdoc} 
   */ 
  public function stream_eof() { 
    return feof($this->handle); 
  } 
 
  /** 
   * {@inheritdoc} 
   */ 
  public function stream_flush() { 
    return FALSE; 
  } 
 
  /** 
   * {@inheritdoc} 
   */ 
  public function stream_lock($operation) { 
    return FALSE; 
  } 
 
  /** 
   * {@inheritdoc} 
   */ 
  public function stream_metadata($path, $option, $value) { 
    return FALSE; 
  } 
 
  /** 
   * {@inheritdoc} 
   */ 
  public function stream_read($count) { 
    return fread($this->handle, $count); 
  } 
 
  /** 
   * {@inheritdoc} 
   */ 
  public function stream_seek($offset, $whence = SEEK_SET) { 
    return FALSE; 
  } 
 
  /** 
   * {@inheritdoc} 
   */ 
  public function stream_set_option($option, $arg1, $arg2) { 
    return FALSE; 
  } 
 
  /** 
   * {@inheritdoc} 
   */ 
  public function stream_stat() { 
    return FALSE; 
  } 
 
  /** 
   * {@inheritdoc} 
   */ 
  public function stream_tell() { 
    return FALSE; 
  } 
 
  /** 
   * {@inheritdoc} 
   */ 
  public function stream_truncate($new_size) { 
    return FALSE; 
  } 
 
  /** 
   * {@inheritdoc} 
   */ 
  public function stream_write($data) { 
    return FALSE; 
  } 
 
  /** 
   * {@inheritdoc} 
   */ 
  public function unlink($path) { 
    return FALSE; 
  } 
 
  /** 
   * {@inheritdoc} 
   */ 
  public function url_stat($path, $flags) { 
    return FALSE; 
  } 
 
  /** 
   * {@inheritdoc} 
   */ 
  public function dirname($uri = NULL) { 
    return FALSE; 
  } 
}  

The first thing to look at is the constructor in which we statically load the Settings service and store it as a class property. And speaking of which, we also define a $uri property to hold the actual URI this wrapper wraps and a $handle property to hold a generic PHP resource handle.

The getName() and getDescription() methods are pretty straightforward and are used for identifying the stream wrapper, while the getType() method returns the type of stream. We'll go with the hidden type because we don't want it visible in the UI. It's strictly for programmatic use so that we can read our product images. Do check out the available types and their meanings by looking at the StreamWrapperInterface constants.

Then, we have a getter and setter for the $uri property by which the Drupal StreamWrapperManager can create an instance of our wrapper based on a given URI. The getTarget() method is actually not in the interface but is a helper to extract a clean target from the URI (the target being the second part of the URI that comes after scheme://). And we use this method in getExternalUrl(), which is quite an important method responsible for returning an absolute URL to the resource in question. But here we also use our Settings service to get the product_images_path key. If you remember in the beginning of the chapter, we saw that the path to the public filesystem is defined in the settings.php file like so:

$settings['file_public_path'] = 'sites/default/files';  

That $settings variable is the data array that is wrapped by the Settings service. So we want to do the same for defining our own remote path to the product images:

$settings['product_images_path'] = 'http://path/to/the/remote/product/images'; 

This way we are not committing to Git the actual remote URL and we can also change it later if we want. And this is the URL we are reading inside the getExternalUrl() method.

The other pillar of our read-only stream wrapper is the ability to open a file handle to the resource and allow us to read the data from it. And the stream_open() method does this as it gets called when we run either file_get_contents() or fopen() on our URI. Using the $mode parameter, we ensure that the operation is read-only and return FALSE otherwisewe do not support write or other flags.

Any mode can have b appended to it to indicate that the file should be opened in binary mode. So, where r indicates read-only, rb indicates read-only in binary mode.

The third argument is a bitmask of options defined by PHP. The one we're dealing with here is STREAM_REPORT_ERRORS, which indicates whether or not PHP errors should be suppressed (for instance, if a file is not found). The second is STREAM_USE_PATH, which indicates whether PHP's include path should be checked if a file is not found. This is not relevant to us, so we ignore it. If a file is found on the include path, then the fourth argument, ($opened_url)), should be set with the file's real path.

What we do then is translate the URI into the absolute URL of the external resource so that we can open a file handle on it. And in doing so, we make use of the STREAM_REPORT_ERRORS option to either prepend the @ to the fopen() function or not (doing so suppresses errors). Finally, we store the reference to the resource handle and return a Boolean based on it to indicate whether the operation succeeded.

Finally, we also implement the stream_read(), stream_eof(), and stream_close() methods so that we can actually also stream the resources if we want to. As for the rest of the methods, as already mentioned, we return FALSE.

All we have to do now is clear the cache and make use of our stream. As long as we have a valid URL declared in the settings.php file, our stream should work fine. And here are the kinds of things we could do with a URI like this:

$uri = 'products://tv.jpg'; 

To get the entire file content into a string, we can do this:

$contents = file_get_contents($uri);  

Or we can use the example from the beginning of the chapter and stream the file bit by bit:

$handle = fopen($uri, 'r'); 
$contents = ''; 
while (!feof($handle)) { 
  $contents .= fread($handle, 8192); 
} 
fclose($handle);  

All these file operations, such as opening, reading, checking the end of a file and closing, are possible due to our stream_*() method implementations from the wrapper.

And finally, maybe now it's also a bit clearer what we did when writing the CSV Importer and using the StreamWrapperManager to identify the stream wrapper responsible for a given URI, and based on that, the real path of the URI.

To end the section on stream wrappers, let's do some clean-up work by refactoring a bit our JsonImporter::handleProductImage() method. Our logic there involved hardcoding the URL to the remote API, which is really not a good idea. Instead, now that we have our stream wrapper, we can go ahead and use it. We can replace this:

// This needs to be hardcoded for the moment. 
$image_path = ''; 
$image = file_get_contents($image_path . '/' . $name);  

With this:

$image = file_get_contents('products://' . $name);  

It's that simple. And now we can control the remote URL from outside the Git repository and, if it changes, we don't even have to alter our code. Granted, solely for this purpose, implementing a stream wrapper seems a bit excessive. After all, you can simply inject the Settings service and use the URL in the Importer plugin itself allowing for the same kind of flexibility. But we used the opportunity to learn about stream wrappers and how to create our own. And we even managed to find a small use case in the process.