Browse Source

Media source update (#74)

* Route to PUT file contents for a Media

* README update

* Updating permissions

* Updating thumbnails

* Touching up tests
pull/756/head
dannylamb 7 years ago committed by Natkeeran
parent
commit
28f9bdbc8c
  1. 19
      README.md
  2. 10
      islandora.routing.yml
  3. 4
      islandora.services.yml
  4. 130
      src/Controller/MediaSourceController.php
  5. 196
      src/MediaSource/MediaSourceService.php
  6. 1
      tests/src/Functional/IslandoraFunctionalTestBase.php
  7. 158
      tests/src/Functional/MediaSourceControllerTest.php
  8. BIN
      tests/static/test.jpeg

19
README.md

@ -10,8 +10,23 @@ CLAW's core Islandora module for Drupal 8.x
## Installation
For a fully automated install, see [claw-playbook](https://github.com/Islandora-Devops/claw-playbook). If you're installing
manually, the REST configuration for both Nodes and Media need to be enabled. `jwt_auth` and `jsonld` formats needs to be set
for both, with Media additionally needing the `json` format.
manually, the REST configuration for both Nodes and Media need to be enabled with `jwt_auth` for authentication and both
`json` and `jsonld` formats.
## REST API
Islandora has a light, mostly RESTful HTTP API that relies heavily on Drupal's core Rest module.
### /media/{media}/source
You can PUT content to the `/media/{media}/source` endpoint to update the File associated with a Media. The `Content-Type`
header is expected, as well as a `Content-Disposition` header of the form `attachment; filename="your_filename"` to indicate
the name to give the file. Requests with empty bodies or no `Content-Length` header will be rejected.
Example usage:
```
curl -u admin:islandora -v -X PUT -H 'Content-Type: image/png' -H 'Content-Disposition: attachment; filename="my_image.png"' --data-binary @my_image.png localhost:8000/media/1/source
```
## Maintainers

10
islandora.routing.yml

@ -23,3 +23,13 @@ islandora.jsonldcontext:
_controller: '\Drupal\islandora\Controller\JsonLdContextController::content'
requirements:
_permission: 'access content'
islandora.media_source_update:
path: '/media/{media}/source'
defaults:
_controller: '\Drupal\islandora\Controller\MediaSourceController::put'
methods: [PUT]
requirements:
_permission: 'update media'
options:
_auth: ['basic_auth', 'cookie', 'jwt_auth']

4
islandora.services.yml

@ -39,3 +39,7 @@ services:
arguments: ['@current_route_match']
tags:
- { name: 'context_provider' }
islandora.media_source_service:
class: Drupal\islandora\MediaSource\MediaSourceService
factory: ['Drupal\islandora\MediaSource\MediaSourceService', create]
arguments: ['@entity_type.manager', '@stream_wrapper_manager']

130
src/Controller/MediaSourceController.php

@ -0,0 +1,130 @@
<?php
namespace Drupal\islandora\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Database\Connection;
use Drupal\media_entity\MediaInterface;
use Drupal\islandora\MediaSource\MediaSourceService;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\HttpException;
/**
* Class MediaSourceController.
*
* @package Drupal\islandora\Controller
*/
class MediaSourceController extends ControllerBase {
/**
* Service for business logic.
*
* @var \Drupal\islandora\MediaSource\MediaSourceService
*/
protected $service;
/**
* Database connection.
*
* @var \Drupal\Core\Database\Connection
*/
protected $database;
/**
* MediaSourceController constructor.
*
* @param \Drupal\islandora\MediaSource\MediaSourceService $service
* Service for business logic.
* @param \Drupal\Core\Database\Connection $database
* Database connection.
*/
public function __construct(
MediaSourceService $service,
Connection $database
) {
$this->service = $service;
$this->database = $database;
}
/**
* Controller's create method for dependecy injection.
*
* @param \Symfony\Component\DependencyInjection\ContainerInterface $container
* The App Container.
*
* @return \Drupal\islandora\Controller\MediaSourceController
* Controller instance.
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('islandora.media_source_service'),
$container->get('database')
);
}
/**
* Updates a source file for a Media.
*
* @param \Drupal\media_entity\MediaInterface $media
* The media whose source file you want to update.
* @param \Symfony\Component\HttpFoundation\Request $request
* The request object.
*
* @return \Symfony\Component\HttpFoundation\Response
* 204 on success.
*
* @throws \Symfony\Component\HttpKernel\Exception\HttpException
*/
public function put(MediaInterface $media, Request $request) {
// Since we update both the Media and its File, do this in a transaction.
$transaction = $this->database->startTransaction();
try {
$content_type = $request->headers->get('Content-Type', "");
if (empty($content_type)) {
throw new BadRequestHttpException("Missing Content-Type header");
}
$content_length = $request->headers->get('Content-Length', 0);
if ($content_length <= 0) {
throw new BadRequestHttpException("Missing Content-Length");
}
$content_disposition = $request->headers->get('Content-Disposition', "");
if (empty($content_disposition)) {
throw new BadRequestHttpException("Missing Content-Disposition header");
}
$matches = [];
if (!preg_match('/attachment; filename="(.*)"/', $content_disposition, $matches)) {
throw new BadRequestHttpException("Malformed Content-Disposition header");
}
$filename = $matches[1];
$this->service->updateSourceField(
$media,
$request->getContent(TRUE),
$content_type,
$content_length,
$filename
);
return new Response("", 204);
}
catch (HttpException $e) {
$transaction->rollBack();
throw $e;
}
catch (\Exception $e) {
$transaction->rollBack();
throw new HttpException(500, $e->getMessage());
}
}
}

196
src/MediaSource/MediaSourceService.php

@ -0,0 +1,196 @@
<?php
namespace Drupal\islandora\MediaSource;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeManager;
use Drupal\Core\StreamWrapper\StreamWrapperManager;
use Drupal\media_entity\MediaInterface;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Utility functions for working with source files for Media.
*/
class MediaSourceService {
/**
* Media bundle storage.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
protected $mediaBundleStorage;
/**
* Field config storage.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
protected $fieldConfigStorage;
/**
* Stream wrapper manager.
*
* @var \Drupal\Core\StreamWrapper\StreamWrapperManager
*/
protected $streamWrapperManager;
/**
* Constructor.
*
* @param \Drupal\Core\Entity\EntityStorageInterface $media_bundle_storage
* Media bundle storage.
* @param \Drupal\Core\Entity\EntityStorageInterface $field_config_storage
* Field config storage.
* @param \Drupal\Core\StreamWrapper\StreamWrapperManager $stream_wrapper_manager
* Stream wrapper manager.
*/
public function __construct(
EntityStorageInterface $media_bundle_storage,
EntityStorageInterface $field_config_storage,
StreamWrapperManager $stream_wrapper_manager
) {
$this->mediaBundleStorage = $media_bundle_storage;
$this->fieldConfigStorage = $field_config_storage;
$this->streamWrapperManager = $stream_wrapper_manager;
}
/**
* Factory.
*
* @param \Drupal\Core\Entity\EntityTypeManager $entity_type_manager
* The entity type manager.
* @param \Drupal\Core\StreamWrapper\StreamWrapperManager $stream_wrapper_manager
* Stream wrapper manager.
*
* @return \Drupal\islandora\MediaSource\MediaSourceService
* MediaSourceService instance.
*/
public static function create(
EntityTypeManager $entity_type_manager,
StreamWrapperManager $stream_wrapper_manager
) {
return new static(
$entity_type_manager->getStorage('media_bundle'),
$entity_type_manager->getStorage('field_config'),
$stream_wrapper_manager
);
}
/**
* Gets the name of a source field for a Media.
*
* @param string $media_bundle
* Media bundle whose source field you are searching for.
*
* @return string|null
* Field name if it exists in configuration, else NULL.
*/
public function getSourceFieldName($media_bundle) {
$bundle = $this->mediaBundleStorage->load($media_bundle);
$type_configuration = $bundle->getTypeConfiguration();
if (!isset($type_configuration['source_field'])) {
return NULL;
}
return $type_configuration['source_field'];
}
/**
* Gets a list of valid file extensions for a field.
*
* @param string $entity_type
* Entity type (node, media, etc...).
* @param string $bundle
* Bundle the field belongs to.
* @param string $field
* The field whose valid extensions you're looking for.
*
* @return string
* Space delimited string containing valid extensions.
*/
public function getFileFieldExtensions($entity_type, $bundle, $field) {
$field_config = $this->fieldConfigStorage->load("$entity_type.$bundle.$field");
if (!$field_config) {
return "";
}
return $field_config->getSetting('file_extensions');
}
/**
* Updates a media's source field with the supplied resource.
*
* @param \Drupal\media_entity\MediaInterface $media
* The media to update.
* @param resource $resource
* New file contents as a resource.
* @param string $mimetype
* New mimetype of contents.
* @param string $content_length
* New size of contents.
* @param string $filename
* New filename for contents.
*
* @throws HttpException
*/
public function updateSourceField(
MediaInterface $media,
$resource,
$mimetype,
$content_length,
$filename
) {
// Get the source field for the media type.
$source_field = $this->getSourceFieldName($media->bundle());
if (empty($source_field)) {
throw new NotFoundHttpException("Source field not set for {$media->bundle()} media");
}
// Get the file from the media.
$files = $media->get($source_field)->referencedEntities();
$file = reset($files);
// Set relevant fields on file.
$file->setMimeType($mimetype);
$file->setFilename($filename);
$file->setSize($content_length);
// Validate file extension.
$entity_type = $media->getEntityTypeId();
$bundle = $media->bundle();
$valid_extensions = $this->getFileFieldExtensions($entity_type, $bundle, $source_field);
$errors = file_validate_extensions($file, $valid_extensions);
if (!empty($errors)) {
throw new BadRequestHttpException("Invalid file extension. Valid types are :$valid_extensions");
}
// Copy the contents over using streams.
$uri = $file->getFileUri();
$file_stream_wrapper = $this->streamWrapperManager->getViaUri($uri);
$path = "";
$file_stream_wrapper->stream_open($uri, 'w', STREAM_REPORT_ERRORS, $path);
$file_stream = $file_stream_wrapper->stream_cast(STREAM_CAST_AS_STREAM);
if (stream_copy_to_stream($resource, $file_stream) === FALSE) {
throw new HttpException(500, "The file could not be copied into $uri");
}
$file->save();
// Set fields provided by type plugin and mapped in bundle configuration
// for the media.
foreach ($media->bundle->entity->field_map as $source => $destination) {
if ($media->hasField($destination) && $value = $media->getType()->getField($media, $source)) {
$media->set($destination, $value);
}
}
// Flush the image cache for the image so thumbnails get regenerated.
image_path_flush($uri);
$media->save();
}
}

1
tests/src/Functional/IslandoraFunctionalTestBase.php

@ -103,6 +103,7 @@ class IslandoraFunctionalTestBase extends BrowserTestBase {
$this->getSession()->getPage()->fillField('edit-field-image-0-alt', 'alt text');
$this->getSession()->getPage()->pressButton(t('Save and publish'));
$this->assertResponse(200);
return $this->getUrl();
}
/**

158
tests/src/Functional/MediaSourceControllerTest.php

@ -0,0 +1,158 @@
<?php
namespace Drupal\Tests\islandora\Functional;
use Drupal\Core\Url;
/**
* Tests updating Media source File with PUT.
*
* @group islandora
*/
class MediaSourceControllerTest extends IslandoraFunctionalTestBase {
/**
* {@inheritdoc}
*/
public function setUp() {
parent::setUp();
$media_rest_resource = $this->container->get('entity_type.manager')->getStorage('rest_resource_config')->create([
'id' => 'entity.media',
'granularity' => 'resource',
'configuration' => [
'methods' => ['GET'],
'authentication' => ['basic_auth'],
'formats' => ['json'],
],
'status' => TRUE,
]);
$media_rest_resource->save(TRUE);
$this->container->get('router.builder')->rebuildIfNeeded();
}
/**
* @covers \Drupal\islandora\Controller\MediaSourceController::put
*/
public function testMediaSourceUpdate() {
$account = $this->drupalCreateUser([
'view media',
'create media',
'update media',
]);
$this->drupalLogin($account);
// Make a media and give it a png.
$url = $this->createThumbnailWithFile();
// Hack out the guzzle client.
$client = $this->getSession()->getDriver()->getClient()->getClient();
// GET the media to stash its original values for comparison later.
$options = [
'auth' => [$account->getUsername(), $account->pass_raw],
'http_errors' => FALSE,
];
$response = $client->request('GET', $url . '?_format=json', $options);
$media = json_decode($response->getBody(), TRUE);
$mid = $media['mid'][0]['value'];
$original_mimetype = $media['field_mimetype'][0]['value'];
$original_width = $media['field_width'][0]['value'];
$original_height = $media['field_height'][0]['value'];
$original_image = file_get_contents($media['field_image'][0]['url']);
$media_update_url = Url::fromRoute('islandora.media_source_update', ['media' => $mid])
->setAbsolute()
->toString();
$image = file_get_contents(__DIR__ . '/../../static/test.jpeg');
// Update without Content-Type header should fail with 400.
$options = [
'auth' => [$account->getUsername(), $account->pass_raw],
'http_errors' => FALSE,
'headers' => [
'Content-Disposition' => 'attachment; filename="test.jpeg"',
],
'body' => $image,
];
$response = $client->request('PUT', $media_update_url, $options);
$this->assertTrue($response->getStatusCode() == 400, "Expected 400, received {$response->getStatusCode()}");
// Update without Content-Disposition header should fail with 400.
$options = [
'auth' => [$account->getUsername(), $account->pass_raw],
'http_errors' => FALSE,
'headers' => [
'Content-Type' => 'image/jpeg',
],
'body' => $image,
];
$response = $client->request('PUT', $media_update_url, $options);
$this->assertTrue($response->getStatusCode() == 400, "Expected 400, received {$response->getStatusCode()}");
// Update with malformed Content-Disposition header should fail with 400.
$options = [
'auth' => [$account->getUsername(), $account->pass_raw],
'http_errors' => FALSE,
'headers' => [
'Content-Type' => 'image/jpeg',
'Content-Disposition' => 'attachment; garbage="test.jpeg"',
],
'body' => $image,
];
$response = $client->request('PUT', $media_update_url, $options);
$this->assertTrue($response->getStatusCode() == 400, "Expected 400, received {$response->getStatusCode()}");
// Update without body should fail with 400.
$options = [
'auth' => [$account->getUsername(), $account->pass_raw],
'http_errors' => FALSE,
'headers' => [
'Content-Type' => 'image/jpeg',
'Content-Disposition' => 'attachment; filename="test.jpeg"',
],
];
$response = $client->request('PUT', $media_update_url, $options);
$this->assertTrue($response->getStatusCode() == 400, "Expected 400, received {$response->getStatusCode()}");
// Should be successful.
$options = [
'auth' => [$account->getUsername(), $account->pass_raw],
'http_errors' => FALSE,
'headers' => [
'Content-Type' => 'image/jpeg',
'Content-Disposition' => 'attachment; filename="test.jpeg"',
],
'body' => $image,
];
$response = $client->request('PUT', $media_update_url, $options);
$this->assertTrue($response->getStatusCode() == 204, "Expected 204, received {$response->getStatusCode()}");
// GET the media again and compare image and metadata.
$options = [
'auth' => [$account->getUsername(), $account->pass_raw],
'http_errors' => FALSE,
];
$response = $client->request('GET', $url . '?_format=json', $options);
$updated = json_decode($response->getBody(), TRUE);
$updated_mimetype = $updated['field_mimetype'][0]['value'];
$updated_width = $updated['field_width'][0]['value'];
$updated_height = $updated['field_height'][0]['value'];
$updated_image = file_get_contents($updated['field_image'][0]['url']);
$this->assertTrue($original_mimetype != $updated_mimetype, "Mimetypes should be updated with media source update");
$this->assertTrue($original_width != $updated_width, "Height should be updated with media source update");
$this->assertTrue($original_height != $updated_height, "Width should be updated with media source update");
$this->assertTrue($original_image != $updated_image, "Width should be updated with media source update");
$this->assertTrue($updated_mimetype == "image/jpeg", "Invalid mimetype. Expected image/jpeg, received $updated_mimetype");
$this->assertTrue($updated_width == 295, "Invalid width. Expected 295, received $updated_width");
$this->assertTrue($updated_height == 70, "Invalid height. Expected 70, received $updated_height");
$this->assertTrue($updated_image == file_get_contents(__DIR__ . '/../../static/test.jpeg'), "Updated image not the same as PUT body.");
}
}

BIN
tests/static/test.jpeg

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Loading…
Cancel
Save