diff --git a/islandora.services.yml b/islandora.services.yml index 8e24ca1c..133b9e01 100644 --- a/islandora.services.yml +++ b/islandora.services.yml @@ -20,12 +20,12 @@ services: - { name: event_subscriber } islandora.media_link_header_subscriber: class: Drupal\islandora\EventSubscriber\MediaLinkHeaderSubscriber - arguments: ['@entity_field.manager', '@current_route_match', '@entity_type.manager'] + arguments: ['@entity_type.manager', '@entity_field.manager', '@current_route_match', '@access_manager', '@current_user'] tags: - { name: event_subscriber } islandora.node_link_header_subscriber: class: Drupal\islandora\EventSubscriber\NodeLinkHeaderSubscriber - arguments: ['@entity_field.manager', '@current_route_match'] + arguments: ['@entity_type.manager', '@entity_field.manager', '@current_route_match', '@access_manager', '@current_user'] tags: - { name: event_subscriber } islandora.versioncounter: diff --git a/src/EventSubscriber/LinkHeaderSubscriber.php b/src/EventSubscriber/LinkHeaderSubscriber.php index 0e08ab80..eb016ce7 100644 --- a/src/EventSubscriber/LinkHeaderSubscriber.php +++ b/src/EventSubscriber/LinkHeaderSubscriber.php @@ -2,8 +2,13 @@ namespace Drupal\islandora\EventSubscriber; +use Drupal\Core\Access\AccessManagerInterface; use Drupal\Core\Entity\EntityFieldManager; +use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Entity\EntityTypeManager; use Drupal\Core\Routing\RouteMatchInterface; +use Drupal\Core\Session\AccountInterface; +use Drupal\Core\Url; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\FilterResponseEvent; @@ -16,6 +21,13 @@ use Symfony\Component\HttpKernel\KernelEvents; */ abstract class LinkHeaderSubscriber implements EventSubscriberInterface { + /** + * The entity type manager. + * + * @var \Drupal\Core\Entity\EntityTypeManager + */ + protected $entityTypeManager; + /** * The entity field manager. * @@ -33,17 +45,45 @@ abstract class LinkHeaderSubscriber implements EventSubscriberInterface { /** * Constructor. * + * The access manager. + * + * @var \Drupal\Core\Access\AccessManagerInterface + */ + protected $accessManager; + + /** + * Current user. + * + * @var \Drupal\Core\Session\AccountInterface + */ + protected $account; + + /** + * Constructor. + * + * @param \Drupal\Core\Entity\EntityTypeManager $entity_type_manager + * The entity type manager. * @param \Drupal\Core\Entity\EntityFieldManager $entity_field_manager * The entity field manager. * @param \Drupal\Core\Routing\RouteMatchInterface $route_match * The route match object. + * @param \Drupal\Core\Access\AccessManagerInterface $access_manager + * The access manager. + * @param \Drupal\Core\Session\AccountInterface $account + * The current user. */ public function __construct( + EntityTypeManager $entity_type_manager, EntityFieldManager $entity_field_manager, - RouteMatchInterface $route_match + RouteMatchInterface $route_match, + AccessManagerInterface $access_manager, + AccountInterface $account ) { + $this->entityTypeManager = $entity_type_manager; $this->entityFieldManager = $entity_field_manager; $this->routeMatch = $route_match; + $this->accessManager = $access_manager; + $this->account = $account; } /** @@ -113,6 +153,109 @@ abstract class LinkHeaderSubscriber implements EventSubscriberInterface { return $object; } + /** + * Generates link headers for each referenced entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * Entity that has reference fields. + * + * @return string[] + * Array of link headers + */ + protected function generateEntityReferenceLinks(EntityInterface $entity) { + // Use the node to add link headers for each entity reference. + $entity_type = $entity->getEntityType()->id(); + $bundle = $entity->bundle(); + + // Get all fields for the entity. + $fields = $this->entityFieldManager->getFieldDefinitions($entity_type, $bundle); + + // Strip out everything but entity references that are not base fields. + $entity_reference_fields = array_filter($fields, function ($field) { + return $field->getFieldStorageDefinition()->isBaseField() == FALSE && $field->getType() == "entity_reference"; + }); + + // Collect links for referenced entities. + $links = []; + foreach ($entity_reference_fields as $field_name => $field_definition) { + foreach ($entity->get($field_name)->referencedEntities() as $referencedEntity) { + // Headers are subject to an access check. + if ($referencedEntity->access('view')) { + $entity_url = $referencedEntity->url('canonical', ['absolute' => TRUE]); + $field_label = $field_definition->label(); + $links[] = "<$entity_url>; rel=\"related\"; title=\"$field_label\""; + } + } + } + + return $links; + } + + /** + * Generates link headers for REST endpoints. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * Entity that has reference fields. + * + * @return string[] + * Array of link headers + */ + protected function generateRestLinks(EntityInterface $entity) { + $rest_resource_config_storage = $this->entityTypeManager->getStorage('rest_resource_config'); + $entity_type = $entity->getEntityType()->id(); + $rest_resource_config = $rest_resource_config_storage->load("entity.$entity_type"); + + $links = []; + $route_name = $this->routeMatch->getRouteName(); + + if ($rest_resource_config) { + $configuration = $rest_resource_config->get('configuration'); + + foreach ($configuration['GET']['supported_formats'] as $format) { + switch ($format) { + case 'json': + $mime = 'application/json'; + break; + + case 'jsonld': + $mime = 'application/ld+json'; + break; + + case 'hal_json': + $mime = 'application/hal+json'; + break; + + case 'xml': + $mime = 'application/xml'; + break; + + default: + continue; + } + + $meta_route_name = "rest.entity.$entity_type.GET.$format"; + + if ($route_name == $meta_route_name) { + continue; + } + + $route_params = [$entity_type => $entity->id()]; + + if (!$this->accessManager->checkNamedRoute($meta_route_name, $route_params, $this->account)) { + continue; + } + + $meta_url = Url::fromRoute($meta_route_name, $route_params) + ->setAbsolute() + ->toString(); + + $links[] = "<$meta_url?_format=$format>; rel=\"alternate\"; type=\"$mime\""; + } + } + + return $links; + } + /** * Adds resource-specific link headers to appropriate responses. * diff --git a/src/EventSubscriber/MediaLinkHeaderSubscriber.php b/src/EventSubscriber/MediaLinkHeaderSubscriber.php index 6974183b..e45d2c88 100644 --- a/src/EventSubscriber/MediaLinkHeaderSubscriber.php +++ b/src/EventSubscriber/MediaLinkHeaderSubscriber.php @@ -2,11 +2,8 @@ namespace Drupal\islandora\EventSubscriber; -use Drupal\Core\Entity\EntityFieldManager; -use Drupal\Core\Entity\EntityTypeManagerInterface; -use Drupal\Core\Entity\FieldableEntityInterface; -use Drupal\Core\Routing\RouteMatchInterface; use Drupal\Core\Url; +use Drupal\media_entity\MediaInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpKernel\Event\FilterResponseEvent; @@ -17,80 +14,77 @@ use Symfony\Component\HttpKernel\Event\FilterResponseEvent; */ class MediaLinkHeaderSubscriber extends LinkHeaderSubscriber implements EventSubscriberInterface { - /** - * Media storage interface. - * - * @var \Drupal\Core\Entity\EntityStorageInterface - */ - protected $mediaBundleStorage; - - /** - * MediaLinkHeaderSubscriber constructor. - * - * @param \Drupal\Core\Entity\EntityFieldManager $entity_field_manager - * The entity field manager. - * @param \Drupal\Core\Routing\RouteMatchInterface $route_match - * The route match object. - * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager - * The entity type manager. - */ - public function __construct( - EntityFieldManager $entity_field_manager, - RouteMatchInterface $route_match, - EntityTypeManagerInterface $entity_type_manager) { - $this->mediaBundleStorage = $entity_type_manager->getStorage('media_bundle'); - parent::__construct($entity_field_manager, $route_match); - } - /** * {@inheritdoc} */ public function onResponse(FilterResponseEvent $event) { $response = $event->getResponse(); - $entity = $this->getObject($response, 'media'); + $media = $this->getObject($response, 'media'); - if ($entity === FALSE) { + if ($media === FALSE) { return; } - $media_bundle = $this->mediaBundleStorage->load($entity->bundle()); + $links = array_merge( + $this->generateEntityReferenceLinks($media), + $this->generateRestLinks($media), + $this->generateMediaLinks($media) + ); + + // Add the link headers to the response. + if (empty($links)) { + return; + } + + $response->headers->set('Link', $links, FALSE); + } + + /** + * Generates link headers for the described file and source update routes. + * + * @param \Drupal\media_entity\MediaInterface $media + * Media to generate link headers. + * + * @return string[] + * Array of link headers + */ + protected function generateMediaLinks(MediaInterface $media) { + $media_bundle = $this->entityTypeManager->getStorage('media_bundle')->load($media->bundle()); $type_configuration = $media_bundle->getTypeConfiguration(); + $links = []; + + $update_route_name = 'islandora.media_source_update'; + $update_route_params = ['media' => $media->id()]; + if ($this->accessManager->checkNamedRoute($update_route_name, $update_route_params, $this->account)) { + $edit_media_url = Url::fromRoute($update_route_name, $update_route_params) + ->setAbsolute() + ->toString(); + $links[] = "<$edit_media_url>; rel=\"edit-media\""; + } + if (!isset($type_configuration['source_field'])) { - return; + return $links; } $source_field = $type_configuration['source_field']; if (empty($source_field) || - !$entity instanceof FieldableEntityInterface || - !$entity->hasField($source_field) + !$media->hasField($source_field) ) { - return; + return $links; } // Collect file links for the media. - $links = []; - foreach ($entity->get($source_field)->referencedEntities() as $referencedEntity) { - if ($entity->access('view')) { + foreach ($media->get($source_field)->referencedEntities() as $referencedEntity) { + if ($referencedEntity->access('view')) { $file_url = $referencedEntity->url('canonical', ['absolute' => TRUE]); - $edit_media_url = Url::fromRoute('islandora.media_source_update', ['media' => $referencedEntity->id()]) - ->setAbsolute() - ->toString(); $links[] = "<$file_url>; rel=\"describes\"; type=\"{$referencedEntity->getMimeType()}\""; - $links[] = "<$edit_media_url>; rel=\"edit-media\""; } } - // Exit early if there aren't any. - if (empty($links)) { - return; - } - - // Add the link headers to the response. - $response->headers->set('Link', $links, FALSE); - + return $links; } } diff --git a/src/EventSubscriber/NodeLinkHeaderSubscriber.php b/src/EventSubscriber/NodeLinkHeaderSubscriber.php index 3155d936..e5bff9c6 100644 --- a/src/EventSubscriber/NodeLinkHeaderSubscriber.php +++ b/src/EventSubscriber/NodeLinkHeaderSubscriber.php @@ -21,42 +21,22 @@ class NodeLinkHeaderSubscriber extends LinkHeaderSubscriber implements EventSubs public function onResponse(FilterResponseEvent $event) { $response = $event->getResponse(); - $entity = $this->getObject($response, 'node'); + $node = $this->getObject($response, 'node'); - if ($entity === FALSE) { + if ($node === FALSE) { return; } - // Use the node to add link headers for each entity reference. - $bundle = $entity->bundle(); - - // Get all fields for the entity. - $fields = $this->entityFieldManager->getFieldDefinitions('node', $bundle); - - // Strip out everything but entity references that are not base fields. - $entity_reference_fields = array_filter($fields, function ($field) { - return $field->getFieldStorageDefinition()->isBaseField() == FALSE && $field->getType() == "entity_reference"; - }); - - // Collect links for referenced entities. - $links = []; - foreach ($entity_reference_fields as $field_name => $field_definition) { - foreach ($entity->get($field_name)->referencedEntities() as $referencedEntity) { - // Headers are subject to an access check. - if ($referencedEntity->access('view')) { - $entity_url = $referencedEntity->url('canonical', ['absolute' => TRUE]); - $field_label = $field_definition->label(); - $links[] = "<$entity_url>; rel=\"related\"; title=\"$field_label\""; - } - } - } + $links = array_merge( + $this->generateEntityReferenceLinks($node), + $this->generateRestLinks($node) + ); - // Exit early if there aren't any. + // Add the link headers to the response. if (empty($links)) { return; } - // Add the link headers to the response. $response->headers->set('Link', $links, FALSE); } diff --git a/tests/src/Functional/IslandoraFunctionalTestBase.php b/tests/src/Functional/IslandoraFunctionalTestBase.php index 63d2b727..f347287a 100644 --- a/tests/src/Functional/IslandoraFunctionalTestBase.php +++ b/tests/src/Functional/IslandoraFunctionalTestBase.php @@ -4,6 +4,8 @@ namespace Drupal\Tests\islandora\Functional; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Url; +use Drupal\rest\Entity\RestResourceConfig; +use Drupal\rest\RestResourceConfigInterface; use Drupal\Tests\BrowserTestBase; use Drupal\Tests\TestFileCreationTrait; @@ -38,6 +40,144 @@ class IslandoraFunctionalTestBase extends BrowserTestBase { $this->container->get('entity_type.manager')->getStorage('context')->load('media')->delete(); $this->container->get('entity_type.manager')->getStorage('context')->load('file')->delete(); + // Set up basic REST config. + // Delete the node rest config that's bootstrapped with Drupal. + $this->container->get('entity_type.manager')->getStorage('rest_resource_config')->load('entity.node')->delete(); + + // Create our own for Nodes, Media, and Files. + $this->container->get('entity_type.manager')->getStorage('rest_resource_config')->create([ + 'id' => 'entity.node', + 'granularity' => RestResourceConfigInterface::METHOD_GRANULARITY, + 'configuration' => [ + 'GET' => [ + 'supported_auth' => [ + 'cookie', + 'basic_auth', + 'jwt_auth', + ], + 'supported_formats' => [ + 'json', + 'jsonld', + ], + ], + 'POST' => [ + 'supported_auth' => [ + 'basic_auth', + 'jwt_auth', + ], + 'supported_formats' => [ + 'json', + ], + ], + 'DELETE' => [ + 'supported_auth' => [ + 'basic_auth', + 'jwt_auth', + ], + 'supported_formats' => [ + 'json', + ], + ], + 'PATCH' => [ + 'supported_auth' => [ + 'basic_auth', + 'jwt_auth', + ], + 'supported_formats' => [ + 'json', + ], + ], + ], + ])->save(); + $this->container->get('entity_type.manager')->getStorage('rest_resource_config')->create([ + 'id' => 'entity.media', + 'granularity' => RestResourceConfigInterface::METHOD_GRANULARITY, + 'configuration' => [ + 'GET' => [ + 'supported_auth' => [ + 'cookie', + 'basic_auth', + 'jwt_auth', + ], + 'supported_formats' => [ + 'json', + 'jsonld', + ], + ], + 'POST' => [ + 'supported_auth' => [ + 'basic_auth', + 'jwt_auth', + ], + 'supported_formats' => [ + 'json', + ], + ], + 'DELETE' => [ + 'supported_auth' => [ + 'basic_auth', + 'jwt_auth', + ], + 'supported_formats' => [ + 'json', + ], + ], + 'PATCH' => [ + 'supported_auth' => [ + 'basic_auth', + 'jwt_auth', + ], + 'supported_formats' => [ + 'json', + ], + ], + ], + ])->save(); + RestResourceConfig::create([ + 'id' => 'entity.file', + 'granularity' => RestResourceConfigInterface::METHOD_GRANULARITY, + 'configuration' => [ + 'GET' => [ + 'supported_auth' => [ + 'cookie', + 'basic_auth', + 'jwt_auth', + ], + 'supported_formats' => [ + 'json', + 'jsonld', + ], + ], + 'POST' => [ + 'supported_auth' => [ + 'basic_auth', + 'jwt_auth', + ], + 'supported_formats' => [ + 'json', + ], + ], + 'DELETE' => [ + 'supported_auth' => [ + 'basic_auth', + 'jwt_auth', + ], + 'supported_formats' => [ + 'json', + ], + ], + 'PATCH' => [ + 'supported_auth' => [ + 'basic_auth', + 'jwt_auth', + ], + 'supported_formats' => [ + 'json', + ], + ], + ], + ])->save(); + // Create a test content type. $test_type = $this->container->get('entity_type.manager')->getStorage('node_type')->create([ 'type' => 'test_type', diff --git a/tests/src/Functional/MappingUriPredicateReactionTest.php b/tests/src/Functional/MappingUriPredicateReactionTest.php index 6b95e087..31d27bd5 100644 --- a/tests/src/Functional/MappingUriPredicateReactionTest.php +++ b/tests/src/Functional/MappingUriPredicateReactionTest.php @@ -33,25 +33,6 @@ class MappingUriPredicateReactionTest extends IslandoraFunctionalTestBase { ]) ->save(); - $resourceConfigStorage = $this->container - ->get('entity_type.manager') - ->getStorage('rest_resource_config'); - // There is already a setting for entity.node, so delete it. - $resourceConfigStorage - ->delete($resourceConfigStorage - ->loadMultiple(['entity.node'])); - // Create it new. - $resourceConfigStorage->create([ - 'id' => 'entity.node', - 'granularity' => 'resource', - 'configuration' => [ - 'methods' => ['GET'], - 'authentication' => ['basic_auth', 'cookie'], - 'formats' => ['jsonld'], - ], - 'status' => TRUE, - ])->save(TRUE); - $this->container->get('router.builder')->rebuildIfNeeded(); } diff --git a/tests/src/Functional/MediaLinkHeaderTest.php b/tests/src/Functional/MediaLinkHeaderTest.php index b7b742b9..232dc117 100644 --- a/tests/src/Functional/MediaLinkHeaderTest.php +++ b/tests/src/Functional/MediaLinkHeaderTest.php @@ -33,6 +33,21 @@ class MediaLinkHeaderTest extends IslandoraFunctionalTestBase { $this->validateLinkHeaderWithUrl('edit-media', $urls['file']['rest'], '', '') == 1, "Malformed 'edit-media' link header" ); + + // Check for links to REST endpoints for metadata. + $this->assertTrue( + $this->validateLinkHeaderWithUrl('alternate', $urls['media'] . "?_format=json", NULL, 'application/json') == 1, + "Media must have link header pointing to json REST endpoint." + ); + $this->assertTrue( + $this->validateLinkHeaderWithUrl('alternate', $urls['media'] . "?_format=jsonld", NULL, 'application/ld+json') == 1, + "Media must have link header pointing to jsonld REST endpoint." + ); + $this->assertTrue( + $this->validateLinkHeaderWithUrl('alternate', $urls['media'] . "?_format=xml", NULL, 'application/xml') == 0, + "Media must not have link header pointing to disabled xml REST endpoint." + ); + } } diff --git a/tests/src/Functional/MediaSourceUpdateTest.php b/tests/src/Functional/MediaSourceUpdateTest.php index 1f5cc487..80c45f24 100644 --- a/tests/src/Functional/MediaSourceUpdateTest.php +++ b/tests/src/Functional/MediaSourceUpdateTest.php @@ -11,27 +11,6 @@ use Drupal\Core\Url; */ class MediaSourceUpdateTest 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 */ diff --git a/tests/src/Functional/RelatedLinkHeaderTest.php b/tests/src/Functional/NodeLinkHeaderTest.php similarity index 68% rename from tests/src/Functional/RelatedLinkHeaderTest.php rename to tests/src/Functional/NodeLinkHeaderTest.php index 385c66f2..f80367d7 100644 --- a/tests/src/Functional/RelatedLinkHeaderTest.php +++ b/tests/src/Functional/NodeLinkHeaderTest.php @@ -10,7 +10,7 @@ use Drupal\Tests\media_entity\Functional\MediaEntityFunctionalTestTrait; * * @group islandora */ -class RelatedLinkHeaderTest extends IslandoraFunctionalTestBase { +class NodeLinkHeaderTest extends IslandoraFunctionalTestBase { use EntityReferenceTestTrait; use MediaEntityFunctionalTestTrait; @@ -92,23 +92,23 @@ class RelatedLinkHeaderTest extends IslandoraFunctionalTestBase { /** * @covers \Drupal\islandora\EventSubscriber\NodeLinkHeaderSubscriber::onResponse */ - public function testRelatedLinkHeader() { + public function testNodeLinkHeaders() { // Create a test user that can see media. $account = $this->drupalCreateUser([ 'view media', ]); $this->drupalLogin($account); - // Visit the other, there should not be a header since it does not even - // have the field. + // Visit the other, there should not be a related header since it does not + // even have the field. $this->drupalGet('node/' . $this->other->id()); $this->assertTrue( $this->doesNotHaveLinkHeader('related'), "Node that does not have entity reference field must not return related link header." ); - // Visit the referenced node, there should not be a header since its - // entity reference field is empty. + // Visit the referenced node, there should not be a related header since + // its entity reference field is empty. $this->drupalGet('node/' . $this->referenced->id()); $this->assertTrue( $this->doesNotHaveLinkHeader('related'), @@ -127,12 +127,41 @@ class RelatedLinkHeaderTest extends IslandoraFunctionalTestBase { "Malformed related link header" ); + // Check for links to REST endpoints for metadata. + $entity_url = $this->referencer->toUrl('canonical', ['absolute' => TRUE]) + ->toString(); + $this->assertTrue( + $this->validateLinkHeaderWithUrl('alternate', "$entity_url?_format=json", NULL, 'application/json') == 1, + "Node must have link header pointing to json REST endpoint." + ); + $this->assertTrue( + $this->validateLinkHeaderWithUrl('alternate', "$entity_url?_format=jsonld", NULL, 'application/ld+json') == 1, + "Node must have link header pointing to jsonld REST endpoint." + ); + $this->assertTrue( + $this->validateLinkHeaderWithUrl('alternate', "$entity_url?_format=xml", NULL, 'application/xml') == 0, + "Node must not have link header pointing to disabled xml REST endpoint." + ); + + // Check that the current representation is not advertised when visitng + // a REST endpoint (e.g. the json link header doesn't appear when you're + // visiting the ?_format=json endpoint). + $this->drupalGet('node/' . $this->referencer->id(), ['query' => ['_format' => 'json']]); + $this->assertTrue( + $this->validateLinkHeaderWithUrl('alternate', "$entity_url?_format=json", NULL, 'application/json') == 0, + "Node must not have link header pointing to json REST endpoint when vising the json REST endpoint." + ); + $this->assertTrue( + $this->validateLinkHeaderWithUrl('alternate', "$entity_url?_format=jsonld", NULL, 'application/ld+json') == 1, + "Node must have link header pointing to jsonld REST endpoint when visiting the json REST endpoint." + ); + // Log in as anonymous. $account = $this->drupalCreateUser(); $this->drupalLogin($account); // Visit the referencer. It should return a rel="related" link header - // for both the referenced node and media entity. + // for both the referenced node bun not the media entity b/c permissions. $this->drupalGet('node/' . $this->referencer->id()); $this->assertTrue( $this->validateLinkHeaderWithEntity('related', $this->referenced, 'Referenced Entity') == 1,