Browse Source

Adding 'related' link headers for entity references (#71)

* Adding 'related' link headers for entity references

(cherry picked from commit 10ac4e444b68c4fa8db13a04289acd68276b786b)

* Using event listener now so REST requests get the headers too

* Updating @covers annotation on test

* Coding standards

* Adding authZ test

* Caching properly now
pull/756/head
dannylamb 7 years ago committed by Jared Whiklo
parent
commit
23f696959a
  1. 44
      islandora.module
  2. 5
      islandora.services.yml
  3. 136
      src/EventSubscriber/NodeLinkHeaderSubscriber.php
  4. 61
      tests/src/Functional/IslandoraFunctionalTestBase.php
  5. 147
      tests/src/Functional/RelatedLinkHeaderTest.php

44
islandora.module

@ -181,47 +181,3 @@ function islandora_entity_delete(EntityInterface $entity) {
}
}
}
/**
* Implements hook_node_view_alter().
*/
function islandora_node_view_alter(&$build, EntityInterface $entity) {
// Return if memberof field does not exist.
if ($entity->hasField('field_memberof') == FALSE) {
return;
}
// Return if memberof field has no values.
$collection_members = $entity->get('field_memberof')->getValue();
if (count($collection_members) == 0) {
return;
}
// Loop through each member and add to the collection_links.
$collection_links = [];
foreach ($collection_members as $member_info) {
$collection_id = $member_info['target_id'];
$collection_entity = $entity->load($collection_id);
// If collection entity does not exist, skip.
if ($collection_entity == NULL) {
continue;
}
// If entity bundle type is not Collection, skip.
$collection_entity_bundle = $collection_entity->bundle();
if ($collection_entity_bundle != "islandora_collection") {
continue;
}
$collection_entity_url = $collection_entity->url('canonical', ['absolute' => TRUE]);
array_push($collection_links, "<" . $collection_entity_url . ">; rel='collection'");
}
if (count($collection_links) > 0) {
$collection_links_str = implode(", ", $collection_links);
$build['#attached']['http_header'] = [
['Link', $collection_links_str],
];
}
}

5
islandora.services.yml

@ -18,6 +18,11 @@ services:
arguments: ['@entity_type.manager', '@current_user']
tags:
- { name: event_subscriber }
islandora.node_link_header_subscriber:
class: Drupal\islandora\EventSubscriber\NodeLinkHeaderSubscriber
arguments: ['@entity_field.manager', '@current_route_match']
tags:
- { name: event_subscriber }
islandora.versioncounter:
class: Drupal\islandora\VersionCounter\VersionCounter
arguments: ['@database']

136
src/EventSubscriber/NodeLinkHeaderSubscriber.php

@ -0,0 +1,136 @@
<?php
namespace Drupal\islandora\EventSubscriber;
use Drupal\Core\Entity\EntityFieldManager;
use Drupal\Core\Routing\RouteMatchInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Class NodeLinkHeaderSubscriber.
*
* @package Drupal\islandora\EventSubscriber
*/
class NodeLinkHeaderSubscriber implements EventSubscriberInterface {
/**
* The entity field manager.
*
* @var \Drupal\Core\Entity\EntityFieldManager
*/
protected $entityFieldManager;
/**
* The route match object.
*
* @var \Drupal\Core\Routing\RouteMatchInterface
*/
protected $routeMatch;
/**
* Constructor.
*
* @param \Drupal\Core\Entity\EntityFieldManager $entity_field_manager
* The entity field manager.
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The route match object.
*/
public function __construct(
EntityFieldManager $entity_field_manager,
RouteMatchInterface $route_match
) {
$this->entityFieldManager = $entity_field_manager;
$this->routeMatch = $route_match;
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents() {
// Run this early so the headers get cached.
$events[KernelEvents::RESPONSE][] = ['onResponse', 129];
return $events;
}
/**
* Adds node-specific link headers to appropriate responses.
*
* @param \Symfony\Component\HttpKernel\Event\FilterResponseEvent $event
* Event containing the response.
*/
public function onResponse(FilterResponseEvent $event) {
$response = $event->getResponse();
// Exit early if the response is already cached.
if ($response->headers->get('X-Drupal-Dynamic-Cache') == 'HIT') {
return;
}
if (!$response->isOk()) {
return;
}
// Hack the node out of the route.
$route_object = $this->routeMatch->getRouteObject();
if (!$route_object) {
return;
}
$methods = $route_object->getMethods();
$is_get = in_array('GET', $methods);
$is_head = in_array('HEAD', $methods);
if (!($is_get || $is_head)) {
return;
}
$route_contexts = $route_object->getOption('parameters');
if (!$route_contexts) {
return;
}
if (!isset($route_contexts['node'])) {
return;
}
$node = $this->routeMatch->getParameter('node');
if (!$node) {
return;
}
// Use the node to add link headers for each entity reference.
$bundle = $node->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 ($node->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\"";
}
}
}
// Exit early if there aren't any.
if (empty($links)) {
return;
}
// Add the link headers to the response.
$response->headers->set('Link', $links, FALSE);
}
}

61
tests/src/Functional/IslandoraFunctionalTestBase.php

@ -2,6 +2,7 @@
namespace Drupal\Tests\islandora\Functional;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Tests\BrowserTestBase;
use Drupal\Tests\TestFileCreationTrait;
@ -120,4 +121,64 @@ class IslandoraFunctionalTestBase extends BrowserTestBase {
$this->assertResponse(200);
}
/**
* Utility function to check if a link header is included in the response.
*
* @param string $rel
* The relation to search for.
*
* @return bool
* TRUE if link header with relation is included in the response.
*/
protected function doesNotHaveLinkHeader($rel) {
$headers = $this->getSession()->getResponseHeaders();
foreach ($headers['Link'] as $link_header) {
if (strpos($link_header, "rel=\"$rel\"") !== FALSE) {
return FALSE;
}
}
return TRUE;
}
/**
* Checks if the collection link header contains the correct uri.
*
* @param string $rel
* The expected relation type of the link header.
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity whose uri is expected in the link header.
* @param string $title
* The expected title of the link header.
*
* @return int
* The number of times the correct header appears.
*/
protected function validateLinkHeader($rel, EntityInterface $entity, $title = '') {
$entity_url = $entity->url('canonical', ['absolute' => TRUE]);
$regex = '/<(.*)>; rel="' . preg_quote($rel) . '"';
if (!empty($title)) {
$regex .= '; title="' . preg_quote($title) . '"';
}
$regex .= '/';
$count = 0;
$headers = $this->getSession()->getResponseHeaders();
foreach ($headers['Link'] as $link_headers) {
$split = explode(',', $link_headers);
foreach ($split as $link_header) {
$matches = [];
if (preg_match($regex, $link_header, $matches) && $matches[1] == $entity_url) {
$count++;
}
}
}
return $count;
}
}

147
tests/src/Functional/RelatedLinkHeaderTest.php

@ -0,0 +1,147 @@
<?php
namespace Drupal\Tests\islandora\Functional;
use Drupal\field\Tests\EntityReference\EntityReferenceTestTrait;
use Drupal\Tests\media_entity\Functional\MediaEntityFunctionalTestTrait;
/**
* Tests the RelatedLinkHeader view alter.
*
* @group islandora
*/
class RelatedLinkHeaderTest extends IslandoraFunctionalTestBase {
use EntityReferenceTestTrait;
use MediaEntityFunctionalTestTrait;
/**
* Node that has entity reference field.
*
* @var \Drupal\node\NodeInterface
*/
protected $referencer;
/**
* Node that has entity reference field, but it's empty.
*
* @var \Drupal\node\NodeInterface
*/
protected $referenced;
/**
* Media to be referenced (to check authZ).
*
* @var \Drupal\media\MediaInterface
*/
protected $media;
/**
* Node of a bundle that does _not_ have an entity reference field.
*
* @var \Drupal\node\NodeInterface
*/
protected $other;
/**
* {@inheritdoc}
*/
public function setUp() {
parent::setUp();
// Create a test content type with an entity reference field.
$test_type_with_reference = $this->container->get('entity_type.manager')->getStorage('node_type')->create([
'type' => 'test_type_with_reference',
'label' => 'Test Type With Reference',
]);
$test_type_with_reference->save();
// Add two entity reference fields.
// One for nodes and one for media.
$this->createEntityReferenceField('node', 'test_type_with_reference', 'field_reference', 'Referenced Entity', 'node', 'default', [], 2);
$this->createEntityReferenceField('node', 'test_type_with_reference', 'field_media', 'Media Entity', 'media', 'default', [], 2);
$this->other = $this->container->get('entity_type.manager')->getStorage('node')->create([
'type' => 'test_type',
'title' => 'Test object w/o entity reference field',
]);
$this->other->save();
$this->referenced = $this->container->get('entity_type.manager')->getStorage('node')->create([
'type' => 'test_type_with_reference',
'title' => 'Referenced',
]);
$this->referenced->save();
$media_bundle = $this->drupalCreateMediaBundle();
$this->media = $this->container->get('entity_type.manager')->getStorage('media')->create([
'bundle' => $media_bundle->id(),
'name' => 'Media',
]);
$this->media->save();
$this->referencer = $this->container->get('entity_type.manager')->getStorage('node')->create([
'type' => 'test_type_with_reference',
'title' => 'Referencer',
'field_reference' => [$this->referenced->id()],
'field_media' => [$this->media->id()],
]);
$this->referencer->save();
}
/**
* @covers \Drupal\islandora\EventSubscriber\NodeLinkHeaderSubscriber::onResponse
*/
public function testRelatedLinkHeader() {
// 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.
$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.
$this->drupalGet('node/' . $this->referenced->id());
$this->assertTrue(
$this->doesNotHaveLinkHeader('related'),
"Node that has empty entity reference field must not return link header."
);
// Visit the referencer. It should return a rel="related" link header
// for both the referenced node and media entity.
$this->drupalGet('node/' . $this->referencer->id());
$this->assertTrue(
$this->validateLinkHeader('related', $this->referenced, 'Referenced Entity') == 1,
"Malformed related link header"
);
$this->assertTrue(
$this->validateLinkHeader('related', $this->media, 'Media Entity') == 1,
"Malformed related link header"
);
// 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.
$this->drupalGet('node/' . $this->referencer->id());
$this->assertTrue(
$this->validateLinkHeader('related', $this->referenced, 'Referenced Entity') == 1,
"Malformed related link header"
);
$this->assertTrue(
$this->validateLinkHeader('related', $this->media, 'Media Entity') == 0,
"Anonymous should not be able to see media link header"
);
}
}
Loading…
Cancel
Save