GetDKAN/dkan

View on GitHub
modules/datastore/src/Service/ResourceLocalizer.php

Summary

Maintainability
A
0 mins
Test Coverage
A
91%
<?php

namespace Drupal\datastore\Service;

use Contracts\FactoryInterface;
use Drupal\common\DataResource;
use Drupal\common\EventDispatcherTrait;
use Drupal\common\UrlHostTokenResolver;
use Drupal\common\Util\DrupalFiles;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Queue\QueueFactory;
use Drupal\common\Storage\FileFetcherJobStoreFactory;
use Drupal\metastore\Exception\AlreadyRegistered;
use Drupal\metastore\ResourceMapper;
use FileFetcher\FileFetcher;
use Procrastinator\Result;

/**
 * Resource localizer.
 */
class ResourceLocalizer {

  use EventDispatcherTrait;

  /**
   * Event sent when a resource is successfully localized.
   *
   * @var string
   */
  const EVENT_RESOURCE_LOCALIZED = 'event_resource_localized';

  /**
   * Perspective representing the local file with public:// URI scheme.
   *
   * @var string
   */
  const LOCAL_FILE_PERSPECTIVE = 'local_file';

  /**
   * Perspective representing local file with http:// scheme and bogus domain.
   *
   * @var string
   */
  const LOCAL_URL_PERSPECTIVE = 'local_url';

  /**
   * DKAN resource file mapper service.
   *
   * @var \Drupal\metastore\ResourceMapper
   */
  private ResourceMapper $resourceMapper;

  /**
   * DKAN resource file fetcher factory.
   *
   * @var \Contracts\FactoryInterface
   *
   * @see \Drupal\common\FileFetcher\FileFetcherFactory
   */
  private FactoryInterface $fileFetcherFactory;

  /**
   * Drupal files utility service.
   *
   * @var \Drupal\common\Util\DrupalFiles
   */
  private DrupalFiles $drupalFiles;

  /**
   * File fetcher job store factory.
   *
   * @var \Drupal\common\Storage\FileFetcherJobStoreFactory
   */
  private FileFetcherJobStoreFactory $fileFetcherJobStoreFactory;

  /**
   * Drupal queue.
   *
   * @var \Drupal\Core\Queue\QueueFactory
   */
  private QueueFactory $queueFactory;

  /**
   * Constructor.
   */
  public function __construct(
    ResourceMapper $fileMapper,
    FactoryInterface $fileFetcherFactory,
    DrupalFiles $drupalFiles,
    FileFetcherJobStoreFactory $fileFetcherJobStoreFactory,
    QueueFactory $queueFactory
  ) {
    $this->resourceMapper = $fileMapper;
    $this->fileFetcherFactory = $fileFetcherFactory;
    $this->drupalFiles = $drupalFiles;
    $this->fileFetcherJobStoreFactory = $fileFetcherJobStoreFactory;
    $this->queueFactory = $queueFactory;
  }

  /**
   * Copy the source file to the local file system.
   *
   * As a side effect, register new perspectives to the mapper DB.
   */
  protected function localize($identifier, $version = NULL): Result {
    if ($resource = $this->getResourceSource($identifier, $version)) {
      $ff = $this->getFileFetcher($resource);
      $result = $ff->run();
      // The result object should report DONE, even if the file has previously
      // been localized.
      if ($result->getStatus() === Result::DONE) {
        // Localization is done. Register the perspectives.
        $this->registerNewPerspectives($resource, $ff->getStateProperty('destination'));
        // Send the event.
        $this->dispatchEvent(static::EVENT_RESOURCE_LOCALIZED, [
          'identifier' => $resource->getIdentifier(),
          'version' => $resource->getVersion(),
        ]);
      }
      return $result;
    }
    $result = new Result();
    $result->setStatus(Result::ERROR);
    $result->setError('Unable to find resource to localize: ' . $identifier . ':' . $version);
    return $result;
  }

  /**
   * Either localize or queue a localization.
   *
   * @param string $identifier
   *   Resource identifier.
   * @param string|null $version
   *   (Optional) Resource version. If not provided, will use latest revision.
   * @param bool $deferred
   *   (Optional) If TRUE, queue a localization task, otherwise perform the
   *   localization. Defaults to FALSE.
   *
   * @return \Procrastinator\Result
   *   Result of the process. If deferred, will be the result of creating a
   *   queue item. Otherwise, will be the result of localizing. Result will be
   *   DONE if the localization had already occurred.
   */
  public function localizeTask(string $identifier, ?string $version = NULL, bool $deferred = FALSE): Result {
    if (!$deferred) {
      return $this->localize($identifier, $version);
    }
    $result = new Result();
    if ($this->queueFactory->get('localize_import')->createItem([
      'identifier' => $identifier,
      'version' => $version,
    ]) !== FALSE) {
      $result->setStatus(Result::DONE);
      $result->setError('Queued localize_import for ' . $identifier . ':' . $version);
      return $result;
    }
    $result->setStatus(Result::ERROR);
    $result->setError('Failed to create localize_import queue for ' . $identifier . ':' . $version);
    return $result;
  }

  /**
   * Create local file and URL perspectives in the mapper, get a perspective.
   *
   * Requires the localized file to exist so it can be checksummed.
   *
   * @return \Drupal\common\DataResource|null
   *   Return the perspective, or NULL if the source perspective did not exist.
   */
  public function get($identifier, $version = NULL, $perpective = self::LOCAL_FILE_PERSPECTIVE): ?DataResource {
    $resource = $this->getResourceSource($identifier, $version);

    if (!$resource) {
      return NULL;
    }

    $ff = $this->getFileFetcher($resource);

    if ($ff->getResult()->getStatus() != Result::DONE) {
      return NULL;
    }

    $this->registerNewPerspectives($resource, $ff->getStateProperty('destination'));

    return $this->resourceMapper->get($resource->getIdentifier(), $perpective, $resource->getVersion());
  }

  /**
   * Add local file and local URL perspectives to the resource mapper.
   */
  private function registerNewPerspectives(DataResource $resource, string $localFilePath) {
    $public_dir = 'file://' . $this->drupalFiles->getPublicFilesDirectory();
    $localFileDrupalUri = str_replace($public_dir, 'public://', $localFilePath);
    $localUrl = $this->drupalFiles->fileCreateUrl($localFileDrupalUri);
    $localUrl = UrlHostTokenResolver::hostify($localUrl);

    $new = $resource->createNewPerspective(self::LOCAL_FILE_PERSPECTIVE, $localFilePath);

    try {
      $this->resourceMapper->registerNewPerspective($new);
    }
    catch (AlreadyRegistered $e) {
    }

    $localUrlPerspective = $resource->createNewPerspective(self::LOCAL_URL_PERSPECTIVE, $localUrl);

    try {
      $this->resourceMapper->registerNewPerspective($localUrlPerspective);
    }
    catch (AlreadyRegistered $e) {
    }
  }

  /**
   * Remove local file.
   *
   * Also remove local perspectives from mapping DB.
   */
  public function remove($identifier, $version = NULL): void {
    // Remove the LOCAL_URL_PERSPECTIVE if it exists.
    if ($local_url_resource = $this->get($identifier, $version, self::LOCAL_URL_PERSPECTIVE)) {
      $this->resourceMapper->remove($local_url_resource);
    }
    // Remove the LOCAL_FILE_PERSPECTIVE if it exists.
    if ($resource = $this->get($identifier, $version, self::LOCAL_FILE_PERSPECTIVE)) {
      // Remove the file.
      if (file_exists($resource->getFilePath())) {
        $this->drupalFiles->getFilesystem()
          ->deleteRecursive($this->getPublicLocalizedDirectory($resource));
      }
      // Remove the fetcher job.
      $this->removeJob($resource->getUniqueIdentifierNoPerspective());
      // Remove the LOCAL_FILE_PERSPECTIVE.
      $this->resourceMapper->remove($resource);
    }
  }

  /**
   * Remove the filefetcher job record.
   */
  private function removeJob($uuid) {
    if ($uuid) {
      $this->fileFetcherJobStoreFactory->getInstance()->remove($uuid);
    }
  }

  /**
   * Private.
   */
  private function getResourceSource($identifier, $version = NULL): ?DataResource {
    return $this->resourceMapper->get($identifier, DataResource::DEFAULT_SOURCE_PERSPECTIVE, $version);
  }

  /**
   * Get a FileFetcher object for a source data resource, to copy to local.
   *
   * @param \Drupal\common\DataResource $sourceDataResource
   *   Data resource object we want to process. Assumed to be a source
   *   perspective.
   *
   * @return \FileFetcher\FileFetcher
   *   FileFetcher object which is ready to transfer the file.
   */
  public function getFileFetcher(DataResource $sourceDataResource): FileFetcher {
    return $this->fileFetcherFactory->getInstance(
      $sourceDataResource->getUniqueIdentifierNoPerspective(),
      [
        'filePath' => UrlHostTokenResolver::resolveFilePath($sourceDataResource->getFilePath()),
        'temporaryDirectory' => $this->getPublicLocalizedDirectory($sourceDataResource),
      ]
    );
  }

  /**
   * Resolve the source to the localized file path as a public URI.
   *
   * Note: The file fetcher also does this during the fetch.
   *
   * @param \Drupal\common\DataResource $source_resource
   *   Source DataResource.
   *
   * @return string
   *   Public URI for the temp localized file.
   *
   * @see \FileFetcher\Processor\ProcessorBase::getTemporaryFilePath()
   *
   * @todo Remove this from FileFetcher so concerns can be separated properly.
   */
  public function localizeFilePath(DataResource $source_resource): string {
    if ($source_resource->getPerspective() !== DataResource::DEFAULT_SOURCE_PERSPECTIVE) {
      throw new \InvalidArgumentException('DataResource must be source perspective.');
    }
    $public = $this->getPublicLocalizedDirectory($source_resource);
    return $public . '/' . basename($source_resource->getFilePath());
  }

  /**
   * Get the prepared directory path to the localized destination.
   *
   * Will attempt to create the path.
   *
   * @param \Drupal\common\DataResource $dataResource
   *   DataResource object to represent.
   * @param string $public_path
   *   Path within the public:// filesystem where this resource will eventually
   *   be created. Defaults to 'resource'.
   *
   * @return string
   *   Public scheme URI to the directory.
   *
   * @todo Create a config for $public_path.
   */
  public function getPublicLocalizedDirectory(DataResource $dataResource, string $public_path = 'resources'): string {
    $uri = 'public://' . $public_path . '/' . $dataResource->getUniqueIdentifierNoPerspective();
    $this->getFilesystem()
      ->prepareDirectory($uri, FileSystemInterface::CREATE_DIRECTORY);
    return $uri;
  }

  /**
   * Get the Drupal filesystem service.
   *
   * @return \Drupal\Core\File\FileSystemInterface
   *   Drupal filesystem.
   *
   * @todo Properly inject this service.
   */
  public function getFileSystem(): FileSystemInterface {
    return $this->drupalFiles->getFileSystem();
  }

  /**
   * Prepare the local perspective for a resource.
   *
   * Will do the following:
   * - Prepare the directory in the file system.
   * - Add the local_url perspective to the resource mapper. Note this is
   *   missing the file checksum.
   * - Display the info necessary to perform an external file fetch.
   *
   * @param string $identifier
   *   Datastore resource identifier, e.g., "b210fb966b5f68be0421b928631e5d51".
   *
   * @return array
   *   Various localization paths.
   */
  public function prepareLocalized(string $identifier): array {
    $info = [];
    if ($resource = $this->resourceMapper->get($identifier)) {
      $public_dir = $this->getPublicLocalizedDirectory($resource);
      $localized_filepath = $this->localizeFilePath($resource);
      $localized_resource = $resource->createNewPerspective(
        ResourceLocalizer::LOCAL_FILE_PERSPECTIVE, $localized_filepath
      );
      try {
        $this->resourceMapper->registerNewPerspective($localized_resource);
      }
      catch (AlreadyRegistered $e) {
        // Catch the already-registered exception.
      }
      $file_system = $this->getFileSystem();
      $info = [
        'source' => $resource->getFilePath(),
        'path_uri' => $public_dir,
        'path' => $file_system->realpath($public_dir),
        'file_uri' => $localized_filepath,
        'file' => $file_system->realpath($localized_filepath),
      ];
    }
    return $info;
  }

}