diff --git a/islandora.services.yml b/islandora.services.yml index 1a34ed37..0ba96794 100644 --- a/islandora.services.yml +++ b/islandora.services.yml @@ -57,4 +57,4 @@ services: arguments: ['@config.factory', '@logger.channel.islandora'] islandora.gemini.lookup: class: Drupal\islandora\GeminiLookup - arguments: ['@islandora.gemini.client', '@jwt.authentication.jwt', '@logger.channel.islandora'] + arguments: ['@islandora.gemini.client', '@jwt.authentication.jwt', '@islandora.media_source_service', '@http_client', '@logger.channel.islandora'] diff --git a/src/GeminiLookup.php b/src/GeminiLookup.php index b10471b0..20bd42e0 100644 --- a/src/GeminiLookup.php +++ b/src/GeminiLookup.php @@ -3,10 +3,14 @@ namespace Drupal\islandora; use Drupal\Core\Entity\EntityInterface; +use Drupal\islandora\MediaSource\MediaSourceService; use Drupal\jwt\Authentication\Provider\JwtAuth; +use GuzzleHttp\Psr7; +use GuzzleHttp\Client; +use GuzzleHttp\Exception\RequestException; use Islandora\Crayfish\Commons\Client\GeminiClient; use Psr\Log\LoggerInterface; -use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; /** * Locates the matching Fedora URI from the Gemini database. @@ -29,6 +33,20 @@ class GeminiLookup { */ private $jwtProvider; + /** + * A MediaSourceService. + * + * @var \Drupal\islandora\MediaSource\MediaSourceService + */ + private $mediaSource; + + /** + * An http client. + * + * @var \GuzzleHttp\Client + */ + private $guzzle; + /** * The islandora logger channel. * @@ -43,32 +61,27 @@ class GeminiLookup { * The Gemini client. * @param \Drupal\jwt\Authentication\Provider\JwtAuth $jwt_auth * The JWT provider. + * @param \Drupal\islandora\MediaSource\MediaSourceService $media_source + * Media source service. + * @param \GuzzleHttp\Client $guzzle + * Guzzle client. * @param \Psr\Log\LoggerInterface $logger * The Islandora logger. */ - public function __construct(GeminiClient $client, JwtAuth $jwt_auth, LoggerInterface $logger) { + public function __construct( + GeminiClient $client, + JwtAuth $jwt_auth, + MediaSourceService $media_source, + Client $guzzle, + LoggerInterface $logger + ) { $this->geminiClient = $client; $this->jwtProvider = $jwt_auth; + $this->mediaSource = $media_source; + $this->guzzle = $guzzle; $this->logger = $logger; } - /** - * Static creator. - * - * @param \Symfony\Component\DependencyInjection\ContainerInterface $container - * The container. - * - * @return \Drupal\islandora\GeminiLookup - * A GeminiLookup service. - */ - public static function create(ContainerInterface $container) { - return new static( - $container->get('islandora.gemini_client'), - $container->get('jwt.authentication.jwt'), - $container->get('logger.channel.islandora') - ); - } - /** * Lookup this entity's URI in the Gemini db and return the other URI. * @@ -77,24 +90,72 @@ class GeminiLookup { * * @return string|null * Return the URI or null - * - * @throws \Drupal\Core\Entity\EntityMalformedException - * If the entity cannot be converted to a URL. */ public function lookup(EntityInterface $entity) { - if ($entity->id() != NULL) { - $drupal_uri = $entity->toUrl()->setAbsolute()->toString(); - $drupal_uri .= '?_format=jsonld'; - $token = "Bearer " . $this->jwtProvider->generateToken(); - $linked_uri = $this->geminiClient->findByUri($drupal_uri, $token); - if (!is_null($linked_uri)) { - if (is_array($linked_uri)) { - $linked_uri = reset($linked_uri); + // Exit early if the entity hasn't been saved yet. + if ($entity->id() == NULL) { + return NULL; + } + + $is_media = $entity->getEntityTypeId() == 'media'; + + // Use the entity's uuid unless it's a media, + // use its file's uuid instead. + if ($is_media) { + try { + $file = $this->mediaSource->getSourceFile($entity); + $uuid = $file->uuid(); + } + // If the media has no source file, exit early. + catch (NotFoundHttpException $e) { + return NULL; + } + } + else { + $uuid = $entity->uuid(); + } + + // Look it up in Gemini. + $token = "Bearer " . $this->jwtProvider->generateToken(); + $urls = $this->geminiClient->getUrls($uuid, $token); + + // Exit early if there's no results from Gemini. + if (empty($urls)) { + return NULL; + } + + // If it's not a media, just return the url from Gemini;. + if (!$is_media) { + return $urls['fedora']; + } + + // If it's a media, perform a HEAD request against + // the file in Fedora and get its 'describedy' link header. + try { + $head = $this->guzzle->head( + $urls['fedora'], + ['allow_redirects' => FALSE, 'headers' => ['Authorization' => $token]] + ); + $links = Psr7\parse_header($head->getHeader("Link")); + foreach ($links as $link) { + if ($link['rel'] == 'describedby') { + return trim($link[0], '<>'); } - return $linked_uri; } } - // Return null if we weren't in a saved entity or we didn't find a uri. + catch (RequestException $e) { + $this->logger->warn( + "Error performing Gemini lookup for media. Fedora HEAD to @url returned @status => @message", + [ + '@url' => $urls['fedora'], + '@status' => $e->getCode(), + '@message' => $e->getMessage, + ] + ); + return NULL; + } + + // Return null if no link header is found. return NULL; } diff --git a/tests/src/Kernel/GeminiLookupTest.php b/tests/src/Kernel/GeminiLookupTest.php index 9c96b6c2..600fea73 100644 --- a/tests/src/Kernel/GeminiLookupTest.php +++ b/tests/src/Kernel/GeminiLookupTest.php @@ -3,12 +3,17 @@ namespace Drupal\Tests\islandora\Kernel; use Drupal\Core\Entity\EntityInterface; -use Drupal\Core\Url; +use Drupal\file\FileInterface; +use Drupal\media\MediaInterface; use Drupal\islandora\GeminiLookup; +use Drupal\islandora\MediaSource\MediaSourceService; use Drupal\jwt\Authentication\Provider\JwtAuth; +use GuzzleHttp\Client; +use GuzzleHttp\Psr7\Response; use Islandora\Crayfish\Commons\Client\GeminiClient; use Prophecy\Argument; use Psr\Log\LoggerInterface; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; /** * Class GeminiLookupTest. @@ -18,104 +23,233 @@ use Psr\Log\LoggerInterface; */ class GeminiLookupTest extends IslandoraKernelTestBase { - private $geminiLookup; + private $jwtAuth; + + private $logger; + + private $guzzle; private $geminiClient; - private $jwtAuth; + private $mediaSource; - private $logger; + private $entity; + + private $media; /** * {@inheritdoc} */ public function setUp() { parent::setUp(); + + // Mock up dummy objects by default. $prophecy = $this->prophesize(JwtAuth::class); - $prophecy->generateToken()->willReturn("islandora"); $this->jwtAuth = $prophecy->reveal(); $prophecy = $this->prophesize(LoggerInterface::class); $this->logger = $prophecy->reveal(); + $prophecy = $this->prophesize(MediaSourceService::class); + $this->mediaSource = $prophecy->reveal(); + + $prophecy = $this->prophesize(GeminiClient::class); + $this->geminiClient = $prophecy->reveal(); + + $prophecy = $this->prophesize(Client::class); + $this->guzzle = $prophecy->reveal(); + + // Mock up an entity to use (node in this case). + $prophecy = $this->prophesize(EntityInterface::class); + $prophecy->id()->willReturn(1); + $prophecy->getEntityTypeId()->willReturn('node'); + $prophecy->uuid()->willReturn('abc123'); + $this->entity = $prophecy->reveal(); + + // Mock up a media to use. + $prophecy = $this->prophesize(MediaInterface::class); + $prophecy->id()->willReturn(1); + $prophecy->getEntityTypeId()->willReturn('media'); + $prophecy->uuid()->willReturn('abc123'); + $this->media = $prophecy->reveal(); + } + + /** + * Mocks up a gemini client that fails its lookup. + */ + private function mockGeminiClientForFail() { + $prophecy = $this->prophesize(GeminiClient::class); + $prophecy->getUrls(Argument::any(), Argument::any()) + ->willReturn([]); + $this->geminiClient = $prophecy->reveal(); + } + + /** + * Mocks up a gemini client that finds a fedora url. + */ + private function mockGeminiClientForSuccess() { $prophecy = $this->prophesize(GeminiClient::class); - $prophecy->findByUri(Argument::any(), Argument::any())->willReturn(NULL); + $prophecy->getUrls(Argument::any(), Argument::any()) + ->willReturn(['drupal' => '', 'fedora' => 'http://localhost:8080/fcrepo/rest/abc123']); $this->geminiClient = $prophecy->reveal(); } + /** + * Mocks up a media source service that finds the source file for a media. + */ + private function mockMediaSourceForSuccess() { + $prophecy = $this->prophesize(FileInterface::class); + $prophecy->uuid()->willReturn('abc123'); + $file = $prophecy->reveal(); + + $prophecy = $this->prophesize(MediaSourceService::class); + $prophecy->getSourceFile(Argument::any()) + ->willReturn($file); + $this->mediaSource = $prophecy->reveal(); + } + + /** + * Make the gemini lookup out of class variables. + */ + private function createGeminiLookup() { + return new GeminiLookup( + $this->geminiClient, + $this->jwtAuth, + $this->mediaSource, + $this->guzzle, + $this->logger + ); + } + /** * @covers ::lookup * @covers ::__construct - * @throws \Drupal\Core\Entity\EntityMalformedException */ public function testEntityNotSaved() { + // Mock an entity that returns a null id. + // That means it's not saved in the db yet. $prophecy = $this->prophesize(EntityInterface::class); $prophecy->id()->willReturn(NULL); - $entity = $prophecy->reveal(); - $this->geminiLookup = new GeminiLookup( - $this->geminiClient, - $this->jwtAuth, - $this->logger + $this->entity = $prophecy->reveal(); + + $gemini_lookup = $this->createGeminiLookup(); + + $this->assertEquals( + NULL, + $gemini_lookup->lookup($this->entity) ); - $this->assertEquals(NULL, $this->geminiLookup->lookup($entity)); } /** * @covers ::lookup * @covers ::__construct - * @throws \Drupal\Core\Entity\EntityMalformedException */ public function testEntityNotFound() { - $prop1 = $this->prophesize(Url::class); - $prop1->toString()->willReturn("http://localhost:8000/node/456"); + $this->mockGeminiClientForFail(); - $prop2 = $this->prophesize(Url::class); - $prop2->setAbsolute()->willReturn($prop1->reveal()); - $url = $prop2->reveal(); + $gemini_lookup = $this->createGeminiLookup(); - $prophecy = $this->prophesize(EntityInterface::class); - $prophecy->id()->willReturn(456); - $prophecy->toUrl()->willReturn($url); - $entity = $prophecy->reveal(); - - $this->geminiLookup = new GeminiLookup( - $this->geminiClient, - $this->jwtAuth, - $this->logger + $this->assertEquals( + NULL, + $gemini_lookup->lookup($this->entity) ); - - $this->assertEquals(NULL, $this->geminiLookup->lookup($entity)); } /** * @covers ::lookup * @covers ::__construct - * @throws \Drupal\Core\Entity\EntityMalformedException */ public function testEntityFound() { - $prop1 = $this->prophesize(Url::class); - $prop1->toString()->willReturn("http://localhost:8000/node/456"); + $this->mockGeminiClientForSuccess(); - $prop2 = $this->prophesize(Url::class); - $prop2->setAbsolute()->willReturn($prop1->reveal()); - $url = $prop2->reveal(); + $gemini_lookup = $this->createGeminiLookup(); - $prophecy = $this->prophesize(EntityInterface::class); - $prophecy->id()->willReturn(456); - $prophecy->toUrl()->willReturn($url); - $entity = $prophecy->reveal(); + $this->assertEquals( + 'http://localhost:8080/fcrepo/rest/abc123', + $gemini_lookup->lookup($this->entity) + ); + } - $prophecy = $this->prophesize(GeminiClient::class); - $prophecy->findByUri(Argument::any(), Argument::any())->willReturn(["http://fedora:8080/some/uri"]); - $this->geminiClient = $prophecy->reveal(); + /** + * @covers ::lookup + * @covers ::__construct + */ + public function testMediaHasNoSourceFile() { + // Mock a media source service that fails to find + // the source file for a media. + $prophecy = $this->prophesize(MediaSourceService::class); + $prophecy->getSourceFile(Argument::any()) + ->willThrow(new NotFoundHttpException("Media has no source")); + $this->mediaSource = $prophecy->reveal(); + + $gemini_lookup = $this->createGeminiLookup(); + + $this->assertEquals( + NULL, + $gemini_lookup->lookup($this->media) + ); + } - $this->geminiLookup = new GeminiLookup( - $this->geminiClient, - $this->jwtAuth, - $this->logger + /** + * @covers ::lookup + * @covers ::__construct + */ + public function testMediaNotFound() { + $this->mockMediaSourceForSuccess(); + $this->mockGeminiClientForFail(); + + $gemini_lookup = $this->createGeminiLookup(); + + $this->assertEquals( + NULL, + $gemini_lookup->lookup($this->media) + ); + } + + /** + * @covers ::lookup + * @covers ::__construct + */ + public function testFileFoundButNoDescribedby() { + $this->mockMediaSourceForSuccess(); + $this->mockGeminiClientForSuccess(); + + // Mock up a guzzle client that does not return + // the describedby header. + $prophecy = $this->prophesize(Client::class); + $prophecy->head(Argument::any(), Argument::any()) + ->willReturn(new Response(200, [])); + $this->guzzle = $prophecy->reveal(); + + $gemini_lookup = $this->createGeminiLookup(); + + $this->assertEquals( + NULL, + $gemini_lookup->lookup($this->media) ); + } - $this->assertEquals("http://fedora:8080/some/uri", $this->geminiLookup->lookup($entity)); + /** + * @covers ::lookup + * @covers ::__construct + */ + public function testMediaFound() { + $this->mockMediaSourceForSuccess(); + $this->mockGeminiClientForSuccess(); + + // Mock up a guzzle client that returns + // the describedby header. + $prophecy = $this->prophesize(Client::class); + $prophecy->head(Argument::any(), Argument::any()) + ->willReturn(new Response(200, ['Link' => '; rel="describedby"'])); + $this->guzzle = $prophecy->reveal(); + + $gemini_lookup = $this->createGeminiLookup(); + + $this->assertEquals( + 'http://localhost:8080/fcrepo/rest/abc123/fcr:metadata', + $gemini_lookup->lookup($this->media) + ); } }