diff --git a/core/modules/media/media.services.yml b/core/modules/media/media.services.yml index 1b49c6ed4b9eb66da0c521faa03e91354110d8bb..db7e79a2bc9c734a8836e088630f3a23d4af57b3 100644 --- a/core/modules/media/media.services.yml +++ b/core/modules/media/media.services.yml @@ -12,7 +12,7 @@ services: arguments: ['@media.oembed.provider_repository', '@media.oembed.resource_fetcher', '@http_client', '@module_handler', '@cache.default'] media.oembed.provider_repository: class: Drupal\media\OEmbed\ProviderRepository - arguments: ['@http_client', '@config.factory', '@datetime.time', '@cache.default'] + arguments: ['@http_client', '@config.factory', '@datetime.time', '@keyvalue', '@logger.factory'] media.oembed.resource_fetcher: class: Drupal\media\OEmbed\ResourceFetcher arguments: ['@http_client', '@media.oembed.provider_repository', '@cache.default'] diff --git a/core/modules/media/src/OEmbed/ProviderRepository.php b/core/modules/media/src/OEmbed/ProviderRepository.php index 5ee7055ad4721ecdc048d842acd2c75ec314a0f4..159bafda55755ffb60bce8ac7c1eaefa93211723 100644 --- a/core/modules/media/src/OEmbed/ProviderRepository.php +++ b/core/modules/media/src/OEmbed/ProviderRepository.php @@ -4,8 +4,10 @@ use Drupal\Component\Datetime\TimeInterface; use Drupal\Component\Serialization\Json; -use Drupal\Core\Cache\CacheBackendInterface; use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\DependencyInjection\DeprecatedServicePropertyTrait; +use Drupal\Core\KeyValueStore\KeyValueFactoryInterface; +use Drupal\Core\Logger\LoggerChannelFactoryInterface; use GuzzleHttp\ClientInterface; use GuzzleHttp\Exception\TransferException; @@ -14,6 +16,17 @@ */ class ProviderRepository implements ProviderRepositoryInterface { + use DeprecatedServicePropertyTrait; + + /** + * The service properties that should raise a deprecation error. + * + * @var string[] + */ + private $deprecatedProperties = [ + 'cacheBackend' => 'cache.default', + ]; + /** * How long the provider data should be cached, in seconds. * @@ -43,11 +56,18 @@ class ProviderRepository implements ProviderRepositoryInterface { protected $time; /** - * The cache backend. + * The key-value store. + * + * @var \Drupal\Core\KeyValueStore\KeyValueStoreInterface + */ + protected $keyValue; + + /** + * The logger channel. * - * @var \Drupal\Core\Cache\CacheBackendInterface + * @var \Drupal\Core\Logger\LoggerChannelInterface */ - protected $cacheBackend; + protected $logger; /** * Constructs a ProviderRepository instance. @@ -58,44 +78,76 @@ class ProviderRepository implements ProviderRepositoryInterface { * The config factory service. * @param \Drupal\Component\Datetime\TimeInterface $time * The time service. - * @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend - * The cache backend. + * @param \Drupal\Core\KeyValueStore\KeyValueFactoryInterface $key_value_factory + * The key-value store factory. + * @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $logger_factory + * The logger channel factory. * @param int $max_age * (optional) How long the cache data should be kept. Defaults to a week. */ - public function __construct(ClientInterface $http_client, ConfigFactoryInterface $config_factory, TimeInterface $time, CacheBackendInterface $cache_backend = NULL, $max_age = 604800) { + public function __construct(ClientInterface $http_client, ConfigFactoryInterface $config_factory, TimeInterface $time, $key_value_factory = NULL, $logger_factory = NULL, int $max_age = 604800) { $this->httpClient = $http_client; $this->providersUrl = $config_factory->get('media.settings')->get('oembed_providers_url'); $this->time = $time; - if (empty($cache_backend)) { - $cache_backend = \Drupal::cache(); - @trigger_error('Passing NULL as the $cache_backend parameter to ' . __METHOD__ . '() is deprecated in drupal:9.3.0 and is removed from drupal:10.0.0. See https://www.drupal.org/node/3223594', E_USER_DEPRECATED); + if (!($key_value_factory instanceof KeyValueFactoryInterface)) { + @trigger_error('The keyvalue service should be passed to ' . __METHOD__ . '() since drupal:9.3.0 and is required in drupal:10.0.0. See https://www.drupal.org/node/3186186', E_USER_DEPRECATED); + $key_value_factory = \Drupal::service('keyvalue'); + } + if (!($logger_factory instanceof LoggerChannelFactoryInterface)) { + // If $max_age was passed in $logger_factory's position, ensure that we + // use the correct value. + if (is_numeric($logger_factory)) { + $max_age = $logger_factory; + } + @trigger_error('The logger.factory service should be passed to ' . __METHOD__ . '() since drupal:9.3.0 and is required in drupal:10.0.0. See https://www.drupal.org/node/3186186', E_USER_DEPRECATED); + $logger_factory = \Drupal::service('logger.factory'); } - $this->cacheBackend = $cache_backend; - $this->maxAge = (int) $max_age; + $this->maxAge = $max_age; + $this->keyValue = $key_value_factory->get('media'); + $this->logger = $logger_factory->get('media'); } /** * {@inheritdoc} */ public function getAll() { - $cache_id = 'media:oembed_providers'; - - $cached = $this->cacheBackend->get($cache_id); - if ($cached) { - return $cached->data; + $current_time = $this->time->getCurrentTime(); + $stored = $this->keyValue->get('oembed_providers'); + // If we have stored data that hasn't yet expired, return that. We need to + // store the data in a key-value store because, if the remote provider + // database is unavailable, we'd rather return stale data than throw an + // exception. This means we cannot use a normal cache backend or expirable + // key-value store, since those could delete the stale data at any time. + if ($stored && $stored['expires'] > $current_time) { + return $stored['data']; } try { $response = $this->httpClient->request('GET', $this->providersUrl); } catch (TransferException $e) { + if (isset($stored['data'])) { + // Use the stale data to fall back gracefully, but warn site + // administrators that we used stale data. + $this->logger->warning('Remote oEmbed providers could not be retrieved due to error: @error. Using previously stored data. This may contain out of date information.', [ + '@error' => $e->getMessage(), + ]); + return $stored['data']; + } + // We have no previous data and the request failed. throw new ProviderException("Could not retrieve the oEmbed provider database from $this->providersUrl", NULL, $e); } $providers = Json::decode((string) $response->getBody()); if (!is_array($providers) || empty($providers)) { + if (isset($stored['data'])) { + // Use the stale data to fall back gracefully, but as above, warn site + // administrators that we used stale data. + $this->logger->warning('Remote oEmbed providers database returned invalid or empty list. Using previously stored data. This may contain out of date information.'); + return $stored['data']; + } + // We have no previous data and the current data is corrupt. throw new ProviderException('Remote oEmbed providers database returned invalid or empty list.'); } @@ -111,7 +163,10 @@ public function getAll() { } } - $this->cacheBackend->set($cache_id, $keyed_providers, $this->time->getCurrentTime() + $this->maxAge); + $this->keyValue->set('oembed_providers', [ + 'data' => $keyed_providers, + 'expires' => $current_time + $this->maxAge, + ]); return $keyed_providers; } diff --git a/core/modules/media/tests/modules/media_test_oembed/src/ProviderRepository.php b/core/modules/media/tests/modules/media_test_oembed/src/ProviderRepository.php index aaec13a33fecf9404adb3dd53df55d2b97238774..a80ba32fe483d7e661a31186f169c74e3447a381 100644 --- a/core/modules/media/tests/modules/media_test_oembed/src/ProviderRepository.php +++ b/core/modules/media/tests/modules/media_test_oembed/src/ProviderRepository.php @@ -2,7 +2,6 @@ namespace Drupal\media_test_oembed; -use Drupal\Core\Cache\NullBackend; use Drupal\media\OEmbed\Provider; use Drupal\media\OEmbed\ProviderRepository as BaseProviderRepository; @@ -14,14 +13,6 @@ */ class ProviderRepository extends BaseProviderRepository { - /** - * {@inheritdoc} - */ - public function __construct(...$arguments) { - parent::__construct(...$arguments); - $this->cacheBackend = new NullBackend('default'); - } - /** * {@inheritdoc} */ diff --git a/core/modules/media/tests/src/Kernel/ProviderRepositoryTest.php b/core/modules/media/tests/src/Kernel/ProviderRepositoryTest.php index efa0bacc0dd6edf193329fdd64194e3679cfa7db..66dbff2f36aa3b9a7dde0c9b84560b1d53f26017 100644 --- a/core/modules/media/tests/src/Kernel/ProviderRepositoryTest.php +++ b/core/modules/media/tests/src/Kernel/ProviderRepositoryTest.php @@ -2,6 +2,8 @@ namespace Drupal\Tests\media\Kernel; +use Drupal\Core\Cache\CacheBackendInterface; +use Drupal\Core\Cache\NullBackend; use Drupal\KernelTests\KernelTestBase; use Drupal\media\OEmbed\ProviderRepository; @@ -18,12 +20,26 @@ class ProviderRepositoryTest extends KernelTestBase { * @group legacy */ public function testDeprecations(): void { - $this->expectDeprecation('Passing NULL as the $cache_backend parameter to Drupal\media\OEmbed\ProviderRepository::__construct() is deprecated in drupal:9.3.0 and is removed from drupal:10.0.0. See https://www.drupal.org/node/3223594'); - new ProviderRepository( + // Passing a cache backend in the key-value store's place, and the max age + // in the logger factory's place, should raise deprecation notices. + $this->expectDeprecation('The keyvalue service should be passed to Drupal\media\OEmbed\ProviderRepository::__construct() since drupal:9.3.0 and is required in drupal:10.0.0. See https://www.drupal.org/node/3186186'); + $this->expectDeprecation('The logger.factory service should be passed to Drupal\media\OEmbed\ProviderRepository::__construct() since drupal:9.3.0 and is required in drupal:10.0.0. See https://www.drupal.org/node/3186186'); + $providers = new ProviderRepository( $this->container->get('http_client'), $this->container->get('config.factory'), - $this->container->get('datetime.time') + $this->container->get('datetime.time'), + new NullBackend('test'), + 86400 ); + $this->expectDeprecation('The property cacheBackend (cache.default service) is deprecated in Drupal\media\OEmbed\ProviderRepository and will be removed before Drupal 10.0.0.'); + $this->assertInstanceOf(CacheBackendInterface::class, $providers->cacheBackend); + + // Ensure that the $max_age was properly set, even though it was passed in + // the logger factory's position. + $reflector = new \ReflectionClass($providers); + $property = $reflector->getProperty('maxAge'); + $property->setAccessible(TRUE); + $this->assertSame(86400, $property->getValue($providers)); } } diff --git a/core/modules/media/tests/src/Unit/ProviderRepositoryTest.php b/core/modules/media/tests/src/Unit/ProviderRepositoryTest.php new file mode 100644 index 0000000000000000000000000000000000000000..359c3cc87d60f04084e8e1b9a53fb0cd8a441b8b --- /dev/null +++ b/core/modules/media/tests/src/Unit/ProviderRepositoryTest.php @@ -0,0 +1,234 @@ +<?php + +namespace Drupal\Tests\media\Unit; + +use Drupal\Core\KeyValueStore\KeyValueMemoryFactory; +use Drupal\Core\Logger\LoggerChannelFactory; +use Drupal\media\OEmbed\ProviderException; +use Drupal\media\OEmbed\ProviderRepository; +use Drupal\Tests\UnitTestCase; +use GuzzleHttp\Client; +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Psr7\Response; + +/** + * @coversDefaultClass \Drupal\media\OEmbed\ProviderRepository + * + * @group media + */ +class ProviderRepositoryTest extends UnitTestCase { + + /** + * The provider repository under test. + * + * @var \Drupal\media\OEmbed\ProviderRepository + */ + private $repository; + + /** + * The HTTP client handler which will serve responses. + * + * @var \GuzzleHttp\Handler\MockHandler + */ + private $responses; + + /** + * The key-value store. + * + * @var \Drupal\Core\KeyValueStore\KeyValueStoreInterface + */ + private $keyValue; + + /** + * The time that the current test began. + * + * @var int + */ + private $currentTime; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $config_factory = $this->getConfigFactoryStub([ + 'media.settings' => [ + 'oembed_providers_url' => 'https://oembed.com/providers.json', + ], + ]); + + $key_value_factory = new KeyValueMemoryFactory(); + $this->keyValue = $key_value_factory->get('media'); + + $this->currentTime = time(); + $time = $this->prophesize('\Drupal\Component\Datetime\TimeInterface'); + $time->getCurrentTime()->willReturn($this->currentTime); + + $this->responses = new MockHandler(); + $client = new Client([ + 'handler' => HandlerStack::create($this->responses), + ]); + $this->repository = new ProviderRepository( + $client, + $config_factory, + $time->reveal(), + $key_value_factory, + new LoggerChannelFactory() + ); + } + + /** + * Tests that a successful fetch stores the provider database in key-value. + */ + public function testSuccessfulFetch(): void { + $body = <<<END +[ + { + "provider_name": "YouTube", + "provider_url": "https:\/\/www.youtube.com\/", + "endpoints": [ + { + "schemes": [ + "https:\/\/*.youtube.com\/watch*", + "https:\/\/*.youtube.com\/v\/*" + ], + "url": "https:\/\/www.youtube.com\/oembed", + "discovery": true + } + ] + } +] +END; + $response = new Response(200, [], $body); + $this->responses->append($response); + + $provider = $this->repository->get('YouTube'); + $stored_data = [ + 'data' => [ + 'YouTube' => $provider, + ], + 'expires' => $this->currentTime + 604800, + ]; + $this->assertSame($stored_data, $this->keyValue->get('oembed_providers')); + } + + /** + * Tests handling of invalid JSON when fetching the provider database. + * + * @param int $expiration_offset + * An offset to add to the current time to determine when the primed data, + * if any, expires. + * + * @dataProvider providerInvalidResponse + */ + public function testInvalidResponse(int $expiration_offset): void { + $provider = $this->prophesize('\Drupal\media\OEmbed\Provider') + ->reveal(); + + // This stored data should be returned, irrespective of whether it's fresh. + $this->keyValue->set('oembed_providers', [ + 'data' => [ + 'YouTube' => $provider, + ], + 'expires' => $this->currentTime + $expiration_offset, + ]); + + $response = new Response(200, [], "This certainly isn't valid JSON."); + $this->responses->append($response, $response); + $this->assertSame($provider, $this->repository->get('YouTube')); + + // When there is no stored data, we should get an exception. + $this->keyValue->delete('oembed_providers'); + $this->expectException(ProviderException::class); + $this->repository->get('YouTube'); + } + + /** + * Data provider for ::testInvalidResponse(). + * + * @return array[] + * Sets of arguments to pass to the test method. + */ + public function providerInvalidResponse(): array { + return [ + 'expired' => [ + -86400, + ], + 'fresh' => [ + 86400, + ], + ]; + } + + /** + * Tests handling of exceptions when fetching the provider database. + */ + public function testRequestException(): void { + $provider = $this->prophesize('\Drupal\media\OEmbed\Provider') + ->reveal(); + + // This data is expired (stale), but it should be returned anyway. + $this->keyValue->set('oembed_providers', [ + 'data' => [ + 'YouTube' => $provider, + ], + 'expires' => $this->currentTime - 86400, + ]); + + $response = new Response(503); + $this->responses->append($response, $response); + $this->assertSame($provider, $this->repository->get('YouTube')); + + // When there is no stored data, we should get an exception. + $this->keyValue->delete('oembed_providers'); + $this->expectException(ProviderException::class); + $this->repository->get('YouTube'); + } + + /** + * Tests a successful fetch but with a single corrupt item. + */ + public function testCorruptProviderIgnored(): void { + $body = <<<END +[ + { + "provider_name": "YouTube", + "provider_url": "https:\/\/www.youtube.com\/", + "endpoints": [ + { + "schemes": [ + "https:\/\/*.youtube.com\/watch*", + "https:\/\/*.youtube.com\/v\/*" + ], + "url": "https:\/\/www.youtube.com\/oembed", + "discovery": true + } + ] + }, + { + "provider_name": "Uncle Rico's football videos", + "provider_url": "not a real url", + "endpoints": [] + } +] +END; + $response = new Response(200, [], $body); + $this->responses->append($response); + + $youtube = $this->repository->get('YouTube'); + // The corrupt provider should not be stored. + $stored_data = [ + 'data' => [ + 'YouTube' => $youtube, + ], + 'expires' => $this->currentTime + 604800, + ]; + $this->assertSame($stored_data, $this->keyValue->get('oembed_providers')); + + $this->expectException('InvalidArgumentException'); + $this->repository->get("Uncle Rico's football videos"); + } + +}